fix(ai): read a Codex sign-in's memory only under its own account

Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.

The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 21:36:33 +01:00
1 parent 648810745a
commit dfe34386be
15 files changed
+353 -92

No files matched your search

+1 -1
View File
@@ -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:<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.
**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 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.
@@ -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
+6
View File
@@ -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),
@@ -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.
@@ -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),
+43 -10
View File
@@ -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);
@@ -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",
@@ -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);
}
});
});
+82 -34
View File
@@ -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();
});
});
@@ -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",
@@ -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", () => {
+1 -1
View File
@@ -297,7 +297,7 @@ export class CodexClient implements AIProvider {
): Promise<CompletionResult> {
// 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({
+10 -3
View File
@@ -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<CodexCatalogRead> {
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;
}
+107 -27
View File
@@ -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<string> {
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<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());
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<string>;
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);
}
/**
+8 -8
View File
@@ -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<string[]> {
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) {