Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions open-sse/config/codexConstants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// 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}`;

export const CODEX_IMAGE_NO_RESULT_ERROR = "Codex completed without returning an image.";
export const CODEX_IMAGE_ERROR_TEXT_LIMIT = 1000;
36 changes: 28 additions & 8 deletions open-sse/handlers/imageProviders/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
import { randomUUID } from "node:crypto";
import { nowSec } from "./_base.js";
import { PROVIDERS } from "../../config/providers.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_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";
Expand Down Expand Up @@ -50,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;
Expand All @@ -73,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;
Expand All @@ -86,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 });
}
Expand All @@ -95,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;
}

Expand All @@ -121,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 }] });
Expand Down Expand Up @@ -156,8 +176,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(),
};
},
Expand Down Expand Up @@ -191,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 }] };
},
Expand Down
4 changes: 3 additions & 1 deletion open-sse/providers/registry/codex.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CODEX_CLIENT_VERSION, CODEX_USER_AGENT } from "../../config/codexConstants.js";
import { withCodexReviewModels } from "../models/helpers.js";

export default {
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/providers/[id]/models/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@ 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";
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";

// The /codex/models endpoint gates each entry by minimal_client_version against this
// 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) => {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/providers/[id]/test/testUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/__baseline__/providers-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions tests/unit/codex-client-identity.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions tests/unit/codex-image-stream-errors.test.js
Original file line number Diff line number Diff line change
@@ -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.");
});
});
3 changes: 2 additions & 1 deletion tests/unit/image-generation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
})
);
Expand Down