diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4dbaf5b00..df9bbfe88 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -23,6 +23,7 @@ ### Fixed +- Fixed OpenAI Codex turn requests to include the Codex `version` header, matching upstream Codex request metadata for newly gated models. - Fixed xAI SuperGrok multi-account rotation to correctly treat HTTP 403 credit exhaustion and spending limit errors as usage limits, triggering a credential rotation to a sibling account. - Fixed error classification for AWS credential-resolution failures (AwsCredentialsError) to correctly map them as authentication failures. - Fixed OpenAI-compatible chat-completions streams to preserve vLLM-style trailing cached-token usage chunks, ensuring accurate cacheRead and billable input session statistics. diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 0fa0f720c..67b578d3c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1,5 +1,6 @@ import * as os from "node:os"; import { scheduler } from "node:timers/promises"; +import { resolveCodexClientVersion } from "@oh-my-pi/pi-catalog/discovery/codex"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { CODEX_BASE_URL, @@ -632,6 +633,7 @@ interface CodexRequestContext { baseUrl: string; url: string; requestHeaders: Record; + codexClientVersion: string; transportSessionId?: string; providerSessionState?: CodexProviderSessionState; isolatedTransportState?: CodexProviderSessionState; @@ -1200,6 +1202,7 @@ async function buildCodexRequestContext( const url = resolveCodexResponsesUrl(baseUrl); const promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId); const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId); + const codexClientVersion = await resolveCodexClientVersion(undefined, options?.fetch ?? fetch, options?.signal); const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey); const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) }; @@ -1275,6 +1278,7 @@ async function buildCodexRequestContext( websocketState, responsesLite, requestMetadata, + codexClientVersion, transformedBody, rawRequestDump, }; @@ -1427,6 +1431,7 @@ async function openCodexWebSocketTransport( requestContext.requestHeaders, requestContext.accountId, requestContext.apiKey, + requestContext.codexClientVersion, requestContext.transportSessionId, "websocket", websocketState, @@ -1527,6 +1532,7 @@ async function openCodexSseTransport( wireBody, state, requestContext.responsesLite, + requestContext.codexClientVersion, requestContext.requestMetadata, requestSetup.requestSignal, requestSetup.firstEventTimeoutMs, @@ -2496,6 +2502,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" baseUrl: model.baseUrl || CODEX_BASE_URL, url: "", requestHeaders: {}, + codexClientVersion: packageJson.version, responsesLite: options?.responsesLite === true, transformedBody: { model: model.id }, rawRequestDump: { @@ -2559,6 +2566,7 @@ export async function prewarmOpenAICodexResponses( transportSessionId ?? crypto.randomUUID(), providerSessionState, ); + const codexClientVersion = await resolveCodexClientVersion(undefined, fetch, options?.signal); const requestIdentity = createCodexCompatibilityIdentity(metadataSession); const headers = logger.time( "prewarmCodex:createHeaders", @@ -2566,6 +2574,7 @@ export async function prewarmOpenAICodexResponses( { ...(model.headers ?? {}), ...(options?.headers ?? {}) }, accountId, apiKey, + codexClientVersion, promptCacheKey, "websocket", state, @@ -3685,6 +3694,7 @@ async function openCodexSseEventStream( body: RequestBody, state: CodexWebSocketSessionState | undefined, responsesLite: boolean, + codexClientVersion: string, requestMetadata: CodexRequestMetadata | undefined, signal: AbortSignal | undefined, firstEventTimeoutMs: number | undefined, @@ -3695,6 +3705,7 @@ async function openCodexSseEventStream( requestHeaders, accountId, apiKey, + codexClientVersion, sessionId, "sse", state, @@ -3756,6 +3767,7 @@ function createCodexHeaders( initHeaders: Record | undefined, accountId: string | undefined, accessToken: string, + codexClientVersion: string, sessionId?: string, transport: CodexTransport = "sse", state?: CodexWebSocketSessionState, @@ -3774,6 +3786,7 @@ function createCodexHeaders( headers.delete("openai-beta"); headers.set(OPENAI_HEADERS.BETA, betaHeader); headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); + headers.set(OPENAI_HEADERS.VERSION, codexClientVersion); headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`); if (sessionId) { headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId); diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 2a56e4a59..a2e16ff7d 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -100,6 +100,9 @@ function createCodexFetchMock(sse: string, onRequest: (captured: CapturedCodexRe if (url === "https://api.github.com/repos/openai/codex/releases/latest") { return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); } + if (url === "https://registry.npmjs.org/@openai%2Fcodex/latest") { + return new Response(JSON.stringify({ version: "0.144.1" }), { status: 200 }); + } if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) { return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } }); } @@ -666,6 +669,7 @@ describe("openai-codex Responses Lite and client metadata wire format", () => { expect(result.stopReason).toBe("stop"); expect(captured?.headers.get("x-openai-internal-codex-responses-lite")).toBe("true"); + expect(captured?.headers.get("version")).toBe("0.144.1"); expect(captured?.body.reasoning).toEqual({ context: "all_turns" }); expect(captured?.body.instructions).toBeUndefined(); expect(captured?.body.tools).toBeUndefined(); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 4d9cfbdd6..3ea5b3229 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -19,6 +19,9 @@ ### Removed - Removed the generated GPT-5.6 pro-reasoning aliases (`gpt-5.6-{luna,sol,terra}-pro`) from the `openai-codex` subscription provider — pro reasoning is not offered on subscriptions; the `openai` API-key aliases remain +### Fixed + +- Fixed OpenAI Codex model discovery to include the Codex `version` header alongside the `client_version` query parameter. ## [16.4.0] - 2026-07-10 diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index 5784d7088..118e37fde 100644 --- a/packages/catalog/src/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -86,12 +86,12 @@ export async function fetchCodexModels(options: CodexModelDiscoveryOptions): Pro const fetchFn = discoveryFetch(options.fetchFn); const baseUrl = normalizeBaseUrl(options.baseUrl); const paths = normalizePaths(options.paths); - const headers = buildCodexHeaders(options); const clientVersion = await resolveCodexClientVersion( options.clientVersion, options.registryFetchFn ?? fetchFn, options.signal, ); + const headers = buildCodexHeaders(options, clientVersion); let sawSuccessfulResponse = false; for (const path of paths) { @@ -156,7 +156,7 @@ function buildModelsUrl(baseUrl: string, path: string, clientVersion: string | u return url.toString(); } -function buildCodexHeaders(options: CodexModelDiscoveryOptions): Headers { +function buildCodexHeaders(options: CodexModelDiscoveryOptions, clientVersion: string): Headers { const headers = new Headers(options.headers); headers.set("Authorization", `Bearer ${options.accessToken}`); if (options.accountId && options.accountId.trim().length > 0) { @@ -164,11 +164,12 @@ function buildCodexHeaders(options: CodexModelDiscoveryOptions): Headers { } headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES); headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); + headers.set(OPENAI_HEADERS.VERSION, clientVersion); headers.set("accept", "application/json"); return headers; } -async function resolveCodexClientVersion( +export async function resolveCodexClientVersion( clientVersion: string | undefined, fetchFn: FetchImpl, signal: AbortSignal | undefined, diff --git a/packages/catalog/src/wire/codex.ts b/packages/catalog/src/wire/codex.ts index 329ace70f..48edeb392 100644 --- a/packages/catalog/src/wire/codex.ts +++ b/packages/catalog/src/wire/codex.ts @@ -8,6 +8,7 @@ export const OPENAI_HEADERS = { BETA: "OpenAI-Beta", ACCOUNT_ID: "chatgpt-account-id", ORIGINATOR: "originator", + VERSION: "version", SESSION_ID: "session_id", CONVERSATION_ID: "conversation_id", SCOPED_SESSION_ID: "session-id", diff --git a/packages/catalog/test/codex-discovery.test.ts b/packages/catalog/test/codex-discovery.test.ts index babff9c28..b647ac895 100644 --- a/packages/catalog/test/codex-discovery.test.ts +++ b/packages/catalog/test/codex-discovery.test.ts @@ -11,9 +11,11 @@ import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; describe("Codex model discovery", () => { it("marks discovered models for provider-native V2 compaction", async () => { + let capturedHeaders: Headers | undefined; const fetchFn: typeof fetch = Object.assign( - async () => - new Response( + async (_input: string | URL | Request, init?: RequestInit) => { + capturedHeaders = new Headers(init?.headers); + return new Response( JSON.stringify({ models: [ { @@ -28,7 +30,8 @@ describe("Codex model discovery", () => { ], }), { headers: { etag: "models-v1" } }, - ), + ); + }, { preconnect() {} }, ); const result = await fetchCodexModels({ @@ -38,6 +41,7 @@ describe("Codex model discovery", () => { fetchFn, }); + expect(capturedHeaders?.get("version")).toBe("0.99.0"); expect(result?.etag).toBe("models-v1"); expect(result?.models).toHaveLength(1); expect(result?.models[0]).toMatchObject({