mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'feat/v1463-codex' into release/v1.46.3
This commit is contained in:
commit
646ad3c5d8
27 files changed
+3211
-376
No files matched your search
@@ -375,11 +375,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# per-user BYO keys keep the pinned public-host posture either way.
|
||||
# ADMIN_AI_BASE_URL_ALLOWLIST=""
|
||||
|
||||
# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the
|
||||
# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright
|
||||
# (comma-separated, first match wins). Reach for these when the provider
|
||||
# rotates a slug and requests start failing with 503 -- without them a
|
||||
# rotation needs a new release to fix.
|
||||
# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from
|
||||
# the models its account is offered (read from the provider the way the Codex
|
||||
# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one
|
||||
# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright
|
||||
# (comma-separated, first match wins) and the account's list is then not read.
|
||||
# CODEX_MODEL=""
|
||||
# CODEX_MODEL_FALLBACK_CHAIN=""
|
||||
|
||||
|
||||
+5
-5
@@ -347,11 +347,11 @@ x-healthlog-env: &healthlog_env
|
||||
# you run a gateway this instance should trust; it widens only the admin
|
||||
# setting, never the per-user BYO-key posture.
|
||||
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
|
||||
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
|
||||
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
|
||||
# separated) to replace the chain outright. Reach for these when the
|
||||
# provider rotates a slug and every request starts coming back 503 —
|
||||
# without them a rotation needs a new release to fix.
|
||||
# Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the
|
||||
# models its account is offered, with a built-in order as the fallback.
|
||||
# Set CODEX_MODEL to pin one slug ahead of that, or
|
||||
# CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order
|
||||
# outright; the account's list is then not read.
|
||||
CODEX_MODEL: "${CODEX_MODEL:-}"
|
||||
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
|
||||
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
|
||||
|
||||
@@ -642,9 +642,23 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _
|
||||
|
||||
The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein."
|
||||
|
||||
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
|
||||
To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered.
|
||||
|
||||
**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
|
||||
**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call:
|
||||
|
||||
```http
|
||||
GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1
|
||||
Host: chatgpt.com
|
||||
Authorization: Bearer <oauth-access-token>
|
||||
ChatGPT-Account-ID: <chatgpt_account_id>
|
||||
originator: healthlog
|
||||
```
|
||||
|
||||
The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch.
|
||||
|
||||
**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family.
|
||||
|
||||
**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
|
||||
|
||||
```ts
|
||||
const DEFAULT_SLUG_FALLBACK_CHAIN = [
|
||||
@@ -662,9 +676,9 @@ Every default rung must be a model family the reasoning, sampling and vision gat
|
||||
|
||||
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
|
||||
|
||||
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this.
|
||||
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up.
|
||||
|
||||
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
|
||||
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated.
|
||||
|
||||
**Trigger condition** (when to walk to the next slug):
|
||||
|
||||
@@ -678,15 +692,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep
|
||||
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
|
||||
| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) |
|
||||
|
||||
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write).
|
||||
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a credential reconnected to another account starts empty. Two sign-ins on different plans no longer overwrite each other's working slug.
|
||||
|
||||
**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds.
|
||||
**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds.
|
||||
|
||||
**All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings".
|
||||
|
||||
**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`.
|
||||
**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug.
|
||||
|
||||
The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`.
|
||||
The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
@@ -781,7 +795,10 @@ All paths relative to `openai/codex` repo:
|
||||
- `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers
|
||||
- `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait
|
||||
- `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc.
|
||||
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum
|
||||
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire)
|
||||
- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…`
|
||||
- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity
|
||||
- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL
|
||||
- `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums
|
||||
- `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers
|
||||
- `codex-rs/model-provider/src/auth.rs` — auth-provider selection
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94692,
|
||||
"total": 94554,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -28,7 +28,7 @@
|
||||
"lib/ai/coach/snapshot.ts": 1346,
|
||||
"lib/ai/coach/stream-events.ts": 817,
|
||||
"lib/ai/coach/types.ts": 1011,
|
||||
"lib/ai/codex-client.ts": 1045,
|
||||
"lib/ai/codex-client.ts": 907,
|
||||
"lib/ai/prompts/insight-generator.ts": 1110,
|
||||
"lib/ai/provider.ts": 1632,
|
||||
"lib/analytics/score/reader.ts": 998,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+60
-198
@@ -11,25 +11,21 @@ import type {
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
import {
|
||||
annotateReasoningDowngrade,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
rememberReasoningDialect,
|
||||
} from "./reasoning/dialect-cache";
|
||||
import type { ReasoningLevel } from "./reasoning/levels";
|
||||
import { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support";
|
||||
import {
|
||||
getCachedCodexSlug,
|
||||
invalidateCachedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
} from "./codex-slug-cache";
|
||||
CODEX_ENDPOINT,
|
||||
asksEncryptedReasoning,
|
||||
learnFromReasoningRejection,
|
||||
planCodexReasoning,
|
||||
type CodexReasoningWire,
|
||||
} from "./codex-reasoning-wire";
|
||||
import {
|
||||
DEFAULT_SLUG_FALLBACK_CHAIN,
|
||||
loadFallbackChain,
|
||||
} from "./codex-slug-chain";
|
||||
|
||||
export { resolveCodexVisionSlug } from "./codex-slug-chain";
|
||||
getCachedCodexSlug,
|
||||
rememberRejectedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
type CodexCredential,
|
||||
} from "./codex-slug-cache";
|
||||
import { loadCodexAttemptChain } from "./codex-slug-chain";
|
||||
|
||||
/**
|
||||
* Codex backend client — talks to `chatgpt.com/backend-api/codex/responses`
|
||||
@@ -47,15 +43,12 @@ 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.
|
||||
*/
|
||||
|
||||
const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/**
|
||||
* Triggers per spec §7b — body / status that mean "this slug is no
|
||||
* longer accepted, walk to the next one in the chain".
|
||||
@@ -207,142 +200,6 @@ function codexStateItems(
|
||||
);
|
||||
}
|
||||
|
||||
// ── v1.41 reasoning wire ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Responses-API efforts in ascending order. The Codex backend names the
|
||||
* values a slug accepts in its 400 body ("Supported values are: 'none',
|
||||
* 'low', …"), which is how a slug's ladder is learned.
|
||||
*/
|
||||
const CODEX_EFFORT_ORDER = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
] as const;
|
||||
|
||||
/** What one request sends: `null` is today's `reasoning: null`. */
|
||||
type CodexReasoningWire = { effort: string; summary: boolean } | null;
|
||||
|
||||
/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */
|
||||
interface CodexReasoningDialect {
|
||||
/** The slug refused `reasoning` altogether. */
|
||||
disabled?: true;
|
||||
/** The slug refused `reasoning.summary`. */
|
||||
noSummary?: true;
|
||||
/** The efforts the slug named as supported when it refused one. */
|
||||
efforts?: string[];
|
||||
}
|
||||
|
||||
function dialectKey(slug: string) {
|
||||
return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug };
|
||||
}
|
||||
|
||||
function readCodexDialect(slug: string): CodexReasoningDialect {
|
||||
const raw = learnedReasoningDialect(dialectKey(slug));
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as CodexReasoningDialect;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function wantedEffort(level: ReasoningLevel): string {
|
||||
// "Off" is a real off on the current slugs: a 2026-10-06 probe answered
|
||||
// `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that
|
||||
// refuses it learns its lowest supported value instead.
|
||||
return level === "off" ? "none" : level;
|
||||
}
|
||||
|
||||
/** The supported effort closest to `wanted` from below, else the lowest. */
|
||||
function nearestEffort(wanted: string, supported: string[]): string | null {
|
||||
const rank = (e: string) =>
|
||||
(CODEX_EFFORT_ORDER as readonly string[]).indexOf(e);
|
||||
const known = supported
|
||||
.filter((e) => rank(e) >= 0)
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
if (known.length === 0) return null;
|
||||
const below = known.filter((e) => rank(e) <= rank(wanted));
|
||||
return below.length > 0 ? below[below.length - 1] : known[0];
|
||||
}
|
||||
|
||||
function planCodexReasoning(
|
||||
reasoning: CompletionParams["reasoning"],
|
||||
slug: string,
|
||||
): CodexReasoningWire {
|
||||
if (!reasoning) return null;
|
||||
const dialect = readCodexDialect(slug);
|
||||
if (dialect.disabled) return null;
|
||||
let effort: string | null = wantedEffort(reasoning.effort);
|
||||
if (dialect.efforts && !dialect.efforts.includes(effort)) {
|
||||
effort = nearestEffort(effort, dialect.efforts);
|
||||
}
|
||||
if (!effort) return null;
|
||||
return {
|
||||
effort,
|
||||
summary: reasoning.summaries && effort !== "none" && !dialect.noSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request asks for the encrypted reasoning items, the only
|
||||
* reasoning state a `store: false` request can hand to the next round.
|
||||
*/
|
||||
function asksEncryptedReasoning(wire: CodexReasoningWire): boolean {
|
||||
return wire !== null && wire.effort !== "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 400 that refuses a reasoning parameter and learn what the slug
|
||||
* accepts instead. Returns the lesser wire to retry with, or `undefined`
|
||||
* when the body is not about reasoning (the caller's error path takes it).
|
||||
*/
|
||||
function learnFromReasoningRejection(
|
||||
slug: string,
|
||||
wire: Exclude<CodexReasoningWire, null>,
|
||||
status: number,
|
||||
body: string,
|
||||
): CodexReasoningWire | undefined {
|
||||
// Only a 400 that refuses the reasoning parameter counts; a 400 about the
|
||||
// prompt or a tool, and a replay error about a reasoning item handed back
|
||||
// from an earlier round, must not teach the slug anything.
|
||||
if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) {
|
||||
return undefined;
|
||||
}
|
||||
const dialect = readCodexDialect(slug);
|
||||
let next: CodexReasoningWire;
|
||||
if (wire.summary && /reasoning\.summary|summary/i.test(body)) {
|
||||
dialect.noSummary = true;
|
||||
next = { effort: wire.effort, summary: false };
|
||||
} else {
|
||||
const listed = /supported values are:([^"\n]*)/i.exec(body);
|
||||
const efforts = listed
|
||||
? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1])
|
||||
: [];
|
||||
const nearest =
|
||||
efforts.length > 0 && !efforts.includes(wire.effort)
|
||||
? nearestEffort(wire.effort, efforts)
|
||||
: null;
|
||||
if (nearest) {
|
||||
dialect.efforts = efforts;
|
||||
next = { effort: nearest, summary: wire.summary && nearest !== "none" };
|
||||
} else {
|
||||
dialect.disabled = true;
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect));
|
||||
annotateReasoningDowngrade(
|
||||
"codex",
|
||||
wire.effort,
|
||||
next ? next.effort : "default",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map tool defs into the Responses `function` tool shape.
|
||||
*
|
||||
@@ -369,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. */
|
||||
@@ -407,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);
|
||||
@@ -472,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
|
||||
@@ -512,7 +378,7 @@ export class CodexClient implements AIProvider {
|
||||
cacheState,
|
||||
workingSlug: slug,
|
||||
};
|
||||
setCachedCodexSlug(slug);
|
||||
setCachedCodexSlug(this.credential, slug);
|
||||
return this.parseResponse(attempt, slug, params, wire);
|
||||
}
|
||||
|
||||
@@ -521,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})`,
|
||||
);
|
||||
@@ -1036,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;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The reasoning half of the Codex wire: what `reasoning` field a request
|
||||
* sends for a requested level, and what a slug that refuses it is asked
|
||||
* instead. A slug's refusals are learned per slug in the shared dialect cache
|
||||
* (`reasoning/dialect-cache.ts`), so the next request does not pay the
|
||||
* refused round-trip again.
|
||||
*/
|
||||
import {
|
||||
annotateReasoningDowngrade,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
rememberReasoningDialect,
|
||||
} from "./reasoning/dialect-cache";
|
||||
import type { ReasoningLevel } from "./reasoning/levels";
|
||||
import type { CompletionParams } from "./types";
|
||||
|
||||
/** The responses endpoint, and the key its learned dialects are kept under. */
|
||||
export const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/**
|
||||
* The Responses-API efforts in ascending order. The Codex backend names the
|
||||
* values a slug accepts in its 400 body ("Supported values are: 'none',
|
||||
* 'low', …"), which is how a slug's ladder is learned.
|
||||
*/
|
||||
const CODEX_EFFORT_ORDER = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
] as const;
|
||||
|
||||
/** What one request sends: `null` is today's `reasoning: null`. */
|
||||
export type CodexReasoningWire = { effort: string; summary: boolean } | null;
|
||||
|
||||
/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */
|
||||
interface CodexReasoningDialect {
|
||||
/** The slug refused `reasoning` altogether. */
|
||||
disabled?: true;
|
||||
/** The slug refused `reasoning.summary`. */
|
||||
noSummary?: true;
|
||||
/** The efforts the slug named as supported when it refused one. */
|
||||
efforts?: string[];
|
||||
}
|
||||
|
||||
function dialectKey(slug: string) {
|
||||
return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug };
|
||||
}
|
||||
|
||||
function readCodexDialect(slug: string): CodexReasoningDialect {
|
||||
const raw = learnedReasoningDialect(dialectKey(slug));
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as CodexReasoningDialect;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function wantedEffort(level: ReasoningLevel): string {
|
||||
// "Off" is a real off on the current slugs: a 2026-10-06 probe answered
|
||||
// `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that
|
||||
// refuses it learns its lowest supported value instead.
|
||||
return level === "off" ? "none" : level;
|
||||
}
|
||||
|
||||
/** The supported effort closest to `wanted` from below, else the lowest. */
|
||||
function nearestEffort(wanted: string, supported: string[]): string | null {
|
||||
const rank = (e: string) =>
|
||||
(CODEX_EFFORT_ORDER as readonly string[]).indexOf(e);
|
||||
const known = supported
|
||||
.filter((e) => rank(e) >= 0)
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
if (known.length === 0) return null;
|
||||
const below = known.filter((e) => rank(e) <= rank(wanted));
|
||||
return below.length > 0 ? below[below.length - 1] : known[0];
|
||||
}
|
||||
|
||||
export function planCodexReasoning(
|
||||
reasoning: CompletionParams["reasoning"],
|
||||
slug: string,
|
||||
): CodexReasoningWire {
|
||||
if (!reasoning) return null;
|
||||
const dialect = readCodexDialect(slug);
|
||||
if (dialect.disabled) return null;
|
||||
let effort: string | null = wantedEffort(reasoning.effort);
|
||||
if (dialect.efforts && !dialect.efforts.includes(effort)) {
|
||||
effort = nearestEffort(effort, dialect.efforts);
|
||||
}
|
||||
if (!effort) return null;
|
||||
return {
|
||||
effort,
|
||||
summary: reasoning.summaries && effort !== "none" && !dialect.noSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request asks for the encrypted reasoning items, the only
|
||||
* reasoning state a `store: false` request can hand to the next round.
|
||||
*/
|
||||
export function asksEncryptedReasoning(wire: CodexReasoningWire): boolean {
|
||||
return wire !== null && wire.effort !== "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 400 that refuses a reasoning parameter and learn what the slug
|
||||
* accepts instead. Returns the lesser wire to retry with, or `undefined`
|
||||
* when the body is not about reasoning (the caller's error path takes it).
|
||||
*/
|
||||
export function learnFromReasoningRejection(
|
||||
slug: string,
|
||||
wire: Exclude<CodexReasoningWire, null>,
|
||||
status: number,
|
||||
body: string,
|
||||
): CodexReasoningWire | undefined {
|
||||
// Only a 400 that refuses the reasoning parameter counts; a 400 about the
|
||||
// prompt or a tool, and a replay error about a reasoning item handed back
|
||||
// from an earlier round, must not teach the slug anything.
|
||||
if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) {
|
||||
return undefined;
|
||||
}
|
||||
const dialect = readCodexDialect(slug);
|
||||
let next: CodexReasoningWire;
|
||||
if (wire.summary && /reasoning\.summary|summary/i.test(body)) {
|
||||
dialect.noSummary = true;
|
||||
next = { effort: wire.effort, summary: false };
|
||||
} else {
|
||||
const listed = /supported values are:([^"\n]*)/i.exec(body);
|
||||
const efforts = listed
|
||||
? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1])
|
||||
: [];
|
||||
const nearest =
|
||||
efforts.length > 0 && !efforts.includes(wire.effort)
|
||||
? nearestEffort(wire.effort, efforts)
|
||||
: null;
|
||||
if (nearest) {
|
||||
dialect.efforts = efforts;
|
||||
next = { effort: nearest, summary: wire.summary && nearest !== "none" };
|
||||
} else {
|
||||
dialect.disabled = true;
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect));
|
||||
annotateReasoningDowngrade(
|
||||
"codex",
|
||||
wire.effort,
|
||||
next ? next.effort : "default",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
+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