diff --git a/docs/codex-protocol-spec.md b/docs/codex-protocol-spec.md index 0baf43877..ba81bf20f 100644 --- a/docs/codex-protocol-spec.md +++ b/docs/codex-protocol-spec.md @@ -692,7 +692,7 @@ 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)` 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:` 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. +**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:` 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 client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug. **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. diff --git a/src/app/api/admin/central-codex/device-poll/route.ts b/src/app/api/admin/central-codex/device-poll/route.ts index 62d9df175..18d7b70d3 100644 --- a/src/app/api/admin/central-codex/device-poll/route.ts +++ b/src/app/api/admin/central-codex/device-poll/route.ts @@ -10,6 +10,10 @@ import { getClientIp, apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; +import { + codexCredentialKey, + startCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; /** * Operator-shared central Codex — device-code poll (admin only). @@ -93,6 +97,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); cookieStore.delete("central_codex_device"); + // What this process learned about the old sign-in's models no longer + // describes a different account (`codex-slug-cache.ts`). + startCodexSignIn({ + key: codexCredentialKey.central, + accountId: result.creds.accountId, + }); // A new central sign-in: every account's ledger rows describe the refused // one, and the central connection is one credential shared by all of them. // #1279 — and every record's note windows close, as on the admin AI diff --git a/src/app/api/admin/central-codex/route.ts b/src/app/api/admin/central-codex/route.ts index fc766d0c2..eb5c25a7a 100644 --- a/src/app/api/admin/central-codex/route.ts +++ b/src/app/api/admin/central-codex/route.ts @@ -5,6 +5,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + codexCredentialKey, + forgetCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; /** * Operator-shared central Codex (ChatGPT subscription) — status + disconnect. @@ -55,6 +59,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { create: { id: "singleton", adminCodexConnectionStatus: "disconnected" }, }); + forgetCodexSignIn(codexCredentialKey.central); + await auditLog("admin.central-codex.disconnected", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/api/auth/codex/device-poll/route.ts b/src/app/api/auth/codex/device-poll/route.ts index e4fb52758..9c3a34ac9 100644 --- a/src/app/api/auth/codex/device-poll/route.ts +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context"; import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher"; import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; +import { + codexCredentialKey, + startCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; /** * v1.4.7.1: Device-code flow polling endpoint. @@ -90,6 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); cookieStore.delete("codex_device"); + // What this process learned about the old sign-in's models no longer + // describes a different account (`codex-slug-cache.ts`). + startCodexSignIn({ + key: codexCredentialKey.user(user.id), + accountId: result.creds.accountId, + }); // A new sign-in: whatever the ledger held against the old one no longer // describes this credential, and (#1279) the person's notes stop waiting // out windows it earned, as on a save of their provider settings. diff --git a/src/app/api/auth/codex/disconnect/route.ts b/src/app/api/auth/codex/disconnect/route.ts index 4302d4a5f..1fcb786ea 100644 --- a/src/app/api/auth/codex/disconnect/route.ts +++ b/src/app/api/auth/codex/disconnect/route.ts @@ -6,6 +6,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher"; +import { + codexCredentialKey, + forgetCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; export const DELETE = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -27,6 +31,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { }, }); + forgetCodexSignIn(codexCredentialKey.user(user.id)); + await auditLog("codex.oauth.disconnected", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/lib/ai/__tests__/codex-live-models.test.ts b/src/lib/ai/__tests__/codex-live-models.test.ts index 9e310c50a..8f30a8de2 100644 --- a/src/lib/ai/__tests__/codex-live-models.test.ts +++ b/src/lib/ai/__tests__/codex-live-models.test.ts @@ -245,13 +245,21 @@ describe("Codex keeps the working model per sign-in", () => { 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"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("a"), + accountId: "pro", + }), + ).toBe("gpt-5.6-terra"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("b"), + accountId: "edu", + }), + ).toBe("gpt-6.1-sol"); + expect( + getCachedCodexSlug({ key: codexCredentialKey.central, accountId: "edu" }), + ).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", @@ -291,6 +299,28 @@ describe("Codex keeps the working model per sign-in", () => { expect(log).toEqual(["stale models", "stale responses:gpt-6-sol"]); }); + it("a credential reconnected to another account tries nothing the old account learned", async () => { + const log = stubBackend({ + pro: { + models: planList("gpt-5.6-terra", "gpt-6-sol"), + accepts: ["gpt-5.6-terra", "gpt-6-sol"], + }, + // Another plan: terra is neither offered nor accepted. + edu: { models: planList("gpt-6.1-sol"), accepts: ["gpt-6.1-sol"] }, + }); + const key = codexCredentialKey.user("a"); + await ask(client(key, "pro")); + expect(log).toEqual(["pro models", "pro responses:gpt-5.6-terra"]); + + // The same stored credential, signed in again on the other account, in + // a process that has not seen the connect (no write has rebound it). + log.length = 0; + const again = client(key, "edu"); + await ask(again); + expect(log).toEqual(["edu models", "edu responses:gpt-6.1-sol"]); + expect(again.getLastDiagnostics()).toMatchObject({ cacheState: "miss" }); + }); + 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({ @@ -298,9 +328,12 @@ describe("Codex keeps the working model per sign-in", () => { }); const c = client(codexCredentialKey.user("u1"), "plus"); await ask(c); - expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBe( - "gpt-5.6-terra", - ); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u1"), + accountId: "plus", + }), + ).toBe("gpt-5.6-terra"); // terra retires between two requests. accepts.splice(0, 1); diff --git a/src/lib/ai/__tests__/codex-model-catalog.test.ts b/src/lib/ai/__tests__/codex-model-catalog.test.ts index e8893b50d..0e3c23c21 100644 --- a/src/lib/ai/__tests__/codex-model-catalog.test.ts +++ b/src/lib/ai/__tests__/codex-model-catalog.test.ts @@ -251,7 +251,7 @@ describe("readCodexModelCatalog", () => { const second = await readCodexModelCatalog(PERSONAL, AUTH); expect(second.from).toBe("cache"); expect(safeFetch).toHaveBeenCalledTimes(1); - expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); + expect(cachedCodexModels(PERSONAL)).toHaveLength(9); vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1); expect((await readCodexModelCatalog(PERSONAL, AUTH)).from).toBe("live"); @@ -363,8 +363,8 @@ describe("readCodexModelCatalog", () => { 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([ + expect(cachedCodexModels(PERSONAL)).toHaveLength(9); + expect(cachedCodexModels(second)?.map((m) => m.slug)).toEqual([ "gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", diff --git a/src/lib/ai/__tests__/codex-sign-in-routes.test.ts b/src/lib/ai/__tests__/codex-sign-in-routes.test.ts new file mode 100644 index 000000000..928963744 --- /dev/null +++ b/src/lib/ai/__tests__/codex-sign-in-routes.test.ts @@ -0,0 +1,36 @@ +/** + * The routes that connect or disconnect a stored Codex credential tell the + * per-sign-in memory, so the vision gate, which holds no account id, stops + * reading what the previous sign-in learned (`codex-slug-cache.ts`). The + * account-checked client reads cover every other path; this file holds the + * four call sites. + */ +import { describe, expect, it } from "vitest"; + +import { readSource } from "@/__tests__/helpers/source-files"; + +describe("Codex sign-in routes", () => { + it("tells the sign-in memory about every connect and disconnect", () => { + const routes: Array<[string, RegExp]> = [ + [ + "src/app/api/auth/codex/device-poll/route.ts", + /startCodexSignIn\(\{\s*key: codexCredentialKey\.user\(user\.id\),\s*accountId: result\.creds\.accountId/, + ], + [ + "src/app/api/admin/central-codex/device-poll/route.ts", + /startCodexSignIn\(\{\s*key: codexCredentialKey\.central,\s*accountId: result\.creds\.accountId/, + ], + [ + "src/app/api/auth/codex/disconnect/route.ts", + /forgetCodexSignIn\(codexCredentialKey\.user\(user\.id\)\)/, + ], + [ + "src/app/api/admin/central-codex/route.ts", + /forgetCodexSignIn\(codexCredentialKey\.central\)/, + ], + ]; + for (const [file, call] of routes) { + expect(readSource(file, call), file).toMatch(call); + } + }); +}); diff --git a/src/lib/ai/__tests__/codex-slug-cache.test.ts b/src/lib/ai/__tests__/codex-slug-cache.test.ts index 819b4277f..f77994dff 100644 --- a/src/lib/ai/__tests__/codex-slug-cache.test.ts +++ b/src/lib/ai/__tests__/codex-slug-cache.test.ts @@ -8,12 +8,15 @@ import { clearCodexSlugCache, codexCredentialCacheSize, codexCredentialKey, + codexGateMemory, + forgetCodexSignIn, getCachedCodexSlug, getCodexCatalogState, rejectedCodexSlugs, rememberRejectedCodexSlug, setCachedCodexSlug, setCodexCatalogState, + startCodexSignIn, } from "../codex-slug-cache"; /** @@ -35,19 +38,19 @@ describe("Codex per-sign-in cache", () => { }); it("starts empty", () => { - expect(getCachedCodexSlug(A.key)).toBeNull(); - expect(rejectedCodexSlugs(A.key).size).toBe(0); - expect(getCodexCatalogState(A.key)).toBeNull(); + expect(getCachedCodexSlug(A)).toBeNull(); + expect(rejectedCodexSlugs(A).size).toBe(0); + expect(getCodexCatalogState(A)).toBeNull(); }); 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( + expect(getCachedCodexSlug(A, 1_000)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( "gpt-6-sol", ); expect( - getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1), + getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1), ).toBeNull(); expect(CODEX_SLUG_CACHE_TTL_MS).toBe(60 * 60 * 1000); }); @@ -57,59 +60,89 @@ describe("Codex per-sign-in cache", () => { 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"); + expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra"); + expect(getCachedCodexSlug(B)).toBe("gpt-6.1-sol"); + expect(getCachedCodexSlug(CENTRAL)).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); + expect(getCachedCodexSlug(B)).toBeNull(); + expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra"); + expect(rejectedCodexSlugs(A).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(getCachedCodexSlug(A, 10)).toBeNull(); + expect([...rejectedCodexSlugs(A, 10)]).toEqual(["gpt-5.6-terra"]); + expect([...rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS)]).toEqual( + ["gpt-5.6-terra"], + ); expect( - rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size, + rejectedCodexSlugs(A, 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); + expect(rejectedCodexSlugs(A).size).toBe(0); }); - it("a credential reconnected to another account starts afresh", () => { + it("a credential reconnected to another account reads as empty before anything is written", () => { 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(); + // Same stored credential, a different ChatGPT account (another plan): + // its first read finds nothing the old account learned. + const other = { key: A.key, accountId: "acct-other" }; + expect(getCachedCodexSlug(other)).toBeNull(); + expect(rejectedCodexSlugs(other).size).toBe(0); + expect(getCodexCatalogState(other)).toBeNull(); + + // Its first write starts the entry afresh, and the old account's memory + // is gone with it. + setCachedCodexSlug(other, "gpt-6-luna"); + expect(getCachedCodexSlug(other)).toBe("gpt-6-luna"); + expect(rejectedCodexSlugs(other).size).toBe(0); + expect(getCachedCodexSlug(A)).toBeNull(); + expect(getCodexCatalogState(A)).toBeNull(); + }); + + it("a connect rebinds the gate's memory and a disconnect forgets it", () => { + setCachedCodexSlug(A, "gpt-5.6-terra"); + rememberRejectedCodexSlug(A, "gpt-6-sol"); + expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra"); + + // A reconnect to the same account keeps what it learned. + startCodexSignIn(A); + expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra"); + + // A reconnect to another account starts it afresh. + startCodexSignIn({ key: A.key, accountId: "acct-other" }); + expect(codexGateMemory(A.key)).toEqual({ + working: null, + rejected: new Set(), + catalog: null, + }); + + setCachedCodexSlug(B, "gpt-6-sol"); + forgetCodexSignIn(B.key); + expect(codexGateMemory(B.key).working).toBeNull(); + expect(getCachedCodexSlug(B)).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", - ); - expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS + 1)).toBeNull(); + expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS)?.kind).toBe("offered"); + expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS + 1)).toBeNull(); setCodexCatalogState(B, { kind: "unreadable", reason: "timeout", at: 0 }); - expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS)?.kind).toBe( + expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS)?.kind).toBe( "unreadable", ); - expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS + 1)).toBeNull(); + expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS + 1)).toBeNull(); }); it("reports the first unreadable read of a stretch, and re-arms on success", () => { @@ -135,13 +168,28 @@ describe("Codex per-sign-in cache", () => { ); } // Touch the oldest so the second-oldest is the one to go. - expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u0"), + accountId: "acct-0", + }), + ).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(); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u0"), + accountId: "acct-0", + }), + ).toBe("gpt-6-sol"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u1"), + accountId: "acct-1", + }), + ).toBeNull(); }); }); diff --git a/src/lib/ai/__tests__/codex-slug-chain.test.ts b/src/lib/ai/__tests__/codex-slug-chain.test.ts index dd21fd811..4c35c46c6 100644 --- a/src/lib/ai/__tests__/codex-slug-chain.test.ts +++ b/src/lib/ai/__tests__/codex-slug-chain.test.ts @@ -26,9 +26,11 @@ import { import { clearCodexSlugCache, codexCredentialKey, + forgetCodexSignIn, rememberRejectedCodexSlug, setCachedCodexSlug, setCodexCatalogState, + startCodexSignIn, } from "../codex-slug-cache"; const OFFERED = parseCodexModelList( @@ -278,6 +280,29 @@ describe("vision for a Codex sign-in", () => { expect(codexSignInReadsImages(B.key)).toBe(true); }); + it("answers from the new sign-in as soon as the credential is connected to another account", () => { + // The old account: its working model listed as text-only. + setCodexCatalogState(A, { + kind: "offered", + models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }], + at: Date.now(), + }); + setCachedCodexSlug(A, "gpt-6-sol"); + expect(codexSignInReadsImages(A.key)).toBe(false); + + // What the device-poll route records on a reconnect. + startCodexSignIn({ key: A.key, accountId: "acct-new" }); + expect(resolveCodexVisionSlug(A.key)).toBe(DEFAULT_SLUG_FALLBACK_CHAIN[0]); + expect(codexSignInReadsImages(A.key)).toBe(true); + expect(chainEntryReadsImages("codex", "a", null)).toBe(true); + + // And on a disconnect. + setCachedCodexSlug(B, "o3-mini"); + expect(codexSignInReadsImages(B.key)).toBe(false); + forgetCodexSignIn(B.key); + expect(codexSignInReadsImages(B.key)).toBe(true); + }); + it("falls back to the model family when the list says nothing", () => { setCodexCatalogState(A, { kind: "offered", diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index badd9f330..ac0aa8c1a 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -137,7 +137,7 @@ describe("Codex slug fallback chain", () => { "gpt-6-sol", ]); expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol"); - expect(getCachedCodexSlug(KEY)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-6-sol"); } finally { if (origModel === undefined) delete process.env.CODEX_MODEL; else process.env.CODEX_MODEL = origModel; @@ -169,7 +169,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.cacheState).toBe("miss"); expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); - expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 'not supported when using Codex with a ChatGPT account' 400", async () => { @@ -199,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(KEY)).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 400 with model_not_found body", async () => { @@ -374,7 +374,7 @@ describe("Codex slug fallback chain", () => { it("cache invalidated when cached slug starts rejecting", async () => { setCachedCodexSlug(CREDENTIAL, "gpt-5-codex"); - expect(getCachedCodexSlug(KEY)).toBe("gpt-5-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5-codex"); const mockFetch = vi .fn() @@ -393,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(KEY)).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex"); }); it("isSlugRejection helper recognises canonical rejection bodies", () => { diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 7655f5b03..da239552f 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -297,7 +297,7 @@ export class CodexClient implements AIProvider { ): Promise { // 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 cached = getCachedCodexSlug(this.credential); const cacheState = cached === null ? "miss" : "hit"; const loadChain = () => loadCodexAttemptChain({ diff --git a/src/lib/ai/codex-model-catalog.ts b/src/lib/ai/codex-model-catalog.ts index 0ea5781b6..d95b1abd5 100644 --- a/src/lib/ai/codex-model-catalog.ts +++ b/src/lib/ai/codex-model-catalog.ts @@ -39,6 +39,7 @@ import { codexCredentialKey, getCodexCatalogState, setCodexCatalogState, + type CodexCatalogState, type CodexCredential, } from "./codex-slug-cache"; @@ -228,7 +229,7 @@ export function readCodexModelCatalog( credential: CodexCredential, auth: CodexCatalogAuth, ): Promise { - const state = getCodexCatalogState(credential.key); + const state = getCodexCatalogState(credential); if (state?.kind === "offered") { return Promise.resolve({ models: state.models, @@ -289,9 +290,15 @@ export function readCodexModelCatalog( /** The offered models from an earlier read, without touching the network. */ export function cachedCodexModels( - key: string, + credential: CodexCredential, now: number = Date.now(), ): readonly CodexOfferedModel[] | null { - const state = getCodexCatalogState(key, now); + return offeredModels(getCodexCatalogState(credential, now)); +} + +/** The models a list read offered, or null when it offered none. */ +export function offeredModels( + state: CodexCatalogState | null, +): readonly CodexOfferedModel[] | null { return state?.kind === "offered" ? state.models : null; } diff --git a/src/lib/ai/codex-slug-cache.ts b/src/lib/ai/codex-slug-cache.ts index b06d12c77..8e6198629 100644 --- a/src/lib/ai/codex-slug-cache.ts +++ b/src/lib/ai/codex-slug-cache.ts @@ -12,10 +12,21 @@ * - `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. + * under, and a client reads it only under that account: a credential + * reconnected to a different account (a different plan) reads as empty from + * its first request, before anything is written under the new account, so no + * slug, refusal or list learned on the old account carries over. That holds + * in every process, including one that never saw the reconnect. + * + * The vision gate is the one reader without an account id (the provider + * presence probe decrypts nothing), so it reads the entry as this process last + * bound it ({@link codexGateMemory}). The routes that connect or disconnect a + * credential rebind or drop the entry ({@link startCodexSignIn}, + * {@link forgetCodexSignIn}), so in the process that handled the change the + * gate answers from the new sign-in at once; elsewhere it catches up with that + * process's first request on the new sign-in, and a wrong guess in between is + * what the runtime probe and the OCR fallback already cover. 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 @@ -76,7 +87,7 @@ function accountDigest(accountId: string): string { .slice(0, 32); } -/** Read an entry without binding it to an account; LRU-touches a hit. */ +/** Read an entry whatever account it was learned under; LRU-touches a hit. */ function peek(key: string): CredentialEntry | undefined { const entry = entries.get(key); if (!entry) return undefined; @@ -109,12 +120,22 @@ function bind(credential: CodexCredential): CredentialEntry { return fresh; } -/** The model that last worked for this sign-in, while it is fresh. */ -export function getCachedCodexSlug( - key: string, - now: number = Date.now(), +/** + * The entry learned under this sign-in's account, or none: an entry left by + * another account on the same credential reads as empty. + */ +function read(credential: CodexCredential): CredentialEntry | undefined { + const entry = peek(credential.key); + if (!entry) return undefined; + return entry.accountDigest === accountDigest(credential.accountId) + ? entry + : undefined; +} + +function liveWorking( + entry: CredentialEntry | undefined, + now: number, ): string | null { - const entry = peek(key); if (!entry?.working) return null; if (now - entry.working.at > CODEX_SLUG_CACHE_TTL_MS) { entry.working = null; @@ -123,6 +144,40 @@ export function getCachedCodexSlug( return entry.working.slug; } +function liveRejected( + entry: CredentialEntry | undefined, + now: number, +): ReadonlySet { + 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()); +} + +function liveCatalog( + entry: CredentialEntry | undefined, + now: number, +): CodexCatalogState | null { + 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; +} + +/** The model that last worked for this sign-in, while it is fresh. */ +export function getCachedCodexSlug( + credential: CodexCredential, + now: number = Date.now(), +): string | null { + return liveWorking(read(credential), now); +} + export function setCachedCodexSlug( credential: CodexCredential, slug: string, @@ -151,32 +206,57 @@ export function rememberRejectedCodexSlug( /** The models this sign-in was refused recently. */ export function rejectedCodexSlugs( - key: string, + credential: CodexCredential, now: number = Date.now(), ): ReadonlySet { - const entry = peek(key); - if (!entry) return new Set(); - for (const [slug, at] of entry.rejected) { - if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug); - } - return new Set(entry.rejected.keys()); + return liveRejected(read(credential), now); } /** The last read of the account's model list, while it is still trusted. */ export function getCodexCatalogState( - key: string, + credential: CodexCredential, now: number = Date.now(), ): CodexCatalogState | null { + return liveCatalog(read(credential), now); +} + +/** What the vision gate reasons from for one stored credential. */ +export interface CodexGateMemory { + working: string | null; + rejected: ReadonlySet; + catalog: CodexCatalogState | null; +} + +/** + * The credential's memory as this process last bound it, for the vision gate + * alone, which holds no account id (see the module header). Every other + * reader goes through the account-checked reads above. + */ +export function codexGateMemory( + key: string, + now: number = Date.now(), +): CodexGateMemory { 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; + return { + working: liveWorking(entry, now), + rejected: liveRejected(entry, now), + catalog: liveCatalog(entry, now), + }; +} + +/** + * A credential has just been connected to `accountId`: a reconnect to another + * account starts the entry afresh, so the vision gate in this process stops + * reading what the old account learned; a reconnect to the same account keeps + * it. + */ +export function startCodexSignIn(credential: CodexCredential): void { + bind(credential); +} + +/** A credential has been disconnected: forget what it learned. */ +export function forgetCodexSignIn(key: string): void { + entries.delete(key); } /** diff --git a/src/lib/ai/codex-slug-chain.ts b/src/lib/ai/codex-slug-chain.ts index 8b345e62d..766051598 100644 --- a/src/lib/ai/codex-slug-chain.ts +++ b/src/lib/ai/codex-slug-chain.ts @@ -8,14 +8,14 @@ */ import { annotate } from "@/lib/logging/context"; import { - cachedCodexModels, + offeredModels, readCodexModelCatalog, type CodexCatalogRead, type CodexOfferedModel, } from "./codex-model-catalog"; import { codexCredentialKey, - getCachedCodexSlug, + codexGateMemory, rejectedCodexSlugs, type CodexCredential, } from "./codex-slug-cache"; @@ -172,7 +172,7 @@ export async function loadCodexAttemptChain(input: { slugChain?: readonly string[]; readModels?: CodexModelReader; }): Promise { - const rejected = rejectedCodexSlugs(input.credential.key); + const rejected = rejectedCodexSlugs(input.credential); if (input.slugChain && input.slugChain.length > 0) { return [ ...input.slugChain.filter((s) => !rejected.has(s)), @@ -218,13 +218,13 @@ export async function loadCodexAttemptChain(input: { * fallback cover. */ export function resolveCodexVisionSlug(credentialKey: string): string | null { - const cached = getCachedCodexSlug(credentialKey); - if (cached) return cached; + const memory = codexGateMemory(credentialKey); + if (memory.working) return memory.working; const { chain } = buildCodexSlugChain({ offered: operatorReplacedCodexChain() ? null - : cachedCodexModels(credentialKey), - rejected: rejectedCodexSlugs(credentialKey), + : offeredModels(memory.catalog), + rejected: memory.rejected, }); return chain[0] ?? null; } @@ -237,7 +237,7 @@ export function resolveCodexVisionSlug(credentialKey: string): string | null { export function codexSignInReadsImages(credentialKey: string): boolean { const slug = resolveCodexVisionSlug(credentialKey); if (!slug) return false; - const offered = cachedCodexModels(credentialKey)?.find( + const offered = offeredModels(codexGateMemory(credentialKey).catalog)?.find( (m) => m.slug === slug, ); if (offered?.inputModalities) {