mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(providers/test): probe real Codex /responses endpoint (#4551)
Integrated into release/v3.8.33
This commit is contained in:
committed by
GitHub
parent
20099f7ff2
commit
9746b206db
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
130
tests/unit/oauth-connection-test-codex-endpoint.test.ts
Normal file
130
tests/unit/oauth-connection-test-codex-endpoint.test.ts
Normal file
@@ -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<string, string>;
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user