diff --git a/web/app/v1/models/route.ts b/web/app/v1/models/route.ts new file mode 100644 index 000000000000..31c1c14b5763 --- /dev/null +++ b/web/app/v1/models/route.ts @@ -0,0 +1,8 @@ +import { proxyCodexModels } from "@/services/coderouter/codexProxy"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +export async function GET(request: Request): Promise { + return await proxyCodexModels(request); +} diff --git a/web/services/coderouter/codexProxy.ts b/web/services/coderouter/codexProxy.ts index bccbfd0ee937..a5ef1d8fcd5f 100644 --- a/web/services/coderouter/codexProxy.ts +++ b/web/services/coderouter/codexProxy.ts @@ -6,6 +6,7 @@ import { import { freshCredential } from "./refresh"; const CODEX_UPSTREAM = "https://chatgpt.com/backend-api/codex/responses"; +const CODEX_MODELS_UPSTREAM = "https://chatgpt.com/backend-api/codex/models"; const ALLOWED_REQUEST_HEADERS = [ "accept", "content-type", @@ -94,6 +95,60 @@ export async function proxyCodexRequest(request: Request): Promise { }); } +export async function proxyCodexModels(request: Request): Promise { + const token = bearerToken(request); + if (!token) return jsonError("unauthorized", 401); + const identity = await authenticateRouteToken(token); + if (!identity) return jsonError("unauthorized", 401); + + const attempted: string[] = []; + let upstream: Response | null = null; + for (let attempt = 0; attempt < 8; attempt++) { + const account = await selectAccountForRequest( + identity.teamId, + "codex", + attempted, + ); + if (!account) break; + attempted.push(account.id); + let credential; + try { + credential = await freshCredential({ + teamId: identity.teamId, + accountId: account.id, + expectedRevision: account.vaultRevision, + }); + } catch { + continue; + } + if (credential.provider !== "codex") continue; + const upstreamUrl = new URL(CODEX_MODELS_UPSTREAM); + upstreamUrl.search = new URL(request.url).search; + upstream = await fetch(upstreamUrl, { + headers: { + authorization: `Bearer ${credential.accessToken}`, + "chatgpt-account-id": credential.accountId, + originator: "codex_cli_rs", + "user-agent": request.headers.get("user-agent") ?? "coderouter", + }, + cache: "no-store", + }); + if (upstream.status === 429) { + await markAccountCooldown(account.id, rateLimitDelay(upstream.headers)); + continue; + } + break; + } + if (!upstream) return jsonError("no_usable_account", 503); + return new Response(upstream.body, { + status: upstream.status, + headers: { + "cache-control": "no-store", + "content-type": upstream.headers.get("content-type") ?? "application/json", + }, + }); +} + async function sendCodex( request: Request, forwardedHeaders: Headers, diff --git a/web/tests/coderouter-models-proxy.test.ts b/web/tests/coderouter-models-proxy.test.ts new file mode 100644 index 000000000000..79a6c0cc6334 --- /dev/null +++ b/web/tests/coderouter-models-proxy.test.ts @@ -0,0 +1,58 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; + +mock.module("../services/coderouter/repository", () => ({ + authenticateRouteToken: async () => ({ + teamId: "team-1", + routeTokenId: "route-1", + }), + selectAccountForRequest: async () => ({ + id: "account-1", + vaultRevision: 1, + }), + markAccountCooldown: async () => {}, + listAccounts: async () => [], + findAccountByProviderIdentity: async () => null, + upsertAccountMetadata: async () => {}, + withVaultLease: async (_teamId: string, operation: () => unknown) => + await operation(), +})); +mock.module("../services/coderouter/refresh", () => ({ + freshCredential: async () => ({ + provider: "codex", + accessToken: "provider-access", + accountId: "chatgpt-account", + }), +})); + +const originalFetch = globalThis.fetch; +let upstreamUrl = ""; +beforeAll(() => { + globalThis.fetch = mock(async (...args: unknown[]) => { + const input = args[0] as string | URL | Request; + upstreamUrl = String(input); + return Response.json({ models: [{ slug: "gpt-test" }] }); + }) as typeof fetch; +}); +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +const { proxyCodexModels } = await import("../services/coderouter/codexProxy"); + +describe("CodeRouter models proxy", () => { + test("forwards Codex model discovery through the authenticated account", async () => { + const response = await proxyCodexModels( + new Request("https://coderouter.dev/v1/models?client_version=0.146.0", { + headers: { authorization: "Bearer crt_route" }, + }), + ); + + expect(response.status).toBe(200); + expect(upstreamUrl).toBe( + "https://chatgpt.com/backend-api/codex/models?client_version=0.146.0", + ); + expect(await response.json()).toEqual({ + models: [{ slug: "gpt-test" }], + }); + }); +});