mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(ai): pick Codex models from the account's live list, per sign-in
A fixed preference list goes stale at every retirement: when GPT-5.5 retired for ChatGPT sign-in on 2026-10-14, every model the shipped list named was refused and Codex answered 503 until a release changed it. The working model also sat in one slot for the whole process, so two sign-ins on different plans overwrote each other's model and the vision gate read whichever had answered last. The client now reads the models the account is offered the way the Codex CLI does (GET /backend-api/codex/models?client_version=..., same sign-in as the responses call; shape from codex-rs/protocol/src/openai_models.rs) and walks: CODEX_MODEL first; our preference order kept to what is offered; the other offered models in the list's priority order, leaving out hidden, specialist and frontier-tier entries; the rest of our order last, so the walk never holds fewer models than before. With CODEX_MODEL_FALLBACK_CHAIN set the order is the operator's and the list is not read. The list is a hint, never a gate. The read goes through safeFetch with a 5 s bound and a 4 MiB cap, is shared by concurrent requests, cached per sign-in for an hour, and a failed read waits ten minutes. Any failure leaves the static walk, one ai.codex.models_unreadable warning per sign-in reports it, and each request that builds the walk annotates a pinned codex.models.* shape. The working model, refused models and the list are kept per stored credential (a person's connection, or the operator-shared central one), bounded by TTL and a 1000-entry LRU, with a digest of the account id so a credential reconnected to another account starts empty. A model the list offered that then answers model_not_found goes to the back of that sign-in's line for six hours. When the list names a model's input modalities, the Codex vision gate reads them; otherwise the model family decides, as before. Refs #1287
This commit is contained in:
1 parent
1d374dd4d5
commit
5ab1fb782e
22 files changed
+3012
-208
No files matched your search
@@ -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,
|
||||
|
||||
@@ -122,6 +122,7 @@ function jsonResponse(body: unknown) {
|
||||
|
||||
function codexClient() {
|
||||
return new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Response>);
|
||||
/** The models the responses endpoint accepts for this account. */
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A backend with one entry per ChatGPT account id. Records every request as
|
||||
* `<account> models` or `<account> responses:<slug>`.
|
||||
*/
|
||||
function stubBackend(accounts: Record<string, Account>) {
|
||||
const log: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
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<string, string | undefined> = {};
|
||||
|
||||
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("<html>", { 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<Response>((_, 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/safe-fetch")>()),
|
||||
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("<html>not json</html>"), "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<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
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<Response>((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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined> = {};
|
||||
|
||||
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<CodexCatalogRead> => ({
|
||||
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<CodexCatalogRead> => ({
|
||||
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<CodexCatalogRead> => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -45,6 +45,7 @@ function sseResponse(): Response {
|
||||
|
||||
function makeClient() {
|
||||
return new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "acct",
|
||||
onTokenRefresh: vi
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -81,6 +81,7 @@ const CLIENTS: Record<string, () => AIProvider> = {
|
||||
new AnthropicClient({ apiKey: "k", model: "claude-sonnet-4-6" }),
|
||||
codex: () =>
|
||||
new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi.fn(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+51
-51
@@ -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<typeof loadCodexAttemptChain>[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<CompletionResult> {
|
||||
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,
|
||||
};
|
||||
@@ -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=<x.y.z>
|
||||
* Authorization: Bearer <access token>
|
||||
* ChatGPT-Account-ID: <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<string>();
|
||||
const out: CodexOfferedModel[] = [];
|
||||
for (const raw of list.slice(0, MAX_MODELS)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
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<string, Promise<CodexCatalogRead>>();
|
||||
|
||||
/**
|
||||
* 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<CodexCatalogRead> {
|
||||
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;
|
||||
}
|
||||
+184
-36
@@ -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:<id>` 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<string, number>;
|
||||
catalog: CodexCatalogState | null;
|
||||
/** Whether the current unreadable stretch has been reported. */
|
||||
unreadableReported: boolean;
|
||||
}
|
||||
|
||||
const entries = new Map<string, CredentialEntry>();
|
||||
|
||||
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<string, CachedEntry>();
|
||||
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
+231
-33
@@ -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<string>();
|
||||
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<string>;
|
||||
}): { 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<string>();
|
||||
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<string[]> {
|
||||
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);
|
||||
}
|
||||
+12
-16
@@ -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<AIProvider | null> {
|
||||
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;
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user