From 9746b206db3d45ed990ac8ade838369cc67e502f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:06:44 -0300 Subject: [PATCH] fix(providers/test): probe real Codex /responses endpoint (#4551) Integrated into release/v3.8.33 --- config/quality/file-size-baseline.json | 2 +- src/app/api/providers/[id]/test/route.ts | 50 +++++-- ...uth-connection-test-codex-endpoint.test.ts | 130 ++++++++++++++++++ 3 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 tests/unit/oauth-connection-test-codex-endpoint.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 26ff20d8d4..9da7070a14 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -176,7 +176,7 @@ "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1121, "src/app/api/oauth/[provider]/[action]/route.ts": 918, "src/app/api/providers/[id]/models/route.ts": 2590, - "src/app/api/providers/[id]/test/route.ts": 887, + "src/app/api/providers/[id]/test/route.ts": 917, "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1493, "src/lib/cloudflaredTunnel.ts": 934, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 7acdd77614..da59e883a1 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -37,10 +37,26 @@ const OAUTH_TEST_CONFIG = { refreshable: true, }, codex: { - // Codex OAuth tokens are ChatGPT session tokens, NOT standard OpenAI API keys. - // They don't work with api.openai.com/v1/models (returns 403 "Access denied"). - // Use checkExpiry mode instead — actual connectivity is validated via Usage/Limits. - checkExpiry: true, + // Port of decolua/9router#347: probe the real Codex /responses endpoint instead + // of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens + // (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403. + // Hitting the actual endpoint with a minimal invalid body returns 400 when + // auth is accepted (the body is the reason for the failure) and 401/403 when + // the token is bad. That is a real auth signal — checkExpiry alone could not + // distinguish a revoked-but-not-yet-expired token from a working one. + url: "https://chatgpt.com/backend-api/codex/responses", + method: "POST", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { + "Content-Type": "application/json", + originator: "codex-cli", + "User-Agent": "codex-cli/1.0.18 (macOS; arm64)", + }, + // Minimal invalid body — triggers a fast 400 without consuming quota. + body: JSON.stringify({ model: "gpt-5.3-codex", input: [], stream: false, store: false }), + // 400 = bad request, but auth was accepted; only 401/403 means the token is bad. + acceptStatuses: [400], refreshable: true, }, "gemini-cli": { @@ -524,13 +540,22 @@ export async function testOAuthConnection( }; const url = typeof config.getUrl === "function" ? config.getUrl(connection) : config.url; - const res = await fetch(url, { + const fetchInit: RequestInit = { method: config.method, headers, signal: AbortSignal.timeout(timeoutMs), - }); + }; + // Port of decolua/9router#347: providers like Codex must send a body so the + // upstream returns 400 (auth ok) instead of 405/415. + if (config.body) fetchInit.body = config.body; + const res = await fetch(url, fetchInit); - if (res.ok) { + // Port of decolua/9router#347: some providers (Codex) intentionally trigger a + // 400 because the probe body is invalid. A 400 from such a provider means auth + // succeeded; only 401/403 means the token is bad. + const accepted = + res.ok || (Array.isArray(config.acceptStatuses) && config.acceptStatuses.includes(res.status)); + if (accepted) { return { valid: true, error: null, @@ -566,16 +591,21 @@ export async function testOAuthConnection( const tokens = await refreshOAuthToken(connection); if (tokens) { // Retry with new token - const retryRes = await fetch(url, { + const retryInit: RequestInit = { method: config.method, headers: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders, }, signal: AbortSignal.timeout(timeoutMs), - }); + }; + if (config.body) retryInit.body = config.body; + const retryRes = await fetch(url, retryInit); - if (retryRes.ok) { + const retryAccepted = + retryRes.ok || + (Array.isArray(config.acceptStatuses) && config.acceptStatuses.includes(retryRes.status)); + if (retryAccepted) { return { valid: true, error: null, diff --git a/tests/unit/oauth-connection-test-codex-endpoint.test.ts b/tests/unit/oauth-connection-test-codex-endpoint.test.ts new file mode 100644 index 0000000000..e3d955d30a --- /dev/null +++ b/tests/unit/oauth-connection-test-codex-endpoint.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { testOAuthConnection } from "../../src/app/api/providers/[id]/test/route"; + +// Port of decolua/9router#347 (author: Ibrahim Ryan). +// +// Prior to this fix, the Codex OAuth test only validated `checkExpiry: true` — i.e. +// it inspected the local token's `expiresAt` and returned valid=true if the +// timestamp wasn't in the past. A token that the server has already revoked or that +// belongs to a deactivated account would still report as valid. The new test +// actually probes ChatGPT's `/backend-api/codex/responses` endpoint with a minimal +// invalid body. The endpoint returns 400 (bad request) when auth is accepted and +// 401/403 when the token is bad — exactly the signal the test should be using. +// +// Important OmniRoute-specific constraint: codex is a `rotating` provider (shares +// an Auth0 family with openai — see `rotationGroupFor`). The probe path must NOT +// burn a single-use refresh_token from a connection test (precedent: openai/codex +// #9648, see comment in route.ts above the rotating-provider guard). The probe +// only validates the access token as-is. + +const CODEX_TEST_URL = "https://chatgpt.com/backend-api/codex/responses"; + +function futureExpiresAt(): string { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + +function mockFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fn = (async (url: RequestInfo | URL, init?: RequestInit) => { + const u = typeof url === "string" ? url : url instanceof URL ? url.toString() : String(url); + calls.push({ url: u, init }); + return handler(u, init); + }) as typeof fetch; + return { fn, calls }; +} + +test("codex test probes the real /responses endpoint and treats 400 as 'auth ok' (port PR#347)", async (t) => { + const original = globalThis.fetch; + const { fn, calls } = mockFetch( + () => + new Response(JSON.stringify({ error: { message: "Bad request" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + ); + globalThis.fetch = fn; + t.after(() => { + globalThis.fetch = original; + }); + + const result = await testOAuthConnection( + { + provider: "codex", + authType: "oauth", + accessToken: "fake-codex-token", + refreshToken: "fake-refresh", + expiresAt: futureExpiresAt(), + }, + 5000 + ); + + assert.equal(result.valid, true, "400 from the real endpoint must be treated as auth ok"); + assert.equal(calls.length, 1, "exactly one upstream probe"); + assert.equal(calls[0].url, CODEX_TEST_URL, "must probe the actual codex /responses endpoint"); + assert.equal(calls[0].init?.method, "POST"); + const headers = (calls[0].init?.headers ?? {}) as Record; + assert.equal(headers.Authorization, "Bearer fake-codex-token"); + assert.ok(calls[0].init?.body, "must send a minimal body so the endpoint returns 400 (not 405)"); +}); + +test("codex test reports invalid when the endpoint returns 401 (port PR#347)", async (t) => { + const original = globalThis.fetch; + const { fn, calls } = mockFetch( + () => + new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + ); + globalThis.fetch = fn; + t.after(() => { + globalThis.fetch = original; + }); + + const result = await testOAuthConnection( + { + provider: "codex", + authType: "oauth", + accessToken: "revoked-token", + // Token NOT expired locally — that's the whole point: the local check would + // have lied; the real endpoint surfaces the revocation. + refreshToken: "fake-refresh", + expiresAt: futureExpiresAt(), + }, + 5000 + ); + + assert.equal(result.valid, false, "401 from the real endpoint must be reported as invalid"); + assert.equal(calls.length, 1, "must NOT burn the refresh_token from a connection test (codex is a rotating provider — openai/codex#9648)"); +}); + +test("codex test reports invalid when the endpoint returns 403 (port PR#347)", async (t) => { + const original = globalThis.fetch; + const { fn } = mockFetch( + () => + new Response("Forbidden", { + status: 403, + headers: { "content-type": "text/plain" }, + }) + ); + globalThis.fetch = fn; + t.after(() => { + globalThis.fetch = original; + }); + + const result = await testOAuthConnection( + { + provider: "codex", + authType: "oauth", + accessToken: "fake-token", + refreshToken: "fake-refresh", + expiresAt: futureExpiresAt(), + }, + 5000 + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 403); +});