feat(codex): self-contained codex app-server transport (executor + provider + sign-in) (#11205)

Merged after conflict resolution: the 5 conflicting test files were the base-red drains that #11201 already landed on the tip — kept the tip versions; the feature content is untouched. Validated on the combined batch board + this branch: codex-app-server + codex-gpt56-catalog 25/25, typecheck:core clean, docs-counts green (351 providers), provider-consistency 268/351/0. The opt-in codex-app-server transport (JSON-RPC-over-WS, turn/completed-awaited close, Responses SSE bridge) leaves the default codex path untouched. Thank you @arminanton — a 3.4k-line transport with the docs wave and tests to match!
This commit is contained in:
Armin Anton” ∴
2026-08-23 06:20:06 -07:00
committed by GitHub
parent 968fa96105
commit 8f390efffd
153 changed files with 3358 additions and 182 deletions

View File

@@ -409,7 +409,7 @@ export default function ConnectionsListPanel({
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
: undefined
}
isCodex={providerId === "codex"}
isCodex={providerId === "codex" || providerId === "codex-app-server"}
isCcCompatible={isCcCompatible}
cliproxyapiEnabled={cpaProviderEnabled}
onToggleCliproxyapiMode={(enabled) => handleToggleCliproxyapiMode(conn.id, enabled)}
@@ -610,7 +610,7 @@ export default function ConnectionsListPanel({
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
: undefined
}
isCodex={providerId === "codex"}
isCodex={providerId === "codex" || providerId === "codex-app-server"}
isCcCompatible={isCcCompatible}
cliproxyapiEnabled={cpaProviderEnabled}
onToggleCliproxyapiMode={(enabled) =>

View File

@@ -1,10 +1,22 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile";
import {
CodexAuthFileError,
writeCodexAuthFileToLocalCliIfNeeded,
} from "@/lib/oauth/utils/codexAuthFile";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
// Optional body { force?: boolean }. Unknown keys are stripped rather than
// rejected so the endpoint stays tolerant of the empty/no-body calls it
// historically accepted. Non-boolean `force` is coerced away to the default.
const ApplyLocalBodySchema = z
.object({ force: z.boolean().optional() })
.partial()
.passthrough();
function toErrorResponse(error: unknown) {
if (error instanceof CodexAuthFileError) {
return NextResponse.json(
@@ -33,7 +45,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
}
const { id } = await params;
const result = await writeCodexAuthFileToLocalCli(id);
// Optional { force?: boolean } body. By default we DON'T clobber an existing,
// fresh ~/.codex/auth.json (a session the user may be managing themselves);
// force overwrites it (a backup is always taken regardless). Malformed/empty
// bodies are tolerated — this endpoint historically took no body.
let force = false;
try {
const parsed = ApplyLocalBodySchema.safeParse(await request.json());
force = parsed.success ? parsed.data.force === true : false;
} catch {
/* no body — default force=false */
}
const applied = await writeCodexAuthFileToLocalCliIfNeeded(id, { force });
const result = applied.result;
logAuditEvent({
action: "provider.credentials.applied",
@@ -45,18 +71,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
requestId: auditContext.requestId,
metadata: {
provider: "codex",
authPath: result.authPath,
savedBakPath: result.savedBakPath,
decision: applied.decision,
authPath: applied.authPath,
savedBakPath: result?.savedBakPath,
},
});
return NextResponse.json({
success: true,
connectionId: id,
connectionLabel: result.connectionLabel,
authPath: result.authPath,
savedBakPath: result.savedBakPath,
centralizedBackupPath: result.centralizedBackupPath,
// "skipped_present_fresh" means an existing healthy auth.json was kept.
decision: applied.decision,
connectionLabel: result?.connectionLabel,
authPath: applied.authPath,
savedBakPath: result?.savedBakPath,
centralizedBackupPath: result?.centralizedBackupPath,
writtenAt: new Date().toISOString(),
});
} catch (error) {

View File

@@ -0,0 +1,136 @@
/**
* Build the structured diagnosis object the connection-test route returns.
* Lives here (rather than inline in test/route.ts) so both the route and the
* codex-app-server health probe share one definition. Pure.
*/
export function makeDiagnosis(
type: string,
source: string,
message: string | null,
code: string | null = null
) {
return {
type,
source,
message: message || null,
code: code ?? null,
};
}
export type CodexAppServerHealth = {
valid: boolean;
error?: string;
diagnosis: unknown;
refreshed: boolean;
};
/**
* A codex "app-server" connection (providerSpecificData.codexTransport ===
* "app-server") does NOT carry a validatable OpenAI token: it drives the codex
* CLI's own `codex app-server` process over JSON-RPC/WebSocket, and THAT process
* self-manages its OpenAI OAuth (its own ~/.codex/auth.json), exactly like an
* interactive codex session. So the ordinary OAuth token probe is meaningless for
* these connections — it validates a placeholder and reports a false "Token
* invalid or revoked" 401 (which then trips the rate-limit cooldown on retest).
*
* The correct health signal for this transport is whether the app-server itself
* is reachable and ready. The app-server exposes an unauthenticated liveness
* endpoint at <httpBase>/readyz (200 = ready) alongside its ws:// listener, so we
* derive the http(s) origin from the configured ws(s):// URL and probe /readyz.
* Returns null when this connection is NOT an app-server connection (so the caller
* falls through to the normal token validation).
*/
export async function testCodexAppServerConnection(
connection: any
): Promise<CodexAppServerHealth | null> {
const psd = (connection?.providerSpecificData as Record<string, unknown> | undefined) || undefined;
// Fire the /readyz probe when EITHER (a) the connection opted into the
// app-server transport via the per-connection flag (a `codex` provider
// connection with codexTransport==="app-server"), OR (b) this is the
// first-class `codex-app-server` provider, which is app-server by definition
// and needs no flag. Otherwise return null so the caller falls through to the
// normal OAuth/apikey token validation.
const isAppServerProvider = connection?.provider === "codex-app-server";
const isAppServerFlag = psd?.codexTransport === "app-server";
if (!isAppServerProvider && !isAppServerFlag) return null;
// Dynamic import (not a static top-level import) so this executor-config module
// stays behind the open-sse boundary the no-restricted-imports lint rule enforces.
const { resolveAppServerConfig } = await import(
"@omniroute/open-sse/executors/codex/appServerConfig.ts"
);
const config = resolveAppServerConfig(psd);
if (!config) {
const error = "Codex app-server transport is not configured (missing url or token)";
return {
valid: false,
error,
refreshed: false,
diagnosis: makeDiagnosis("validation_error", "local", error, "app_server_unconfigured"),
};
}
// ws://host:port → http://host:port/readyz ; wss:// → https://.
const httpBase = config.url.replace(/^ws(s?):\/\//i, (_m, s) => `http${s}://`).replace(/\/+$/, "");
const readyzUrl = `${httpBase}/readyz`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(readyzUrl, {
method: "GET",
headers: { Authorization: `Bearer ${config.token}` },
signal: controller.signal,
});
if (res.status !== 200) {
const error = `Codex app-server not ready (${readyzUrl} → HTTP ${res.status})`;
return {
valid: false,
error,
refreshed: false,
diagnosis: makeDiagnosis("provider_error", "app_server", error, "app_server_not_ready"),
};
}
// The server PROCESS is up. Now confirm its Codex CLI is actually SIGNED IN —
// /readyz alone would show green for a logged-out CLI, which then fails on the
// first real turn. Probe account/read over the JSON-RPC WebSocket.
let authStatus;
try {
const [{ probeCodexAppServerAuth }, { getCodexAppServerWebsocketTransport }] =
await Promise.all([
import("@omniroute/open-sse/executors/codex/appServerAuthProbe.ts"),
import("@omniroute/open-sse/executors/codex.ts"),
]);
authStatus = await probeCodexAppServerAuth(config, getCodexAppServerWebsocketTransport(), 8000);
} catch (probeErr: any) {
// If the auth probe itself fails to load/run, don't fail the whole health
// check — the server IS reachable. Treat as unknown-but-reachable (valid).
authStatus = { state: "unknown", reason: probeErr?.message ?? "auth probe failed" } as const;
}
if (authStatus.state === "logged_out") {
const error =
"Codex app-server is running but its Codex CLI is not signed in. Use \u201cSign in with ChatGPT\u201d to authenticate.";
return {
valid: false,
error,
refreshed: false,
diagnosis: makeDiagnosis("auth_required", "app_server", error, "app_server_login_required"),
};
}
// "authenticated" → healthy; "unknown" (probe unavailable/timed out) → treat
// the reachable server as healthy rather than blocking on an inconclusive probe.
return { valid: true, refreshed: false, diagnosis: null };
} catch (err: any) {
const reason = err?.name === "AbortError" ? "timed out" : (err?.message ?? "unreachable");
const error = `Codex app-server unreachable (${readyzUrl}: ${reason})`;
return {
valid: false,
error,
refreshed: false,
diagnosis: makeDiagnosis("provider_error", "app_server", error, "app_server_unreachable"),
};
} finally {
clearTimeout(timer);
}
}

View File

@@ -28,6 +28,7 @@ import {
} from "@/lib/oauth/gitlab";
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import { shouldUseApiKeyConnectionTest } from "./webSessionTestDispatch";
import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHealth";
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
@@ -52,20 +53,6 @@ function toSafeMessage(value: any, fallback = "Unknown error"): string {
return trimmed || fallback;
}
function makeDiagnosis(
type: string,
source: string,
message: string | null,
code: string | null = null
) {
return {
type,
source,
message: message || null,
code: code ?? null,
};
}
/**
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
@@ -1024,6 +1011,13 @@ export async function testSingleConnection(connectionId: string, validationModel
const startTime = Date.now();
const runtime = await getProviderRuntimeStatus(connection);
// Codex app-server connections carry no validatable OpenAI token (the codex
// app-server process self-manages its own OAuth). Probe the app-server's
// /readyz liveness endpoint instead of the meaningless token check — otherwise
// every sweep reports a false "Token invalid or revoked" 401 and cools the
// connection down. Returns null for non-app-server connections (fall through).
const appServerResult = await testCodexAppServerConnection(connection);
if ((runtime as any)?.diagnosis) {
result = {
valid: false,
@@ -1031,6 +1025,10 @@ export async function testSingleConnection(connectionId: string, validationModel
refreshed: false,
diagnosis: (runtime as any).diagnosis,
};
} else if (appServerResult) {
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
Promise.resolve(appServerResult)
);
} else if (shouldUseApiKeyConnectionTest(connection.authType, provider)) {
const enrichedConnection = validationModelId
? {

View File

@@ -13873,5 +13873,12 @@
"toolsMismatch": "O provedor nao suporta chamada de ferramentas",
"structuredOutputMismatch": "O provedor nao suporta saida estruturada",
"contextWindowMismatch": "A requisicao excede a janela de contexto do provedor"
},
"cheaperInferenceSponsorBanner": {
"title": "Cheaper Inference é um Amigo Open Source do OmniRoute",
"description": "Um gateway ordenado por custo que revende dezenas de modelos de fronteira atrás de um único endpoint compatível com OpenAI, roteando cada requisição para o provedor elegível mais barato, nunca acima do preço de tabela.",
"cta": "Obter uma chave de API",
"partnerLinkNote": "Link de parceiro",
"dismissAriaLabel": "Dispensar"
}
}

View File

@@ -6,6 +6,10 @@ export interface NodeSqliteDatabaseLike {
run(...p: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint };
get(...p: unknown[]): unknown;
all(...p: unknown[]): unknown[];
// node:sqlite (DatabaseSync) statements expose these tuning setters. They
// are optional here so the shared adapter also accepts lighter test doubles.
setAllowUnknownNamedParameters?(enabled: boolean): void;
setAllowBareNamedParameters?(enabled: boolean): void;
};
exec(sql: string): void;
close(): void;
@@ -13,6 +17,46 @@ export interface NodeSqliteDatabaseLike {
const MAX_STMT_CACHE_SIZE = 200;
// node:sqlite hands back rows whose prototype is `null` (Object.create(null)),
// whereas better-sqlite3 (the driver we ship and run in production/CI) returns
// ordinary Object.prototype rows. The difference is invisible for normal
// property access but breaks callers that compare rows with structural
// equality that also checks the prototype (e.g. Node's assert.deepStrictEqual,
// used by unit tests written against the better-sqlite3 row shape). Normalize
// every row to a plain object so the node:sqlite fallback is behaviourally
// identical to the native better-sqlite3 path.
function toPlainRow<T>(row: T): T {
if (row === null || typeof row !== "object") return row;
return { ...(row as Record<string, unknown>) } as T;
}
// better-sqlite3 (the production/CI driver) and sql.js both accept `undefined`
// as a bound value and treat it as SQL NULL. node:sqlite is stricter and throws
// "Provided value cannot be bound to SQLite parameter N" for undefined. Several
// call sites pass undefined for absent optional columns (e.g. a capability sync
// that omits modalities_input), so coerce undefined -> null here to keep the
// node:sqlite fallback behaviourally compatible with the native driver. This
// handles both positional params and a single named-params object.
function normalizeBindParams(params: unknown[]): unknown[] {
const [first] = params;
const isLoneNamedParamsObject =
params.length === 1 &&
first !== null &&
typeof first === "object" &&
!Array.isArray(first) &&
!Buffer.isBuffer(first) &&
!(first instanceof Uint8Array);
if (isLoneNamedParamsObject) {
const source = first as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const key of Object.keys(source)) {
normalized[key] = source[key] === undefined ? null : source[key];
}
return [normalized];
}
return params.map((value) => (value === undefined ? null : value));
}
export function createNodeSqliteAdapterFromDatabase(
db: NodeSqliteDatabaseLike,
filePath: string,
@@ -41,6 +85,14 @@ export function createNodeSqliteAdapterFromDatabase(
stmtCache.set(sql, entry);
} else {
const stmt = db.prepare(sql);
// better-sqlite3 (the production/CI driver) silently ignores named
// parameters supplied in the bind object that the SQL text does not
// reference. node:sqlite instead throws "Unknown named parameter '<x>'".
// Several call sites deliberately pass a superset params object (e.g. an
// UPDATE that omits @createdAt while the shared params builder still
// includes it), so relax node:sqlite to match better-sqlite3 and keep the
// fallback driver behaviourally compatible.
stmt.setAllowUnknownNamedParameters?.(true);
if (stmtCache.size >= MAX_STMT_CACHE_SIZE) {
const oldestKey = stmtCache.keys().next().value;
if (oldestKey !== undefined) {
@@ -119,17 +171,19 @@ export function createNodeSqliteAdapterFromDatabase(
const stmt = getCached(sql);
return {
run(...params: unknown[]): RunResult {
const r = stmt.run(...params);
const r = stmt.run(...normalizeBindParams(params));
return {
changes: Number(r.changes ?? 0),
lastInsertRowid: Number(r.lastInsertRowid ?? 0),
};
},
get(...params: unknown[]): unknown {
return stmt.get(...params);
return toPlainRow(stmt.get(...normalizeBindParams(params)));
},
all(...params: unknown[]): unknown[] {
return stmt.all(...params);
return (stmt.all(...normalizeBindParams(params)) as unknown[]).map((row) =>
toPlainRow(row)
);
},
};
},

View File

@@ -6,6 +6,19 @@ import type { SqliteAdapter, PreparedStatement, RunResult } from "./types";
const SAVE_DEBOUNCE_MS = 100;
const CHECKPOINT_INTERVAL_MS = 60_000;
// sql.js's stmt.getAsObject() returns rows whose prototype is `null`
// (Object.create(null)), whereas better-sqlite3 (the driver we ship and run in
// production/CI) hands back ordinary Object.prototype rows. That difference is
// invisible for normal property access but breaks callers that compare rows
// with structural equality that also checks the prototype (e.g. Node's
// assert.deepStrictEqual, used by several unit tests written against the
// better-sqlite3 row shape). Normalize every row to a plain object so the
// sql.js fallback is behaviourally identical to the native better-sqlite3 path.
function toPlainRow<T>(row: T): T {
if (row === null || typeof row !== "object") return row;
return { ...(row as Record<string, unknown>) } as T;
}
let _sqlJsLib: Awaited<ReturnType<(typeof import("sql.js"))["default"]>> | null = null;
function resolveSqlJsWasmPath(): string {
@@ -240,7 +253,7 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
try {
const bindValue = toBindValue(params);
if (bindValue !== undefined) stmt.bind(bindValue);
if (stmt.step()) return stmt.getAsObject();
if (stmt.step()) return toPlainRow(stmt.getAsObject());
return undefined;
} finally {
stmt.free();
@@ -252,7 +265,7 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
const bindValue = toBindValue(params);
if (bindValue !== undefined) stmt.bind(bindValue);
const rows: unknown[] = [];
while (stmt.step()) rows.push(stmt.getAsObject());
while (stmt.step()) rows.push(toPlainRow(stmt.getAsObject()));
return rows;
} finally {
stmt.free();

View File

@@ -350,3 +350,89 @@ export async function writeCodexAuthFileToLocalCli(connectionId: string) {
centralizedBackupPath,
};
}
/**
* Decision for the guarded write (see writeCodexAuthFileToLocalCliIfNeeded).
*/
export type CodexAuthWriteDecision =
| "written" // wrote a fresh auth.json (was absent, stale, or force)
| "skipped_present_fresh"; // an existing, non-stale auth.json was left untouched
/**
* Guarded variant of writeCodexAuthFileToLocalCli for the codex-app-server
* "Sign in with ChatGPT" flow. Per the design decision (William, Q2):
*
* - Write ONLY when ~/.codex/auth.json is ABSENT, or STALE (its token is at/
* past the refresh buffer), or when `force` is set.
* - NEVER clobber an existing, healthy (non-stale) auth.json — a user may be
* managing the CLI session themselves. (The underlying writer always makes a
* backup regardless, so even a forced overwrite is recoverable.)
*
* Staleness is read from the existing file's `last_refresh` + the token's own
* expiry claim (JWT `exp` on the access_token) when present; if neither is
* readable we treat the file as fresh (do not clobber).
*
* Returns the write decision plus (when written) the underlying write result.
*/
export async function writeCodexAuthFileToLocalCliIfNeeded(
connectionId: string,
options: { force?: boolean } = {}
): Promise<{ decision: CodexAuthWriteDecision; authPath: string | null; result?: Awaited<ReturnType<typeof writeCodexAuthFileToLocalCli>> }> {
const paths = getCliConfigPaths("codex");
const authPath = paths?.auth ?? null;
if (!options.force && authPath) {
const existing = await readExistingCodexAuth(authPath);
if (existing && !isCodexAuthStale(existing)) {
// Present and healthy — do not clobber a session we didn't (or don't need
// to) manage. The connection can still authenticate turns via this file.
return { decision: "skipped_present_fresh", authPath };
}
}
const result = await writeCodexAuthFileToLocalCli(connectionId);
return { decision: "written", authPath: result.authPath, result };
}
/** Read + parse an existing ~/.codex/auth.json; null when absent/unreadable. */
async function readExistingCodexAuth(authPath: string): Promise<CodexAuthFilePayload | null> {
try {
const raw = await fs.readFile(authPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const rec = toRecord(parsed);
const tokens = toRecord(rec.tokens);
if (!toNonEmptyString(tokens.access_token)) return null;
return parsed as CodexAuthFilePayload;
} catch {
return null;
}
}
/**
* A stored auth.json is "stale" when its access_token is at/past the refresh
* buffer. Prefer the JWT `exp` claim on the access_token; fall back to
* `last_refresh` + a conservative validity window; if neither is parseable,
* treat as NOT stale (never clobber on ambiguity).
*/
function isCodexAuthStale(payload: CodexAuthFilePayload): boolean {
const accessToken = toNonEmptyString(payload?.tokens?.access_token);
if (accessToken) {
const claims = decodeJwtPayload(accessToken);
const exp = claims && typeof claims.exp === "number" ? claims.exp : null;
if (exp) {
const expiresAtMs = exp * 1000;
return expiresAtMs - Date.now() <= CODEX_REFRESH_BUFFER_MS;
}
}
// No usable exp claim — fall back to last_refresh age. Codex access tokens are
// short-lived (~hours); if the file hasn't refreshed in > 6h, consider it stale.
const lastRefresh = toNonEmptyString(payload?.last_refresh);
if (lastRefresh) {
const refreshedMs = new Date(lastRefresh).getTime();
if (!Number.isNaN(refreshedMs)) {
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
return Date.now() - refreshedMs >= SIX_HOURS_MS;
}
}
return false;
}

View File

@@ -1,6 +1,7 @@
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { isSecurityBlockError } from "@/lib/providers/validation/transport";
import {
SAFE_OUTBOUND_FETCH_PRESETS,
SafeOutboundFetchError,
@@ -62,7 +63,7 @@ function toValidationErrorResult(error: unknown) {
...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
? { timeout: true }
: {}),
...(statusCode === 400 ? { securityBlocked: true } : {}),
...(isSecurityBlockError(error) ? { securityBlocked: true } : {}),
};
}

View File

@@ -1,4 +1,9 @@
export const DEFAULT_CODEX_CLIENT_VERSION = "0.146.0";
// Kept in lockstep with the codex CLI actually installed in the OmniRoute image
// (bin/omniroute-fix.Containerfile installs `codex` latest; app-server runtime is
// 0.149.0 as of 2026-08-22). When the image's codex is bumped, refresh this so the
// fingerprint OpenAI sees from the OAuth/Responses face matches the real client
// version. Overridable per-deployment via the CODEX_CLIENT_VERSION env.
export const DEFAULT_CODEX_CLIENT_VERSION = "0.149.0";
export const CODEX_CLI_RS_ORIGINATOR = "codex_cli_rs";
export function getCodexCliRsHeaders(

View File

@@ -401,6 +401,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
requiresRestart: false,
warningLevel: "info",
},
{
key: "OMNIROUTE_CODEX_APP_SERVER_ENABLED",
label: "Codex App-Server Transport",
description:
"Allow Codex to use the local app-server WebSocket JSON-RPC transport (codexTransport=app-server). When off, connections opted into app-server fall back to Codex's other transports.",
descriptionI18nKey: "featureFlagOmnirouteCodexAppServerEnabledDescription",
category: "runtime",
defaultValue: "true",
type: "boolean",
requiresRestart: false,
warningLevel: "info",
},
{
key: "OMNIROUTE_EMERGENCY_FALLBACK",
label: "Emergency Fallback",

View File

@@ -175,6 +175,30 @@ export const NOAUTH_PROVIDERS = {
text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.",
},
},
"codex-app-server": {
id: "codex-app-server",
alias: "cxa",
name: "OpenAI Codex (App-Server)",
icon: "code",
color: "#10A37F",
textIcon: "CA",
website: "https://developers.openai.com/codex/cli",
noAuth: true,
hasFree: false,
serviceKinds: ["llm"],
isLocalCli: true,
// No subscriptionRisk / riskNoticeVariant: unlike the `codex` provider (which
// replays your ChatGPT/OpenAI session token to the API), this transport drives
// the Codex CLI's own `codex app-server` over JSON-RPC/WebSocket. The CLI owns
// and self-refreshes its OAuth (~/.codex/auth.json) exactly like an interactive
// `codex` session — OmniRoute never replays a token to the API — so the
// "official session not authorized for proxy use" caveat does not apply.
authHint:
"No token stored by OmniRoute. The Codex CLI app-server manages its own ChatGPT sign-in (~/.codex/auth.json, auto-refreshed). Use \u201cSign in with ChatGPT\u201d if the CLI is not yet authenticated.",
notice: {
text: "OpenAI Codex (App-Server) drives the Codex CLI's local app-server (JSON-RPC over WebSocket). The CLI self-manages its OpenAI OAuth, so OmniRoute never sees or replays your token. Requires the codex CLI reachable at the configured app-server URL; sign in via the CLI or the dashboard \u201cSign in with ChatGPT\u201d action.",
},
},
uncloseai: {
id: "uncloseai",
alias: "unc",

View File

@@ -338,6 +338,7 @@ export const CLI_TOOL_ALIASES: Readonly<Record<string, string>> = {
"claude-code": "claude",
"openai-codex": "codex",
openai: "codex",
"codex-app-server": "codex",
cn: "continue",
qodercli: "qoder",
};