diff --git a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx index 7e2061ac0c78..6bfd3a08b2b1 100644 --- a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx +++ b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx @@ -44,11 +44,14 @@ type FormStatus = { const idleStatus: FormStatus = { state: "idle" }; /** Everything the add panel offers, in display order. */ -type AddKind = ClaudeUpstreamKind | "codex" | "opencode"; +type ApiKeyAddKind = "openai-apikey" | "openrouter-apikey"; +type AddKind = ClaudeUpstreamKind | ApiKeyAddKind | "codex" | "opencode"; const ADD_KINDS: readonly AddKind[] = [ "anthropic_api_key", "anthropic_oauth", "bedrock", + "openai-apikey", + "openrouter-apikey", "codex", "opencode", ]; @@ -82,6 +85,8 @@ export function CoderouterAccountsSection({ const nativeAccounts = native.kind === "ok" ? native.accounts : []; const sharedAccounts = shared.kind === "ok" ? shared.accounts : []; const total = claudeAccounts.length + nativeAccounts.length + sharedAccounts.length; + // Field ids are per form, so switching the add tab never leaves two inputs + // with one id. const partialFailure = claude.kind === "error" || native.kind === "error" || shared.kind === "error"; return ( @@ -569,6 +574,8 @@ function AddAccountPanel({ teamId }: { readonly teamId: string }) { command="npx coderouter@latest add opencode" t={t} /> + ) : kind === "openai-apikey" || kind === "openrouter-apikey" ? ( + ) : ( )} @@ -598,6 +605,86 @@ function CliInstructions({ ); } +/** + * Stores an OpenAI or OpenRouter key as a coderouter account. Codex on this + * team's machines then routes Responses calls through it, next to any Codex + * sign-ins, and moves off it on a rate limit or a rejected key. + */ +function ApiKeyForm({ + teamId, + kind, +}: { + readonly teamId: string; + readonly kind: ApiKeyAddKind; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const router = useRouter(); + const [status, setStatus] = useState(idleStatus); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (status.state === "submitting") return; + const form = event.currentTarget; + const data = new FormData(form); + const label = String(data.get("label") ?? "").trim(); + setStatus({ state: "submitting" }); + try { + const response = await fetch("/api/coderouter/accounts", { + method: "POST", + headers: { "content-type": "application/json", "x-cmux-team-id": teamId }, + body: JSON.stringify({ + provider: kind, + apiKey: String(data.get("apiKey") ?? "").trim(), + ...(label ? { label } : {}), + }), + }); + if (!response.ok) { + setStatus({ + state: "error", + message: errorMessageForStatus(response.status, t, t("saveError")), + }); + return; + } + form.reset(); + setStatus({ state: "success", message: t("saveSuccess") }); + router.refresh(); + } catch { + setStatus({ state: "error", message: t("saveError") }); + } + }; + + return ( +
+

+ {kind === "openai-apikey" ? t("openAiKeyHint") : t("openRouterKeyHint")} +

+ + +
+ + {status.message ? ( + + {status.message} + + ) : null} +
+ + ); +} + function ClaudeUpstreamForm({ teamId, kind, @@ -759,6 +846,10 @@ function addKindLabel(kind: AddKind, t: Translator): string { return t("kindCodex"); case "opencode": return t("kindOpencode"); + case "openai-apikey": + return t("kindOpenAiApiKey"); + case "openrouter-apikey": + return t("kindOpenRouterApiKey"); default: return claudeKindLabel(kind, t); } @@ -771,6 +862,10 @@ function nativeKindLabel(kind: CodeRouterAccountSummary["provider"], t: Translat return t("kindCodex"); case "opencode-go": return t("kindOpencodeGo"); + case "openai-apikey": + return t("kindOpenAiApiKey"); + case "openrouter-apikey": + return t("kindOpenRouterApiKey"); } } diff --git a/web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql b/web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql new file mode 100644 index 000000000000..c636b996c996 --- /dev/null +++ b/web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql @@ -0,0 +1,20 @@ +-- coderouter routes the OpenAI Responses surface through pasted OpenAI and +-- OpenRouter API keys as well as Codex sign-ins. The provider check on every +-- coderouter table widens to admit the two key-based providers. +ALTER TABLE "coderouter_accounts" + DROP CONSTRAINT IF EXISTS "coderouter_accounts_provider_check"; +ALTER TABLE "coderouter_accounts" + ADD CONSTRAINT "coderouter_accounts_provider_check" + CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey')); + +ALTER TABLE "coderouter_credentials" + DROP CONSTRAINT IF EXISTS "coderouter_credentials_provider_check"; +ALTER TABLE "coderouter_credentials" + ADD CONSTRAINT "coderouter_credentials_provider_check" + CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey')); + +ALTER TABLE "coderouter_session_accounts" + DROP CONSTRAINT IF EXISTS "coderouter_session_accounts_provider_check"; +ALTER TABLE "coderouter_session_accounts" + ADD CONSTRAINT "coderouter_session_accounts_provider_check" + CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey')); diff --git a/web/db/schema.ts b/web/db/schema.ts index 63b1e7dcacaa..41a7f7fb2815 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1085,12 +1085,14 @@ export const subrouterTenants = pgTable( * live in the envelope-encrypted coderouterCredentials table; this table * coordinates selection and rotating refresh-token leases. */ +type CodeRouterProviderColumn = "codex" | "opencode-go" | "openai-apikey" | "openrouter-apikey"; + export const coderouterAccounts = pgTable( "coderouter_accounts", { id: uuid("id").defaultRandom().primaryKey(), teamId: text("team_id").notNull(), - provider: text("provider").$type<"codex" | "opencode-go">().notNull(), + provider: text("provider").$type().notNull(), providerAccountId: text("provider_account_id").notNull(), label: text("label").notNull(), state: text("state") @@ -1166,7 +1168,7 @@ export const coderouterCredentials = pgTable( .primaryKey() .references(() => coderouterAccounts.id, { onDelete: "cascade" }), teamId: text("team_id").notNull(), - provider: text("provider").$type<"codex" | "opencode-go">().notNull(), + provider: text("provider").$type().notNull(), credentialRevision: bigint("credential_revision", { mode: "number" }) .notNull(), algorithm: text("algorithm").notNull().default("aes-256-gcm"), @@ -1219,7 +1221,7 @@ export const coderouterSessionAccounts = pgTable( "coderouter_session_accounts", { teamId: text("team_id").notNull(), - provider: text("provider").$type<"codex" | "opencode-go">().notNull(), + provider: text("provider").$type().notNull(), sessionKey: text("session_key").notNull(), accountId: uuid("account_id") .notNull() diff --git a/web/messages/en.json b/web/messages/en.json index 9a38d1767a98..c3ac2e1f574e 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -260,6 +260,7 @@ "kindOpencode": "OpenCode", "kindOpencodeGo": "OpenCode Go", "kindOpenAiApiKey": "OpenAI API key", + "kindOpenRouterApiKey": "OpenRouter API key", "kindUnknown": "Unknown provider", "stateActive": "Active", "stateDisabled": "Disabled", @@ -280,6 +281,8 @@ "apiKeyField": "API key", "oauthTokenField": "OAuth token", "oauthHint": "Create a long-lived token on your own machine with", + "openAiKeyHint": "Codex on this team's machines sends Responses API calls to api.openai.com with this key, alongside any Codex sign-ins. Billing goes to the OpenAI project that owns the key.", + "openRouterKeyHint": "Codex on this team's machines sends Responses API calls to OpenRouter with this key. Bare OpenAI model ids are sent as openai/.", "regionField": "AWS region", "accessKeyIdField": "Access key ID", "secretAccessKeyField": "Secret access key", diff --git a/web/messages/ja.json b/web/messages/ja.json index cca7da9bd8ef..fef7e420db58 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -260,6 +260,7 @@ "kindOpencode": "OpenCode", "kindOpencodeGo": "OpenCode Go", "kindOpenAiApiKey": "OpenAI APIキー", + "kindOpenRouterApiKey": "OpenRouter APIキー", "kindUnknown": "不明なプロバイダー", "stateActive": "有効", "stateDisabled": "無効", @@ -280,6 +281,8 @@ "apiKeyField": "APIキー", "oauthTokenField": "OAuthトークン", "oauthHint": "自分のマシンで次のコマンドを実行して長期トークンを作成します:", + "openAiKeyHint": "このチームのマシン上の Codex は、Codex サインインと並んでこのキーで api.openai.com に Responses API を送信します。請求はキーを所有する OpenAI プロジェクトに行われます。", + "openRouterKeyHint": "このチームのマシン上の Codex は、このキーで OpenRouter に Responses API を送信します。ベンダー接頭辞のない OpenAI モデル ID は openai/ として送られます。", "regionField": "AWSリージョン", "accessKeyIdField": "アクセスキーID", "secretAccessKeyField": "シークレットアクセスキー", diff --git a/web/services/coderouter/accounts.ts b/web/services/coderouter/accounts.ts index 4a7574508417..3e8177d13a06 100644 --- a/web/services/coderouter/accounts.ts +++ b/web/services/coderouter/accounts.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { findAccountByProviderIdentity, deleteAccount, @@ -8,7 +8,11 @@ import { withVaultLease, } from "./repository"; import { encryptCredential } from "./encryption"; -import type { CodeRouterCredential } from "./types"; +import { + CODEROUTER_API_KEY_PROVIDERS, + type CodeRouterApiKeyProvider, + type CodeRouterCredential, +} from "./types"; import { deleteVaultCredential } from "./vault"; import { reportCoderouterFailure } from "./observability"; @@ -97,9 +101,14 @@ export const removeAccount = createAccountRemover({ report: reportCoderouterFailure, }); +const MAX_API_KEY_LENGTH = 512; +const MAX_LABEL_LENGTH = 120; +const API_KEY_PATTERN = /^[A-Za-z0-9._~+/=-]+$/; + export function parseCredential(value: unknown): CodeRouterCredential | null { if (!isRecord(value)) return null; const provider = value.provider; + if (isApiKeyProviderName(provider)) return parseApiKeyCredential(provider, value); const accessToken = boundedString(value.accessToken, 32_768); const refreshToken = boundedString(value.refreshToken, 32_768); const accountId = boundedString(value.accountId, 512); @@ -147,6 +156,40 @@ export function parseCredential(value: unknown): CodeRouterCredential | null { return null; } +function isApiKeyProviderName(value: unknown): value is CodeRouterApiKeyProvider { + return typeof value === "string" && + (CODEROUTER_API_KEY_PROVIDERS as readonly string[]).includes(value); +} + +/** + * `{ provider, apiKey, label? }` from the dashboard or `cr add`. The key is + * validated as one printable token; the fingerprint becomes the provider + * account id, so re-adding the same key updates the existing row. + */ +function parseApiKeyCredential( + provider: CodeRouterApiKeyProvider, + value: Record, +): CodeRouterCredential | null { + const apiKey = typeof value.apiKey === "string" ? value.apiKey.trim() : ""; + if (apiKey.length < 16 || apiKey.length > MAX_API_KEY_LENGTH || !API_KEY_PATTERN.test(apiKey)) { + return null; + } + const rawLabel = value.label; + if (rawLabel !== undefined && rawLabel !== null && typeof rawLabel !== "string") return null; + const label = typeof rawLabel === "string" ? rawLabel.trim() : ""; + if (label.length > MAX_LABEL_LENGTH) return null; + return { + provider, + apiKey, + accountId: apiKeyFingerprint(provider, apiKey), + label, + }; +} + +export function apiKeyFingerprint(provider: CodeRouterApiKeyProvider, apiKey: string): string { + return createHash("sha256").update(`${provider}\n${apiKey}`).digest("hex").slice(0, 24); +} + function boundedString(value: unknown, max: number): string | null { return typeof value === "string" && value.length > 0 && value.length <= max ? value diff --git a/web/services/coderouter/analytics.ts b/web/services/coderouter/analytics.ts index 16629dc91fa9..7a27d13f65c9 100644 --- a/web/services/coderouter/analytics.ts +++ b/web/services/coderouter/analytics.ts @@ -418,6 +418,7 @@ function accountProvider(value: unknown): string | null { "openai-apikey", "anthropic-apikey", "opencode-go", + "openrouter-apikey", ]); } diff --git a/web/services/coderouter/codexProxy.ts b/web/services/coderouter/codexProxy.ts index 154a04b62e37..969d2d918f5a 100644 --- a/web/services/coderouter/codexProxy.ts +++ b/web/services/coderouter/codexProxy.ts @@ -6,6 +6,7 @@ import { } from "./repository"; import { freshCredential } from "./refresh"; import { fetchProviderRead } from "./providerFetch"; +import { RESPONSES_PROVIDERS, type CodeRouterCredential } from "./types"; import { captureCoderouterEvent } from "./analytics"; import { addCoderouterBreadcrumb, @@ -37,6 +38,10 @@ import { const CODEX_UPSTREAM = "https://chatgpt.com/backend-api/codex/responses"; const CODEX_MODELS_UPSTREAM = "https://chatgpt.com/backend-api/codex/models"; +const OPENAI_UPSTREAM = "https://api.openai.com/v1/responses"; +const OPENAI_MODELS_UPSTREAM = "https://api.openai.com/v1/models"; +const OPENROUTER_UPSTREAM = "https://openrouter.ai/api/v1/responses"; +const OPENROUTER_MODELS_UPSTREAM = "https://openrouter.ai/api/v1/models"; const ALLOWED_REQUEST_HEADERS = [ "accept", "content-encoding", @@ -228,7 +233,7 @@ async function proxyCodexRequestWith( runtime.now, (signal) => dependencies.select({ teamId: identity.teamId, - provider: "codex", + provider: RESPONSES_PROVIDERS, sessionKey, excludedAccountIds: attempted, signal, @@ -308,7 +313,7 @@ async function proxyCodexRequestWith( if (tag === "CodeRouterCredentialBroken") continue; throw error; } - if (credential.provider !== "codex") continue; + if (!servesResponses(credential)) continue; throwIfRequestAborted(request); const headersTimeoutMs = remainingUpstreamHeadersTimeoutMs( upstreamHeaderDeadlineAt, @@ -321,7 +326,7 @@ async function proxyCodexRequestWith( } const upstreamStartedAt = performance.now(); try { - upstream = await sendCodex( + upstream = await sendResponses( request.clone(), forwardedHeaders, credential, @@ -331,7 +336,7 @@ async function proxyCodexRequestWith( recordCoderouterSpan({ name: "upstream_attempt", startedAt: upstreamStartedAt, - attributes: { provider: "codex", attempt: attempt + 1, status: upstream.status }, + attributes: { provider: credential.provider, attempt: attempt + 1, status: upstream.status }, }); } catch (error) { if (request.signal.aborted) throw error; @@ -387,7 +392,7 @@ async function proxyCodexRequestWith( break; } const retryStartedAt = performance.now(); - upstream = await sendCodex( + upstream = await sendResponses( request.clone(), forwardedHeaders, refreshed, @@ -547,7 +552,7 @@ export function createCodexModelsProxy(dependencies: CodexModelsDependencies) { const selectStartedAt = performance.now(); const account = await dependencies.select( identity.teamId, - "codex", + RESPONSES_PROVIDERS, attempted, ); recordCoderouterSpan({ @@ -568,19 +573,13 @@ export function createCodexModelsProxy(dependencies: CodexModelsDependencies) { failureStage = "credential_refresh"; continue; } - if (credential.provider !== "codex") continue; - const upstreamUrl = new URL(CODEX_MODELS_UPSTREAM); - upstreamUrl.search = new URL(request.url).search; + if (!servesResponses(credential)) continue; + const models = modelsRequest(credential, request); const upstreamStartedAt = performance.now(); try { upstream = await dependencies.providerRead(() => - fetch(upstreamUrl, { - headers: { - authorization: `Bearer ${credential.accessToken}`, - "chatgpt-account-id": credential.accountId, - originator: "codex_cli_rs", - "user-agent": request.headers.get("user-agent") ?? "coderouter", - }, + fetch(models.url, { + headers: models.headers, cache: "no-store", signal: AbortSignal.timeout(5_000), }), @@ -622,6 +621,10 @@ export function createCodexModelsProxy(dependencies: CodexModelsDependencies) { ); continue; } + if (upstream.status === 401) { + await retireRejectedCredential(dependencies, identity.teamId, account); + continue; + } break; } if (!upstream) { @@ -669,29 +672,143 @@ export const proxyCodexModels = createCodexModelsProxy({ providerRead: fetchProviderRead, }); -async function sendCodex( +type ResponsesCredential = Extract< + CodeRouterCredential, + { provider: "codex" | "openai-apikey" | "openrouter-apikey" } +>; + +function servesResponses(credential: CodeRouterCredential): credential is ResponsesCredential { + return (RESPONSES_PROVIDERS as readonly string[]).includes(credential.provider); +} + +/** + * Forwards one Responses call to the account's own upstream. Codex sign-ins go + * to the ChatGPT backend with the account header; an OpenAI key goes to the + * public API; an OpenRouter key goes to OpenRouter, whose model catalog is + * vendor-prefixed, so a bare OpenAI model id is rewritten to `openai/`. + */ +async function sendResponses( request: Request, forwardedHeaders: Headers, - credential: { accessToken: string; accountId: string }, + credential: ResponsesCredential, fetchImpl: typeof fetch, headersTimeoutMs: number, ): Promise { const headers = new Headers(forwardedHeaders); - headers.set("authorization", `Bearer ${credential.accessToken}`); - headers.set("chatgpt-account-id", credential.accountId); - headers.set("originator", "coderouter"); + let url = CODEX_UPSTREAM; + let body: BodyInit | null = request.body; + switch (credential.provider) { + case "codex": + headers.set("authorization", `Bearer ${credential.accessToken}`); + headers.set("chatgpt-account-id", credential.accountId); + headers.set("originator", "coderouter"); + break; + case "openai-apikey": + url = OPENAI_UPSTREAM; + headers.set("authorization", `Bearer ${credential.apiKey}`); + headers.delete("session_id"); + break; + case "openrouter-apikey": + url = OPENROUTER_UPSTREAM; + headers.set("authorization", `Bearer ${credential.apiKey}`); + headers.set("http-referer", "https://cmux.com"); + headers.set("x-title", "cmux coderouter"); + headers.delete("session_id"); + headers.delete("openai-beta"); + body = await openRouterBody(request); + break; + } // Bounded to headers only: a hung upstream fails over instead of holding // the function for the full maxDuration; the body streams unbounded. - return await fetchWithHeadersTimeout(fetchImpl, CODEX_UPSTREAM, { + return await fetchWithHeadersTimeout(fetchImpl, url, { method: "POST", headers, - body: request.body, + body, signal: request.signal, duplex: "half", cache: "no-store", } as RequestInit & { duplex: "half" }, headersTimeoutMs); } +/** Rewrites a bare model id to OpenRouter's `openai/`; anything else passes through. */ +async function openRouterBody(request: Request): Promise { + const text = await request.text(); + try { + const parsed: unknown = JSON.parse(text); + if ( + parsed && typeof parsed === "object" && !Array.isArray(parsed) && + typeof (parsed as { model?: unknown }).model === "string" + ) { + return JSON.stringify({ ...parsed, model: openRouterModelId((parsed as { model: string }).model) }); + } + } catch { + // Not JSON: forward as received and let OpenRouter answer. + } + return text; +} + +export function openRouterModelId(model: string): string { + return model.includes("/") ? model : `openai/${model}`; +} + +/** + * A 401 on model discovery: force a refresh so an expired sign-in rotates and + * a rejected API key is marked broken, then let the loop pick another account. + */ +async function retireRejectedCredential( + dependencies: Pick, + teamId: string, + account: { readonly id: string; readonly vaultRevision: number }, +): Promise { + try { + await dependencies.credential({ + teamId, + accountId: account.id, + expectedRevision: account.vaultRevision, + force: true, + }); + } catch { + // Busy or broken: either way this account is not used for this request. + } +} + +function modelsRequest( + credential: ResponsesCredential, + request: Request, +): { readonly url: URL; readonly headers: Record } { + const userAgent = request.headers.get("user-agent") ?? "coderouter"; + switch (credential.provider) { + case "codex": { + const url = new URL(CODEX_MODELS_UPSTREAM); + url.search = new URL(request.url).search; + return { + url, + headers: { + authorization: `Bearer ${credential.accessToken}`, + "chatgpt-account-id": credential.accountId, + originator: "codex_cli_rs", + "user-agent": userAgent, + }, + }; + } + case "openai-apikey": + return { + url: new URL(OPENAI_MODELS_UPSTREAM), + headers: { authorization: `Bearer ${credential.apiKey}`, "user-agent": userAgent }, + }; + case "openrouter-apikey": + return { + url: new URL(OPENROUTER_MODELS_UPSTREAM), + headers: { + authorization: `Bearer ${credential.apiKey}`, + "http-referer": "https://cmux.com", + "x-title": "cmux coderouter", + "user-agent": userAgent, + }, + }; + } +} + function rateLimitDelay(headers: Headers): number { const retryAfter = headers.get("retry-after"); if (retryAfter && /^\d+$/.test(retryAfter)) { diff --git a/web/services/coderouter/encryption.ts b/web/services/coderouter/encryption.ts index 7c1408ff3f32..c29ad039d5ae 100644 --- a/web/services/coderouter/encryption.ts +++ b/web/services/coderouter/encryption.ts @@ -10,9 +10,10 @@ import { KMSClient, } from "@aws-sdk/client-kms"; import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider"; -import type { - CodeRouterCredential, - CodeRouterProvider, +import { + CODEROUTER_PROVIDERS, + type CodeRouterCredential, + type CodeRouterProvider, } from "./types"; const ALGORITHM = "aes-256-gcm" as const; @@ -257,7 +258,7 @@ function assertIdentity(input: { if ( !input.accountId || !input.teamId || - !["codex", "opencode-go"].includes(input.provider) || + !CODEROUTER_PROVIDERS.includes(input.provider) || !Number.isSafeInteger(input.credentialRevision) || input.credentialRevision < 1 ) { @@ -285,6 +286,9 @@ function strictBase64(value: string, label: string): Buffer { function parseCredential(value: unknown): CodeRouterCredential | null { if (!isRecord(value)) return null; + if (value.provider === "openai-apikey" || value.provider === "openrouter-apikey") { + return parseApiKeyCredential(value.provider, value); + } const { accessToken, refreshToken, @@ -333,6 +337,15 @@ function parseCredential(value: unknown): CodeRouterCredential | null { return null; } +function parseApiKeyCredential( + provider: "openai-apikey" | "openrouter-apikey", + value: Record, +): CodeRouterCredential | null { + return string(value.apiKey) && string(value.accountId) && typeof value.label === "string" + ? { provider, apiKey: value.apiKey, accountId: value.accountId, label: value.label } + : null; +} + function string(value: unknown): value is string { return typeof value === "string" && value.length > 0; } diff --git a/web/services/coderouter/refresh.ts b/web/services/coderouter/refresh.ts index 5c5962079169..1ee81b380a0a 100644 --- a/web/services/coderouter/refresh.ts +++ b/web/services/coderouter/refresh.ts @@ -10,7 +10,7 @@ import { encryptCredential, type EncryptedCredential, } from "./encryption"; -import type { CodeRouterCredential } from "./types"; +import { isApiKeyCredential, type ApiKeyCredential, type CodeRouterCredential } from "./types"; import { addCoderouterBreadcrumb, reportCoderouterFailure } from "./observability"; const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -73,6 +73,9 @@ export function createCredentialRefresher( if (!input.signal?.aborted) reportCoderouterFailure("credential_decrypt", error); throw error; } + if (isApiKeyCredential(before.credential)) { + return await settleApiKeyCredential(dependencies, input, before.credential); + } if (!input.force && before.credential.expiresAt > Date.now() + REFRESH_SKEW_MS) { return before.credential; } @@ -97,7 +100,7 @@ export function createCredentialRefresher( throwIfAborted(input.signal); if ( !input.force && - current.credential.expiresAt > Date.now() + REFRESH_SKEW_MS + credentialExpiryMs(current.credential) > Date.now() + REFRESH_SKEW_MS ) { await dependencies.release(input.accountId, leaseId, input.signal); throwIfAborted(input.signal); @@ -153,6 +156,29 @@ export function createCredentialRefresher( }; } +/** + * An API key has nothing to refresh. A forced refresh after a 401 means the + * provider rejected the key itself, so the account is marked broken (visible + * in the dashboard) and the request moves to the next account. + */ +async function settleApiKeyCredential( + dependencies: CredentialRefreshDependencies, + input: FreshCredentialInput, + credential: ApiKeyCredential, +): Promise { + if (!input.force) return credential; + const leaseId = await dependencies.claim(input.accountId, new Date(), input.signal); + if (!leaseId) throw new CodeRouterRefreshBusy("credential refresh already in progress"); + await dependencies.fail(input.accountId, leaseId, true, "api_key_rejected", input.signal) + .catch(() => undefined); + throw new CodeRouterCredentialBroken("provider rejected the API key"); +} + +/** API keys never expire; only a forced refresh reaches the provider for them. */ +function credentialExpiryMs(credential: CodeRouterCredential): number { + return isApiKeyCredential(credential) ? Number.POSITIVE_INFINITY : credential.expiresAt; +} + function currentProvider(credential: CodeRouterCredential): string { return credential.provider; } @@ -185,6 +211,7 @@ export async function refreshProviderCredential( credential: CodeRouterCredential, signal?: AbortSignal, ): Promise { + if (isApiKeyCredential(credential)) return credential; if (credential.provider === "codex") { const token = await postForm("https://auth.openai.com/oauth/token", { grant_type: "refresh_token", diff --git a/web/services/coderouter/repository.ts b/web/services/coderouter/repository.ts index 2b8f6bf3b26c..0091a4c97336 100644 --- a/web/services/coderouter/repository.ts +++ b/web/services/coderouter/repository.ts @@ -10,10 +10,12 @@ import { coderouterVaultLeases, } from "../../db/schema"; import type { EncryptedCredential } from "./encryption"; -import type { - CodeRouterAccountSummary, - CodeRouterCredential, - CodeRouterProvider, +import { + credentialExpiresAt, + credentialLabel, + type CodeRouterAccountSummary, + type CodeRouterCredential, + type CodeRouterProvider, } from "./types"; const ROUTE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000; @@ -325,7 +327,7 @@ export async function insertAccountWithCredential(input: { label, state: "active", vaultRevision: input.encrypted.credentialRevision, - credentialExpiresAt: new Date(input.credential.expiresAt), + credentialExpiresAt: credentialExpiresAt(input.credential), updatedAt: new Date(), }) .onConflictDoNothing({ @@ -369,7 +371,7 @@ export async function replaceAccountCredential(input: { label: credentialLabel(input.credential), state: "active", vaultRevision: input.encrypted.credentialRevision, - credentialExpiresAt: new Date(input.credential.expiresAt), + credentialExpiresAt: credentialExpiresAt(input.credential), refreshLeaseId: null, refreshLeaseExpiresAt: null, lastFailureCode: null, @@ -419,7 +421,7 @@ export async function importEncryptedCredential(input: { .set({ label: credentialLabel(input.credential), vaultRevision: input.encrypted.credentialRevision, - credentialExpiresAt: new Date(input.credential.expiresAt), + credentialExpiresAt: credentialExpiresAt(input.credential), updatedAt: new Date(), }) .where(and( @@ -437,11 +439,7 @@ export async function upsertAccountMetadata(input: { readonly vaultRevision: number; }): Promise { const providerAccountId = input.credential.accountId; - const label = input.credential.email || - (input.credential.provider === "opencode-go" - ? input.credential.orgName - : undefined) || - providerAccountId; + const label = credentialLabel(input.credential); await cloudDb() .insert(coderouterAccounts) .values({ @@ -452,7 +450,7 @@ export async function upsertAccountMetadata(input: { label, state: "active", vaultRevision: input.vaultRevision, - credentialExpiresAt: new Date(input.credential.expiresAt), + credentialExpiresAt: credentialExpiresAt(input.credential), updatedAt: new Date(), }) .onConflictDoUpdate({ @@ -465,7 +463,7 @@ export async function upsertAccountMetadata(input: { label, state: "active", vaultRevision: input.vaultRevision, - credentialExpiresAt: new Date(input.credential.expiresAt), + credentialExpiresAt: credentialExpiresAt(input.credential), lastFailureCode: null, updatedAt: new Date(), }, @@ -495,10 +493,34 @@ export async function findAccountByProviderIdentity( export type RoutedAccount = { id: string; + provider: CodeRouterProvider; vaultRevision: number; credentialExpiresAt: Date | null; }; +/** One provider or a pool of providers that serve the same API surface. */ +export type ProviderPool = CodeRouterProvider | readonly CodeRouterProvider[]; + +function providerList(pool: ProviderPool): readonly CodeRouterProvider[] { + return typeof pool === "string" ? [pool] : pool; +} + +/** + * The provider under which a surface's session bindings are stored: the pool's + * first entry. One row per (team, surface, session) means a session that moves + * from a Codex sign-in to an API key replaces its binding instead of adding a + * second one that could later pull it back and drop its prompt cache. + */ +export function bindingProvider(pool: ProviderPool): CodeRouterProvider { + return providerList(pool)[0]; +} + +function providerMatch(column: ReturnType, pool: ProviderPool) { + const providers = providerList(pool); + if (providers.length === 1) return sql`${column} = ${providers[0]}`; + return sql`${column} in (${sql.join(providers.map((provider) => sql`${provider}`), sql`, `)})`; +} + export type StickyRoutedAccount = RoutedAccount & { /** True when the session's existing account binding was honored. */ sticky: boolean; @@ -538,7 +560,7 @@ async function sweepExpiredRefreshLeases( */ export async function findSessionAccount( teamId: string, - provider: CodeRouterProvider, + provider: ProviderPool, sessionKey: string, excludedAccountIds: readonly string[] = [], signal?: AbortSignal, @@ -571,7 +593,7 @@ export async function findSessionAccount( async function findSessionAccountStatement( teamId: string, - provider: CodeRouterProvider, + provider: ProviderPool, sessionKey: string, excludedAccountIds: readonly string[], signal?: AbortSignal, @@ -581,7 +603,7 @@ async function findSessionAccountStatement( set "last_seen_at" = now() from "coderouter_accounts" as account where binding."team_id" = ${teamId} - and binding."provider" = ${provider} + and binding."provider" = ${bindingProvider(provider)} and binding."session_key" = ${sessionKey} and account."id" = binding."account_id" -- 'refreshing' is a healthy account with a credential refresh in @@ -592,6 +614,7 @@ async function findSessionAccountStatement( ${accountExclusion(sql`account."id"`, excludedAccountIds)} returning account."id" as "id", + account."provider" as "provider", account."vault_revision" as "vaultRevision", account."credential_expires_at" as "credentialExpiresAt" `)); @@ -607,7 +630,7 @@ async function findSessionAccountStatement( */ export async function claimAccountForPlacement( teamId: string, - provider: CodeRouterProvider, + provider: ProviderPool, excludedAccountIds: readonly string[] = [], signal?: AbortSignal, ): Promise { @@ -623,7 +646,7 @@ export async function claimAccountForPlacement( async function claimWithOrdering( teamId: string, - provider: CodeRouterProvider, + provider: ProviderPool, excludedAccountIds: readonly string[], withSessionLoad: boolean, signal?: AbortSignal, @@ -654,7 +677,7 @@ async function claimWithOrdering( async function claimStatement( teamId: string, - provider: CodeRouterProvider, + provider: ProviderPool, excludedAccountIds: readonly string[], skipLocked: boolean, withSessionLoad: boolean, @@ -665,7 +688,7 @@ async function claimStatement( select account."id" from "coderouter_accounts" as account where account."team_id" = ${teamId} - and account."provider" = ${provider} + and ${providerMatch(sql`account."provider"`, provider)} and account."state" = 'active' and (account."cooldown_until" is null or account."cooldown_until" <= now()) ${accountExclusion(sql`account."id"`, excludedAccountIds)} @@ -689,6 +712,7 @@ async function claimStatement( where claimed."id" = candidate."id" returning claimed."id" as "id", + claimed."provider" as "provider", claimed."vault_revision" as "vaultRevision", claimed."credential_expires_at" as "credentialExpiresAt" `)); @@ -773,7 +797,7 @@ export function createSessionAccountSelector( dependencies: SessionAccountSelectorDependencies, ): (input: { teamId: string; - provider: CodeRouterProvider; + provider: ProviderPool; sessionKey: string | null; excludedAccountIds?: readonly string[]; signal?: AbortSignal; @@ -805,7 +829,7 @@ export function createSessionAccountSelector( if (input.sessionKey) { await dependencies.bind( input.teamId, - input.provider, + bindingProvider(input.provider), input.sessionKey, placed.id, input.signal, @@ -830,7 +854,7 @@ export const selectAccountForSession = createSessionAccountSelector({ export async function selectAccountForRequest( teamId: string, - provider: CodeRouterProvider, + provider: ProviderPool, excludedAccountIds: readonly string[] = [], signal?: AbortSignal, ): Promise { @@ -855,6 +879,7 @@ function accountExclusion( function routedAccountRow(row: Record): RoutedAccount { return { id: String(row.id), + provider: String(row.provider) as CodeRouterProvider, vaultRevision: Number(row.vaultRevision), credentialExpiresAt: row.credentialExpiresAt instanceof Date ? row.credentialExpiresAt @@ -939,7 +964,7 @@ export async function completeRefreshLease(input: { .set({ state: "active", vaultRevision: input.encrypted.credentialRevision, - credentialExpiresAt: new Date(input.credential.expiresAt), + credentialExpiresAt: credentialExpiresAt(input.credential), refreshLeaseId: null, refreshLeaseExpiresAt: null, lastFailureCode: null, @@ -1043,12 +1068,6 @@ export async function withVaultLease( } } -function credentialLabel(credential: CodeRouterCredential): string { - return credential.email || - (credential.provider === "opencode-go" ? credential.orgName : undefined) || - credential.accountId; -} - function encryptedValues(encrypted: EncryptedCredential) { return { accountId: encrypted.accountId, diff --git a/web/services/coderouter/types.ts b/web/services/coderouter/types.ts index 390c30485cac..f0b763cdc90c 100644 --- a/web/services/coderouter/types.ts +++ b/web/services/coderouter/types.ts @@ -1,4 +1,34 @@ -export type CodeRouterProvider = "codex" | "opencode-go"; +export type CodeRouterProvider = + | "codex" + | "opencode-go" + | "openai-apikey" + | "openrouter-apikey"; + +/** Every provider a coderouter account row may carry. Mirrors the DB CHECK. */ +export const CODEROUTER_PROVIDERS: readonly CodeRouterProvider[] = [ + "codex", + "opencode-go", + "openai-apikey", + "openrouter-apikey", +]; + +/** Providers whose credentials are OAuth tokens that expire and refresh. */ +export type CodeRouterOAuthProvider = "codex" | "opencode-go"; + +/** Providers whose credential is one long-lived API key. */ +export type CodeRouterApiKeyProvider = "openai-apikey" | "openrouter-apikey"; + +export const CODEROUTER_API_KEY_PROVIDERS: readonly CodeRouterApiKeyProvider[] = [ + "openai-apikey", + "openrouter-apikey", +]; + +/** Every provider that can serve the OpenAI Responses surface (`/v1/responses`, `/v1/models`). */ +export const RESPONSES_PROVIDERS: readonly CodeRouterProvider[] = [ + "codex", + "openai-apikey", + "openrouter-apikey", +]; export type CodexCredential = { readonly provider: "codex"; @@ -21,7 +51,58 @@ export type OpenCodeGoCredential = { readonly expiresAt: number; }; -export type CodeRouterCredential = CodexCredential | OpenCodeGoCredential; +/** + * A pasted OpenAI or OpenRouter API key. `accountId` is a fingerprint of the + * key, so the same key added twice is one account and the row never carries + * the key itself. `label` is what the dashboard shows; the masked key is the + * fallback. API keys have no expiry, so there is no refresh token. + */ +export type ApiKeyCredential = { + readonly provider: CodeRouterApiKeyProvider; + readonly apiKey: string; + readonly accountId: string; + readonly label: string; +}; + +export type OAuthCredential = CodexCredential | OpenCodeGoCredential; + +export type CodeRouterCredential = OAuthCredential | ApiKeyCredential; + +export function isApiKeyCredential( + credential: CodeRouterCredential, +): credential is ApiKeyCredential { + return credential.provider === "openai-apikey" || credential.provider === "openrouter-apikey"; +} + +export function isApiKeyProvider( + provider: CodeRouterProvider, +): provider is CodeRouterApiKeyProvider { + return provider === "openai-apikey" || provider === "openrouter-apikey"; +} + +/** When the stored credential stops working on its own. API keys never do. */ +export function credentialExpiresAt(credential: CodeRouterCredential): Date | null { + return isApiKeyCredential(credential) ? null : new Date(credential.expiresAt); +} + +/** The dashboard name for an account: the sign-in email, org, label, or a masked key. */ +export function credentialLabel(credential: CodeRouterCredential): string { + if (isApiKeyCredential(credential)) { + return credential.label || maskApiKey(credential.apiKey); + } + return credential.email || + (credential.provider === "opencode-go" ? credential.orgName : undefined) || + credential.accountId; +} + +/** `sk-or-v1-…a1b2`: enough to tell keys apart, never enough to use one. */ +export function maskApiKey(apiKey: string): string { + const trimmed = apiKey.trim(); + if (trimmed.length <= 12) return "…"; + const prefixEnd = trimmed.lastIndexOf("-", 12); + const prefix = prefixEnd > 0 ? trimmed.slice(0, prefixEnd + 1) : trimmed.slice(0, 5); + return `${prefix}…${trimmed.slice(-4)}`; +} export type VaultAccount = { readonly revision: number; diff --git a/web/tests/coderouter-accounts.test.tsx b/web/tests/coderouter-accounts.test.tsx index 16dbe2fb47d7..bb7d30e53f95 100644 --- a/web/tests/coderouter-accounts.test.tsx +++ b/web/tests/coderouter-accounts.test.tsx @@ -119,6 +119,8 @@ describe("coderouter accounts section", () => { "Anthropic API key", "Claude Code OAuth", "Amazon Bedrock", + "OpenAI API key", + "OpenRouter API key", "Codex", "OpenCode", ]); diff --git a/web/tests/coderouter-api-key-providers.test.ts b/web/tests/coderouter-api-key-providers.test.ts new file mode 100644 index 000000000000..1e254620cc53 --- /dev/null +++ b/web/tests/coderouter-api-key-providers.test.ts @@ -0,0 +1,358 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { + decryptCredential, + encryptCredential, + type CredentialKeyService, +} from "../services/coderouter/encryption"; +import { bindingProvider, createSessionAccountSelector } from "../services/coderouter/repository"; +import { apiKeyFingerprint, parseCredential } from "../services/coderouter/accounts"; +import { + CodeRouterCredentialBroken, + createCredentialRefresher, + type CredentialRefreshDependencies, +} from "../services/coderouter/refresh"; +import type { EncryptedCredential } from "../services/coderouter/encryption"; +import { + credentialExpiresAt, + credentialLabel, + maskApiKey, + type ApiKeyCredential, + type CodeRouterCredential, +} from "../services/coderouter/types"; + +type UpstreamCall = { url: string; headers: Headers; body: string }; +let upstreamCalls: UpstreamCall[] = []; +let upstreamStatuses: number[] = []; +let accountsToServe: { id: string; credential: CodeRouterCredential }[] = []; +let cooldowns: { accountId: string; durationMs: number }[] = []; +let forcedRefreshes: string[] = []; + +const originalFetch = globalThis.fetch; +beforeAll(() => { + globalThis.fetch = mock(async (...args: unknown[]) => { + const input = args[0] as string | URL | Request; + const init = args[1] as RequestInit | undefined; + const request = input instanceof Request ? input : new Request(input, init); + upstreamCalls.push({ + url: request.url, + headers: request.headers, + body: request.method === "POST" ? await request.text() : "", + }); + const status = upstreamStatuses.shift() ?? 200; + return new Response("data: done\n\n", { + status, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; +}); +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +const { createCodexModelsProxy, createCodexResponsesProxy, openRouterModelId } = await import( + "../services/coderouter/codexProxy" +); + +const openAiKey: ApiKeyCredential = { + provider: "openai-apikey", + apiKey: "sk-proj-0123456789abcdef0123456789abcdef", + accountId: "fp-openai", + label: "team openai", +}; +const openRouterKey: ApiKeyCredential = { + provider: "openrouter-apikey", + apiKey: "sk-or-v1-0123456789abcdef0123456789abcdef", + accountId: "fp-openrouter", + label: "", +}; +const codexSignIn: CodeRouterCredential = { + provider: "codex", + accessToken: "codex-access", + refreshToken: "codex-refresh", + idToken: "codex-id", + accountId: "chatgpt-account", + email: "person@example.com", + expiresAt: Date.now() + 60_000, +}; + +const authenticate = async () => ({ teamId: "team-1", stackUserId: "user-1", vmId: null }); +const credentialFor = () => { + const served = accountsToServe.find((account) => account.id === lastSelected); + if (!served) throw new Error("no credential for account"); + return served.credential; +}; +let lastSelected = ""; +const nextAccount = () => { + const next = accountsToServe[selectIndex++]; + if (!next) return null; + lastSelected = next.id; + return { id: next.id, provider: next.credential.provider, vaultRevision: 1, credentialExpiresAt: null }; +}; +let selectIndex = 0; + +const responses = createCodexResponsesProxy({ + authenticate, + select: async () => { + const account = nextAccount(); + return account ? { ...account, sticky: false } : null; + }, + credential: async (input) => { + const credential = credentialFor(); + if (input.force) { + forcedRefreshes.push(input.accountId); + if (credential.provider === "openai-apikey" || credential.provider === "openrouter-apikey") { + // What the real refresher does: mark the key broken, then throw. + throw new CodeRouterCredentialBroken("provider rejected the API key"); + } + } + return credential; + }, + cooldown: async (accountId, durationMs) => { + cooldowns.push({ accountId, durationMs }); + }, +}); + +const models = createCodexModelsProxy({ + authenticate, + select: async () => nextAccount(), + credential: async (input) => { + const credential = credentialFor(); + if (input.force) { + forcedRefreshes.push(input.accountId); + throw new CodeRouterCredentialBroken("provider rejected the API key"); + } + return credential; + }, + cooldown: async () => {}, + providerRead: async (request) => await request(), +}); + +beforeEach(() => { + upstreamCalls = []; + upstreamStatuses = []; + accountsToServe = []; + cooldowns = []; + forcedRefreshes = []; + selectIndex = 0; + lastSelected = ""; +}); + +function responsesRequest(body: unknown = { model: "gpt-5.3-codex", input: [] }): Request { + return new Request("https://coderouter.dev/v1/responses", { + method: "POST", + headers: { + authorization: "Bearer crt_token", + "content-type": "application/json", + session_id: "session-1", + "openai-beta": "responses=experimental", + }, + body: JSON.stringify(body), + }); +} + +describe("API key credentials", () => { + test("parses a pasted key into a fingerprinted account with no secret in the id", () => { + const parsed = parseCredential({ + provider: "openrouter-apikey", + apiKey: " sk-or-v1-0123456789abcdef0123456789abcdef ", + label: " personal ", + }); + expect(parsed).toEqual({ + provider: "openrouter-apikey", + apiKey: "sk-or-v1-0123456789abcdef0123456789abcdef", + accountId: apiKeyFingerprint("openrouter-apikey", "sk-or-v1-0123456789abcdef0123456789abcdef"), + label: "personal", + }); + expect(parsed?.accountId).not.toContain("sk-or"); + expect(parsed?.accountId).toHaveLength(24); + }); + + test("rejects short, malformed, or mistyped keys", () => { + expect(parseCredential({ provider: "openai-apikey", apiKey: "short" })).toBeNull(); + expect(parseCredential({ provider: "openai-apikey", apiKey: "sk-proj-with spaces inside the key" })).toBeNull(); + expect(parseCredential({ provider: "openai-apikey", apiKey: 42 })).toBeNull(); + expect(parseCredential({ provider: "openai-apikey", apiKey: "sk-proj-0123456789abcdef", label: 1 })).toBeNull(); + expect(parseCredential({ provider: "anthropic-apikey", apiKey: "sk-ant-0123456789abcdef" })).toBeNull(); + }); + + test("labels fall back to a masked key and API keys never expire", () => { + expect(credentialLabel(openAiKey)).toBe("team openai"); + expect(credentialLabel(openRouterKey)).toBe("sk-or-v1-…cdef"); + expect(maskApiKey("sk-proj-0123456789abcdef0123456789abcdef")).toBe("sk-proj-…cdef"); + expect(credentialExpiresAt(openAiKey)).toBeNull(); + expect(credentialExpiresAt(codexSignIn)).toBeInstanceOf(Date); + }); + + test("the refresher returns an API key as-is, and a forced refresh marks the account broken", async () => { + let claims = 0; + let failed: { terminal: boolean; code: string } | null = null; + const envelope: EncryptedCredential = { + accountId: "00000000-0000-4000-8000-000000000001", + teamId: "team-1", + provider: "openai-apikey", + credentialRevision: 1, + algorithm: "aes-256-gcm", + ciphertext: "c", + nonce: "n", + authTag: "t", + encryptedDataKey: "k", + kmsKeyId: "kms", + }; + const dependencies: CredentialRefreshDependencies = { + read: async () => ({ envelope, credential: openAiKey }), + decrypt: async () => openAiKey, + claim: async () => { + claims += 1; + return "lease"; + }, + release: async () => {}, + refresh: async (credential) => credential, + encrypt: async () => envelope, + complete: async () => {}, + fail: async (_accountId, _leaseId, terminal, code) => { + failed = { terminal, code }; + }, + isTerminal: () => false, + failureCode: () => "n/a", + }; + const refresh = createCredentialRefresher(dependencies); + expect(await refresh({ teamId: "team-1", accountId: envelope.accountId, expectedRevision: 1 })).toBe(openAiKey); + expect(claims).toBe(0); + await expect( + refresh({ teamId: "team-1", accountId: envelope.accountId, expectedRevision: 1, force: true }), + ).rejects.toBeInstanceOf(CodeRouterCredentialBroken); + expect(claims).toBe(1); + expect(failed).toEqual({ terminal: true, code: "api_key_rejected" }); + }); +}); + +describe("API key storage and placement", () => { + test("an API key credential round-trips through the KMS envelope", async () => { + const dataKey = randomBytes(32); + const keys: CredentialKeyService = { + async generateDataKey() { + return { plaintext: Buffer.from(dataKey), encrypted: Buffer.from(dataKey) }; + }, + async decryptDataKey() { + return Buffer.from(dataKey); + }, + }; + const encrypted = await encryptCredential({ + accountId: "00000000-0000-4000-8000-000000000002", + teamId: "team-1", + provider: "openrouter-apikey", + credentialRevision: 1, + credential: openRouterKey, + keyId: "test-key", + keys, + }); + expect(JSON.stringify(encrypted)).not.toContain(openRouterKey.apiKey); + expect(await decryptCredential(encrypted, keys)).toEqual(openRouterKey); + }); + + test("a pooled surface keeps one binding row per session under its first provider", async () => { + expect(bindingProvider(["codex", "openai-apikey", "openrouter-apikey"])).toBe("codex"); + expect(bindingProvider("opencode-go")).toBe("opencode-go"); + const bindCalls: unknown[][] = []; + const select = createSessionAccountSelector({ + sweepLeases: async () => {}, + findBound: async () => null, + claim: async () => ({ + id: "acct-or", + provider: "openrouter-apikey" as const, + vaultRevision: 1, + credentialExpiresAt: null, + }), + bind: async (...args: unknown[]) => { + bindCalls.push(args); + }, + }); + await select({ + teamId: "team-1", + provider: ["codex", "openai-apikey", "openrouter-apikey"], + sessionKey: "session-1", + }); + // The placed account is an OpenRouter key, but the row is stored under the + // surface's provider so a later move back to Codex replaces it. + expect(bindCalls).toEqual([["team-1", "codex", "session-1", "acct-or", undefined]]); + }); +}); + +describe("responses routing through API keys", () => { + test("sends an OpenAI key to api.openai.com with a bearer header and no ChatGPT account header", async () => { + accountsToServe = [{ id: "acct-openai", credential: openAiKey }]; + const response = await responses(responsesRequest()); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); + const call = upstreamCalls[0]!; + expect(call.url).toBe("https://api.openai.com/v1/responses"); + expect(call.headers.get("authorization")).toBe(`Bearer ${openAiKey.apiKey}`); + expect(call.headers.get("chatgpt-account-id")).toBeNull(); + expect(call.headers.get("session_id")).toBeNull(); + expect(call.headers.get("openai-beta")).toBe("responses=experimental"); + expect(JSON.parse(call.body)).toEqual({ model: "gpt-5.3-codex", input: [] }); + }); + + test("sends an OpenRouter key to openrouter.ai and vendor-prefixes a bare model id", async () => { + accountsToServe = [{ id: "acct-or", credential: openRouterKey }]; + const response = await responses(responsesRequest({ model: "gpt-5.3-codex", input: [], store: false })); + expect(response.status).toBe(200); + const call = upstreamCalls[0]!; + expect(call.url).toBe("https://openrouter.ai/api/v1/responses"); + expect(call.headers.get("authorization")).toBe(`Bearer ${openRouterKey.apiKey}`); + expect(call.headers.get("x-title")).toBe("cmux coderouter"); + expect(call.headers.get("openai-beta")).toBeNull(); + expect(JSON.parse(call.body)).toEqual({ model: "openai/gpt-5.3-codex", input: [], store: false }); + }); + + test("keeps a vendor-prefixed model id untouched for OpenRouter", () => { + expect(openRouterModelId("anthropic/claude-sonnet-4")).toBe("anthropic/claude-sonnet-4"); + expect(openRouterModelId("gpt-5")).toBe("openai/gpt-5"); + }); + + test("a Codex sign-in in the same pool still goes to the ChatGPT backend", async () => { + accountsToServe = [{ id: "acct-codex", credential: codexSignIn }]; + await responses(responsesRequest()); + const call = upstreamCalls[0]!; + expect(call.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(call.headers.get("chatgpt-account-id")).toBe("chatgpt-account"); + }); + + test("a rejected API key is treated as broken and the request fails over", async () => { + accountsToServe = [ + { id: "acct-or", credential: openRouterKey }, + { id: "acct-codex", credential: codexSignIn }, + ]; + upstreamStatuses = [401, 200]; + const response = await responses(responsesRequest()); + expect(response.status).toBe(200); + expect(forcedRefreshes).toEqual(["acct-or"]); + expect(cooldowns).toEqual([]); + expect(upstreamCalls.map((call) => new URL(call.url).host)).toEqual(["openrouter.ai", "chatgpt.com"]); + }); + + test("model discovery fails over when a key is rejected", async () => { + accountsToServe = [ + { id: "acct-or", credential: openRouterKey }, + { id: "acct-openai", credential: openAiKey }, + ]; + upstreamStatuses = [401, 200]; + const listed = await models(new Request("https://coderouter.dev/v1/models", { + headers: { authorization: "Bearer crt_route" }, + })); + expect(listed.status).toBe(200); + expect(forcedRefreshes).toEqual(["acct-or"]); + expect(upstreamCalls.map((call) => new URL(call.url).host)).toEqual(["openrouter.ai", "api.openai.com"]); + }); + + test("model discovery uses each provider's own catalog", async () => { + accountsToServe = [{ id: "acct-or", credential: openRouterKey }]; + const listed = await models(new Request("https://coderouter.dev/v1/models?client_version=1", { + headers: { authorization: "Bearer crt_route" }, + })); + expect(listed.status).toBe(200); + expect(upstreamCalls[0]!.url).toBe("https://openrouter.ai/api/v1/models"); + expect(upstreamCalls[0]!.headers.get("authorization")).toBe(`Bearer ${openRouterKey.apiKey}`); + }); +}); diff --git a/web/tests/coderouter-models-proxy.test.ts b/web/tests/coderouter-models-proxy.test.ts index 6e9637fe4708..c0735c73a17b 100644 --- a/web/tests/coderouter-models-proxy.test.ts +++ b/web/tests/coderouter-models-proxy.test.ts @@ -32,7 +32,7 @@ const proxyCodexModels = createCodexModelsProxy({ select: async () => { const id = selectedAccounts.shift(); return id - ? { id, vaultRevision: 1, credentialExpiresAt: new Date() } + ? { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: new Date() } : null; }, credential: async ({ accountId }) => { diff --git a/web/tests/coderouter-opencode-proxy.test.ts b/web/tests/coderouter-opencode-proxy.test.ts index 42211bba3bb7..ba826ec320a0 100644 --- a/web/tests/coderouter-opencode-proxy.test.ts +++ b/web/tests/coderouter-opencode-proxy.test.ts @@ -76,7 +76,7 @@ describe("coderouter OpenCode Go proxy", () => { selected.push(...(excluded ?? [])); const id = ids.shift(); return id - ? { id, vaultRevision: 1, credentialExpiresAt: new Date() } + ? { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: new Date() } : null; }, credential: async ({ accountId }) => { @@ -119,6 +119,7 @@ describe("coderouter OpenCode Go proxy VM-bound route tokens", () => { }, select: async () => ({ id: "acct-1", + provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: new Date(), }), diff --git a/web/tests/coderouter-refresh.test.ts b/web/tests/coderouter-refresh.test.ts index 254da6f21c3d..bd0bbb38f5cf 100644 --- a/web/tests/coderouter-refresh.test.ts +++ b/web/tests/coderouter-refresh.test.ts @@ -60,7 +60,7 @@ describe("coderouter credential refresh coordination", () => { await didClaim; await expect(refresh(input())).rejects.toBeInstanceOf(CodeRouterRefreshBusy); releaseProvider(); - expect((await first).refreshToken).toBe("new-refresh"); + expect(((await first) as CodexCredential).refreshToken).toBe("new-refresh"); }); test("an abandoned lease becomes claimable after expiry", async () => { @@ -76,7 +76,7 @@ describe("coderouter credential refresh coordination", () => { const refresh = createCredentialRefresher(dependencies); await expect(refresh(input())).rejects.toBeInstanceOf(CodeRouterRefreshBusy); now = 1_001; - expect((await refresh(input())).accessToken).toBe("new-access"); + expect(((await refresh(input())) as CodexCredential).accessToken).toBe("new-access"); }); test("persists a rotated provider refresh token at the next revision", async () => { @@ -88,9 +88,9 @@ describe("coderouter credential refresh coordination", () => { completed = value; }, })); - const result = await refresh(input()); + const result = (await refresh(input())) as CodexCredential; expect(result.refreshToken).toBe("new-refresh"); - expect(completed?.credential.refreshToken).toBe("new-refresh"); + expect((completed?.credential as CodexCredential | undefined)?.refreshToken).toBe("new-refresh"); expect(completed?.encrypted.credentialRevision).toBe(2); }); diff --git a/web/tests/coderouter-responses-proxy.test.ts b/web/tests/coderouter-responses-proxy.test.ts index 6f8439297fdc..b7ff29ffd6cd 100644 --- a/web/tests/coderouter-responses-proxy.test.ts +++ b/web/tests/coderouter-responses-proxy.test.ts @@ -4,7 +4,7 @@ import { VM_PLACEHOLDER_API_KEY } from "../services/coderouter/routeTokenAuth"; type SelectInput = { teamId: string; - provider: string; + provider: string | readonly string[]; sessionKey: string | null; excludedAccountIds?: readonly string[]; signal?: AbortSignal; @@ -53,6 +53,7 @@ const proxy = createCodexResponsesProxy({ return next ? { id: next.id, + provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: next.sticky, @@ -113,7 +114,8 @@ describe("codex responses proxy session routing", () => { expect(selectInputs).toHaveLength(1); expect(selectInputs[0]?.sessionKey).toBe("session-abc"); expect(selectInputs[0]?.teamId).toBe("team-1"); - expect(selectInputs[0]?.provider).toBe("codex"); + // The Responses surface pools Codex sign-ins with OpenAI and OpenRouter keys. + expect(selectInputs[0]?.provider).toEqual(["codex", "openai-apikey", "openrouter-apikey"]); }); test("selects without a session key when the header is missing", async () => { @@ -163,7 +165,7 @@ describe("codex responses proxy session routing", () => { select: async () => { const id = `acct-${selected.length + 1}`; selected.push(id); - return { id, vaultRevision: 1, credentialExpiresAt: null, sticky: false }; + return { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: false }; }, credential: async ({ accountId }) => ({ provider: "codex" as const, @@ -227,7 +229,7 @@ describe("codex responses proxy session routing", () => { stackUserId: "stack-user-1", vmId: null, }), - select: async () => ({ id: "acct-1", vaultRevision: 1, credentialExpiresAt: null, sticky: false }), + select: async () => ({ id: "acct-1", provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: false }), credential: async (input) => { credentialSignal = (input as typeof input & { signal?: AbortSignal }).signal; return await new Promise(() => undefined); @@ -264,7 +266,7 @@ describe("codex responses proxy session routing", () => { select: async () => { const id = `acct-${selected.length + 1}`; selected.push(id); - return { id, vaultRevision: 1, credentialExpiresAt: null, sticky: false }; + return { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: false }; }, credential: async ({ accountId }) => ({ provider: "codex" as const, @@ -329,7 +331,7 @@ describe("codex models proxy outcomes", () => { select: async () => { if (selected) return null; selected = true; - return { id: "acct-1", vaultRevision: 1, credentialExpiresAt: null }; + return { id: "acct-1", provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null }; }, credential: async () => ({ provider: "codex" as const, diff --git a/web/tests/coderouter-session-selector.test.ts b/web/tests/coderouter-session-selector.test.ts index 9f4b5014a744..8e46b631234e 100644 --- a/web/tests/coderouter-session-selector.test.ts +++ b/web/tests/coderouter-session-selector.test.ts @@ -5,8 +5,8 @@ import { createSessionAccountSelector } from "../services/coderouter/repository" type Call = { fn: string; args: unknown[] }; function makeDependencies(options: { - bound?: { id: string; vaultRevision: number; credentialExpiresAt: Date | null } | null; - placed?: { id: string; vaultRevision: number; credentialExpiresAt: Date | null } | null; + bound?: { id: string; provider: "codex"; vaultRevision: number; credentialExpiresAt: Date | null } | null; + placed?: { id: string; provider: "codex"; vaultRevision: number; credentialExpiresAt: Date | null } | null; }) { const calls: Call[] = []; return { @@ -32,6 +32,7 @@ function makeDependencies(options: { const account = (id: string) => ({ id, + provider: "codex" as const, vaultRevision: 3, credentialExpiresAt: null, });