From 1dfd4f9952db4b2833bdab23455f5c8c869eca2b Mon Sep 17 00:00:00 2001 From: Sutarto Jordan Chrisfivo Date: Mon, 7 Sep 2026 10:54:47 +0000 Subject: [PATCH 1/4] fix(codex): align image client version with model discovery --- open-sse/config/codexConstants.js | 3 +++ open-sse/handlers/imageProviders/codex.js | 7 +++---- src/app/api/providers/[id]/models/route.js | 2 +- tests/unit/image-generation.test.js | 3 ++- 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 open-sse/config/codexConstants.js diff --git a/open-sse/config/codexConstants.js b/open-sse/config/codexConstants.js new file mode 100644 index 00000000000..b66322116a8 --- /dev/null +++ b/open-sse/config/codexConstants.js @@ -0,0 +1,3 @@ +// Keep model discovery and image requests on the same client version. GPT-5.6 +// models require >= 0.144.0 in openai/codex's models-manager/models.json. +export const CODEX_CLIENT_VERSION = "0.144.6"; diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 218302abf8c..b0b769b8e8b 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -2,10 +2,9 @@ import { randomUUID } from "node:crypto"; import { nowSec } from "./_base.js"; import { PROVIDERS } from "../../config/providers.js"; +import { CODEX_CLIENT_VERSION } from "../../config/codexConstants.js"; const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl; -const CODEX_USER_AGENT = "codex_cli_rs/0.136.0"; -const CODEX_VERSION = "0.136.0"; const CODEX_ORIGINATOR = "codex_cli_rs"; const CODEX_MODEL_SUFFIX = "-image"; const CODEX_REF_DETAIL = "high"; @@ -156,8 +155,8 @@ export default { "content-type": "application/json", "originator": CODEX_ORIGINATOR, "session_id": randomUUID(), - "user-agent": CODEX_USER_AGENT, - "version": CODEX_VERSION, + "user-agent": `${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION}`, + "version": CODEX_CLIENT_VERSION, "x-client-request-id": randomUUID(), }; }, diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index d73ec11dfac..05c0d90d7bc 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -11,6 +11,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { CODEX_CLIENT_VERSION } from "open-sse/config/codexConstants.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -18,7 +19,6 @@ const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fe // value, and codex CLI's own manifest (openai/codex codex-rs/models-manager/models.json) // already requires 0.144.0 for its newest models, so a stale client_version here comes // back 200 with those entries quietly missing instead of erroring. -const CODEX_CLIENT_VERSION = "0.144.6"; const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`; const parseOpenAIStyleModels = (data) => { diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index 12dce95d66c..ba12b2116f0 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -351,7 +351,8 @@ describe("handleImageGenerationCore", () => { headers: expect.objectContaining({ authorization: "Bearer codex-token", "chatgpt-account-id": "account-123", - version: "0.136.0", + version: "0.144.6", + "user-agent": "codex_cli_rs/0.144.6", }), }) ); From a12d3664667c876fe78b34915ca79186ae18bce8 Mon Sep 17 00:00:00 2001 From: anndev-69 <264959754+anndev-69@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:54:47 +0700 Subject: [PATCH 2/4] fix(codex): keep chat and connection probes aligned with discovery --- CHANGELOG.md | 5 + open-sse/config/codexConstants.js | 3 +- open-sse/handlers/imageProviders/codex.js | 2 +- open-sse/providers/registry/codex.js | 4 +- src/app/api/providers/[id]/models/route.js | 4 +- src/app/api/providers/[id]/test/testUtils.js | 2 +- tests/__baseline__/providers-baseline.json | 3 +- tests/unit/codex-client-identity.test.js | 107 +++++++++++++++++++ 8 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 tests/unit/codex-client-identity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b3010c9e7..5d775ddfce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased + +## Fixes +- **Codex**: share the existing discovery client version across chat, image generation and connection probes, preventing GPT-5.6 requests from advertising an older client than the model catalog. Updating the gateway is required; upgrading a caller's local Codex CLI does not change these outbound headers. + # v0.5.69 (2026-09-05) ## Features diff --git a/open-sse/config/codexConstants.js b/open-sse/config/codexConstants.js index b66322116a8..84435c28001 100644 --- a/open-sse/config/codexConstants.js +++ b/open-sse/config/codexConstants.js @@ -1,3 +1,4 @@ -// Keep model discovery and image requests on the same client version. GPT-5.6 +// Keep discovery, chat, images and connection probes on the same client version. GPT-5.6 // models require >= 0.144.0 in openai/codex's models-manager/models.json. export const CODEX_CLIENT_VERSION = "0.144.6"; +export const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLIENT_VERSION}`; diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index b0b769b8e8b..e5e79302e02 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { nowSec } from "./_base.js"; import { PROVIDERS } from "../../config/providers.js"; -import { CODEX_CLIENT_VERSION } from "../../config/codexConstants.js"; +import { CODEX_CLIENT_VERSION, CODEX_USER_AGENT } from "../../config/codexConstants.js"; const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl; const CODEX_ORIGINATOR = "codex_cli_rs"; diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 1909fe977ba..2af55eaf2be 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -1,3 +1,4 @@ +import { CODEX_CLIENT_VERSION, CODEX_USER_AGENT } from "../../config/codexConstants.js"; import { withCodexReviewModels } from "../models/helpers.js"; export default { @@ -36,7 +37,8 @@ export default { forceStream: true, headers: { originator: "codex_cli_rs", - "User-Agent": "codex_cli_rs/0.136.0", + "User-Agent": CODEX_USER_AGENT, + version: CODEX_CLIENT_VERSION, }, usage: { url: "https://chatgpt.com/backend-api/wham/usage", diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 05c0d90d7bc..4f5f39b30a5 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -3,7 +3,7 @@ import { getProviderConnectionById } from "@/models"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth"; import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; -import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; +import { resolveOllamaLocalHost, PROVIDERS } from "open-sse/config/providers.js"; import { getModelsByProviderId } from "open-sse/config/providerModels.js"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; @@ -153,7 +153,7 @@ const PROVIDER_MODELS_CONFIG = { "Content-Type": "application/json", "Accept": "application/json", "Authorization": `Bearer ${token}`, - "originator": "codex_cli_rs" + ...PROVIDERS.codex.headers } }), parseFn: parseCodexModels, diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index bd0c4782aee..91d1618d798 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -27,7 +27,7 @@ const OAUTH_TEST_CONFIG = { method: "POST", authHeader: "Authorization", authPrefix: "Bearer ", - extraHeaders: { "Content-Type": "application/json", "originator": "codex_cli_rs", "User-Agent": "codex_cli_rs/0.136.0" }, + extraHeaders: { "Content-Type": "application/json", ...PROVIDERS.codex.headers }, // Minimal invalid body — triggers fast 400 without consuming quota body: JSON.stringify({ model: "gpt-5.3-codex", input: [], stream: false, store: false }), // 400 (bad request) means auth succeeded; only 401/403 means token is bad diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index db0b8badc77..1c8adc0956d 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -194,7 +194,8 @@ "forceStream": true, "headers": { "originator": "codex_cli_rs", - "User-Agent": "codex_cli_rs/0.136.0" + "User-Agent": "codex_cli_rs/0.144.6", + "version": "0.144.6" }, "usage": { "url": "https://chatgpt.com/backend-api/wham/usage", diff --git a/tests/unit/codex-client-identity.test.js b/tests/unit/codex-client-identity.test.js new file mode 100644 index 00000000000..1fc2876f8dd --- /dev/null +++ b/tests/unit/codex-client-identity.test.js @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CODEX_CLIENT_VERSION } from "../../open-sse/config/codexConstants.js"; +import { CodexExecutor } from "../../open-sse/executors/codex.js"; +import imageProvider from "../../open-sse/handlers/imageProviders/codex.js"; +import * as proxyFetch from "../../open-sse/utils/proxyFetch.js"; + +const mocks = vi.hoisted(() => ({ + connection: vi.fn(), + update: vi.fn(), +})); +vi.mock("@/models", () => ({ getProviderConnectionById: mocks.connection })); +vi.mock("@/lib/localDb", () => ({ + getProviderConnectionById: mocks.connection, + updateProviderConnection: mocks.update, +})); +vi.mock("@/lib/network/connectionProxy", () => ({ resolveConnectionProxyConfig: async () => ({}) })); +vi.mock("@/sse/services/tokenRefresh", () => ({ + refreshCodexToken: vi.fn(), refreshGoogleToken: vi.fn(), updateProviderCredentials: vi.fn(), +})); + +const credentials = { + accessToken: "test-token", + connectionId: "test-connection", + providerSpecificData: { chatgptAccountId: "test-account" }, +}; + +function expectIdentity(rawHeaders) { + const headers = new Headers(rawHeaders); + expect(headers.get("version")).toBe(CODEX_CLIENT_VERSION); + expect(headers.get("user-agent")).toBe(`codex_cli_rs/${CODEX_CLIENT_VERSION}`); + expect(headers.get("originator")).toBe("codex_cli_rs"); +} + +describe("Codex identity across discovery and requests", () => { + beforeEach(() => { + mocks.connection.mockResolvedValue({ + id: "test-connection", provider: "codex", authType: "oauth", + ...credentials, expiresAt: "2099-01-01T00:00:00Z", + }); + mocks.update.mockResolvedValue(undefined); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("advertises at least the GPT-5.6 manifest's minimum version", () => { + // Pinned upstream manifest: openai/codex@0df39752, models-manager/models.json. + const [major, minor] = CODEX_CLIENT_VERSION.split(".").map(Number); + expect(major > 0 || minor >= 144).toBe(true); + }); + + it.each([true, false])("uses the same identity for chat and image (stream=%s)", (stream) => { + const chatHeaders = new CodexExecutor().buildHeaders(credentials, stream); + const imageHeaders = imageProvider.buildHeaders(credentials); + expectIdentity(chatHeaders); + expectIdentity(imageHeaders); + for (const raw of [chatHeaders, imageHeaders]) { + const headers = new Headers(raw); + expect(headers.get("authorization")).toBe("Bearer test-token"); + expect(headers.get("chatgpt-account-id")).toBe("test-account"); + expect(headers.get("session_id")).toBeTruthy(); + } + }); + + it("sends matching identity headers with the discovery client_version", async () => { + const fetch = vi.fn(async () => Response.json({ models: [{ slug: "gpt-5.6-luna" }] })); + vi.stubGlobal("fetch", fetch); + const { GET } = await import("../../src/app/api/providers/[id]/models/route.js"); + const response = await GET(new Request("http://localhost/api/providers/test-connection/models"), { + params: Promise.resolve({ id: "test-connection" }), + }); + expect(response.status).toBe(200); + expect((await response.json()).models.some((model) => model.id === "gpt-5.6-luna")).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, options] = fetch.mock.calls[0]; + expect(new URL(url).searchParams.get("client_version")).toBe(CODEX_CLIENT_VERSION); + expectIdentity(options.headers); + expect(new Headers(options.headers).get("authorization")).toBe("Bearer test-token"); + }); + + it("tests credentials with the same identity as generation", async () => { + const fetch = vi.fn(async () => Response.json({ detail: "Missing input" }, { status: 400 })); + vi.stubGlobal("fetch", fetch); + const { testSingleConnection } = await import("../../src/app/api/providers/[id]/test/testUtils.js"); + expect((await testSingleConnection("test-connection")).valid).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + expectIdentity(fetch.mock.calls[0][1].headers); + }); + + it.each(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"])("passes a version-gated upstream without rewriting %s", async (model) => { + const fetch = vi.spyOn(proxyFetch, "proxyAwareFetch").mockImplementation(async (_url, options) => { + const headers = new Headers(options.headers); + if (headers.get("version") !== CODEX_CLIENT_VERSION || + headers.get("user-agent") !== `codex_cli_rs/${CODEX_CLIENT_VERSION}`) { + return Response.json({ detail: `The '${model}' model requires a newer version of Codex.` }, { status: 400 }); + } + return Response.json({ id: "response-test", output: [] }); + }); + const result = await new CodexExecutor().execute({ + model, body: { model, input: "hello" }, stream: true, credentials, + }); + expect(result.response.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(fetch.mock.calls[0][1].body).model).toBe(model); + }); +}); From 975d9582e525211251600011012fd9f761ee5c06 Mon Sep 17 00:00:00 2001 From: anndev-69 <264959754+anndev-69@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:50:55 +0700 Subject: [PATCH 3/4] fix(codex): preserve upstream image stream failures instead of guessing entitlement --- open-sse/config/codexConstants.js | 3 ++ open-sse/handlers/imageProviders/codex.js | 31 +++++++++++++--- tests/unit/codex-image-stream-errors.test.js | 38 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 tests/unit/codex-image-stream-errors.test.js diff --git a/open-sse/config/codexConstants.js b/open-sse/config/codexConstants.js index 84435c28001..a643f162bfe 100644 --- a/open-sse/config/codexConstants.js +++ b/open-sse/config/codexConstants.js @@ -2,3 +2,6 @@ // models require >= 0.144.0 in openai/codex's models-manager/models.json. export const CODEX_CLIENT_VERSION = "0.144.6"; export const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLIENT_VERSION}`; + +export const CODEX_IMAGE_NO_RESULT_ERROR = "Codex completed without returning an image."; +export const CODEX_IMAGE_ERROR_TEXT_LIMIT = 1000; diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index e5e79302e02..1726cbc3d64 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto"; import { nowSec } from "./_base.js"; import { PROVIDERS } from "../../config/providers.js"; -import { CODEX_CLIENT_VERSION, CODEX_USER_AGENT } from "../../config/codexConstants.js"; +import { + CODEX_CLIENT_VERSION, + CODEX_IMAGE_ERROR_TEXT_LIMIT, + CODEX_IMAGE_NO_RESULT_ERROR, +} from "../../config/codexConstants.js"; const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl; const CODEX_ORIGINATOR = "codex_cli_rs"; @@ -49,6 +53,7 @@ async function parseStream(response, log, callbacks = {}) { const decoder = new TextDecoder(); let buffer = ""; let imageB64 = null; + let outputText = ""; let lastEvent = null; let bytesReceived = 0; let lastProgressLogMs = 0; @@ -72,6 +77,19 @@ async function parseStream(response, log, callbacks = {}) { else if (line.startsWith("data:")) dataStr += line.slice(5).trim(); } if (!eventName) continue; + let data; + try { data = JSON.parse(dataStr); } catch { /* Ignore non-JSON SSE frames. */ } + // HTTP 200 can carry an upstream failure. Preserve its reason so callers + // can distinguish client-version, quota and content errors from no output. + const failed = eventName === "error" || eventName === "response.failed" || + data?.response?.status === "failed" || data?.response?.status === "incomplete"; + if (failed) { + const error = data?.response?.error || data?.error; + const message = error?.message || (typeof error === "string" ? error : null) || + data?.message || data?.response?.incomplete_details?.reason || "Codex image response failed."; + await reader.cancel().catch(() => {}); + throw new Error(message); + } if (eventName !== lastEvent) { log?.info?.("IMAGE", `codex progress: ${eventName}`); lastEvent = eventName; @@ -85,7 +103,6 @@ async function parseStream(response, log, callbacks = {}) { if (eventName === "response.image_generation_call.partial_image" && dataStr) { try { - const data = JSON.parse(dataStr); if (callbacks.onPartialImage && data?.partial_image_b64) { callbacks.onPartialImage({ b64_json: data.partial_image_b64, index: data.partial_image_index }); } @@ -94,15 +111,19 @@ async function parseStream(response, log, callbacks = {}) { if (eventName === "response.output_item.done" && dataStr) { try { - const data = JSON.parse(dataStr); const item = data?.item; if (item?.type === "image_generation_call" && item.result) { imageB64 = item.result; } + if (item?.type === "message" && Array.isArray(item.content)) { + outputText += item.content.map((part) => part.refusal || part.text || "").join(" "); + outputText = outputText.slice(0, CODEX_IMAGE_ERROR_TEXT_LIMIT); + } } catch {} } } } + if (!imageB64 && outputText) throw new Error(`${CODEX_IMAGE_NO_RESULT_ERROR} ${outputText}`); return imageB64; } @@ -120,7 +141,7 @@ function buildSseResponse(providerResponse, log, onSuccess) { onPartialImage: (info) => send("partial_image", info), }); if (!b64) { - send("error", { message: "Codex did not return an image. Account may not be entitled (Plus/Pro required)." }); + send("error", { message: CODEX_IMAGE_NO_RESULT_ERROR }); } else { if (onSuccess) await onSuccess(); send("done", { created: nowSec(), data: [{ b64_json: b64 }] }); @@ -190,7 +211,7 @@ export default { } const b64 = await parseStream(response, log); if (!b64) { - throw new Error("Codex did not return an image. Account may not be entitled (Plus/Pro required)."); + throw new Error(CODEX_IMAGE_NO_RESULT_ERROR); } return { created: nowSec(), data: [{ b64_json: b64 }] }; }, diff --git a/tests/unit/codex-image-stream-errors.test.js b/tests/unit/codex-image-stream-errors.test.js new file mode 100644 index 00000000000..9b9a55a6d99 --- /dev/null +++ b/tests/unit/codex-image-stream-errors.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import codex from "../../open-sse/handlers/imageProviders/codex.js"; + +function response(event, data) { + return new Response(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, { + headers: { "Content-Type": "text/event-stream" }, + }); +} + +describe("Codex image stream errors", () => { + const message = "This model requires a newer version of Codex."; + + it.each([ + ["error", { error: { message } }], + ["response.failed", { response: { error: { message } } }], + ["response.completed", { response: { status: "failed", error: { message } } }], + ])("preserves the upstream error in %s for binary/JSON clients", async (event, data) => { + await expect(codex.parseResponse(response(event, data), {})).rejects.toThrow(message); + }); + + it("preserves upstream errors for streaming clients and never signals success", async () => { + let successes = 0; + const { sseResponse } = await codex.parseResponse( + response("response.failed", { response: { error: { message } } }), + { streamToClient: true, onRequestSuccess: () => { successes++; } }, + ); + const body = await sseResponse.text(); + expect(body).toContain(message); + expect(body).not.toContain("event: done"); + expect(successes).toBe(0); + }); + + it("reports a text-only response without claiming an entitlement failure", async () => { + await expect(codex.parseResponse(response("response.output_item.done", { + item: { type: "message", content: [{ type: "output_text", text: "Image generation is temporarily unavailable." }] }, + }), {})).rejects.toThrow("Image generation is temporarily unavailable."); + }); +}); From 1137fefc113c51ae68ba67de0a2f1d984d4b8702 Mon Sep 17 00:00:00 2001 From: An Nguyen <264959754+anndev-69@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:14:35 +0700 Subject: [PATCH 4/4] fix(codex): scope model health and validate image and quota streams Keep model-access errors on the account/model pair, make quota auto-ping use an operator-selected model, and require a completed ping response. Parse incremental SSE and preserve upstream failure statuses for image clients. Support explicit image aliases without assuming global model retirement. --- .env.example | 4 + .gitignore | 4 +- CHANGELOG.md | 4 +- docs/CODEX_COMPATIBILITY.md | 73 +++++++++++ open-sse/config/codexConstants.js | 20 +++- open-sse/handlers/imageGenerationCore.js | 4 +- open-sse/handlers/imageProviders/codex.js | 113 ++++++------------ open-sse/utils/codexSse.js | 68 +++++++++++ .../dashboard/providers/[id]/ConnectionRow.js | 8 +- .../dashboard/providers/[id]/page.js | 30 ++++- .../usage/components/ProviderLimits/index.js | 2 +- src/shared/constants/config.js | 7 +- src/shared/services/quotaAutoPing.js | 33 ++--- src/sse/handlers/imageGeneration.js | 10 +- src/sse/services/auth.js | 25 +++- tests/__baseline__/providers-baseline.json | 4 +- tests/fixtures/codex-http-check.mjs | 40 +++++++ tests/fixtures/codex-http-server.cjs | 56 +++++++++ tests/unit/codex-image-alias.test.js | 51 ++++++++ tests/unit/codex-image-framing.test.js | 50 ++++++++ tests/unit/codex-model-health.test.js | 58 +++++++++ tests/unit/image-generation.test.js | 4 +- tests/unit/quota-auto-ping.test.js | 77 ++++++------ 23 files changed, 588 insertions(+), 157 deletions(-) create mode 100644 docs/CODEX_COMPATIBILITY.md create mode 100644 open-sse/utils/codexSse.js create mode 100644 tests/fixtures/codex-http-check.mjs create mode 100644 tests/fixtures/codex-http-server.cjs create mode 100644 tests/unit/codex-image-alias.test.js create mode 100644 tests/unit/codex-image-framing.test.js create mode 100644 tests/unit/codex-model-health.test.js diff --git a/.env.example b/.env.example index 6ed8f81e868..a534eae64ce 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,7 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com # Currently unused by application runtime (kept as reference) # INSTANCE_NAME=9router + +# Optional, explicit Codex image aliases (no remapping when unset). +# Choose a target your connected accounts can access. Restart after changing. +# CODEX_IMAGE_MODEL_ALIASES={"gpt-5.5-image":"gpt-5.6-luna-image"} diff --git a/.gitignore b/.gitignore index 0f0fb03c351..1cbf9bf92db 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,6 @@ graphify-out/* .next-analyze/* # Kiro local workspace state -.kiro/ \ No newline at end of file +.kiro/ +# Public Codex compatibility and verification guide +!docs/CODEX_COMPATIBILITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d775ddfce5..2c77bf60619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Unreleased ## Fixes -- **Codex**: share the existing discovery client version across chat, image generation and connection probes, preventing GPT-5.6 requests from advertising an older client than the model catalog. Updating the gateway is required; upgrading a caller's local Codex CLI does not change these outbound headers. +- **Codex**: align discovery, chat, images and credential probes on one client identity; preserve image SSE errors and handle incremental/data-only frames. +- **Codex**: keep model-access errors scoped to the affected account/model and stop account rotation for client-version failures. +- **Codex**: make quota auto-ping model selectable (Luna by default), require a completed response before recording success, and support explicit opt-in image aliases. See [compatibility notes](docs/CODEX_COMPATIBILITY.md). # v0.5.69 (2026-09-05) diff --git a/docs/CODEX_COMPATIBILITY.md b/docs/CODEX_COMPATIBILITY.md new file mode 100644 index 00000000000..ddf31eaa81b --- /dev/null +++ b/docs/CODEX_COMPATIBILITY.md @@ -0,0 +1,73 @@ +# Codex compatibility and model health + +9Router sends its own Codex client identity. Updating a caller's CLI does not update +these outbound headers. Discovery, chat, images and credential probes share +`CODEX_CLIENT_VERSION` and `CODEX_USER_AGENT` in `open-sse/config/codexConstants.js`. + +A request rejected with “requires a newer version of Codex” needs a gateway update. +Rotating through accounts cannot repair it, so this error does not lock accounts. +A model-access 400/404 is different: availability can vary by account. The affected +account/model pair is temporarily locked using the existing fallback cooldown, and +another account can be tried. The dashboard shows a model warning while preserving +the account's connection status. Other models remain eligible on that account. +Authentication, quota and server errors retain their existing handling. + +## Quota auto-ping + +On the Codex provider page, select **Auto-ping model** to choose an accessible model. +The default is `gpt-5.6-luna` with low reasoning. The selection is stored in +`settings.codexAutoPing.model`; changing it preserves the existing per-account +opt-ins. Only opted-in accounts are pinged after a quota reset. A ping is recorded +as successful only after a completed Responses event, not merely HTTP 200. + +## Optional image aliases + +Prefer updating clients to request an accessible model directly. For clients that +cannot be updated immediately, operators can explicitly configure exact aliases: + +```dotenv +CODEX_IMAGE_MODEL_ALIASES={"gpt-5.5-image":"gpt-5.6-luna-image"} +``` + +Restart 9Router after changing the environment. Aliases apply only to Codex image +requests, before account selection and cooldown tracking. Both sides are unprefixed +image IDs; aliases are single-hop, and unrelated models/providers are unchanged. +Invalid JSON or non-image IDs produce a configuration error before an upstream call. +The configured substitution is logged. The target must be accessible to the accounts +being used; an alias cannot grant model access. With the variable unset, requests +retain their original model. A 404 does not establish global model retirement. + +Image streams preserve upstream errors, including failed/incomplete events inside +HTTP 200. JSON/binary clients receive the mapped failure status; SSE clients receive +an `error` event with message, status and code when supplied, without a successful +`done` event. A response without an image reports a neutral no-result error. + +## Reproducible checks + +```sh +npm install +npm install --prefix tests +npm test --prefix tests -- unit/codex-client-identity.test.js unit/codex-image-stream-errors.test.js unit/codex-image-framing.test.js unit/codex-model-health.test.js unit/codex-image-alias.test.js unit/quota-auto-ping.test.js unit/image-generation.test.js +npm run build +``` + +These tests use synthetic upstream responses. They cover framing, error propagation, +account/model isolation, opt-in aliasing, API-key rejection and quota-ping completion; +they do not claim availability of any model on every live account. + +For an isolated HTTP and dashboard check (Node 22.13+), run the fixture server after +building, then the check script in another terminal: + +```sh +node tests/fixtures/codex-http-server.cjs +node tests/fixtures/codex-http-check.mjs +``` + +Open `http://127.0.0.1:20139/dashboard/providers/codex`. Confirm the synthetic +account stays active with a separate model warning; select another auto-ping model +and reload to verify persistence. To check failed saves, create an empty file named +`fail-next-settings-save` under the printed `FIXTURE_DATA_DIR`, then change the model: +the selection should revert and an error should appear. The fixture binds only to +loopback, creates a fresh temporary database, and replaces all upstream fetches with +synthetic responses. It uses `fixture-key` for API requests; no real account is needed. +Stop with Ctrl-C and remove its printed temporary data directory when done. diff --git a/open-sse/config/codexConstants.js b/open-sse/config/codexConstants.js index a643f162bfe..26781272f2a 100644 --- a/open-sse/config/codexConstants.js +++ b/open-sse/config/codexConstants.js @@ -1,7 +1,21 @@ -// Keep discovery, chat, images and connection probes on the same client version. GPT-5.6 -// models require >= 0.144.0 in openai/codex's models-manager/models.json. -export const CODEX_CLIENT_VERSION = "0.144.6"; +// Shared outbound identity for discovery, chat, images and connection probes. +export const CODEX_CLIENT_VERSION = "0.153.4"; export const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLIENT_VERSION}`; export const CODEX_IMAGE_NO_RESULT_ERROR = "Codex completed without returning an image."; export const CODEX_IMAGE_ERROR_TEXT_LIMIT = 1000; + +export const CODEX_AUTO_PING_MODEL = "gpt-5.6-luna"; + +// Explicit, image-only compatibility policy. No model is rewritten by default. +export function resolveCodexImageModel(model, raw = process.env.CODEX_IMAGE_MODEL_ALIASES) { + if (!raw) return model; + let aliases; + try { aliases = JSON.parse(raw); } catch { throw new Error("CODEX_IMAGE_MODEL_ALIASES must be a JSON object"); } + const validId = /^[a-zA-Z0-9][a-zA-Z0-9._-]*-image$/; + if (!aliases || Array.isArray(aliases) || typeof aliases !== "object" || + Object.entries(aliases).some(([from, to]) => !validId.test(from) || typeof to !== "string" || !validId.test(to))) { + throw new Error("CODEX_IMAGE_MODEL_ALIASES must map image model IDs to image model IDs"); + } + return Object.hasOwn(aliases, model) ? aliases[model] : model; +} diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js index d2d0eba2911..2205fcaffcd 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.js @@ -183,7 +183,9 @@ export async function handleImageGenerationCore({ parsed = await providerResponse.json(); } } catch (parseError) { - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, parseError.message || `Invalid response from ${provider}`); + const status = Number.isInteger(parseError.statusCode) && parseError.statusCode >= 400 && parseError.statusCode <= 599 + ? parseError.statusCode : HTTP_STATUS.BAD_GATEWAY; + return createErrorResult(status, parseError.message || `Invalid response from ${provider}`); } if (onRequestSuccess) await onRequestSuccess(); diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 1726cbc3d64..51e400f66b1 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -4,10 +4,13 @@ import { nowSec } from "./_base.js"; import { PROVIDERS } from "../../config/providers.js"; import { CODEX_CLIENT_VERSION, + CODEX_USER_AGENT, CODEX_IMAGE_ERROR_TEXT_LIMIT, CODEX_IMAGE_NO_RESULT_ERROR, } from "../../config/codexConstants.js"; +import { readCodexEvents, codexEventError } from "../../utils/codexSse.js"; + const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl; const CODEX_ORIGINATOR = "codex_cli_rs"; const CODEX_MODEL_SUFFIX = "-image"; @@ -48,110 +51,72 @@ function buildContent(prompt, refs, detail = CODEX_REF_DETAIL) { } // Parse Codex SSE stream → final base64 image. Optional callbacks for client streaming. -async function parseStream(response, log, callbacks = {}) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; +async function parseStream(response, log, callbacks = {}, signal) { let imageB64 = null; let outputText = ""; let lastEvent = null; - let bytesReceived = 0; let lastProgressLogMs = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - bytesReceived += value?.byteLength || 0; - buffer += decoder.decode(value, { stream: true }); - - let sepIdx; - while ((sepIdx = buffer.indexOf("\n\n")) !== -1) { - const block = buffer.slice(0, sepIdx); - buffer = buffer.slice(sepIdx + 2); - - const lines = block.split("\n"); - let eventName = null; - let dataStr = ""; - for (const line of lines) { - if (line.startsWith("event:")) eventName = line.slice(6).trim(); - else if (line.startsWith("data:")) dataStr += line.slice(5).trim(); - } - if (!eventName) continue; - let data; - try { data = JSON.parse(dataStr); } catch { /* Ignore non-JSON SSE frames. */ } - // HTTP 200 can carry an upstream failure. Preserve its reason so callers - // can distinguish client-version, quota and content errors from no output. - const failed = eventName === "error" || eventName === "response.failed" || - data?.response?.status === "failed" || data?.response?.status === "incomplete"; - if (failed) { - const error = data?.response?.error || data?.error; - const message = error?.message || (typeof error === "string" ? error : null) || - data?.message || data?.response?.incomplete_details?.reason || "Codex image response failed."; - await reader.cancel().catch(() => {}); - throw new Error(message); - } - if (eventName !== lastEvent) { - log?.info?.("IMAGE", `codex progress: ${eventName}`); - lastEvent = eventName; - } - - const now = Date.now(); - if (callbacks.onProgress && now - lastProgressLogMs > 200) { - lastProgressLogMs = now; - callbacks.onProgress({ stage: eventName, bytesReceived }); - } - - if (eventName === "response.image_generation_call.partial_image" && dataStr) { - try { - if (callbacks.onPartialImage && data?.partial_image_b64) { - callbacks.onPartialImage({ b64_json: data.partial_image_b64, index: data.partial_image_index }); - } - } catch {} - } - - if (eventName === "response.output_item.done" && dataStr) { - try { - const item = data?.item; - if (item?.type === "image_generation_call" && item.result) { - imageB64 = item.result; - } - if (item?.type === "message" && Array.isArray(item.content)) { - outputText += item.content.map((part) => part.refusal || part.text || "").join(" "); - outputText = outputText.slice(0, CODEX_IMAGE_ERROR_TEXT_LIMIT); - } - } catch {} + for await (const { event, data, bytesReceived } of readCodexEvents(response, signal)) { + const error = codexEventError(event, data); + if (error) throw error; + if (event !== lastEvent) { + log?.info?.("IMAGE", `codex progress: ${event}`); + lastEvent = event; + } + const now = Date.now(); + if (callbacks.onProgress && now - lastProgressLogMs > 200) { + lastProgressLogMs = now; + callbacks.onProgress({ stage: event, bytesReceived }); + } + if (event === "response.image_generation_call.partial_image" && data?.partial_image_b64) { + callbacks.onPartialImage?.({ b64_json: data.partial_image_b64, index: data.partial_image_index }); + } + const items = event === "response.output_item.done" ? [data?.item] : + event === "response.completed" ? data?.response?.output || [] : []; + for (const item of items) { + if (item?.type === "image_generation_call" && item.result) imageB64 = item.result; + if (item?.type === "message" && Array.isArray(item.content)) { + for (const part of item.content) { + const text = part.refusal || part.text; + if (typeof text === "string") outputText = (outputText + " " + text).slice(0, CODEX_IMAGE_ERROR_TEXT_LIMIT); + } } } } - if (!imageB64 && outputText) throw new Error(`${CODEX_IMAGE_NO_RESULT_ERROR} ${outputText}`); + if (!imageB64 && outputText) throw new Error(`${CODEX_IMAGE_NO_RESULT_ERROR} ${outputText.trim()}`); return imageB64; } // SSE Response that pipes codex progress + partial + done events to client function buildSseResponse(providerResponse, log, onSuccess) { + const abort = new AbortController(); + let cancelled = false; const stream = new ReadableStream({ async start(controller) { const enc = new TextEncoder(); const send = (event, data) => { + if (cancelled) return; controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); }; try { const b64 = await parseStream(providerResponse, log, { onProgress: (info) => send("progress", info), onPartialImage: (info) => send("partial_image", info), - }); + }, abort.signal); + if (cancelled) return; if (!b64) { - send("error", { message: CODEX_IMAGE_NO_RESULT_ERROR }); + send("error", { message: CODEX_IMAGE_NO_RESULT_ERROR, status: 502 }); } else { if (onSuccess) await onSuccess(); send("done", { created: nowSec(), data: [{ b64_json: b64 }] }); } } catch (err) { - send("error", { message: err?.message || "Stream failed" }); + send("error", { message: err?.message || "Stream failed", status: err?.statusCode || 502, code: err?.code }); } finally { - controller.close(); + if (!cancelled) controller.close(); } }, + cancel() { cancelled = true; abort.abort(); }, }); return new Response(stream, { headers: { @@ -176,7 +141,7 @@ export default { "content-type": "application/json", "originator": CODEX_ORIGINATOR, "session_id": randomUUID(), - "user-agent": `${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION}`, + "user-agent": CODEX_USER_AGENT, "version": CODEX_CLIENT_VERSION, "x-client-request-id": randomUUID(), }; diff --git a/open-sse/utils/codexSse.js b/open-sse/utils/codexSse.js new file mode 100644 index 00000000000..c0bb4d3d2cc --- /dev/null +++ b/open-sse/utils/codexSse.js @@ -0,0 +1,68 @@ +import { CODEX_IMAGE_ERROR_TEXT_LIMIT } from "../config/codexConstants.js"; + +export function isCodexClientVersionError(message) { + return /requires a newer version of Codex/i.test(String(message || "")); +} + +export function isCodexModelAccessError(status, message) { + return [400, 404].includes(Number(status)) && + /model_not_found|model.{0,200}(?:does not exist|not found|not supported|do not have access)/i.test(String(message || "")); +} + +// HTTP 200 can still contain a failed Responses API event. +export function codexEventError(event, data) { + if (event !== "error" && event !== "response.failed" && event !== "response.incomplete" && + !["failed", "incomplete"].includes(data?.response?.status)) return null; + const detail = data?.response?.error || data?.error; + const message = detail?.message || (typeof detail === "string" ? detail : null) || + data?.message || data?.response?.incomplete_details?.reason || "Codex response failed."; + const error = new Error(String(message).slice(0, CODEX_IMAGE_ERROR_TEXT_LIMIT)); + error.code = typeof detail?.code === "string" ? detail.code : undefined; + const explicitStatus = Number(detail?.status_code || data?.status_code); + error.statusCode = Number.isInteger(explicitStatus) && explicitStatus >= 400 && explicitStatus <= 599 ? explicitStatus : + error.code === "model_not_found" ? 404 : + ["rate_limit_exceeded", "usage_limit_reached"].includes(error.code) ? 429 : + error.code === "invalid_api_key" ? 401 : + isCodexClientVersionError(error.message) ? 400 : 502; + return error; +} + +// Incremental SSE framing shared by images and quota pings. Accept data-only events, +// CRLF and an EOF without a blank separator, including split UTF-8 sequences. +export async function* readCodexEvents(response, signal) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let finished = false; + let bytesReceived = 0; + const cancel = () => { reader.cancel().catch(() => {}); }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + while (!finished) { + if (signal?.aborted) return; + const { done, value } = await reader.read(); + finished = done; + bytesReceived += value?.byteLength || 0; + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + let separator; + while ((separator = /\r\n\r\n|\n\n|\r\r/.exec(buffer)) || (done && buffer)) { + const block = separator ? buffer.slice(0, separator.index) : buffer; + buffer = separator ? buffer.slice(separator.index + separator[0].length) : ""; + let event = null; + const lines = []; + for (const line of block.split(/\r\n|\n|\r/)) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) lines.push(line.slice(5).trimStart()); + } + let data; + try { data = JSON.parse(lines.join("\n")); } catch { /* Ignore keepalives and malformed frames. */ } + event ||= data?.type; + if (event) yield { event, data, bytesReceived }; + } + } + } finally { + signal?.removeEventListener("abort", cancel); + if (!finished) await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index 0b5bea19a36..513fda221d0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -24,7 +24,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` : ""; const autoPingTooltip = autoPing?.provider === "codex" - ? "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota." + ? "Auto-starts the next 5h Codex window after reset by sending a tiny request using the configured Codex auto-ping model. Consumes a small amount of quota." : "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away."; let maskedProxyUrl = ""; @@ -181,6 +181,11 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst {connection.lastError} )} + {connection.lastModelError && connection.isActive !== false && ( + + Model unavailable: {connection.lastModelError.model} + + )} #{connection.priority} {connection.globalPriority && ( Auto: {connection.globalPriority} @@ -287,6 +292,7 @@ ConnectionRow.propTypes = { testStatus: PropTypes.string, isActive: PropTypes.bool, lastError: PropTypes.string, + lastModelError: PropTypes.shape({ model: PropTypes.string, message: PropTypes.string }), priority: PropTypes.number, globalPriority: PropTypes.number, }).isRequired, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index df1c9db6b9a..c2d4506cea7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -24,6 +24,8 @@ import AddCustomModelModal from "./AddCustomModelModal"; import BulkImportCodexModal from "./BulkImportCodexModal"; import BulkImportGrokCliModal from "./BulkImportGrokCliModal"; +import { CODEX_AUTO_PING_MODEL } from "open-sse/config/codexConstants.js"; + const ONE_BY_ONE_DELAY_MS = 1000; const AUTO_PING_SETTINGS_KEYS = { @@ -67,6 +69,7 @@ export default function ProviderDetailPage() { const [providerStrategy, setProviderStrategy] = useState(null); const [providerStickyLimit, setProviderStickyLimit] = useState(""); const [thinkingMode, setThinkingMode] = useState("auto"); + const [autoPingError, setAutoPingError] = useState(null); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [suggestedModels, setSuggestedModels] = useState([]); const [liveModels, setLiveModels] = useState([]); @@ -320,7 +323,7 @@ export default function ProviderDetailPage() { setThinkingMode(thinkingCfg.mode || "auto"); const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; const apCfg = autoPingSettingsKey ? settingsData[autoPingSettingsKey] || {} : {}; - setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} }); + setAutoPing({ ...apCfg, enabled: apCfg.enabled === true, connections: apCfg.connections || {} }); if (nodesRes.ok) { let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; @@ -437,15 +440,19 @@ export default function ProviderDetailPage() { const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; if (!autoPingSettingsKey) return; + const previous = autoPing; setAutoPing(next); + setAutoPingError(null); try { - await fetch("/api/settings", { + const response = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [autoPingSettingsKey]: next }), }); + if (!response.ok) throw new Error("Could not save auto-ping settings"); } catch (error) { - console.log("Error saving auto-ping config:", error); + setAutoPing(previous); + setAutoPingError(error.message); } }; @@ -692,6 +699,7 @@ export default function ProviderDetailPage() { } } } finally { + await fetchConnections(); setOneByOneCurrentConnectionId(null); setOneByOneRunning(false); setOneByOneStopping(false); @@ -1470,6 +1478,22 @@ export default function ProviderDetailPage() { )} )} + {providerId === "codex" && ( + + )} + {autoPingError &&

{autoPingError}

} {/* Round Robin toggle */}
Round Robin diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 568c4808078..7696ba4bb53 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -62,7 +62,7 @@ const AUTO_PING_SETTINGS_KEYS = { const AUTO_PING_TOOLTIPS = { claude: "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.", - codex: "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota.", + codex: "Auto-starts the next 5h Codex window after reset by sending a tiny request using the configured Codex auto-ping model. Consumes a small amount of quota.", }; function kiroMethodLabel(conn) { diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index 0650d086afa..0d81cd65a76 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -1,3 +1,4 @@ +import { CODEX_AUTO_PING_MODEL } from "open-sse/config/codexConstants.js"; import pkg from "../../../package.json" with { type: "json" }; // App configuration @@ -83,11 +84,11 @@ export const QUOTA_AUTOPING_CONFIG = { resetAtDriftMs: 30000, minPingIntervalMs: 600000, skipWhenBlockingQuotaExhausted: true, - // Free and Plus Codex accounts both expose gpt-5.5; avoid fallback probes that waste requests. - pingModel: "gpt-5.5", + // Operators can select an accessible model in codexAutoPing.model. + pingModel: CODEX_AUTO_PING_MODEL, pingText: "hi", pingInstructions: "Reply with OK.", - pingReasoningEffort: "none", + pingReasoningEffort: "low", }, }, }; diff --git a/src/shared/services/quotaAutoPing.js b/src/shared/services/quotaAutoPing.js index 1fbf39f797f..beab8ee6ad7 100644 --- a/src/shared/services/quotaAutoPing.js +++ b/src/shared/services/quotaAutoPing.js @@ -1,5 +1,6 @@ // Quota auto-ping scheduler: warms 5h windows by sending tiny opt-in requests right after reset. import "open-sse/index.js"; +import { readCodexEvents, codexEventError } from "open-sse/utils/codexSse.js"; import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb"; import { getClaudeUsage } from "open-sse/services/usage/claude.js"; @@ -128,25 +129,6 @@ function buildCodexPingInput(text) { }]; } -async function drainResponseBody(response) { - if (typeof response?.text === "function") { - await response.text(); - return; - } - - const reader = response?.body?.getReader?.(); - if (!reader) return; - - try { - while (true) { - const { done } = await reader.read(); - if (done) return; - } - } finally { - reader.releaseLock?.(); - } -} - async function sendCodexPing(connection, providerConfig, proxyOptions, deps) { const executor = deps.getExecutor("codex"); const { response } = await executor.execute({ @@ -176,8 +158,12 @@ async function sendCodexPing(connection, providerConfig, proxyOptions, deps) { } // Codex only starts the 5h window after the streaming response completes. - await drainResponseBody(response); - return true; + let completed = false; + for await (const { event, data } of readCodexEvents(response)) { + if (codexEventError(event, data)) return false; + if (event === "response.completed" && data?.response?.status === "completed") completed = true; + } + return completed; } function shouldSkipAfterFailure(state, key, nowMs = Date.now()) { @@ -264,7 +250,10 @@ export async function runQuotaAutoPingTick(deps = createDefaultDeps(), state = g try { const settings = await deps.getSettings(); - for (const [provider, providerConfig] of Object.entries(C.providers)) { + for (const [provider, defaults] of Object.entries(C.providers)) { + const configuredModel = settings?.[defaults.settingsKey]?.model; + const providerConfig = provider === "codex" && typeof configuredModel === "string" && configuredModel.trim() + ? { ...defaults, pingModel: configuredModel.trim() } : defaults; const handler = providerHandlers[provider]; if (!handler) continue; diff --git a/src/sse/handlers/imageGeneration.js b/src/sse/handlers/imageGeneration.js index 78e738d0609..2065bb23e11 100644 --- a/src/sse/handlers/imageGeneration.js +++ b/src/sse/handlers/imageGeneration.js @@ -5,6 +5,7 @@ import { extractApiKey, isValidApiKey, } from "../services/auth.js"; +import { resolveCodexImageModel } from "open-sse/config/codexConstants.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo, getComboModels } from "../services/model.js"; import { handleImageGenerationCore } from "open-sse/handlers/imageGenerationCore.js"; @@ -71,7 +72,14 @@ async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutpu const modelInfo = await getModelInfo(modelStr); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); - const { provider, model } = modelInfo; + const { provider } = modelInfo; + let model = modelInfo.model; + try { + if (provider === "codex") model = resolveCodexImageModel(model); + } catch (error) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, error.message); + } + if (model !== modelInfo.model) log.info("IMAGE", `Configured model alias ${modelInfo.model} -> ${model}`); // noAuth providers — no credential needed if (NO_AUTH_PROVIDERS.has(provider)) { diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index d85c4901159..87ebffc683a 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,6 +1,7 @@ import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy"; import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; +import { isCodexClientVersionError, isCodexModelAccessError } from "open-sse/utils/codexSse.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js"; import { getAntigravityQuotaCache } from "./antigravityQuota.js"; @@ -120,13 +121,14 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu const earliest = expiries.sort()[0] || null; if (earliest) { const earliestConn = lockedConns[0]; + const modelError = earliestConn?.lastModelError?.model === model ? earliestConn.lastModelError : null; log.warn("AUTH", `${provider} | all ${connections.length} accounts locked for ${model || "all"} (${formatRetryAfter(earliest)}) | lastError=${earliestConn?.lastError?.slice(0, 50)}`); return { allRateLimited: true, retryAfter: earliest, retryAfterHuman: formatRetryAfter(earliest), - lastError: earliestConn?.lastError || null, - lastErrorCode: earliestConn?.errorCode || null + lastError: modelError?.message || earliestConn?.lastError || null, + lastErrorCode: modelError?.status || earliestConn?.errorCode || null }; } log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); @@ -238,6 +240,10 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu */ export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null, resetsAtMs = null) { if (!connectionId || connectionId === "noauth") return { shouldFallback: false, cooldownMs: 0 }; + const isCodex = resolveProviderId(provider) === "codex"; + // Changing accounts cannot repair the gateway's outbound client identity. + if (isCodex && isCodexClientVersionError(errorText)) return { shouldFallback: false, cooldownMs: 0 }; + const modelAccessError = isCodex && model && isCodexModelAccessError(status, errorText); const connections = await getProviderConnections({ provider }); const conn = connections.find(c => c.id === connectionId); const backoffLevel = conn?.backoffLevel || 0; @@ -266,6 +272,14 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs); + if (modelAccessError) { + await updateProviderConnection(connectionId, { + ...lockUpdate, + lastModelError: { model, message: reason, status: Number(status), at: new Date().toISOString() }, + }); + return { shouldFallback: true, cooldownMs }; + } + await updateProviderConnection(connectionId, { ...lockUpdate, testStatus: "unavailable", @@ -301,7 +315,7 @@ export async function clearAccountError(connectionId, currentConnection, model = const now = Date.now(); const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_")); - if (!conn.testStatus && !conn.lastError && allLockKeys.length === 0) return; + if (!conn.testStatus && !conn.lastError && !conn.lastModelError && allLockKeys.length === 0) return; // Keys to clear: current model's lock + all expired locks const keysToClear = allLockKeys.filter(k => { @@ -311,7 +325,9 @@ export async function clearAccountError(connectionId, currentConnection, model = return expiry && new Date(expiry).getTime() <= now; // expired }); - if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError) return; + const clearModelError = conn.lastModelError && (conn.lastModelError.model === model || + keysToClear.includes(`modelLock_${conn.lastModelError.model}`)); + if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError && !clearModelError) return; // Check if any active locks remain after clearing const remainingActiveLocks = allLockKeys.filter(k => { @@ -321,6 +337,7 @@ export async function clearAccountError(connectionId, currentConnection, model = }); const clearObj = Object.fromEntries(keysToClear.map(k => [k, null])); + if (clearModelError) clearObj.lastModelError = null; // Only reset error state if no active locks remain if (remainingActiveLocks.length === 0) { diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 1c8adc0956d..c12047831ac 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -194,8 +194,8 @@ "forceStream": true, "headers": { "originator": "codex_cli_rs", - "User-Agent": "codex_cli_rs/0.144.6", - "version": "0.144.6" + "User-Agent": "codex_cli_rs/0.153.4", + "version": "0.153.4" }, "usage": { "url": "https://chatgpt.com/backend-api/wham/usage", diff --git a/tests/fixtures/codex-http-check.mjs b/tests/fixtures/codex-http-check.mjs new file mode 100644 index 00000000000..b851e077afb --- /dev/null +++ b/tests/fixtures/codex-http-check.mjs @@ -0,0 +1,40 @@ +// Run against codex-http-server.cjs; never point this at a real deployment. +import assert from "node:assert/strict"; +const base = `http://127.0.0.1:${process.env.FIXTURE_PORT || "20139"}`; +async function image(model, prompt = "fixture", options = {}) { + return fetch(`${base}/v1/images/generations${options.binary ? "?response_format=binary" : ""}`, { + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.key || "fixture-key"}`, ...(options.stream ? { Accept: "text/event-stream" } : {}) }, + body: JSON.stringify({ model, prompt }), + }); +} +let response = await image("cx/gpt-5.5-image", "fixture", { key: "wrong" }); +assert.equal(response.status, 401); +console.log(`invalid API key: HTTP ${response.status}`); +response = await image("cx/gpt-5.5-image", "fixture", { binary: true }); +assert.equal(response.status, 200); +const bytes = Buffer.from(await response.arrayBuffer()); +assert.equal(bytes.subarray(0, 8).toString("hex"), "89504e470d0a1a0a"); +console.log(`configured legacy image alias: HTTP ${response.status}, ${bytes.length} PNG bytes`); +response = await image("cx/gpt-5.4-image"); +assert.equal(response.status, 404); +console.log(`unavailable unmapped model: HTTP ${response.status}`); +const { connections } = await (await fetch(`${base}/api/providers`)).json(); +assert.equal(connections[0].testStatus, "active"); +assert.equal(connections[0].lastModelError.model, "gpt-5.4-image"); +console.log(`connection remains ${connections[0].testStatus}; warning scoped to ${connections[0].lastModelError.model}`); +response = await image("cx/gpt-5.6-luna-image"); +assert.equal(response.status, 200); +assert.ok((await response.json()).data[0].b64_json); +console.log(`same account, other model: HTTP ${response.status}`); +response = await image("cx/gpt-5.6-luna-image", "fixture-stream-error"); +assert.equal(response.status, 400); +assert.match(await response.text(), /requires a newer version of Codex/); +console.log(`embedded client-version failure, JSON: HTTP ${response.status}`); +response = await image("cx/gpt-5.6-luna-image", "fixture-stream-error", { stream: true }); +const stream = await response.text(); +assert.equal(response.status, 200); +assert.match(stream, /event: error/); +assert.match(stream, /"status":400/); +assert.doesNotMatch(stream, /event: done/); +console.log(`embedded client-version failure, SSE: HTTP ${response.status}, error status 400, no done event`); +console.log("HTTP fixture checks passed"); diff --git a/tests/fixtures/codex-http-server.cjs b/tests/fixtures/codex-http-server.cjs new file mode 100644 index 00000000000..a6702f877c8 --- /dev/null +++ b/tests/fixtures/codex-http-server.cjs @@ -0,0 +1,56 @@ +// Local-only HTTP/UI fixture. Requires Node >=22.13 and `npm run build` first. +// All provider responses and credentials are synthetic; no external fetch is allowed. +const { mkdtempSync, mkdirSync, existsSync, unlinkSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const path = require("node:path"); +const { DatabaseSync } = require("node:sqlite"); +const http = require("node:http"); +const dataDir = mkdtempSync(path.join(tmpdir(), "9router-codex-fixture-")); +process.env.DATA_DIR = dataDir; +process.env.NODE_ENV = "production"; +process.env.CODEX_IMAGE_MODEL_ALIASES = '{"gpt-5.5-image":"gpt-5.6-luna-image"}'; +mkdirSync(path.join(dataDir, "db")); +const db = new DatabaseSync(path.join(dataDir, "db/data.sqlite")); +db.exec(`CREATE TABLE settings(id INTEGER PRIMARY KEY CHECK(id=1), data TEXT NOT NULL); +CREATE TABLE providerConnections(id TEXT PRIMARY KEY, provider TEXT NOT NULL, authType TEXT NOT NULL, name TEXT, email TEXT, priority INTEGER, isActive INTEGER DEFAULT 1, data TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL); +CREATE TABLE apiKeys(id TEXT PRIMARY KEY, key TEXT UNIQUE NOT NULL, name TEXT, machineId TEXT, isActive INTEGER DEFAULT 1, createdAt TEXT NOT NULL);`); +db.prepare("INSERT INTO settings VALUES(1, ?)").run(JSON.stringify({ requireLogin: false, requireApiKey: true, codexAutoPing: { enabled: false, connections: {} } })); +const date = new Date().toISOString(); +db.prepare("INSERT INTO providerConnections VALUES(?, 'codex', 'oauth', ?, NULL, 1, 1, ?, ?, ?)") + .run("fixture-account", "Synthetic Codex account", JSON.stringify({ testStatus: "active", accessToken: "synthetic-token", expiresAt: "2099-01-01T00:00:00Z", providerSpecificData: { chatgptAccountId: "synthetic-account" } }), date, date); +db.prepare("INSERT INTO apiKeys VALUES('fixture-key', 'fixture-key', 'Local fixture', NULL, 1, ?)").run(date); +db.close(); +const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j8ioAAAAASUVORK5CYII="; +function sse(event) { return new Response(`data: ${JSON.stringify(event)}\r\n\r\n`, { headers: { "Content-Type": "text/event-stream" } }); } +globalThis.fetch = async (input, init = {}) => { + const url = new URL(typeof input === "string" ? input : input.url || String(input)); + if (url.hostname !== "chatgpt.com") throw new Error(`Fixture blocks external fetch: ${url.hostname}`); + if (url.pathname.endsWith("/models")) return Response.json({ models: ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.4"].map((id) => ({ id })) }); + if (!url.pathname.endsWith("/responses")) return Response.json({ error: { message: "Unused fixture endpoint" } }, { status: 404 }); + const headers = new Headers(init.headers); + if (headers.get("version") !== "0.153.4" || !headers.get("user-agent")?.includes("0.153.4")) { + return Response.json({ error: { message: "This model requires a newer version of Codex." } }, { status: 400 }); + } + const body = JSON.parse(init.body || "{}"); + if (!body.input?.length) return Response.json({ error: { message: "Missing required input" } }, { status: 400 }); + if (body.model === "gpt-5.4") return Response.json({ error: { message: "The model 'gpt-5.4' does not exist or you do not have access to it.", code: "model_not_found" } }, { status: 404 }); + if (JSON.stringify(body.input).includes("fixture-stream-error")) return sse({ type: "response.failed", response: { error: { message: "This model requires a newer version of Codex.", status_code: 400 } } }); + return sse({ type: "response.completed", response: { status: "completed", output: [{ type: "image_generation_call", result: png }] } }); +}; +// Create this marker to exercise the dashboard's failed-save state once. +const originalEmit = http.Server.prototype.emit; +http.Server.prototype.emit = function(event, ...args) { + const [req, res] = args; + const marker = path.join(dataDir, "fail-next-settings-save"); + if (event === "request" && req.method === "PATCH" && req.url === "/api/settings" && existsSync(marker)) { + unlinkSync(marker); + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Synthetic settings failure" })); + return true; + } + return originalEmit.call(this, event, ...args); +}; +console.log(`FIXTURE_DATA_DIR=${dataDir}`); +require("../../custom-server.js"); +process.argv = [process.argv[0], require.resolve("next/dist/bin/next"), "start", "--hostname", "127.0.0.1", "--port", process.env.FIXTURE_PORT || "20139"]; +require("next/dist/bin/next"); diff --git a/tests/unit/codex-image-alias.test.js b/tests/unit/codex-image-alias.test.js new file mode 100644 index 00000000000..18f60acd3d0 --- /dev/null +++ b/tests/unit/codex-image-alias.test.js @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveCodexImageModel } from "../../open-sse/config/codexConstants.js"; +const mocks = vi.hoisted(() => ({ credentials: vi.fn(), core: vi.fn(), modelInfo: vi.fn() })); +vi.mock("@/lib/localDb", () => ({ getSettings: async () => ({ requireApiKey: true }) })); +vi.mock("../../src/sse/services/auth.js", () => ({ + getProviderCredentials: mocks.credentials, markAccountUnavailable: vi.fn(), clearAccountError: vi.fn(), + extractApiKey: (r) => r.headers.get("authorization"), isValidApiKey: async (key) => key === "Bearer fixture", +})); +vi.mock("../../src/sse/services/model.js", () => ({ getComboModels: async () => null, getModelInfo: mocks.modelInfo })); +vi.mock("../../src/sse/services/tokenRefresh.js", () => ({ checkAndRefreshToken: async (_p, creds) => creds, updateProviderCredentials: vi.fn() })); +vi.mock("open-sse/handlers/imageGenerationCore.js", () => ({ handleImageGenerationCore: mocks.core })); +import { handleImageGeneration } from "../../src/sse/handlers/imageGeneration.js"; +const alias = '{"gpt-5.5-image":"gpt-5.6-luna-image"}'; +function request(key = "fixture") { + return new Request("http://localhost/v1/images/generations", { method: "POST", headers: { authorization: `Bearer ${key}` }, body: JSON.stringify({ model: "cx/gpt-5.5-image", prompt: "fixture" }) }); +} +afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); }); +describe("Codex image aliases", () => { + it("does not rewrite models by default or infer a global retirement", () => { + expect(resolveCodexImageModel("gpt-5.5-image", "")).toBe("gpt-5.5-image"); + expect(resolveCodexImageModel("gpt-5.4-image", alias)).toBe("gpt-5.4-image"); + }); + it.each(["invalid", "null", "[]", '{"gpt-5.5-image":"https://example.org"}', '{"gpt-5.5-image":42}'])("rejects invalid configuration %s", (value) => { + expect(() => resolveCodexImageModel("gpt-5.5-image", value)).toThrow("CODEX_IMAGE_MODEL_ALIASES"); + }); + it("uses only explicit, exact, single-hop aliases", () => { + expect(resolveCodexImageModel("gpt-5.5-image", alias)).toBe("gpt-5.6-luna-image"); + expect(resolveCodexImageModel("gpt-5.5-image", '{"gpt-5.5-image":"gpt-5.6-luna-image","gpt-5.6-luna-image":"gpt-5.6-sol-image"}')).toBe("gpt-5.6-luna-image"); + }); + it.each(["codex", "openai"])("applies configured aliases only to Codex before credential selection (%s)", async (provider) => { + vi.stubEnv("CODEX_IMAGE_MODEL_ALIASES", alias); + mocks.modelInfo.mockResolvedValue({ provider, model: "gpt-5.5-image" }); + mocks.credentials.mockResolvedValue({ connectionId: "fixture" }); + mocks.core.mockResolvedValue({ success: true, response: Response.json({ data: [] }) }); + expect((await handleImageGeneration(request())).status).toBe(200); + const expected = provider === "codex" ? "gpt-5.6-luna-image" : "gpt-5.5-image"; + expect(mocks.credentials.mock.calls[0][2]).toBe(expected); + expect(mocks.core.mock.calls[0][0].modelInfo.model).toBe(expected); + }); + it("rejects invalid API keys before model resolution or upstream access", async () => { + expect((await handleImageGeneration(request("wrong"))).status).toBe(401); + expect(mocks.modelInfo).not.toHaveBeenCalled(); + expect(mocks.credentials).not.toHaveBeenCalled(); + }); + it("reports invalid server alias configuration before using credentials", async () => { + vi.stubEnv("CODEX_IMAGE_MODEL_ALIASES", "invalid"); + mocks.modelInfo.mockResolvedValue({ provider: "codex", model: "gpt-5.5-image" }); + expect((await handleImageGeneration(request())).status).toBe(400); + expect(mocks.credentials).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/codex-image-framing.test.js b/tests/unit/codex-image-framing.test.js new file mode 100644 index 00000000000..b8318c12e9b --- /dev/null +++ b/tests/unit/codex-image-framing.test.js @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import codex from "../../open-sse/handlers/imageProviders/codex.js"; + +const imageItem = { type: "image_generation_call", result: "aW1hZ2U=" }; +const complete = { type: "response.completed", response: { status: "completed", output: [imageItem] } }; +const encoder = new TextEncoder(); +function chunks(text, width = 1) { + const bytes = encoder.encode(text); + return new Response(new ReadableStream({ + start(controller) { + for (let i = 0; i < bytes.length; i += width) controller.enqueue(bytes.slice(i, i + width)); + controller.close(); + }, + })); +} + +describe("Codex image SSE framing", () => { + it.each(["\n", "\r\n", "\r"])("reads data-only events and terminal output with %j separators", async (newline) => { + const response = chunks(`: keepalive${newline}${newline}data: ${JSON.stringify(complete)}${newline}${newline}`); + expect((await codex.parseResponse(response, {})).data[0].b64_json).toBe(imageItem.result); + expect(response.body.locked).toBe(false); + }); + it("flushes a final event without a blank separator", async () => { + expect((await codex.parseResponse(chunks(`data: ${JSON.stringify(complete)}`), {})).data[0].b64_json).toBe(imageItem.result); + }); + it("preserves split UTF-8 and multiline data in an upstream error", async () => { + const response = chunks('event: error\ndata: {"error":\ndata: {"message":"Tạm thời không có ảnh","code":"model_not_found"}}\n\n'); + await expect(codex.parseResponse(response, {})).rejects.toMatchObject({ message: "Tạm thời không có ảnh", statusCode: 404 }); + expect(response.body.locked).toBe(false); + }); + it("does not signal success if an image item is followed by a terminal failure", async () => { + const onRequestSuccess = vi.fn(); + const response = chunks(`data: ${JSON.stringify({ type: "response.output_item.done", item: imageItem })}\n\ndata: ${JSON.stringify({ type: "response.failed", response: { error: { message: "quota exhausted", code: "rate_limit_exceeded" } } })}\n\n`); + const { sseResponse } = await codex.parseResponse(response, { streamToClient: true, onRequestSuccess }); + const text = await sseResponse.text(); + expect(text).toContain('"status":429'); + expect(text).not.toContain("event: done"); + expect(onRequestSuccess).not.toHaveBeenCalled(); + }); + it("cancels the upstream reader when the client disconnects", async () => { + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ cancel })); + const onRequestSuccess = vi.fn(); + const { sseResponse } = await codex.parseResponse(response, { streamToClient: true, onRequestSuccess }); + await sseResponse.body.cancel(); + await vi.waitFor(() => expect(response.body.locked).toBe(false)); + expect(cancel).toHaveBeenCalledTimes(1); + expect(onRequestSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/codex-model-health.test.js b/tests/unit/codex-model-health.test.js new file mode 100644 index 00000000000..84009f96278 --- /dev/null +++ b/tests/unit/codex-model-health.test.js @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ connections: vi.fn(), update: vi.fn() })); +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: mocks.connections, updateProviderConnection: mocks.update, + getSettings: async () => ({}), getProxyPools: async () => [], validateApiKey: vi.fn(), +})); +vi.mock("@/lib/network/connectionProxy", () => ({ resolveConnectionProxyConfig: async () => ({}), pickProxyPoolId: vi.fn() })); +vi.mock("@/shared/constants/providers.js", () => ({ FREE_PROVIDERS: {}, resolveProviderId: (id) => id === "cx" ? "codex" : id })); +vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() })); +import { markAccountUnavailable, getProviderCredentials, clearAccountError } from "../../src/sse/services/auth.js"; +let account; +beforeEach(() => { + vi.clearAllMocks(); + account = { id: "account-a", provider: "codex", isActive: true, testStatus: "active", accessToken: "fixture-token" }; + mocks.connections.mockImplementation(async () => [account]); + mocks.update.mockImplementation(async (_id, fields) => { Object.assign(account, fields); }); +}); + +describe("Codex model health", () => { + it.each([ + [404, "The model `gpt-5.5` does not exist or you do not have access to it."], + [400, "The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account."], + ])("keeps a model access error (%s) scoped while routing other models", async (status, message) => { + const result = await markAccountUnavailable(account.id, status, message, "codex", "gpt-5.5"); + expect(result.shouldFallback).toBe(true); + expect(account.testStatus).toBe("active"); + expect(account.lastError).toBeUndefined(); + expect(account.lastModelError).toMatchObject({ model: "gpt-5.5", status }); + expect(await getProviderCredentials("codex", null, "gpt-5.5")).toMatchObject({ allRateLimited: true, lastErrorCode: status }); + expect(await getProviderCredentials("codex", null, "gpt-5.6-luna")).toMatchObject({ connectionId: account.id }); + }); + + it("does not rotate accounts or lock models for a gateway client-version failure", async () => { + expect(await markAccountUnavailable(account.id, 400, "This model requires a newer version of Codex.", "cx", "gpt-5.6-luna")) + .toEqual({ shouldFallback: false, cooldownMs: 0 }); + expect(mocks.update).not.toHaveBeenCalled(); + }); + + it.each([401, 429, 502])("retains account error behavior for unrelated status %s", async (status) => { + await markAccountUnavailable(account.id, status, "Upstream failure", "codex", "gpt-5.5"); + expect(account.testStatus).toBe("unavailable"); + expect(account.errorCode).toBe(status); + }); + + it("does not reinterpret another provider's 404", async () => { + await markAccountUnavailable(account.id, 404, "model not found", "openai", "gpt-5.5"); + expect(account.testStatus).toBe("unavailable"); + }); + + it("clears a model warning when that model succeeds but retains other active model locks", async () => { + account.modelLock_other = new Date(Date.now() + 60000).toISOString(); + await markAccountUnavailable(account.id, 404, "model not found", "codex", "gpt-5.5"); + await clearAccountError(account.id, account, "gpt-5.5"); + expect(account.lastModelError).toBeNull(); + expect(account['modelLock_gpt-5.5']).toBeNull(); + expect(account.modelLock_other).toBeTruthy(); + }); +}); diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index ba12b2116f0..eea2c3f7da2 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -351,8 +351,8 @@ describe("handleImageGenerationCore", () => { headers: expect.objectContaining({ authorization: "Bearer codex-token", "chatgpt-account-id": "account-123", - version: "0.144.6", - "user-agent": "codex_cli_rs/0.144.6", + version: "0.153.4", + "user-agent": "codex_cli_rs/0.153.4", }), }) ); diff --git a/tests/unit/quota-auto-ping.test.js b/tests/unit/quota-auto-ping.test.js index f7edb771987..5aa9211184e 100644 --- a/tests/unit/quota-auto-ping.test.js +++ b/tests/unit/quota-auto-ping.test.js @@ -16,37 +16,8 @@ vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({ refreshAndUpdateCredentials: vi.fn(), })); -vi.mock("@/shared/constants/config", () => ({ - QUOTA_AUTOPING_CONFIG: { - tickIntervalMs: 60000, - pingLeadMs: 5000, - refreshAheadMs: 300000, - failureCooldownMs: 900000, - providers: { - claude: { - settingsKey: "claudeAutoPing", - quotaKey: "session (5h)", - pingModel: "claude-haiku-4-5-20251001", - pingText: "hi", - pingMaxTokens: 1, - }, - codex: { - settingsKey: "codexAutoPing", - quotaKey: "session", - pingWhenResetAtSlides: true, - resetAtDriftMs: 30000, - minPingIntervalMs: 600000, - skipWhenBlockingQuotaExhausted: true, - pingModel: "gpt-5.5", - pingText: "hi", - pingInstructions: "Reply with OK.", - pingReasoningEffort: "none", - }, - }, - }, -})); - -vi.mock("open-sse/providers/shared.js", () => ({ +vi.mock("open-sse/providers/shared.js", async (original) => ({ + ...await original(), CLAUDE_CLI_SPOOF_HEADERS: { "anthropic-version": "2023-06-01" }, })); @@ -99,12 +70,12 @@ describe("quota auto-ping", () => { refreshAndUpdateCredentials: vi.fn(async (connection) => ({ connection, refreshed: false })), proxyAwareFetch: vi.fn().mockResolvedValue({ ok: true }), getExecutor: vi.fn(() => ({ - execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }), + execute: vi.fn(async () => ({ response: new Response(await codexResponseText()) })), })), }; - codexResponseText = vi.fn().mockResolvedValue(""); + codexResponseText = vi.fn().mockResolvedValue('data: {"type":"response.completed","response":{"status":"completed"}}\n\n'); getExecutor.mockReturnValue({ - execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }), + execute: vi.fn(async () => ({ response: new Response(await codexResponseText()) })), }); state = { running: false, resetCache: {}, failureCache: {} }; vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z")); @@ -278,7 +249,7 @@ describe("quota auto-ping", () => { expect(deps.updateProviderConnection).not.toHaveBeenCalled(); }); - it("sends one tiny gpt-5.5 Codex request through the executor", async () => { + it("sends one tiny gpt-5.6-luna Codex request through the executor", async () => { deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); deps.getProviderConnections.mockImplementation(async ({ provider }) => ( provider === "codex" @@ -295,7 +266,7 @@ describe("quota auto-ping", () => { const executor = deps.getExecutor.mock.results[0].value; expect(deps.getExecutor).toHaveBeenCalledWith("codex"); expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({ - model: "gpt-5.5", + model: "gpt-5.6-luna", stream: true, credentials: expect.objectContaining({ accessToken: "token", @@ -303,14 +274,14 @@ describe("quota auto-ping", () => { providerSpecificData: { workspaceId: "ws-1" }, }), body: { - model: "gpt-5.5", + model: "gpt-5.6-luna", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }], }], instructions: "Reply with OK.", - reasoning: { effort: "none", summary: "auto" }, + reasoning: { effort: "low", summary: "auto" }, store: false, stream: true, }, @@ -322,6 +293,36 @@ describe("quota auto-ping", () => { })); }); + it.each([ + ["failed event", 'data: {"type":"response.failed","response":{"error":{"message":"model not available"}}}\n\n'], + ["incomplete event", 'event: response.incomplete\ndata: {"response":{"incomplete_details":{"reason":"max_output_tokens"}}}\n\n'], + ["truncated stream", 'data: {"type":"response.created"}\n\n'], + ])("does not record a completed ping for an HTTP 200 %s", async (_name, stream) => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockResolvedValue([{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }]); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ quotas: { session: { remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } } }); + codexResponseText.mockResolvedValue(stream); + await runQuotaAutoPingTick(deps, state); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + expect(state.failureCache["codex:codex-1"]).toBeTruthy(); + }); + + it("honors an operator-selected auto-ping model without enabling other accounts", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { model: "gpt-5.6-sol", connections: { "codex-1": true } } }); + deps.getProviderConnections.mockResolvedValue([ + { id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }, + { id: "codex-2", provider: "codex", authType: "oauth", accessToken: "token" }, + ]); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ quotas: { session: { remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } } }); + await runQuotaAutoPingTick(deps, state); + expect(deps.getExecutor).toHaveBeenCalledTimes(1); + expect(deps.getExecutor.mock.results[0].value.execute).toHaveBeenCalledWith(expect.objectContaining({ + model: "gpt-5.6-sol", body: expect.objectContaining({ model: "gpt-5.6-sol" }), + })); + }); + it("does not ping same Codex reset twice when seconds drift", async () => { deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); deps.getProviderConnections.mockImplementation(async ({ provider }) => (