diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 2e5febe0e..8ad581d44 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2896, - "total": 9228, + "count": 2895, + "total": 9226, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1741,8 +1741,7 @@ "lib/ai/coach/turn/types.ts": 17, "lib/ai/coach/types.ts": 58, "lib/ai/coach/visits-snapshot.ts": 1, - "lib/ai/codex-client.ts": 18, - "lib/ai/codex-slug-cache.ts": 1, + "lib/ai/codex-client.ts": 17, "lib/ai/consent-guard.ts": 1, "lib/ai/feedback-attribution.ts": 2, "lib/ai/insight-interpretation.ts": 1, diff --git a/src/lib/ai/__tests__/cached-input-usage.test.ts b/src/lib/ai/__tests__/cached-input-usage.test.ts index 2a555aad5..46a71ec06 100644 --- a/src/lib/ai/__tests__/cached-input-usage.test.ts +++ b/src/lib/ai/__tests__/cached-input-usage.test.ts @@ -122,6 +122,7 @@ function jsonResponse(body: unknown) { function codexClient() { return new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/codex-client.test.ts b/src/lib/ai/__tests__/codex-client.test.ts index 86ab4fd9e..ea7a84011 100644 --- a/src/lib/ai/__tests__/codex-client.test.ts +++ b/src/lib/ai/__tests__/codex-client.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { CodexClient } from "../codex-client"; import { singleUserTurn } from "../types"; +/** A model list that cannot be read: the client walks its static order. */ +const NO_MODEL_LIST = async () => + ({ models: null, from: "live", failure: "network" }) as const; + /** * Build a `Response` whose `.body` streams a list of SSE event strings. * Mirrors the wire format the Codex backend emits (each event terminated @@ -51,6 +55,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -97,6 +103,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -126,6 +134,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "expired-token", accountId: "acct-test", onTokenRefresh: async () => { @@ -156,6 +166,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "bad", accountId: "acct-test", onTokenRefresh: async () => { @@ -183,6 +195,8 @@ describe("CodexClient", () => { const onRefresh = vi.fn(); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test", accountId: "acct-test", onTokenRefresh: async () => { @@ -208,6 +222,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -245,6 +261,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -292,6 +310,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -349,6 +369,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -393,6 +415,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/codex-live-models.test.ts b/src/lib/ai/__tests__/codex-live-models.test.ts new file mode 100644 index 000000000..9e310c50a --- /dev/null +++ b/src/lib/ai/__tests__/codex-live-models.test.ts @@ -0,0 +1,319 @@ +/** + * The Codex client end to end against a stubbed backend: the account's model + * list read the way the Codex CLI reads it, the walk built from it, and the + * working model kept per sign-in (#1287). + * + * Every request goes through the real `safeFetch` into a stubbed `fetch` + * that answers `/codex/models` and `/codex/responses` per account, so the + * list read, the cache around it and the walk are all the production code. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CodexClient } from "../codex-client"; +import { singleUserTurn } from "../types"; +import { + CODEX_SLUG_CACHE_TTL_MS, + clearCodexSlugCache, + codexCredentialKey, + getCachedCodexSlug, +} from "../codex-slug-cache"; + +const FIXTURE = JSON.parse( + readFileSync(join(__dirname, "fixtures/codex/models-response.json"), "utf8"), +) as { models: Array<{ slug: string }> }; + +/** The fixture cut to `slugs`, as a plan that offers only those would see it. */ +function planList(...slugs: string[]): string { + return JSON.stringify({ + models: FIXTURE.models.filter((m) => slugs.includes(m.slug)), + }); +} + +function sse(text = '{"ok":true}'): Response { + const body = [ + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { usage: { total_tokens: 3 } }, + })}\n\n`, + ].join(""); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function retired(slug: string): Response { + return new Response( + JSON.stringify({ + detail: `The '${slug}' model is not supported when using Codex with a ChatGPT account.`, + error: { type: "invalid_request_error", code: "model_not_found" }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} + +interface Account { + /** The `/models` answer, or a function standing in for a failure. */ + models: string | (() => Response | Promise); + /** The models the responses endpoint accepts for this account. */ + accepts: string[]; +} + +/** + * A backend with one entry per ChatGPT account id. Records every request as + * ` models` or ` responses:`. + */ +function stubBackend(accounts: Record) { + const log: string[] = []; + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + const headers = init.headers as Record; + const account = accounts[headers["ChatGPT-Account-ID"]]; + if (!account) return new Response("unknown account", { status: 401 }); + const name = headers["ChatGPT-Account-ID"]; + if (url.includes("/codex/models")) { + log.push(`${name} models`); + return typeof account.models === "string" + ? new Response(account.models, { status: 200 }) + : account.models(); + } + const slug = JSON.parse(String(init.body)).model as string; + log.push(`${name} responses:${slug}`); + return account.accepts.includes(slug) ? sse() : retired(slug); + }); + vi.stubGlobal("fetch", fetchMock); + return log; +} + +function client(userKey: string, accountId: string) { + return new CodexClient({ + accessToken: `token-${accountId}`, + accountId, + credentialKey: userKey, + onTokenRefresh: vi.fn(), + }); +} + +const ask = (c: CodexClient) => + c.generateCompletion(singleUserTurn({ system: "s", user: "u" })); + +const ENV_KEYS = ["CODEX_MODEL", "CODEX_MODEL_FALLBACK_CHAIN"] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + clearCodexSlugCache(); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("Codex reads the account's live model list", () => { + it("goes straight to an offered model after a retirement the built-in order does not know about", async () => { + // The 2026-10-14 shape: nothing on the built-in order's head is offered. + const log = stubBackend({ + plus: { + models: planList("gpt-6.1-sol", "gpt-6-luna"), + accepts: ["gpt-6.1-sol", "gpt-6-luna"], + }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + expect(log).toEqual(["plus models", "plus responses:gpt-6.1-sol"]); + expect(c.getLastDiagnostics()?.attempted).toEqual(["gpt-6.1-sol"]); + }); + + it("does not read the list again while the working model holds", async () => { + const log = stubBackend({ + plus: { models: planList("gpt-6-sol"), accepts: ["gpt-6-sol"] }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + await ask(c); + await ask(c); + expect(log.filter((l) => l.endsWith("models"))).toHaveLength(1); + expect(c.getLastDiagnostics()?.cacheState).toBe("hit"); + }); + + it("a models call that fails still reaches a working model through the static walk", async () => { + for (const failure of [ + () => new Response("bad gateway", { status: 502 }), + () => new Response("", { status: 200 }), + () => new Response('{"models":[]}', { status: 200 }), + () => { + throw new TypeError("fetch failed"); + }, + ]) { + clearCodexSlugCache(); + const log = stubBackend({ + plus: { models: failure, accepts: ["gpt-6-sol"] }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + // Today's walk: terra refused, gpt-6-sol answers. + expect(log).toEqual([ + "plus models", + "plus responses:gpt-5.6-terra", + "plus responses:gpt-6-sol", + ]); + } + }); + + it("a models call that never answers is cut off and the walk goes on", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const log = stubBackend({ + plus: { + // Resolves only when the 5 s bound aborts it. + models: () => + new Promise((_, reject) => + setTimeout( + () => + reject( + new DOMException("The operation timed out.", "TimeoutError"), + ), + 5_000, + ), + ), + accepts: ["gpt-5.6-terra"], + }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + const answer = ask(c); + await vi.advanceTimersByTimeAsync(5_000); + await answer; + expect(log).toEqual(["plus models", "plus responses:gpt-5.6-terra"]); + }); + + it("an explicit CODEX_MODEL still goes first, ahead of the list", async () => { + process.env.CODEX_MODEL = "gpt-6-astra"; + const log = stubBackend({ + plus: { + models: planList("gpt-6-astra", "gpt-6-sol"), + accepts: ["gpt-6-astra", "gpt-6-sol"], + }, + }); + await ask(client(codexCredentialKey.user("u1"), "plus")); + expect(log).toEqual(["plus models", "plus responses:gpt-6-astra"]); + }); + + it("CODEX_MODEL_FALLBACK_CHAIN replaces the order and the list is not read", async () => { + process.env.CODEX_MODEL_FALLBACK_CHAIN = "gpt-6-luna"; + const log = stubBackend({ + plus: { models: planList("gpt-6-sol"), accepts: ["gpt-6-luna"] }, + }); + await ask(client(codexCredentialKey.user("u1"), "plus")); + expect(log).toEqual(["plus responses:gpt-6-luna"]); + }); +}); + +describe("Codex keeps the working model per sign-in", () => { + it("two sign-ins on different plans do not overwrite each other", async () => { + const log = stubBackend({ + // One plan still has terra; the other only the gpt-6 line. + pro: { + models: planList("gpt-5.6-terra", "gpt-6-sol"), + accepts: ["gpt-5.6-terra", "gpt-6-sol"], + }, + edu: { + models: planList("gpt-6.1-sol", "gpt-6-luna"), + accepts: ["gpt-6.1-sol", "gpt-6-luna"], + }, + }); + const a = client(codexCredentialKey.user("a"), "pro"); + const b = client(codexCredentialKey.user("b"), "edu"); + const central = client(codexCredentialKey.central, "edu"); + + await ask(a); + await ask(b); + await ask(central); + await ask(a); + await ask(b); + + expect(getCachedCodexSlug(codexCredentialKey.user("a"))).toBe( + "gpt-5.6-terra", + ); + expect(getCachedCodexSlug(codexCredentialKey.user("b"))).toBe( + "gpt-6.1-sol", + ); + expect(getCachedCodexSlug(codexCredentialKey.central)).toBe("gpt-6.1-sol"); + // Not one refused request: each sign-in only ever asked what it is offered. + expect(log.filter((l) => l.includes("responses"))).toEqual([ + "pro responses:gpt-5.6-terra", + "edu responses:gpt-6.1-sol", + "edu responses:gpt-6.1-sol", + "pro responses:gpt-5.6-terra", + "edu responses:gpt-6.1-sol", + ]); + }); + + it("a listed model that answers model_not_found is skipped for that sign-in only", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const both = planList("gpt-5.6-terra", "gpt-6-sol"); + const log = stubBackend({ + // The list still offers terra, but the backend already retired it. + stale: { models: both, accepts: ["gpt-6-sol"] }, + fresh: { models: both, accepts: ["gpt-5.6-terra", "gpt-6-sol"] }, + }); + const stale = client(codexCredentialKey.user("s"), "stale"); + const fresh = client(codexCredentialKey.user("f"), "fresh"); + + await ask(stale); + await ask(fresh); + expect(log).toEqual([ + "stale models", + "stale responses:gpt-5.6-terra", + "stale responses:gpt-6-sol", + "fresh models", + "fresh responses:gpt-5.6-terra", + ]); + + // Past the working model's TTL the stale sign-in walks again, and terra + // is at the back of its line even though the list still offers it. + log.length = 0; + vi.setSystemTime(Date.now() + CODEX_SLUG_CACHE_TTL_MS + 1); + await ask(stale); + expect(log).toEqual(["stale models", "stale responses:gpt-6-sol"]); + }); + + it("the working model that stops answering is dropped and the walk goes on", async () => { + const accepts = ["gpt-5.6-terra", "gpt-6-sol"]; + const log = stubBackend({ + plus: { models: planList("gpt-5.6-terra", "gpt-6-sol"), accepts }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBe( + "gpt-5.6-terra", + ); + + // terra retires between two requests. + accepts.splice(0, 1); + log.length = 0; + await ask(c); + expect(log).toEqual([ + "plus responses:gpt-5.6-terra", + "plus responses:gpt-6-sol", + ]); + expect(c.getLastDiagnostics()).toMatchObject({ + cacheState: "hit", + attempted: ["gpt-5.6-terra", "gpt-6-sol"], + workingSlug: "gpt-6-sol", + }); + }); +}); diff --git a/src/lib/ai/__tests__/codex-model-catalog.test.ts b/src/lib/ai/__tests__/codex-model-catalog.test.ts new file mode 100644 index 000000000..e8893b50d --- /dev/null +++ b/src/lib/ai/__tests__/codex-model-catalog.test.ts @@ -0,0 +1,374 @@ +/** + * The account's Codex model list: parsing the `/models` body, the bounded + * read, and the per-sign-in cache around it. + * + * The fixture is the upstream catalog bundle (openai/codex + * `codex-rs/models-manager/models.json`) cut to the models a ChatGPT + * sign-in was offered on 2026-10-24, plus one hidden review model and one + * hidden specialist model, every field as upstream ships it except the + * instruction templates, which are trimmed. The live endpoint answers in the + * same `ModelsResponse` shape (`codex-rs/protocol/src/openai_models.rs`). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetch = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: (...a: unknown[]) => safeFetch(...a), +})); +const emitSignal = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/logging/signal", () => ({ + emitSignal: (...a: unknown[]) => emitSignal(...a), +})); + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + CODEX_MODELS_CLIENT_VERSION, + cachedCodexModels, + fetchCodexModelList, + parseCodexModelList, + readCodexModelCatalog, +} from "../codex-model-catalog"; +import { + CODEX_CATALOG_RETRY_MS, + CODEX_CATALOG_TTL_MS, + clearCodexSlugCache, + codexCredentialKey, +} from "../codex-slug-cache"; + +const FIXTURE = readFileSync( + join(__dirname, "fixtures/codex/models-response.json"), + "utf8", +); + +const AUTH = { accessToken: "access-token", accountId: "acct-plus" }; +const PERSONAL = { key: codexCredentialKey.user("u1"), accountId: "acct-plus" }; + +function jsonResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + safeFetch.mockReset(); + emitSignal.mockReset(); + clearCodexSlugCache(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("parseCodexModelList", () => { + it("reads the upstream shape: slug, visibility, priority, modalities, specialty", () => { + const models = parseCodexModelList(JSON.parse(FIXTURE)); + expect(models?.map((m) => m.slug)).toEqual([ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-daybreak-blue-latest", + "codex-auto-review", + ]); + const luna = models?.find((m) => m.slug === "gpt-6-luna"); + expect(luna).toEqual({ + slug: "gpt-6-luna", + listed: true, + priority: 4, + inputModalities: ["text", "image"], + specialty: null, + }); + expect(models?.find((m) => m.slug === "codex-auto-review")?.listed).toBe( + false, + ); + expect( + models?.find((m) => m.slug === "gpt-daybreak-blue-latest")?.specialty, + ).toBe("cyber"); + }); + + it("refuses a body that is not the documented envelope", () => { + for (const body of [ + null, + "models", + 42, + [], + {}, + { models: "gpt-6-sol" }, + { data: [{ id: "gpt-6-sol" }] }, + ]) { + expect(parseCodexModelList(body), JSON.stringify(body)).toBeNull(); + } + }); + + it("skips entries without a usable slug and keeps the rest", () => { + const models = parseCodexModelList({ + models: [ + null, + "gpt-6-sol", + { slug: 7 }, + { slug: "" }, + { slug: "has spaces" }, + { slug: "gpt-6-sol\nInjected: header" }, + { slug: "x".repeat(200) }, + { slug: "gpt-6-luna", visibility: "list" }, + { slug: "gpt-6-luna", visibility: "list" }, + ], + }); + expect(models).toEqual([ + { + slug: "gpt-6-luna", + listed: true, + priority: null, + inputModalities: null, + specialty: null, + }, + ]); + }); + + it("reads missing or odd fields conservatively", () => { + const [model] = parseCodexModelList({ + models: [ + { + slug: "gpt-7-sol", + visibility: "LIST", + priority: "1", + input_modalities: "text", + }, + ], + })!; + // Not "list" exactly → not listed; a non-array modality field says nothing. + expect(model).toEqual({ + slug: "gpt-7-sol", + listed: false, + priority: null, + inputModalities: null, + specialty: null, + }); + }); + + it("returns an empty array for an empty list", () => { + expect(parseCodexModelList({ models: [] })).toEqual([]); + }); +}); + +describe("fetchCodexModelList", () => { + it("asks the endpoint the Codex CLI asks, with the responses call's sign-in", async () => { + safeFetch.mockResolvedValue(jsonResponse(FIXTURE)); + const result = await fetchCodexModelList(AUTH); + expect(result.ok).toBe(true); + const [url, init, options] = safeFetch.mock.calls[0]; + expect(url).toBe( + `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_MODELS_CLIENT_VERSION}`, + ); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer access-token"); + expect(init.headers["ChatGPT-Account-ID"]).toBe("acct-plus"); + expect(init.headers.originator).toBe("healthlog"); + // Bounded: the Codex CLI's own 5 s, and no redirect following. + expect(options).toEqual({ timeoutMs: 5_000 }); + }); + + it.each([ + ["a timeout", new SafeFetchError("timed out", "timeout"), "timeout"], + ["a network error", new SafeFetchError("reset", "network"), "network"], + ])("maps %s to its reason", async (_label, error, reason) => { + safeFetch.mockRejectedValue(error); + expect(await fetchCodexModelList(AUTH)).toEqual({ + ok: false, + reason, + status: null, + }); + }); + + it("maps refusals, bad bodies and empty lists to closed reasons", async () => { + const cases: Array<[Response, string]> = [ + [jsonResponse("unauthorized", 401), "auth"], + [jsonResponse("forbidden", 403), "auth"], + [jsonResponse("bad gateway", 502), "http_error"], + [new Response(null, { status: 302 }), "http_error"], + [jsonResponse("not json"), "unparsable"], + [jsonResponse('{"data":[]}'), "unparsable"], + [jsonResponse('{"models":[]}'), "empty"], + [jsonResponse('{"models":[{"slug":""}]}'), "empty"], + ]; + for (const [response, reason] of cases) { + safeFetch.mockResolvedValueOnce(response); + const result = await fetchCodexModelList(AUTH); + expect(result.ok, reason).toBe(false); + expect(!result.ok && result.reason, reason).toBe(reason); + } + }); + + it("stops reading a body past the size bound", async () => { + const huge = new Response( + new ReadableStream({ + start(controller) { + const chunk = new Uint8Array(1024 * 1024).fill(32); + for (let i = 0; i < 5; i += 1) controller.enqueue(chunk); + controller.close(); + }, + }), + { status: 200 }, + ); + safeFetch.mockResolvedValue(huge); + expect(await fetchCodexModelList(AUTH)).toEqual({ + ok: false, + reason: "too_large", + status: 200, + }); + }); + + it("reports a body that stalls past the timeout as a timeout", async () => { + const stalled = new Response( + new ReadableStream({ + pull() { + throw new DOMException("The operation timed out.", "TimeoutError"); + }, + }), + { status: 200 }, + ); + safeFetch.mockResolvedValue(stalled); + const result = await fetchCodexModelList(AUTH); + expect(!result.ok && result.reason).toBe("timeout"); + }); +}); + +describe("readCodexModelCatalog", () => { + it("reads once, then answers from the cache until the TTL passes", async () => { + vi.useFakeTimers(); + safeFetch.mockImplementation(async () => jsonResponse(FIXTURE)); + + const first = await readCodexModelCatalog(PERSONAL, AUTH); + expect(first.from).toBe("live"); + expect(first.models).toHaveLength(9); + const second = await readCodexModelCatalog(PERSONAL, AUTH); + expect(second.from).toBe("cache"); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); + + vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1); + expect((await readCodexModelCatalog(PERSONAL, AUTH)).from).toBe("live"); + expect(safeFetch).toHaveBeenCalledTimes(2); + }); + + it("shares one read between concurrent requests on a sign-in", async () => { + let release: (r: Response) => void = () => {}; + safeFetch.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const reads = [ + readCodexModelCatalog(PERSONAL, AUTH), + readCodexModelCatalog(PERSONAL, AUTH), + readCodexModelCatalog(PERSONAL, AUTH), + ]; + release(jsonResponse(FIXTURE)); + const results = await Promise.all(reads); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(results.every((r) => r.models?.length === 9)).toBe(true); + }); + + it("remembers a failed read for the retry window and signals it once", async () => { + vi.useFakeTimers(); + safeFetch.mockImplementation(async () => jsonResponse("down", 503)); + + const first = await readCodexModelCatalog(PERSONAL, AUTH); + expect(first).toEqual({ + models: null, + from: "live", + failure: "http_error", + }); + const again = await readCodexModelCatalog(PERSONAL, AUTH); + expect(again).toEqual({ + models: null, + from: "cache", + failure: "http_error", + }); + expect(safeFetch).toHaveBeenCalledTimes(1); + + // Past the retry window the list is asked again; still failing, the + // stretch has already been reported. + vi.advanceTimersByTime(CODEX_CATALOG_RETRY_MS + 1); + await readCodexModelCatalog(PERSONAL, AUTH); + expect(safeFetch).toHaveBeenCalledTimes(2); + expect(emitSignal).toHaveBeenCalledTimes(1); + expect(emitSignal).toHaveBeenCalledWith({ + action: "ai.codex.models_unreadable", + level: "warn", + meta: { connection: "personal", reason: "http_error", status: 503 }, + }); + }); + + it("signals again after the list answered in between", async () => { + vi.useFakeTimers(); + safeFetch + .mockResolvedValueOnce(jsonResponse("down", 503)) + .mockResolvedValueOnce(jsonResponse(FIXTURE)) + .mockResolvedValueOnce(new Response("{", { status: 200 })); + await readCodexModelCatalog(PERSONAL, AUTH); + vi.advanceTimersByTime(CODEX_CATALOG_RETRY_MS + 1); + await readCodexModelCatalog(PERSONAL, AUTH); + vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1); + await readCodexModelCatalog(PERSONAL, AUTH); + expect(emitSignal).toHaveBeenCalledTimes(2); + expect(emitSignal.mock.calls[1][0].meta.reason).toBe("unparsable"); + }); + + it("signals once per sign-in, naming which connection without naming whose", async () => { + safeFetch.mockImplementation(async () => { + throw new SafeFetchError("timed out", "timeout"); + }); + const central = { key: codexCredentialKey.central, accountId: "acct-op" }; + await readCodexModelCatalog(PERSONAL, AUTH); + await readCodexModelCatalog(central, { ...AUTH, accountId: "acct-op" }); + await readCodexModelCatalog(PERSONAL, AUTH); + await readCodexModelCatalog(central, { ...AUTH, accountId: "acct-op" }); + expect(emitSignal.mock.calls.map((c) => c[0].meta)).toEqual([ + { connection: "personal", reason: "timeout", status: null }, + { connection: "central", reason: "timeout", status: null }, + ]); + expect(JSON.stringify(emitSignal.mock.calls)).not.toContain("u1"); + }); + + it("does not remember an expired token, so the next request reads again", async () => { + safeFetch + .mockResolvedValueOnce(jsonResponse("unauthorized", 401)) + .mockResolvedValueOnce(jsonResponse(FIXTURE)); + const refused = await readCodexModelCatalog(PERSONAL, AUTH); + expect(refused.failure).toBe("auth"); + const next = await readCodexModelCatalog(PERSONAL, { + ...AUTH, + accessToken: "refreshed", + }); + expect(next.models).toHaveLength(9); + expect(emitSignal).not.toHaveBeenCalled(); + }); + + it("keeps each sign-in's list to itself", async () => { + const plus = JSON.parse(FIXTURE) as { models: { slug: string }[] }; + const other = { + models: plus.models.filter((m) => m.slug.startsWith("gpt-6")), + }; + safeFetch + .mockResolvedValueOnce(jsonResponse(FIXTURE)) + .mockResolvedValueOnce(jsonResponse(JSON.stringify(other))); + const second = { key: codexCredentialKey.user("u2"), accountId: "acct-b" }; + await readCodexModelCatalog(PERSONAL, AUTH); + await readCodexModelCatalog(second, { ...AUTH, accountId: "acct-b" }); + expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); + expect(cachedCodexModels(second.key)?.map((m) => m.slug)).toEqual([ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + ]); + }); +}); diff --git a/src/lib/ai/__tests__/codex-reasoning.test.ts b/src/lib/ai/__tests__/codex-reasoning.test.ts index a4609130e..6a72e01f9 100644 --- a/src/lib/ai/__tests__/codex-reasoning.test.ts +++ b/src/lib/ai/__tests__/codex-reasoning.test.ts @@ -46,6 +46,7 @@ function errorResponse(status: number, body: string): Response { function client() { return new CodexClient({ + credentialKey: "user:test", accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -158,6 +159,7 @@ describe("Codex reasoning request", () => { ); vi.stubGlobal("fetch", mock); const c = new CodexClient({ + credentialKey: "user:test", accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/codex-slug-cache.test.ts b/src/lib/ai/__tests__/codex-slug-cache.test.ts index 94f414bf2..819b4277f 100644 --- a/src/lib/ai/__tests__/codex-slug-cache.test.ts +++ b/src/lib/ai/__tests__/codex-slug-cache.test.ts @@ -1,59 +1,147 @@ import { describe, it, expect, beforeEach } from "vitest"; import { - getCachedCodexSlug, - setCachedCodexSlug, - invalidateCachedCodexSlug, - clearCodexSlugCache, + CODEX_CATALOG_RETRY_MS, + CODEX_CATALOG_TTL_MS, + CODEX_CREDENTIAL_CACHE_MAX, + CODEX_REJECTED_SLUG_TTL_MS, CODEX_SLUG_CACHE_TTL_MS, + clearCodexSlugCache, + codexCredentialCacheSize, + codexCredentialKey, + getCachedCodexSlug, + getCodexCatalogState, + rejectedCodexSlugs, + rememberRejectedCodexSlug, + setCachedCodexSlug, + setCodexCatalogState, } from "../codex-slug-cache"; /** - * Slug-drift defence positive cache. - * Cache TTL = 1 hour per `docs/codex-protocol-spec.md` §7b. + * Per-sign-in memory: the working slug (1 h, spec §7b), refused slugs, and + * the account's model list, each kept under its stored credential. */ -describe("Codex slug cache", () => { +const A = { key: codexCredentialKey.user("a"), accountId: "acct-a" }; +const B = { key: codexCredentialKey.user("b"), accountId: "acct-b" }; +const CENTRAL = { key: codexCredentialKey.central, accountId: "acct-op" }; + +describe("Codex per-sign-in cache", () => { beforeEach(() => { clearCodexSlugCache(); }); + it("keys a person's connection and the central one apart", () => { + expect(codexCredentialKey.user("a")).toBe("user:a"); + expect(codexCredentialKey.central).toBe("central"); + }); + it("starts empty", () => { - expect(getCachedCodexSlug()).toBeNull(); + expect(getCachedCodexSlug(A.key)).toBeNull(); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + expect(getCodexCatalogState(A.key)).toBeNull(); }); - it("set + get returns the cached slug within TTL", () => { - setCachedCodexSlug("gpt-5.3-codex", 1_000); - expect(getCachedCodexSlug(1_000)).toBe("gpt-5.3-codex"); - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS - 1)).toBe( - "gpt-5.3-codex", + it("holds the working slug for exactly the TTL", () => { + setCachedCodexSlug(A, "gpt-6-sol", 1_000); + expect(getCachedCodexSlug(A.key, 1_000)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( + "gpt-6-sol", ); + expect( + getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1), + ).toBeNull(); + expect(CODEX_SLUG_CACHE_TTL_MS).toBe(60 * 60 * 1000); }); - it("expires entries strictly after the TTL", () => { - setCachedCodexSlug("gpt-5.3-codex", 1_000); - // Exactly at TTL: still valid (the comparison is `now - cached > TTL`). - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( - "gpt-5.3-codex", + it("keeps every sign-in's working slug to itself", () => { + // Two plans: one account works on terra, the other only on gpt-6. + setCachedCodexSlug(A, "gpt-5.6-terra"); + setCachedCodexSlug(B, "gpt-6.1-sol"); + setCachedCodexSlug(CENTRAL, "gpt-6-sol"); + expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra"); + expect(getCachedCodexSlug(B.key)).toBe("gpt-6.1-sol"); + expect(getCachedCodexSlug(CENTRAL.key)).toBe("gpt-6-sol"); + + rememberRejectedCodexSlug(B, "gpt-6.1-sol"); + expect(getCachedCodexSlug(B.key)).toBeNull(); + expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra"); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + }); + + it("a refusal drops the working slot and is held for its own TTL", () => { + setCachedCodexSlug(A, "gpt-5.6-terra", 0); + rememberRejectedCodexSlug(A, "gpt-5.6-terra", 10); + expect(getCachedCodexSlug(A.key, 10)).toBeNull(); + expect([...rejectedCodexSlugs(A.key, 10)]).toEqual(["gpt-5.6-terra"]); + expect([ + ...rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS), + ]).toEqual(["gpt-5.6-terra"]); + expect( + rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size, + ).toBe(0); + }); + + it("a slug that works again leaves the refused set", () => { + rememberRejectedCodexSlug(A, "gpt-6-sol"); + setCachedCodexSlug(A, "gpt-6-sol"); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + }); + + it("a credential reconnected to another account starts afresh", () => { + setCachedCodexSlug(A, "gpt-5.6-terra"); + rememberRejectedCodexSlug(A, "gpt-6-sol"); + setCodexCatalogState(A, { kind: "offered", models: [], at: Date.now() }); + + // Same stored credential, a different ChatGPT account (another plan). + setCachedCodexSlug({ key: A.key, accountId: "acct-other" }, "gpt-6-luna"); + expect(getCachedCodexSlug(A.key)).toBe("gpt-6-luna"); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + expect(getCodexCatalogState(A.key)).toBeNull(); + }); + + it("holds a list for its TTL and a failed read for the shorter retry window", () => { + setCodexCatalogState(A, { kind: "offered", models: [], at: 0 }); + expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS)?.kind).toBe( + "offered", ); - // Past TTL: gone. - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1)).toBeNull(); + expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS + 1)).toBeNull(); + + setCodexCatalogState(B, { kind: "unreadable", reason: "timeout", at: 0 }); + expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS)?.kind).toBe( + "unreadable", + ); + expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS + 1)).toBeNull(); }); - it("expired entry is evicted on read", () => { - setCachedCodexSlug("gpt-5.3-codex", 1_000); - getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1); - // Subsequent get should be null too (no resurrection). - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1)).toBeNull(); + it("reports the first unreadable read of a stretch, and re-arms on success", () => { + const unreadable = { + kind: "unreadable" as const, + reason: "timeout" as const, + at: 0, + }; + expect(setCodexCatalogState(A, unreadable)).toBe(true); + expect(setCodexCatalogState(A, unreadable)).toBe(false); + expect(setCodexCatalogState(B, unreadable)).toBe(true); + expect( + setCodexCatalogState(A, { kind: "offered", models: [], at: 0 }), + ).toBe(false); + expect(setCodexCatalogState(A, unreadable)).toBe(true); }); - it("invalidate clears the slot", () => { - setCachedCodexSlug("gpt-5.3-codex"); - invalidateCachedCodexSlug(); - expect(getCachedCodexSlug()).toBeNull(); - }); - - it("set replaces the previous slot", () => { - setCachedCodexSlug("a", 1_000); - setCachedCodexSlug("b", 2_000); - expect(getCachedCodexSlug(2_500)).toBe("b"); + it("holds at most the cap, dropping the least recently used sign-in", () => { + for (let i = 0; i < CODEX_CREDENTIAL_CACHE_MAX; i += 1) { + setCachedCodexSlug( + { key: codexCredentialKey.user(`u${i}`), accountId: `acct-${i}` }, + "gpt-6-sol", + ); + } + // Touch the oldest so the second-oldest is the one to go. + expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); + setCachedCodexSlug( + { key: codexCredentialKey.user("new"), accountId: "acct-new" }, + "gpt-6-sol", + ); + expect(codexCredentialCacheSize()).toBe(CODEX_CREDENTIAL_CACHE_MAX); + expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBeNull(); }); }); diff --git a/src/lib/ai/__tests__/codex-slug-chain.test.ts b/src/lib/ai/__tests__/codex-slug-chain.test.ts new file mode 100644 index 000000000..dd21fd811 --- /dev/null +++ b/src/lib/ai/__tests__/codex-slug-chain.test.ts @@ -0,0 +1,315 @@ +/** + * The order a Codex sign-in walks, built from the operator's choice, our + * preference order and the models the account is offered; and the vision + * answer the gates read from it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const annotate = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/logging/context", () => ({ annotate })); + +import { + parseCodexModelList, + type CodexCatalogRead, + type CodexOfferedModel, +} from "../codex-model-catalog"; +import { + DEFAULT_SLUG_FALLBACK_CHAIN, + buildCodexSlugChain, + chainEntryReadsImages, + codexSignInReadsImages, + loadCodexAttemptChain, + resolveCodexVisionSlug, +} from "../codex-slug-chain"; +import { + clearCodexSlugCache, + codexCredentialKey, + rememberRejectedCodexSlug, + setCachedCodexSlug, + setCodexCatalogState, +} from "../codex-slug-cache"; + +const OFFERED = parseCodexModelList( + JSON.parse( + readFileSync( + join(__dirname, "fixtures/codex/models-response.json"), + "utf8", + ), + ), +)!; + +function offered(...slugs: string[]): CodexOfferedModel[] { + return slugs.map((slug, i) => ({ + slug, + listed: true, + priority: i, + inputModalities: ["text", "image"], + specialty: null, + })); +} + +const ENV_KEYS = ["CODEX_MODEL", "CODEX_MODEL_FALLBACK_CHAIN"] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + clearCodexSlugCache(); + annotate.mockReset(); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +describe("buildCodexSlugChain", () => { + it("without a list, walks the static preference order", () => { + for (const list of [null, []]) { + expect(buildCodexSlugChain({ offered: list })).toEqual({ + chain: [...DEFAULT_SLUG_FALLBACK_CHAIN], + source: "static", + }); + } + }); + + it("with the live list: preference order kept to what is offered, then the rest, then the static tail", () => { + const { chain, source } = buildCodexSlugChain({ offered: OFFERED }); + expect(source).toBe("live"); + expect(chain).toEqual([ + // Our preference order, kept to what the account is offered. + "gpt-5.6-terra", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-6-luna", + "gpt-5.6-luna", + "gpt-5.6-sol", + // Not offered (retired): only reached when everything above failed. + "gpt-5.5", + ]); + // Never offered to the walk: the frontier tier, the hidden review model, + // the hidden specialist line. + for (const skipped of [ + "gpt-6-astra", + "codex-auto-review", + "gpt-daybreak-blue-latest", + ]) { + expect(chain).not.toContain(skipped); + } + }); + + it("follows an account onto a model our order does not know yet, in the list's priority order", () => { + // A later retirement: the gpt-5.6 line gone, two new models offered. + const list: CodexOfferedModel[] = [ + { ...offered("gpt-7-luna")[0], priority: 9 }, + { ...offered("gpt-7-sol")[0], priority: 2 }, + { ...offered("gpt-6-luna")[0], priority: 5 }, + { ...offered("gpt-7-astra")[0], priority: 1 }, + { ...offered("gpt-7-mystery")[0], priority: null }, + ]; + expect(buildCodexSlugChain({ offered: list }).chain).toEqual([ + "gpt-6-luna", + "gpt-7-sol", + "gpt-7-luna", + "gpt-7-mystery", + // the static tail + "gpt-5.6-terra", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.5", + ]); + }); + + it("does not filter on supported_in_api, which describes API-key access", () => { + const model = parseCodexModelList({ + models: [ + { slug: "gpt-7-sol", visibility: "list", supported_in_api: false }, + ], + })!; + expect(buildCodexSlugChain({ offered: model }).chain[0]).toBe("gpt-7-sol"); + }); + + it("CODEX_MODEL goes first, ahead of the live order", () => { + process.env.CODEX_MODEL = "gpt-6-astra"; + const { chain, source } = buildCodexSlugChain({ offered: OFFERED }); + expect(source).toBe("live"); + expect(chain[0]).toBe("gpt-6-astra"); + expect(chain[1]).toBe("gpt-5.6-terra"); + expect(new Set(chain).size).toBe(chain.length); + }); + + it("CODEX_MODEL_FALLBACK_CHAIN replaces the order outright and the list is ignored", () => { + process.env.CODEX_MODEL = "pinned"; + process.env.CODEX_MODEL_FALLBACK_CHAIN = " a , b,,pinned "; + expect(buildCodexSlugChain({ offered: OFFERED })).toEqual({ + chain: ["pinned", "a", "b"], + source: "operator", + }); + }); + + it("an empty CODEX_MODEL_FALLBACK_CHAIN does not replace anything", () => { + process.env.CODEX_MODEL_FALLBACK_CHAIN = " , "; + expect(buildCodexSlugChain({ offered: null }).chain).toEqual([ + ...DEFAULT_SLUG_FALLBACK_CHAIN, + ]); + }); + + it("moves slugs this sign-in was refused to the very end, never out", () => { + const { chain } = buildCodexSlugChain({ + offered: OFFERED, + rejected: new Set(["gpt-5.6-terra", "gpt-6-sol"]), + }); + expect(chain.slice(0, 2)).toEqual(["gpt-6.1-sol", "gpt-6-luna"]); + expect(chain.slice(-2)).toEqual(["gpt-5.6-terra", "gpt-6-sol"]); + }); +}); + +describe("loadCodexAttemptChain", () => { + const credential = { key: codexCredentialKey.user("u1"), accountId: "acct" }; + + it("reads the list for the sign-in and annotates the outcome", async () => { + const readModels = vi.fn(async (): Promise => ({ + models: offered("gpt-6.1-sol"), + from: "live", + failure: null, + })); + const chain = await loadCodexAttemptChain({ + credential, + accessToken: "tok", + readModels, + }); + expect(chain[0]).toBe("gpt-6.1-sol"); + expect(readModels).toHaveBeenCalledWith(credential, { + accessToken: "tok", + accountId: "acct", + }); + expect(annotate).toHaveBeenCalledWith({ + meta: { + "codex.models.chain": "live", + "codex.models.read": "live", + "codex.models.failure": null, + "codex.models.offered": 1, + }, + }); + }); + + it("falls back to the static walk when the read fails, or throws", async () => { + const failing = vi.fn(async (): Promise => ({ + models: null, + from: "live", + failure: "timeout", + })); + expect( + await loadCodexAttemptChain({ + credential, + accessToken: "t", + readModels: failing, + }), + ).toEqual([...DEFAULT_SLUG_FALLBACK_CHAIN]); + expect(annotate.mock.calls[0][0].meta).toEqual({ + "codex.models.chain": "static", + "codex.models.read": "live", + "codex.models.failure": "timeout", + "codex.models.offered": 0, + }); + + const throwing = vi.fn(async (): Promise => { + throw new Error("bug"); + }); + expect( + await loadCodexAttemptChain({ + credential, + accessToken: "t", + readModels: throwing, + }), + ).toEqual([...DEFAULT_SLUG_FALLBACK_CHAIN]); + }); + + it("does not read the list when the operator replaced the chain", async () => { + process.env.CODEX_MODEL_FALLBACK_CHAIN = "x,y"; + const readModels = vi.fn(); + expect( + await loadCodexAttemptChain({ credential, accessToken: "t", readModels }), + ).toEqual(["x", "y"]); + expect(readModels).not.toHaveBeenCalled(); + expect(annotate.mock.calls[0][0].meta["codex.models.read"]).toBe("skipped"); + }); +}); + +describe("vision for a Codex sign-in", () => { + const A = { key: codexCredentialKey.user("a"), accountId: "acct-a" }; + const B = { key: codexCredentialKey.user("b"), accountId: "acct-b" }; + + it("reads the sign-in's own working slug, else the head of its own order", () => { + setCodexCatalogState(B, { + kind: "offered", + models: offered("gpt-6-luna"), + at: Date.now(), + }); + setCachedCodexSlug(A, "gpt-6.1-sol"); + expect(resolveCodexVisionSlug(A.key)).toBe("gpt-6.1-sol"); + expect(resolveCodexVisionSlug(B.key)).toBe("gpt-6-luna"); + expect(resolveCodexVisionSlug(codexCredentialKey.central)).toBe( + "gpt-5.6-terra", + ); + }); + + it("takes the list's input modalities over the model family", () => { + // A family the gates know as vision-capable, listed as text-only. + setCodexCatalogState(A, { + kind: "offered", + models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }], + at: Date.now(), + }); + expect(codexSignInReadsImages(A.key)).toBe(false); + + // A text-only family by name, listed with image input. + setCodexCatalogState(B, { + kind: "offered", + models: offered("o3-mini"), + at: Date.now(), + }); + expect(codexSignInReadsImages(B.key)).toBe(true); + }); + + it("falls back to the model family when the list says nothing", () => { + setCodexCatalogState(A, { + kind: "offered", + // A text-only family the gates know, listed without modalities. + models: [{ ...offered("o3-mini")[0], inputModalities: null }], + at: Date.now(), + }); + expect(codexSignInReadsImages(A.key)).toBe(false); + // No list at all: the static head, a known vision family. + expect(codexSignInReadsImages(B.key)).toBe(true); + }); + + it("skips a refused slug for the vision answer too", () => { + rememberRejectedCodexSlug(A, "gpt-5.6-terra"); + expect(resolveCodexVisionSlug(A.key)).toBe("gpt-6-sol"); + }); + + it("routes each chain entry to its own sign-in", () => { + setCodexCatalogState( + { key: codexCredentialKey.user("u1"), accountId: "acct-1" }, + { + kind: "offered", + models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }], + at: Date.now(), + }, + ); + expect(chainEntryReadsImages("codex", "u1", null)).toBe(false); + expect(chainEntryReadsImages("codex", "u2", null)).toBe(true); + expect(chainEntryReadsImages("admin-codex", "u1", null)).toBe(true); + expect(chainEntryReadsImages("openai", "u1", "gpt-4")).toBe(false); + expect(chainEntryReadsImages("anthropic", "u1", "claude-sonnet-4-5")).toBe( + true, + ); + }); +}); diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index 6ac75d8a3..badd9f330 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -13,6 +13,18 @@ import { setCachedCodexSlug, CODEX_SLUG_CACHE_TTL_MS, } from "../codex-slug-cache"; +import { + DEFAULT_SLUG_FALLBACK_CHAIN, + buildCodexSlugChain, +} from "../codex-slug-chain"; + +/** The sign-in every client in this file runs on. */ +const KEY = "user:test"; +const CREDENTIAL = { key: KEY, accountId: "acct" }; + +/** A model list that cannot be read: the client walks its static order. */ +const NO_MODEL_LIST = async () => + ({ models: null, from: "live", failure: "network" }) as const; /** * Slug-drift defence. @@ -110,6 +122,8 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test", accountId: "acct", onTokenRefresh, @@ -123,7 +137,7 @@ describe("Codex slug fallback chain", () => { "gpt-6-sol", ]); expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol"); - expect(getCachedCodexSlug()).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-6-sol"); } finally { if (origModel === undefined) delete process.env.CODEX_MODEL; else process.env.CODEX_MODEL = origModel; @@ -138,6 +152,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -154,7 +169,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.cacheState).toBe("miss"); expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); - expect(getCachedCodexSlug()).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 'not supported when using Codex with a ChatGPT account' 400", async () => { @@ -165,6 +180,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -183,7 +199,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); // The working slug — not the rejected one — is now cached. - expect(getCachedCodexSlug()).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 400 with model_not_found body", async () => { @@ -199,6 +215,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -218,6 +235,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -235,6 +253,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -255,6 +274,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -276,6 +296,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "expired", accountId: "acct", onTokenRefresh: vi @@ -304,6 +325,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -332,11 +354,12 @@ describe("Codex slug fallback chain", () => { }); it("cache hit — second call goes straight to the cached slug", async () => { - setCachedCodexSlug("gpt-4o"); + setCachedCodexSlug(CREDENTIAL, "gpt-4o"); const mockFetch = vi.fn().mockResolvedValue(sseSuccessResponse()); vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -350,8 +373,8 @@ describe("Codex slug fallback chain", () => { }); it("cache invalidated when cached slug starts rejecting", async () => { - setCachedCodexSlug("gpt-5-codex"); - expect(getCachedCodexSlug()).toBe("gpt-5-codex"); + setCachedCodexSlug(CREDENTIAL, "gpt-5-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5-codex"); const mockFetch = vi .fn() @@ -360,6 +383,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -369,7 +393,7 @@ describe("Codex slug fallback chain", () => { // After walking to "gpt-5.3-codex" (the next entry after the // cached one), the cache is updated to the new working slug. - expect(getCachedCodexSlug()).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); }); it("isSlugRejection helper recognises canonical rejection bodies", () => { @@ -401,7 +425,7 @@ describe("Codex slug fallback chain", () => { // served the gpt-5.6 and gpt-6 lines. Terra, verified live, leads; its // upstream successor and the rest of the gpt-6 line follow before terra's // own siblings, which a line rotation would retire together with it. - expect([...__test.DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([ + expect([...DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([ "gpt-5.6-terra", "gpt-6-sol", "gpt-6.1-sol", @@ -419,7 +443,7 @@ describe("Codex slug fallback chain", () => { "gpt-5.3-codex", "gpt-5.2", ]) { - expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired); + expect(DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired); } }); @@ -430,8 +454,8 @@ describe("Codex slug fallback chain", () => { // "off" (the Codex client sends `effort: "none"` for it) that reads // images. Nothing is filtered out first: a new line joins the default // only together with its family support. - expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0); - for (const slug of __test.DEFAULT_SLUG_FALLBACK_CHAIN) { + expect(DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0); + for (const slug of DEFAULT_SLUG_FALLBACK_CHAIN) { const family = openAIModelFamily(slug); expect(family, slug).not.toBeNull(); expect(family?.reasoning, slug).not.toBeNull(); @@ -445,13 +469,13 @@ describe("Codex slug fallback chain", () => { } }); - it("loadFallbackChain falls back to the default ladder with no env override", () => { + it("without a model list or an env override, the walk is the default ladder", () => { const origModel = process.env.CODEX_MODEL; const origChain = process.env.CODEX_MODEL_FALLBACK_CHAIN; delete process.env.CODEX_MODEL; delete process.env.CODEX_MODEL_FALLBACK_CHAIN; try { - expect(__test.loadFallbackChain()).toEqual([ + expect(buildCodexSlugChain({ offered: null }).chain).toEqual([ "gpt-5.6-terra", "gpt-6-sol", "gpt-6.1-sol", @@ -469,11 +493,11 @@ describe("Codex slug fallback chain", () => { } }); - it("loadFallbackChain folds CODEX_MODEL into position 0", () => { + it("the static walk folds CODEX_MODEL into position 0", () => { const orig = process.env.CODEX_MODEL; process.env.CODEX_MODEL = "custom-pinned-slug"; try { - const chain = __test.loadFallbackChain(); + const { chain } = buildCodexSlugChain({ offered: null }); expect(chain[0]).toBe("custom-pinned-slug"); // Defaults still present after position 0. expect(chain).toContain("gpt-5.5"); diff --git a/src/lib/ai/__tests__/codex-timeout.test.ts b/src/lib/ai/__tests__/codex-timeout.test.ts index b0c68750b..79033a3c8 100644 --- a/src/lib/ai/__tests__/codex-timeout.test.ts +++ b/src/lib/ai/__tests__/codex-timeout.test.ts @@ -45,6 +45,7 @@ function sseResponse(): Response { function makeClient() { return new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "acct", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/fixtures/codex/models-response.json b/src/lib/ai/__tests__/fixtures/codex/models-response.json new file mode 100644 index 000000000..7c2488b94 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/codex/models-response.json @@ -0,0 +1,1012 @@ +{ + "models": [ + { + "slug": "gpt-6-astra", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "use_responses_lite": true, + "supports_reasoning_effort_updates": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Astra", + "description": "Frontier intelligence for the most demanding work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.153.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 2, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-6.1-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6.1-Sol", + "description": "Latest workhorse model for coding and everyday work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.153.0", + "supported_in_api": true, + "availability_nux": { + "message": "Maximize usage with GPT-6.1 Sol. Try it on complex work for near-Astra performance at a lower cost." + }, + "upgrade": null, + "priority": 1, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "law", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true, + "supports_reasoning_effort_updates": true + }, + { + "slug": "gpt-6-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Sol", + "description": "Previous generation workhorse model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.155.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 3, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": "priority", + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-6-luna", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Luna", + "description": "Fast and affordable model for easier tasks.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.155.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 4, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": "priority", + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Sol", + "description": "Older generation workhorse model.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 5, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-terra", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Terra", + "description": "Older balanced model for straightforward work.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 8, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-luna", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v1", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Luna", + "description": "Older fast and efficient model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-luna", + "migration_markdown": "Meet GPT-6 Luna\n\nOur latest Luna is significantly more efficient, so your usage limits go even further. Reach for it for any job that doesn't require frontier intelligence.\n", + "retirement_at": null + }, + "priority": 9, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-daybreak-blue-latest", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "auto_review_model_override": null, + "model_specialty": "cyber", + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Daybreak Blue", + "description": "Latest frontier agentic coding model for broad defensive cybersecurity work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.142.2", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 11, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "default_service_tier": null, + "service_tiers": [], + "additional_speed_tiers": [], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "codex-auto-review", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v1", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.98.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 43, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "go", + "hc", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + } + ] +} diff --git a/src/lib/ai/__tests__/provider-contract.test.ts b/src/lib/ai/__tests__/provider-contract.test.ts index 70d993be6..b7954bb9e 100644 --- a/src/lib/ai/__tests__/provider-contract.test.ts +++ b/src/lib/ai/__tests__/provider-contract.test.ts @@ -10,6 +10,10 @@ import { type CompletionResult, } from "../types"; +/** A model list that cannot be read: the client walks its static order. */ +const NO_MODEL_LIST = async () => + ({ models: null, from: "live", failure: "network" }) as const; + // safeFetch's requirePublicHost path runs through undici's own `fetch` // (version-locked with its dispatcher). Delegate it to the global `fetch` // stub these tests install so the existing interception still applies. @@ -215,6 +219,8 @@ describe("AIProvider contract", () => { ); const provider: AIProvider = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/response-timeout-binding.test.ts b/src/lib/ai/__tests__/response-timeout-binding.test.ts index 37691b266..d0d3ee69f 100644 --- a/src/lib/ai/__tests__/response-timeout-binding.test.ts +++ b/src/lib/ai/__tests__/response-timeout-binding.test.ts @@ -81,6 +81,7 @@ const CLIENTS: Record AIProvider> = { new AnthropicClient({ apiKey: "k", model: "claude-sonnet-4-6" }), codex: () => new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), diff --git a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts index a97c0f021..90c3e9cf4 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts @@ -308,6 +308,7 @@ describe("the forced final round on each provider wire", () => { }); vi.stubGlobal("fetch", upstream); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 2bcef2a08..7655f5b03 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -21,15 +21,11 @@ import { } from "./codex-reasoning-wire"; import { getCachedCodexSlug, - invalidateCachedCodexSlug, + rememberRejectedCodexSlug, setCachedCodexSlug, + type CodexCredential, } from "./codex-slug-cache"; -import { - DEFAULT_SLUG_FALLBACK_CHAIN, - loadFallbackChain, -} from "./codex-slug-chain"; - -export { resolveCodexVisionSlug } from "./codex-slug-chain"; +import { loadCodexAttemptChain } from "./codex-slug-chain"; /** * Codex backend client — talks to `chatgpt.com/backend-api/codex/responses` @@ -47,11 +43,10 @@ export { resolveCodexVisionSlug } from "./codex-slug-chain"; * * Response is always SSE (server rejects `stream: false` with 400). * - * v1.4.15 (Phase C1) adds a slug-drift defence — see - * `docs/codex-protocol-spec.md` §7b. The client walks an ordered - * fallback chain on every fresh request series (short-circuiting on - * a cached working slug) so a single upstream allow-list rotation no - * longer bricks the integration. + * Slug-drift defence (`docs/codex-protocol-spec.md` §7b): the client tries + * its sign-in's working slug first, then walks an order built from the + * models the account is offered (`codex-slug-chain.ts`), so an upstream + * retirement does not take the integration down. */ /** @@ -231,10 +226,13 @@ interface CodexClientConfig { accountId: string; onTokenRefresh: () => Promise<{ accessToken: string; accountId: string }>; /** - * Override the slug fallback chain (test-only). When unset, the - * chain is loaded from env + DEFAULT_SLUG_FALLBACK_CHAIN. + * The stored credential (`userCodexCredentialKey` or + * `CENTRAL_CODEX_CREDENTIAL_KEY`) its working slug, refused slugs and model + * list are kept under; and two test seams (see `loadCodexAttemptChain`). */ + credentialKey: string; slugChain?: string[]; + readModels?: Parameters[0]["readModels"]; } /** Diagnostic shape returned to the route layer for Wide-Event logging. */ @@ -269,53 +267,59 @@ export class CodexClient implements AIProvider { accessToken: string; accountId: string; }>; - private readonly slugChain: string[]; + private readonly selection: Pick< + CodexClientConfig, + "credentialKey" | "slugChain" | "readModels" + >; private lastDiagnostics: CodexAttemptDiagnostics | null = null; constructor(config: CodexClientConfig) { this.accessToken = config.accessToken; this.accountId = config.accountId; this.onTokenRefresh = config.onTokenRefresh; - this.slugChain = config.slugChain ?? loadFallbackChain(); - if (this.slugChain.length === 0) { - // Hardcoded floor — even with a misconfigured env var we always - // try at least one slug. - this.slugChain = [...DEFAULT_SLUG_FALLBACK_CHAIN]; - } + this.selection = config; + } + + private get credential(): CodexCredential { + return { key: this.selection.credentialKey, accountId: this.accountId }; } /** - * Diagnostics for the most recent generateCompletion() call, used by - * the route layer for Wide-Event annotations. Captures the order of - * attempted slugs, whether the cache was hit, and which slug - * eventually worked (or null on all-failed). + * The last generateCompletion(): slugs attempted in order, whether the + * working slug was cached, and which slug answered (null when none did). */ getLastDiagnostics(): CodexAttemptDiagnostics | null { return this.lastDiagnostics; } - /** - * Build the slug-attempt order: cached slug first (if any and not - * expired), then the rest of the fallback chain in order, deduped - * against the cached slug. - */ - private buildAttemptOrder(): { order: string[]; cacheState: "hit" | "miss" } { - const cached = getCachedCodexSlug(); - if (cached === null) { - return { order: [...this.slugChain], cacheState: "miss" }; - } - const order = [cached, ...this.slugChain.filter((s) => s !== cached)]; - return { order, cacheState: "hit" }; - } - async generateCompletion( params: CompletionParams, ): Promise { - const { order, cacheState } = this.buildAttemptOrder(); + // The working slug alone first: the account's list is read only when + // there is none, or when it stops working. + const cached = getCachedCodexSlug(this.selection.credentialKey); + const cacheState = cached === null ? "miss" : "hit"; + const loadChain = () => + loadCodexAttemptChain({ + ...this.selection, + credential: this.credential, + accessToken: this.accessToken, + }); + const queue = cached ? [cached] : await loadChain(); + let chainLoaded = cached === null; const attempted: string[] = []; let lastSlugRejectionError: Error | null = null; - for (const slug of order) { + for (;;) { + if (queue.length === 0) { + if (chainLoaded) break; + chainLoaded = true; + queue.push( + ...(await loadChain()).filter((s) => !attempted.includes(s)), + ); + continue; + } + const slug = queue.shift() as string; attempted.push(slug); let wire = planCodexReasoning(params.reasoning, slug); @@ -334,7 +338,7 @@ export class CodexClient implements AIProvider { cacheState, workingSlug: slug, }; - setCachedCodexSlug(slug); + setCachedCodexSlug(this.credential, slug); return this.parseResponse(retryAfterRefresh, slug, params, wire); } // Even after refresh — surface the auth failure verbatim, do @@ -374,7 +378,7 @@ export class CodexClient implements AIProvider { cacheState, workingSlug: slug, }; - setCachedCodexSlug(slug); + setCachedCodexSlug(this.credential, slug); return this.parseResponse(attempt, slug, params, wire); } @@ -383,10 +387,9 @@ export class CodexClient implements AIProvider { const bodyExcerpt = redactBody(rawBody); if (isSlugRejection(attempt.status, bodyExcerpt)) { - // Slug rejected — drop the cache (in case it pointed here), - // record the failure for diagnostics, and walk to the next - // chain slot. - invalidateCachedCodexSlug(); + // Refused for this sign-in: out of the working slot and to the back + // of its line, even while its list still offers it; walk on. + rememberRejectedCodexSlug(this.credential, slug); const slugErr = new Error( `Codex slug "${slug}" rejected (${attempt.status})`, ); @@ -898,10 +901,7 @@ function redactBody(rawBody: string): string { .replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***"); } -// Test-only re-exports — keeps the spec-driven defaults visible to unit -// tests without exporting a settable mutable. +// Test-only: the rejection matcher. export const __test = { - DEFAULT_SLUG_FALLBACK_CHAIN, isSlugRejection, - loadFallbackChain, }; diff --git a/src/lib/ai/codex-model-catalog.ts b/src/lib/ai/codex-model-catalog.ts new file mode 100644 index 000000000..0ea5781b6 --- /dev/null +++ b/src/lib/ai/codex-model-catalog.ts @@ -0,0 +1,297 @@ +/** + * The model list a Codex sign-in is offered, read the way the official Codex + * CLI reads it. + * + * A fixed preference list goes stale at every retirement: on 2026-10-14 every + * model the shipped list named was retired for ChatGPT sign-in and Codex + * answered 503 until a release changed the list. The backend says which + * models an account may use, on the same sign-in as the responses call: + * + * GET https://chatgpt.com/backend-api/codex/models?client_version= + * Authorization: Bearer + * ChatGPT-Account-ID: + * + * Wire shape, from openai/codex `codex-rs/protocol/src/openai_models.rs` + * (`ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`), read + * by `codex-rs/codex-api/src/endpoint/models.rs` (`ModelsClient`, which + * appends `client_version`) under the 5 s bound and per-account cache + * identity of `codex-rs/model-provider/src/models_endpoint.rs` and + * `models_identity.rs`: + * + * { "models": [ { "slug": "gpt-6-luna", "visibility": "list", + * "supported_in_api": true, "priority": 4, + * "input_modalities": ["text", "image"], + * "model_specialty": null, ... } ] } + * + * Only the fields read below matter here; everything else in an entry (the + * instruction templates make up most of a ~500 KB body) is ignored. + * + * The list is a hint, never a gate. A read that fails, times out, runs past + * the size bound, does not parse or comes back empty leaves the client on + * its built-in order, exactly as before the list existed. A failed read is + * retried after `CODEX_CATALOG_RETRY_MS`, not on every request, and one + * signal per sign-in reports the stretch. + */ +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; +import { readBodyCapped } from "@/lib/http/read-capped"; +import { emitSignal } from "@/lib/logging/signal"; +import { + codexCredentialKey, + getCodexCatalogState, + setCodexCatalogState, + type CodexCredential, +} from "./codex-slug-cache"; + +const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models"; + +/** + * The `client_version` sent with the read. The backend leaves out models + * whose `minimal_client_version` is above it (every gpt-6 entry in the + * upstream `models-manager/models.json` names 0.153.0 or later), so a version + * far behind the current Codex CLI hides the models an account actually has. + * This is the Codex CLI release current when it was last checked + * (rust-v0.165.0, 2026-10-27); move it when a new line needs a newer client. + */ +export const CODEX_MODELS_CLIENT_VERSION = "0.165.0"; + +/** Same bound as the Codex CLI's model refresh. */ +const MODELS_TIMEOUT_MS = 5_000; +/** The upstream bundle is ~540 KB for twelve models. */ +const MODELS_MAX_BYTES = 4 * 1024 * 1024; +/** More entries than any account has been offered; the rest are ignored. */ +const MAX_MODELS = 200; +/** What a slug may look like before it goes into a request body or a log. */ +const SLUG_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/; + +const ORIGINATOR = "healthlog"; +const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)"; + +/** One model the account is offered, reduced to what the client uses. */ +export interface CodexOfferedModel { + slug: string; + /** `visibility: "list"`: shown in the Codex CLI's picker. */ + listed: boolean; + /** The list's own order (lower first); null when the entry has none. */ + priority: number | null; + /** `input_modalities`, or null when the entry does not say. */ + inputModalities: readonly string[] | null; + /** `model_specialty` (e.g. a security-focused line), null for general models. */ + specialty: string | null; +} + +/** Why a read of the list gave nothing usable. A closed set: it is log meta. */ +export type CodexCatalogFailure = + | "timeout" + | "network" + | "auth" + | "http_error" + | "too_large" + | "unparsable" + | "empty"; + +/** What one request learned about the list. */ +export interface CodexCatalogRead { + /** The offered models, or null when the list could not be used. */ + models: readonly CodexOfferedModel[] | null; + /** Whether this request read the backend or used an earlier read. */ + from: "live" | "cache"; + failure: CodexCatalogFailure | null; +} + +/** The access a read needs: the same sign-in as the responses call. */ +export interface CodexCatalogAuth { + accessToken: string; + accountId: string; +} + +/** + * Parse a `/models` body. Returns null when the body is not the documented + * envelope; entries without a usable slug are skipped, not fatal. An empty + * array is returned as such and the caller treats it as unusable. + */ +export function parseCodexModelList(body: unknown): CodexOfferedModel[] | null { + if (!body || typeof body !== "object") return null; + const list = (body as { models?: unknown }).models; + if (!Array.isArray(list)) return null; + const seen = new Set(); + const out: CodexOfferedModel[] = []; + for (const raw of list.slice(0, MAX_MODELS)) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const slug = typeof entry.slug === "string" ? entry.slug.trim() : ""; + if (!SLUG_SHAPE.test(slug) || seen.has(slug)) continue; + seen.add(slug); + const modalities = Array.isArray(entry.input_modalities) + ? entry.input_modalities.filter((m): m is string => typeof m === "string") + : null; + out.push({ + slug, + listed: entry.visibility === "list", + priority: + typeof entry.priority === "number" && Number.isFinite(entry.priority) + ? entry.priority + : null, + inputModalities: modalities, + specialty: + typeof entry.model_specialty === "string" && entry.model_specialty + ? entry.model_specialty + : null, + }); + } + return out; +} + +/** One read of the backend, never cached and never thrown out of. */ +export async function fetchCodexModelList( + auth: CodexCatalogAuth, +): Promise< + | { ok: true; models: CodexOfferedModel[] } + | { ok: false; reason: CodexCatalogFailure; status: number | null } +> { + let res: Response; + try { + res = await safeFetch( + `${CODEX_MODELS_ENDPOINT}?client_version=${CODEX_MODELS_CLIENT_VERSION}`, + { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-ID": auth.accountId, + originator: ORIGINATOR, + "User-Agent": USER_AGENT, + }, + }, + { timeoutMs: MODELS_TIMEOUT_MS }, + ); + } catch (error) { + const timedOut = + (error instanceof SafeFetchError && error.kind === "timeout") || + (error instanceof Error && error.name === "TimeoutError"); + return { + ok: false, + reason: timedOut ? "timeout" : "network", + status: null, + }; + } + if (!res.ok) { + void res.body?.cancel().catch(() => {}); + return { + ok: false, + reason: res.status === 401 || res.status === 403 ? "auth" : "http_error", + status: res.status, + }; + } + let text: string; + try { + const read = await readBodyCapped(res, MODELS_MAX_BYTES); + if (!read.ok) return { ok: false, reason: "too_large", status: res.status }; + text = read.text; + } catch (error) { + // The timeout signal also covers the body; a stall mid-body lands here. + const timedOut = error instanceof Error && error.name === "TimeoutError"; + return { + ok: false, + reason: timedOut ? "timeout" : "network", + status: res.status, + }; + } + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return { ok: false, reason: "unparsable", status: res.status }; + } + const models = parseCodexModelList(json); + if (models === null) { + return { ok: false, reason: "unparsable", status: res.status }; + } + if (models.length === 0) { + return { ok: false, reason: "empty", status: res.status }; + } + return { ok: true, models }; +} + +/** Reads in flight, one per sign-in, so a burst of requests reads once. */ +const inflight = new Map>(); + +/** + * The account's model list for this sign-in: a trusted earlier read when + * there is one, else one bounded read of the backend shared by every request + * that asks meanwhile. + * + * An expired token (401) is not remembered: the responses call refreshes it + * on its own 401, and the next request reads the list with the new one. + * Every other failure is remembered for `CODEX_CATALOG_RETRY_MS`. + */ +export function readCodexModelCatalog( + credential: CodexCredential, + auth: CodexCatalogAuth, +): Promise { + const state = getCodexCatalogState(credential.key); + if (state?.kind === "offered") { + return Promise.resolve({ + models: state.models, + from: "cache", + failure: null, + }); + } + if (state?.kind === "unreadable") { + return Promise.resolve({ + models: null, + from: "cache", + failure: state.reason, + }); + } + const pending = inflight.get(credential.key); + if (pending) return pending; + const read = fetchCodexModelList(auth) + .then((result): CodexCatalogRead => { + if (result.ok) { + setCodexCatalogState(credential, { + kind: "offered", + models: result.models, + at: Date.now(), + }); + return { models: result.models, from: "live", failure: null }; + } + if (result.status !== 401) { + const first = setCodexCatalogState(credential, { + kind: "unreadable", + reason: result.reason, + at: Date.now(), + }); + if (first) { + emitSignal({ + action: "ai.codex.models_unreadable", + level: "warn", + meta: { + connection: + credential.key === codexCredentialKey.central + ? "central" + : "personal", + reason: result.reason, + status: result.status, + }, + }); + } + } + return { models: null, from: "live", failure: result.reason }; + }) + .finally(() => { + if (inflight.get(credential.key) === read) { + inflight.delete(credential.key); + } + }); + inflight.set(credential.key, read); + return read; +} + +/** The offered models from an earlier read, without touching the network. */ +export function cachedCodexModels( + key: string, + now: number = Date.now(), +): readonly CodexOfferedModel[] | null { + const state = getCodexCatalogState(key, now); + return state?.kind === "offered" ? state.models : null; +} diff --git a/src/lib/ai/codex-slug-cache.ts b/src/lib/ai/codex-slug-cache.ts index 86b0b3a30..b06d12c77 100644 --- a/src/lib/ai/codex-slug-cache.ts +++ b/src/lib/ai/codex-slug-cache.ts @@ -1,62 +1,210 @@ /** - * Process-local positive cache for working Codex model slugs. + * Process-local memory per Codex sign-in: the model that last worked, the + * models the backend refused, and the model list the account was offered. * - * (v1.4.15) — slug-drift defence per - * `docs/codex-protocol-spec.md` §7b. The ChatGPT-Codex backend rotates - * its allow-list silently; pinning a single `CODEX_MODEL` slug bricks - * the integration the moment upstream flips it. Instead of pinning, - * `CodexClient` walks an ordered fallback chain on every fresh - * request series and caches the first slug that returns 200 + - * `response.completed` for `CACHE_TTL_MS` so subsequent requests skip - * the chain walk. + * The ChatGPT-Codex backend offers different models to different plans and + * retires them without notice (docs/codex-protocol-spec.md §7b). One slot for + * the whole process meant two sign-ins on different plans overwrote each + * other's working model, and the vision gate read whichever had answered + * last. Every entry here belongs to one stored credential instead: * - * Lives in its own module so: - * - Tests can `clearCodexSlugCache()` between cases. - * - The shared Map state stays out of `CodexClient` itself (so - * instance creation in `resolveProvider` doesn't reset the cache). - * - Future telemetry (cache hit-rate emission) hooks here cleanly. + * - `user:` for a person's own Codex connection; + * - `central` for the operator-shared connection (`admin-codex`). + * + * An entry also remembers a digest of the ChatGPT account id it was learned + * under. A credential reconnected to a different account (a different plan) + * starts from an empty entry the first time a client runs on it, so nothing + * learned on the old account carries over. No token, and no raw account id, + * is ever a key or a value here. + * + * Bounded both ways: every part of an entry expires on its own TTL, and the + * map holds at most `CODEX_CREDENTIAL_CACHE_MAX` sign-ins, the least recently + * used dropped first. A dropped entry costs one extra model-list read and at + * most one refused request; it never costs an answer. */ +import { createHash } from "node:crypto"; +import type { + CodexCatalogFailure, + CodexOfferedModel, +} from "./codex-model-catalog"; -const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour per spec §7b +/** How long a working model stays first in line (spec §7b). */ +export const CODEX_SLUG_CACHE_TTL_MS = 60 * 60 * 1000; +/** How long a model the backend refused stays at the back of the line. */ +export const CODEX_REJECTED_SLUG_TTL_MS = 6 * 60 * 60 * 1000; +/** How long the account's model list is trusted before it is read again. */ +export const CODEX_CATALOG_TTL_MS = 60 * 60 * 1000; +/** How long a list that could not be read waits before the next attempt. */ +export const CODEX_CATALOG_RETRY_MS = 10 * 60 * 1000; +/** Most sign-ins held at once. */ +export const CODEX_CREDENTIAL_CACHE_MAX = 1_000; -interface CachedEntry { - slug: string; - cachedAtMs: number; +/** The keys a sign-in's memory is kept under. */ +export const codexCredentialKey = { + /** A person's own Codex connection (the `codex` chain entry). */ + user: (userId: string): string => `user:${userId}`, + /** The operator-shared central connection (the `admin-codex` entry). */ + central: "central", +} as const; + +/** The sign-in a client runs on: its stored credential and its account. */ +export interface CodexCredential { + key: string; + accountId: string; +} + +/** What the last read of the account's model list left behind. */ +export type CodexCatalogState = + | { kind: "offered"; models: readonly CodexOfferedModel[]; at: number } + | { kind: "unreadable"; reason: CodexCatalogFailure; at: number }; + +interface CredentialEntry { + accountDigest: string; + working: { slug: string; at: number } | null; + rejected: Map; + catalog: CodexCatalogState | null; + /** Whether the current unreadable stretch has been reported. */ + unreadableReported: boolean; +} + +const entries = new Map(); + +function accountDigest(accountId: string): string { + return createHash("sha256") + .update(`codex-account:${accountId}`) + .digest("hex") + .slice(0, 32); +} + +/** Read an entry without binding it to an account; LRU-touches a hit. */ +function peek(key: string): CredentialEntry | undefined { + const entry = entries.get(key); + if (!entry) return undefined; + entries.delete(key); + entries.set(key, entry); + return entry; } /** - * Cache key — currently a single global slot ("codex"). Kept in a Map - * so future per-account / per-model-family scoping is a one-line - * change. + * The entry for a running client's sign-in, created on first use and started + * afresh when the credential now belongs to a different account. */ -const CACHE_KEY = "codex"; +function bind(credential: CodexCredential): CredentialEntry { + const digest = accountDigest(credential.accountId); + const existing = peek(credential.key); + if (existing && existing.accountDigest === digest) return existing; + const fresh: CredentialEntry = { + accountDigest: digest, + working: null, + rejected: new Map(), + catalog: null, + unreadableReported: false, + }; + entries.set(credential.key, fresh); + while (entries.size > CODEX_CREDENTIAL_CACHE_MAX) { + const oldest = entries.keys().next().value; + if (oldest === undefined) break; + entries.delete(oldest); + } + return fresh; +} -const cache = new Map(); - -export function getCachedCodexSlug(now: number = Date.now()): string | null { - const entry = cache.get(CACHE_KEY); - if (!entry) return null; - if (now - entry.cachedAtMs > CACHE_TTL_MS) { - cache.delete(CACHE_KEY); +/** The model that last worked for this sign-in, while it is fresh. */ +export function getCachedCodexSlug( + key: string, + now: number = Date.now(), +): string | null { + const entry = peek(key); + if (!entry?.working) return null; + if (now - entry.working.at > CODEX_SLUG_CACHE_TTL_MS) { + entry.working = null; return null; } - return entry.slug; + return entry.working.slug; } export function setCachedCodexSlug( + credential: CodexCredential, slug: string, now: number = Date.now(), ): void { - cache.set(CACHE_KEY, { slug, cachedAtMs: now }); + const entry = bind(credential); + entry.working = { slug, at: now }; + entry.rejected.delete(slug); } -export function invalidateCachedCodexSlug(): void { - cache.delete(CACHE_KEY); +/** + * Record that the backend refused `slug` for this sign-in (a retired or + * not-offered model). It drops out of the working slot and goes to the back + * of the line until `CODEX_REJECTED_SLUG_TTL_MS` passes, even while the + * account's list still offers it. + */ +export function rememberRejectedCodexSlug( + credential: CodexCredential, + slug: string, + now: number = Date.now(), +): void { + const entry = bind(credential); + if (entry.working?.slug === slug) entry.working = null; + entry.rejected.set(slug, now); } -/** Test-only: reset the cache between cases. */ +/** The models this sign-in was refused recently. */ +export function rejectedCodexSlugs( + key: string, + now: number = Date.now(), +): ReadonlySet { + const entry = peek(key); + if (!entry) return new Set(); + for (const [slug, at] of entry.rejected) { + if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug); + } + return new Set(entry.rejected.keys()); +} + +/** The last read of the account's model list, while it is still trusted. */ +export function getCodexCatalogState( + key: string, + now: number = Date.now(), +): CodexCatalogState | null { + const entry = peek(key); + const state = entry?.catalog; + if (!entry || !state) return null; + const ttl = + state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS; + if (now - state.at > ttl) { + entry.catalog = null; + return null; + } + return state; +} + +/** + * Store a read of the account's model list. Returns true when this is the + * first unreadable result since the list last answered, which is the one + * the caller reports; a later failure in the same stretch returns false. + */ +export function setCodexCatalogState( + credential: CodexCredential, + state: CodexCatalogState, +): boolean { + const entry = bind(credential); + entry.catalog = state; + if (state.kind === "offered") { + entry.unreadableReported = false; + return false; + } + if (entry.unreadableReported) return false; + entry.unreadableReported = true; + return true; +} + +/** Test-only: forget every sign-in. */ export function clearCodexSlugCache(): void { - cache.clear(); + entries.clear(); } -export const CODEX_SLUG_CACHE_TTL_MS = CACHE_TTL_MS; +/** Test-only: how many sign-ins are held. */ +export function codexCredentialCacheSize(): number { + return entries.size; +} diff --git a/src/lib/ai/codex-slug-chain.ts b/src/lib/ai/codex-slug-chain.ts index 550f3a24d..8b345e62d 100644 --- a/src/lib/ai/codex-slug-chain.ts +++ b/src/lib/ai/codex-slug-chain.ts @@ -1,17 +1,34 @@ /** * The Codex slug ladder: which ChatGPT-auth model slugs the client tries, in * order, and the slug the vision gate reasons about before a request runs. - * The walk itself (which statuses move to the next rung, the positive cache) - * lives in `codex-client.ts` and `codex-slug-cache.ts`; docs/codex-protocol-spec.md - * §7b describes the whole defence. + * The walk itself (which statuses move to the next rung) lives in + * `codex-client.ts`; the per-sign-in memory in `codex-slug-cache.ts`; the + * account's own model list in `codex-model-catalog.ts`. + * docs/codex-protocol-spec.md §7b describes the whole defence. */ -import { getCachedCodexSlug } from "./codex-slug-cache"; +import { annotate } from "@/lib/logging/context"; +import { + cachedCodexModels, + readCodexModelCatalog, + type CodexCatalogRead, + type CodexOfferedModel, +} from "./codex-model-catalog"; +import { + codexCredentialKey, + getCachedCodexSlug, + rejectedCodexSlugs, + type CodexCredential, +} from "./codex-slug-cache"; +import { + supportsVisionForConfig, + type VisionProviderType, +} from "./vision-capability"; /** - * Default fallback chain. Override via `CODEX_MODEL_FALLBACK_CHAIN` - * (comma-separated); `CODEX_MODEL` is folded into position 0 when set, - * duplicates dropped. The backend rotates its ChatGPT-auth slugs without - * notice: by 2026-10-24 it rejected everything from `gpt-5.5` down. + * Our preference order. With the account's model list in hand it is filtered + * to what the account is offered; without it, it is the walk. Override via + * `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated); `CODEX_MODEL` is folded + * into position 0 when set, duplicates dropped. * * `gpt-5.6-terra` stays first because it is the slug verified against the * backend. The upstream catalog (`codex-rs/models-manager/models.json`) @@ -33,37 +50,218 @@ export const DEFAULT_SLUG_FALLBACK_CHAIN = [ ] as const; /** - * The slug the codex vision gate reasons about *before* a request runs: the - * cached working slug if one is live, else the first chain entry. Lets the - * Lab-OCR capability resolver decide whether codex can read images without - * standing up a client or making a network call. Best-effort — the actual - * routed slug may differ once the chain walk runs, which the runtime probe + - * graceful fallback (text/local OCR) covers. + * The frontier tier (`gpt-6-astra` today, and any later `-astra`), skipped + * even when offered, for the reason on `DEFAULT_SLUG_FALLBACK_CHAIN`. An + * operator who wants it names it in `CODEX_MODEL`. */ -export function resolveCodexVisionSlug(): string | null { - const cached = getCachedCodexSlug(); - if (cached) return cached; - return loadFallbackChain()[0] ?? null; +const SKIPPED_TIER = /(?:^|-)astra(?:$|-)/; + +/** Where the order a client walks came from. A closed set: it is log meta. */ +export type CodexChainSource = "operator" | "live" | "static"; + +function dedupe(slugs: readonly string[]): string[] { + return [...new Set(slugs)]; } -/** The chain a client walks: the env override or the default, `CODEX_MODEL` first. */ -export function loadFallbackChain(): string[] { +/** The `CODEX_MODEL` pin and the `CODEX_MODEL_FALLBACK_CHAIN` replacement. */ +function operatorChoice(): { pinned: string | null; chain: string[] | null } { const envChain = process.env.CODEX_MODEL_FALLBACK_CHAIN?.trim(); - const fromEnv = envChain + const chain = envChain ? envChain .split(",") .map((s) => s.trim()) .filter(Boolean) - : [...DEFAULT_SLUG_FALLBACK_CHAIN]; - const pinned = process.env.CODEX_MODEL?.trim(); - const merged = pinned ? [pinned, ...fromEnv] : fromEnv; - // Stable de-duplication. - const seen = new Set(); - const result: string[] = []; - for (const slug of merged) { - if (seen.has(slug)) continue; - seen.add(slug); - result.push(slug); - } - return result; + : null; + return { + pinned: process.env.CODEX_MODEL?.trim() || null, + chain: chain && chain.length > 0 ? chain : null, + }; +} + +/** Whether the operator replaced the chain, so the account's list is not read. */ +function operatorReplacedCodexChain(): boolean { + return operatorChoice().chain !== null; +} + +/** + * The order a client walks for one sign-in. + * + * 1. The operator's choice, unchanged in meaning: `CODEX_MODEL` goes first; + * `CODEX_MODEL_FALLBACK_CHAIN` replaces everything after it, and the + * account's list is then not consulted at all. + * 2. Our preference order, kept to what the account is offered. + * 3. Every other offered model, in the list's own order (`priority`, as the + * Codex CLI sorts it), leaving out what the list hides from its picker + * (`visibility` other than `list`: review and specialist models), the + * specialist lines (`model_specialty`), and the skipped frontier tier. + * `supported_in_api` is not a filter: it describes API-key access, and + * the Codex CLI ignores it for a ChatGPT sign-in (`filter_by_auth`). + * 4. The rest of our preference order, last. A list that offers a model the + * backend then refuses must not be able to take Codex down, so the walk + * never holds fewer models than it did before the list was read; these + * only cost a refused request when everything ahead of them failed. + * + * Without a usable list (none read, a failed read, an empty one) the order is + * today's static walk. Models this sign-in was refused recently move to the + * very end, in their order, whatever step placed them. + */ +export function buildCodexSlugChain(input: { + offered: readonly CodexOfferedModel[] | null; + rejected?: ReadonlySet; +}): { chain: string[]; source: CodexChainSource } { + const { pinned, chain: operatorChain } = operatorChoice(); + let ordered: string[]; + let source: CodexChainSource; + if (operatorChain) { + ordered = operatorChain; + source = "operator"; + } else if (!input.offered || input.offered.length === 0) { + ordered = [...DEFAULT_SLUG_FALLBACK_CHAIN]; + source = "static"; + } else { + const usable = input.offered.filter( + (m) => m.listed && m.specialty === null && !SKIPPED_TIER.test(m.slug), + ); + const usableSlugs = new Set(usable.map((m) => m.slug)); + const preferred = DEFAULT_SLUG_FALLBACK_CHAIN.filter((s) => + usableSlugs.has(s), + ); + const others = usable + .map((m, index) => ({ m, index })) + .sort( + (a, b) => + (a.m.priority ?? Number.POSITIVE_INFINITY) - + (b.m.priority ?? Number.POSITIVE_INFINITY) || a.index - b.index, + ) + .map(({ m }) => m.slug); + ordered = [...preferred, ...others, ...DEFAULT_SLUG_FALLBACK_CHAIN]; + source = "live"; + } + const walk = dedupe(pinned ? [pinned, ...ordered] : ordered); + const rejected = input.rejected ?? new Set(); + return { + chain: [ + ...walk.filter((s) => !rejected.has(s)), + ...walk.filter((s) => rejected.has(s)), + ], + source, + }; +} + +/** How a client reads its account's model list. */ +export type CodexModelReader = typeof readCodexModelCatalog; + +/** + * The walk a client runs after its working slug, for one sign-in, with the + * outcome on the request's wide event (pinned shape, closed values): + * + * - `codex.models.chain`: where the order came from (`CodexChainSource`); + * - `codex.models.read`: `live`, `cache`, or `skipped` when the operator + * replaced the chain and the list was not asked; + * - `codex.models.failure`: why the list was unusable (`CodexCatalogFailure`) + * or null; + * - `codex.models.offered`: how many models the list offered (0 without one). + * + * `slugChain` and `readModels` are test seams: `slugChain` replaces the walk + * the way `CODEX_MODEL_FALLBACK_CHAIN` does (the list is not read, refused + * slugs still go last); `readModels` stands in for the backend read. + */ +export async function loadCodexAttemptChain(input: { + credential: CodexCredential; + accessToken: string; + slugChain?: readonly string[]; + readModels?: CodexModelReader; +}): Promise { + const rejected = rejectedCodexSlugs(input.credential.key); + if (input.slugChain && input.slugChain.length > 0) { + return [ + ...input.slugChain.filter((s) => !rejected.has(s)), + ...input.slugChain.filter((s) => rejected.has(s)), + ]; + } + let read: CodexCatalogRead | null = null; + if (!operatorReplacedCodexChain()) { + const reader = input.readModels ?? readCodexModelCatalog; + // The reader never rejects by contract; a bug in it must still leave + // the static walk rather than fail the request. + read = await reader(input.credential, { + accessToken: input.accessToken, + accountId: input.credential.accountId, + }).catch((): CodexCatalogRead => ({ + models: null, + from: "live", + failure: "network", + })); + } + const { chain, source } = buildCodexSlugChain({ + offered: read?.models ?? null, + rejected, + }); + annotate({ + meta: { + "codex.models.chain": source, + "codex.models.read": read?.from ?? "skipped", + "codex.models.failure": read?.failure ?? null, + "codex.models.offered": read?.models?.length ?? 0, + }, + }); + return chain; +} + +/** + * The slug the codex vision gate reasons about *before* a request runs, for + * one sign-in: its working slug if one is live, else the head of the order + * built from its last model list (or the static walk). Lets the vision + * resolvers decide whether codex can read images without standing up a + * client or making a network call. Best-effort: the routed slug may differ + * once the walk runs, which the runtime probe and the text / local OCR + * fallback cover. + */ +export function resolveCodexVisionSlug(credentialKey: string): string | null { + const cached = getCachedCodexSlug(credentialKey); + if (cached) return cached; + const { chain } = buildCodexSlugChain({ + offered: operatorReplacedCodexChain() + ? null + : cachedCodexModels(credentialKey), + rejected: rejectedCodexSlugs(credentialKey), + }); + return chain[0] ?? null; +} + +/** + * Whether this sign-in's Codex model reads images. When the account's list + * names the model's `input_modalities`, that answer wins; otherwise the model + * family decides (`supportsVisionForConfig`), as it did before the list. + */ +export function codexSignInReadsImages(credentialKey: string): boolean { + const slug = resolveCodexVisionSlug(credentialKey); + if (!slug) return false; + const offered = cachedCodexModels(credentialKey)?.find( + (m) => m.slug === slug, + ); + if (offered?.inputModalities) { + return offered.inputModalities.includes("image"); + } + return supportsVisionForConfig("codex", slug); +} + +/** + * Whether a provider chain entry can read images, for the vision resolvers. + * A Codex entry asks its own sign-in (`codex` the person's connection, + * `admin-codex` the operator-shared one); every other entry is decided by + * `model`'s family, as before. + */ +export function chainEntryReadsImages( + providerType: string, + userId: string, + model: string | null, +): boolean { + if (providerType === "codex") { + return codexSignInReadsImages(codexCredentialKey.user(userId)); + } + if (providerType === "admin-codex") { + return codexSignInReadsImages(codexCredentialKey.central); + } + return supportsVisionForConfig(providerType as VisionProviderType, model); } diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index d589685ce..0dc9da751 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -5,7 +5,9 @@ import { bindReasoningEffort } from "./reasoning-effort"; import { prisma } from "@/lib/db"; import { openStoredKey } from "./stored-key"; import { isAnthropicBaseUrl } from "./anthropic-host"; -import { CodexClient, resolveCodexVisionSlug } from "./codex-client"; +import { CodexClient } from "./codex-client"; +import { codexCredentialKey } from "./codex-slug-cache"; +import { chainEntryReadsImages } from "./codex-slug-chain"; import { OpenAIClient } from "./openai-client"; import { AnthropicClient } from "./anthropic-client"; import { LocalOpenAICompatibleClient } from "./local-client"; @@ -45,10 +47,6 @@ import type { ProviderEntryPresence, ProviderPresence, } from "@/lib/ai/capabilities/resolve"; -import { - supportsVisionForConfig, - type VisionProviderType, -} from "./vision-capability"; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes @@ -602,6 +600,7 @@ async function resolveCodexProvider( return new CodexClient({ accessToken: active.accessToken, accountId: active.accountId, + credentialKey: codexCredentialKey.user(userId), onTokenRefresh: async () => { const current = await prisma.user.findUnique({ where: { id: userId }, @@ -690,6 +689,7 @@ async function resolveAdminCodexProvider(): Promise { return new CodexClient({ accessToken: active.accessToken, accountId: active.accountId, + credentialKey: codexCredentialKey.central, onTokenRefresh: async () => { const current = await prisma.appSettings.findUnique({ where: { id: "singleton" }, @@ -1151,22 +1151,18 @@ const NO_PRESENCE: ProviderPresence = Object.freeze({ /** * Whether one chain entry's model can read an image. Mirrors the model each - * entry runs on (`src/lib/labs/ocr-capability.ts`): the Codex paths use the - * working Codex slug, the operator's slot uses the operator's model, every - * other entry the person's own. + * entry runs on (`src/lib/labs/ocr-capability.ts`): each Codex path asks its + * own sign-in, the operator's slot uses the operator's model, every other + * entry the person's own. */ function entryVision( providerType: string, + recordId: string, userModel: string | null, adminModel: string | null, ): boolean { - const model = - providerType === "codex" || providerType === "admin-codex" - ? resolveCodexVisionSlug() - : providerType === "admin-openai" - ? adminModel - : userModel; - return supportsVisionForConfig(providerType as VisionProviderType, model); + const model = providerType === "admin-openai" ? adminModel : userModel; + return chainEntryReadsImages(providerType, recordId, model); } /** @@ -1212,7 +1208,7 @@ export function probeProviderChain( const adminModel = settings?.adminAiModel ?? null; const vision = (providerType: string): ProviderEntryPresence => ({ providerType, - vision: entryVision(providerType, row.aiModel, adminModel), + vision: entryVision(providerType, recordId, row.aiModel, adminModel), }); if (policy === "deny") return NO_PRESENCE; diff --git a/src/lib/labs/__tests__/ocr-capability.test.ts b/src/lib/labs/__tests__/ocr-capability.test.ts index 3945f39a9..165b86c78 100644 --- a/src/lib/labs/__tests__/ocr-capability.test.ts +++ b/src/lib/labs/__tests__/ocr-capability.test.ts @@ -18,9 +18,6 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: vi.fn(), resolveProvider: vi.fn(), })); -vi.mock("@/lib/ai/codex-client", () => ({ - resolveCodexVisionSlug: vi.fn(() => "gpt-5"), -})); vi.mock("@/lib/ai/capabilities/egress", () => ({ aiEgressRefusal: vi.fn(), })); diff --git a/src/lib/labs/__tests__/ocr-output-budget.test.ts b/src/lib/labs/__tests__/ocr-output-budget.test.ts index b57abe8c9..c7c365953 100644 --- a/src/lib/labs/__tests__/ocr-output-budget.test.ts +++ b/src/lib/labs/__tests__/ocr-output-budget.test.ts @@ -154,6 +154,7 @@ describe("Codex", () => { it("asks for no reasoning instead of the backend's default", async () => { const body = await sentBody( new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), @@ -175,6 +176,7 @@ describe("Codex", () => { ), ); const err = await new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), @@ -202,6 +204,7 @@ describe("Codex", () => { ), ); const err = await new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index b2df0d8ef..e5787d2b5 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -18,13 +18,12 @@ import { import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; -import { resolveCodexVisionSlug } from "@/lib/ai/codex-client"; +import { chainEntryReadsImages } from "@/lib/ai/codex-slug-chain"; import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { supportsPdfForProvider, - supportsVisionForConfig, type VisionProviderType, } from "@/lib/ai/vision-capability"; import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr"; @@ -36,19 +35,19 @@ interface ModelContext { adminModel: string | null; } -/** The model that drives a chain entry's vision allowlist. */ -function modelForEntry( +/** Whether a chain entry's model can read an image. */ +function entryReadsImages( providerType: ProviderChainResolved["providerType"], + userId: string, ctx: ModelContext, -): string | null { - // Codex resolves its working slug at request time from the OAuth slug - // fallback chain — NOT from the user's `aiModel`. Use the cached/chain-head - // slug so the codex vision gate tests the model that actually runs. The - // operator-shared central Codex (`admin-codex`) rides the same slug chain. - if (providerType === "codex" || providerType === "admin-codex") { - return resolveCodexVisionSlug(); - } - return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel; +): boolean { + // Codex picks its model at request time from the sign-in's working slug + // and the models its account is offered, NOT from the user's `aiModel`. + // Each Codex path asks its own sign-in: the person's connection, or the + // operator-shared central one (`admin-codex`). + const model = + providerType === "admin-openai" ? ctx.adminModel : ctx.userModel; + return chainEntryReadsImages(providerType, userId, model); } /** @@ -126,8 +125,7 @@ export async function resolveVisionProvider( for (const entry of chain) { const providerType = entry.providerType as VisionProviderType; - const model = modelForEntry(entry.providerType, ctx); - if (supportsVisionForConfig(providerType, model)) { + if (entryReadsImages(entry.providerType, userId, ctx)) { return { chain, localOcrEnabled,