From 1bbe9e694ef79f4135fc8f1123ededb385701aa1 Mon Sep 17 00:00:00 2001 From: Francesco Zito Date: Tue, 6 Oct 2026 16:44:47 +0200 Subject: [PATCH] fix(codex): refresh discovery client version --- open-sse/config/codexClient.ts | 115 +++++++++ .../providers/[id]/models/discovery/codex.ts | 11 +- .../unit/codex-client-version-refresh.test.ts | 224 ++++++++++++++++++ 3 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 tests/unit/codex-client-version-refresh.test.ts diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index 36b0bfe37b3a..c27ad4f80f5c 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import type { SafeOutboundFetchOptions } from "@/shared/network/safeOutboundFetch"; import { CODEX_CLI_RS_ORIGINATOR, DEFAULT_CODEX_CLIENT_VERSION, @@ -8,6 +10,26 @@ export { DEFAULT_CODEX_CLIENT_VERSION, CODEX_CLI_RS_ORIGINATOR, } from "@/shared/constants/codexClient"; +const CODEX_VERSION_METADATA_URL = "https://registry.npmjs.org/@openai%2Fcodex/latest"; +const CODEX_VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const CODEX_VERSION_RETRY_MS = 5 * 60 * 1000; +const CODEX_VERSION_METADATA_MAX_BYTES = 64 * 1024; +const codexVersionMetadataSchema = z.object({ + name: z.literal("@openai/codex"), + version: z + .string() + .trim() + .regex(/^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/), +}); + +export type CodexClientVersionFetch = ( + input: string, + init: SafeOutboundFetchOptions & { method: "GET"; headers: Record } +) => Promise; + +let cachedCodexVersion: string | null = null; +let codexVersionRefreshAt = 0; +let codexVersionRefresh: Promise | null = null; const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200"; const DEFAULT_CODEX_USER_AGENT_ARCH = "x64"; const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION"; @@ -29,10 +51,103 @@ function getSafeEnvValue(name: string, pattern: RegExp): string | null { export function getCodexClientVersion(): string { return ( getSafeEnvValue(CODEX_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN) || + cachedCodexVersion || DEFAULT_CODEX_CLIENT_VERSION ); } +/** Bound registry body reads as well as the connection; a stalled body must not block discovery. */ +async function readCodexVersionMetadata(response: Response, signal: AbortSignal): Promise { + if (!response.body) throw new Error("Empty Codex version metadata"); + const reader = response.body.getReader(); + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener("abort", cancel, { once: true }); + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + try { + signal.throwIfAborted(); + while (true) { + const { done, value } = await reader.read(); + signal.throwIfAborted(); + if (done) break; + bytes += value.byteLength; + if (bytes > CODEX_VERSION_METADATA_MAX_BYTES) { + cancel(); + throw new Error("Codex version metadata is too large"); + } + text += decoder.decode(value, { stream: true }); + } + return JSON.parse(text + decoder.decode()); + } finally { + signal.removeEventListener("abort", cancel); + reader.releaseLock(); + } +} + +/** Refresh only during model discovery. Inference reads the validated cache synchronously. */ +export function refreshCodexClientVersion(fetchImpl: CodexClientVersionFetch): Promise { + const override = getSafeEnvValue(CODEX_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN); + if (override || Date.now() < codexVersionRefreshAt) { + return Promise.resolve(getCodexClientVersion()); + } + if (codexVersionRefresh) return codexVersionRefresh; + codexVersionRefresh = (async () => { + let refreshAfter = CODEX_VERSION_RETRY_MS; + try { + const signal = AbortSignal.timeout(5_000); + const response = await fetchImpl(CODEX_VERSION_METADATA_URL, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + redirect: "error", + allowRedirect: false, + guard: "public-only", + pinDns: true, + retry: false, + }); + if (response.ok) { + const parsed = codexVersionMetadataSchema.safeParse( + await readCodexVersionMetadata(response, signal) + ); + if (parsed.success) { + const current = (cachedCodexVersion || DEFAULT_CODEX_CLIENT_VERSION) + .split(".") + .map(Number); + const difference = + parsed.data.version + .split(".") + .map((part, index) => Number(part) - current[index]) + .find((part) => part !== 0) ?? 0; + if (difference >= 0) { + cachedCodexVersion = parsed.data.version; + refreshAfter = CODEX_VERSION_CACHE_TTL_MS; + } + } + } else { + await response.body?.cancel(); + } + } catch { + // Registry unavailable or invalid: retain the last-known-good version or offline pin. + } + codexVersionRefreshAt = Date.now() + refreshAfter; + return getCodexClientVersion(); + })(); + const current = codexVersionRefresh; + void current.finally(() => { + if (codexVersionRefresh === current) codexVersionRefresh = null; + }); + return current; +} + +export function resetCodexClientVersionCacheForTests(): void { + cachedCodexVersion = null; + codexVersionRefreshAt = 0; + codexVersionRefresh = null; +} + /** * Extract the Codex client version the CALLER actually reported, so OmniRoute * forwards it upstream instead of substituting a pinned default. The official diff --git a/src/app/api/providers/[id]/models/discovery/codex.ts b/src/app/api/providers/[id]/models/discovery/codex.ts index 1b7a78496fdc..bb27bb49b7a2 100644 --- a/src/app/api/providers/[id]/models/discovery/codex.ts +++ b/src/app/api/providers/[id]/models/discovery/codex.ts @@ -2,6 +2,8 @@ import { CODEX_CLI_RS_ORIGINATOR, getCodexClientVersion, getCodexDefaultHeaders, + refreshCodexClientVersion, + type CodexClientVersionFetch, } from "@omniroute/open-sse/config/codexClient.ts"; import { classifyCodexDiscoveryModel, @@ -44,13 +46,7 @@ export type CodexDiscoveryModel = { compatibilityReason?: string; }; -export type CodexModelsFetch = ( - input: string, - init: { - method: "GET"; - headers: Record; - } -) => Promise; +export type CodexModelsFetch = CodexClientVersionFetch; type CodexGithubCatalogCache = { models: CodexDiscoveryModel[]; @@ -530,6 +526,7 @@ export async function fetchCodexDiscoveryModels({ if (!accessToken) return null; try { + await refreshCodexClientVersion(fetchImpl); const workspaceId = toNonEmptyString(providerSpecificData?.workspaceId) || toNonEmptyString(providerSpecificData?.chatgptAccountId) || diff --git a/tests/unit/codex-client-version-refresh.test.ts b/tests/unit/codex-client-version-refresh.test.ts new file mode 100644 index 000000000000..6994f6abc578 --- /dev/null +++ b/tests/unit/codex-client-version-refresh.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as codexClient from "../../open-sse/config/codexClient.ts"; +import { fetchCodexDiscoveryModels } from "../../src/app/api/providers/[id]/models/discovery/codex.ts"; + +const REGISTRY_URL = "https://registry.npmjs.org/@openai%2Fcodex/latest"; +const originalVersion = process.env.CODEX_CLIENT_VERSION; +const originalUserAgent = process.env.CODEX_USER_AGENT; +const registryResponse = (version = "0.160.1") => Response.json({ name: "@openai/codex", version }); + +test.beforeEach(() => { + delete process.env.CODEX_CLIENT_VERSION; + delete process.env.CODEX_USER_AGENT; +}); + +test.afterEach(() => { + codexClient.resetCodexClientVersionCacheForTests?.(); + test.mock.restoreAll(); + if (originalVersion === undefined) delete process.env.CODEX_CLIENT_VERSION; + else process.env.CODEX_CLIENT_VERSION = originalVersion; + if (originalUserAgent === undefined) delete process.env.CODEX_USER_AGENT; + else process.env.CODEX_USER_AGENT = originalUserAgent; +}); + +test("refresh uses official metadata without credentials and aligns synchronous headers", async () => { + assert.equal(codexClient.getCodexClientVersion(), codexClient.DEFAULT_CODEX_CLIENT_VERSION); + let calls = 0; + const fetchImpl: codexClient.CodexClientVersionFetch = async (url, init) => { + calls++; + assert.equal(url, REGISTRY_URL); + assert.deepEqual(init.headers, { Accept: "application/json" }); + assert.equal(init.redirect, "error"); + assert.equal(init.guard, "public-only"); + assert.equal(init.pinDns, true); + assert.equal(init.retry, false); + assert.ok(init.signal instanceof AbortSignal); + return registryResponse(); + }; + assert.equal(await codexClient.refreshCodexClientVersion(fetchImpl), "0.160.1"); + assert.equal(codexClient.getCodexClientVersion(), "0.160.1"); + assert.equal(codexClient.getCodexDefaultHeaders().Version, "0.160.1"); + assert.match(codexClient.getCodexUserAgent(), /^codex-cli\/0\.160\.1 /); + assert.equal( + codexClient.getCodexCliRsHeaders()["User-Agent"], + `${codexClient.CODEX_CLI_RS_ORIGINATOR}/0.160.1` + ); + assert.equal(codexClient.getCodexBackendIdentityHeaders().Version, "0.160.1"); + assert.equal(calls, 1); +}); + +test("explicit version and user agent retain precedence, caller versions still pass through", async () => { + process.env.CODEX_CLIENT_VERSION = "0.149.0"; + process.env.CODEX_USER_AGENT = "operator-client/1.0"; + assert.equal( + await codexClient.refreshCodexClientVersion(async () => { + assert.fail("an explicit version must not fetch metadata"); + }), + "0.149.0" + ); + assert.equal(codexClient.getCodexUserAgent(), "operator-client/1.0"); + assert.equal(codexClient.getCodexClientVersionFromHeaders({ version: "0.170.0" }), "0.170.0"); + delete process.env.CODEX_CLIENT_VERSION; + await codexClient.refreshCodexClientVersion(async () => registryResponse()); + assert.equal(codexClient.getCodexUserAgent(), "operator-client/1.0"); +}); + +test("fresh cache avoids lookup; expiry rechecks and concurrent callers share lookup", async () => { + let now = 1_000; + test.mock.method(Date, "now", () => now); + let calls = 0; + const fetchImpl = async () => { + calls++; + return registryResponse(); + }; + await Promise.all([ + codexClient.refreshCodexClientVersion(fetchImpl), + codexClient.refreshCodexClientVersion(fetchImpl), + ]); + await codexClient.refreshCodexClientVersion(fetchImpl); + assert.equal(calls, 1); + now += 6 * 60 * 60 * 1_000 + 1; + assert.equal( + await codexClient.refreshCodexClientVersion(async () => { + calls++; + return registryResponse("0.161.0"); + }), + "0.161.0" + ); + assert.equal(calls, 2); +}); + +test("offline refresh preserves last-known-good; failures are retried after a bounded delay", async () => { + let now = 1_000; + test.mock.method(Date, "now", () => now); + let calls = 0; + const offline = async () => { + calls++; + throw new Error("offline"); + }; + assert.equal( + await codexClient.refreshCodexClientVersion(offline), + codexClient.DEFAULT_CODEX_CLIENT_VERSION + ); + await codexClient.refreshCodexClientVersion(offline); + assert.equal(calls, 1); + now += 5 * 60 * 1_000 + 1; + assert.equal( + await codexClient.refreshCodexClientVersion(async () => registryResponse()), + "0.160.1" + ); + now += 6 * 60 * 60 * 1_000 + 1; + assert.equal(await codexClient.refreshCodexClientVersion(offline), "0.160.1"); + assert.equal(codexClient.getCodexClientVersion(), "0.160.1"); +}); + +test("malformed metadata, prereleases, HTTP errors and downgrades cannot replace a known-good version", async () => { + let now = 1_000; + test.mock.method(Date, "now", () => now); + await codexClient.refreshCodexClientVersion(async () => registryResponse()); + const invalid = [ + Response.json({ name: "wrong-package", version: "9.9.9" }), + Response.json({ version: "9.9.9" }), + registryResponse("0.170.0-beta.1"), + registryResponse("0.01.0"), + registryResponse("9.9.9\r\nInjected: evil"), + registryResponse("0.9.0"), + new Response("bad json"), + new Response(null, { status: 503 }), + ]; + for (const response of invalid) { + now += 6 * 60 * 60 * 1_000 + 1; + assert.equal(await codexClient.refreshCodexClientVersion(async () => response), "0.160.1"); + } +}); + +test("discovery refreshes before authenticated model fetch and accepts remote-only IDs", async () => { + const requests: string[] = []; + const models = await fetchCodexDiscoveryModels({ + accessToken: "test-token", + providerSpecificData: { workspaceId: "test-account" }, + fetchImpl: async (url, init) => { + requests.push(url); + if (url === REGISTRY_URL) { + assert.deepEqual(init.headers, { Accept: "application/json" }); + return registryResponse(); + } + assert.equal(new URL(url).searchParams.get("client_version"), "0.160.1"); + assert.equal(init.headers.Version, "0.160.1"); + assert.match(init.headers["User-Agent"], /^codex-cli\/0\.160\.1 /); + assert.equal(init.headers.Authorization, "Bearer test-token"); + assert.equal(init.headers["chatgpt-account-id"], "test-account"); + return Response.json({ + models: [{ slug: "gpt-future-remote-only", display_name: "Future" }], + }); + }, + }); + assert.equal(requests.length, 2); + assert.equal(requests[0], REGISTRY_URL); + assert.equal(models?.[0].id, "gpt-future-remote-only"); +}); + +test("registry failure still discovers models with fallback version; absent credentials do no work", async () => { + const models = await fetchCodexDiscoveryModels({ + accessToken: "test-token", + fetchImpl: async (url) => { + if (url === REGISTRY_URL) throw new Error("registry blocked"); + assert.equal( + new URL(url).searchParams.get("client_version"), + codexClient.DEFAULT_CODEX_CLIENT_VERSION + ); + return Response.json({ models: [{ slug: "gpt-known-model" }] }); + }, + }); + assert.equal(models?.[0].id, "gpt-known-model"); + assert.equal( + await fetchCodexDiscoveryModels({ + accessToken: null, + fetchImpl: async () => { + assert.fail("no credentials must not fetch"); + }, + }), + null + ); +}); + +test("oversized metadata bodies are cancelled and preserve fallback", async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024 + 1)); + }, + cancel() { + cancelled = true; + }, + }); + assert.equal( + await codexClient.refreshCodexClientVersion(async () => new Response(body)), + codexClient.DEFAULT_CODEX_CLIENT_VERSION + ); + assert.equal(cancelled, true); +}); + +test("stalled metadata body obeys the same request deadline", async () => { + const controller = new AbortController(); + test.mock.method(AbortSignal, "timeout", (milliseconds: number) => { + assert.equal(milliseconds, 5_000); + return controller.signal; + }); + const started = Promise.withResolvers(); + let cancelled = false; + const body = new ReadableStream({ + pull() { + started.resolve(); + }, + cancel() { + cancelled = true; + }, + }); + const pending = codexClient.refreshCodexClientVersion(async () => new Response(body)); + await started.promise; + controller.abort(); + assert.equal(await pending, codexClient.DEFAULT_CODEX_CLIENT_VERSION); + assert.equal(cancelled, true); +});