@bitkyc08/opencodex 2.49.0 → 2.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  4. package/gui/dist/index.html +1 -1
  5. package/package.json +1 -1
  6. package/src/claude/inbound.ts +17 -5
  7. package/src/cli/account-api.ts +18 -3
  8. package/src/cli/account-auth.ts +8 -1
  9. package/src/cli/account-extended.ts +2 -1
  10. package/src/cli/account.ts +1 -0
  11. package/src/cli/capabilities.ts +15 -1
  12. package/src/cli/index.ts +5 -1
  13. package/src/cli/models-runtime.ts +8 -3
  14. package/src/cli/observe.ts +13 -3
  15. package/src/clients/config-export/zcode.ts +24 -0
  16. package/src/codex/account-runtime-state.ts +6 -1
  17. package/src/codex/account-store.ts +72 -9
  18. package/src/codex/account-usability.ts +3 -2
  19. package/src/codex/auth-api.ts +107 -23
  20. package/src/codex/auth-context.ts +21 -0
  21. package/src/codex/catalog/parsing.ts +23 -0
  22. package/src/codex/catalog/provider-fetch.ts +71 -2
  23. package/src/codex/catalog/sync.ts +14 -0
  24. package/src/codex/inject.ts +3 -2
  25. package/src/codex/quota-auto-refresh.ts +6 -1
  26. package/src/codex/quota.ts +54 -8
  27. package/src/combos/index.ts +2 -0
  28. package/src/combos/resolve.ts +52 -0
  29. package/src/config.ts +58 -0
  30. package/src/generated/compatibility-version.json +72 -60
  31. package/src/lib/errors.ts +8 -0
  32. package/src/lib/privacy.ts +25 -0
  33. package/src/oauth/health.ts +47 -12
  34. package/src/oauth/index.ts +46 -8
  35. package/src/oauth/token-guardian.ts +32 -6
  36. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  37. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  38. package/src/providers/quota.ts +15 -0
  39. package/src/providers/registry.ts +1 -1
  40. package/src/server/auth-cors.ts +6 -0
  41. package/src/server/chat-completions.ts +4 -4
  42. package/src/server/chat-native.ts +10 -1
  43. package/src/server/claude-messages.ts +5 -5
  44. package/src/server/images.ts +2 -2
  45. package/src/server/index.ts +25 -2
  46. package/src/server/management/logs-usage-routes.ts +4 -1
  47. package/src/server/management/model-rows.ts +16 -1
  48. package/src/server/management/oauth-account-routes.ts +6 -2
  49. package/src/server/management/provider-routes.ts +9 -2
  50. package/src/server/management/request-history-routes.ts +4 -2
  51. package/src/server/management/route-registry.ts +5 -4
  52. package/src/server/management/shared.ts +66 -3
  53. package/src/server/management-api.ts +1 -1
  54. package/src/server/request-decompress.ts +91 -3
  55. package/src/server/request-log.ts +10 -0
  56. package/src/server/responses/codex-ws-wire.ts +1 -1
  57. package/src/server/responses/compact.ts +8 -2
  58. package/src/server/responses/context-overflow.ts +11 -0
  59. package/src/server/responses/core.ts +144 -38
  60. package/src/server/responses/policy-fallback.ts +6 -2
  61. package/src/server/search.ts +2 -2
  62. package/src/service.ts +92 -7
  63. package/src/types/accounts.ts +18 -0
  64. package/src/types/config.ts +36 -0
  65. package/src/types/provider.ts +56 -0
  66. package/src/types.ts +4 -0
  67. package/src/web-search/ollama-executor.ts +127 -0
  68. package/src/web-search/passthrough-bridge.ts +761 -0
  69. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BtyONQrZ.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-C39tnjXO.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BoBRSehJ.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.49.0",
3
+ "version": "2.50.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -150,10 +150,17 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
150
150
 
151
151
  /**
152
152
  * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in
153
- * `messages` despite the published API having no system role. Map them to Responses
154
- * instructions text: the native ChatGPT backend rejects system message items in
155
- * `input` ("System messages are not allowed", verified live), so folding into
156
- * `instructions` is the only shape that works on every route.
153
+ * `messages` despite the published API having no system role. They are emitted as
154
+ * chronological `role:"developer"` input items, which keeps the timeline intact and
155
+ * leaves `instructions` owned solely by the top-level Anthropic `system` field.
156
+ *
157
+ * The original mapping folded them into `instructions` because the native ChatGPT
158
+ * backend rejects `role:"system"` items in `input` ("System messages are not allowed",
159
+ * verified live). That constraint is real and still respected — but it only rules out
160
+ * `system`, not `developer`, which every Responses route accepts. Folding meant each
161
+ * mid-conversation reminder mutated the prompt head, invalidating the upstream KV
162
+ * prefix and rotating the Desktop `prompt_cache_key` fallback below on every turn
163
+ * (#4148).
157
164
  */
158
165
  function systemMessageText(content: unknown): string {
159
166
  if (typeof content === "string") return content;
@@ -333,7 +340,12 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
333
340
  else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget);
334
341
  else if (msg.role === "system") {
335
342
  const text = systemMessageText(msg.content);
336
- if (text.length > 0) systemParts.push(text);
343
+ // Keep it where the client put it. `developer` is first-class in the Responses
344
+ // schema and survives parseRequest as a chronological message, where `system`
345
+ // would be re-hoisted back onto the system prompt and defeat the point.
346
+ if (text.length > 0) {
347
+ input.push({ type: "message", role: "developer", content: [{ type: "input_text", text }] });
348
+ }
337
349
  }
338
350
  else throw new AnthropicRequestError(`unsupported message role: ${String(msg.role)}`);
339
351
  }
@@ -18,10 +18,16 @@ export interface AccountRow {
18
18
  id: string;
19
19
  label?: string;
20
20
  email?: string;
21
- plan?: string;
21
+ /**
22
+ * Subscription tier. `null` means this version looked and the provider did not report one;
23
+ * an absent key means the row's surface does not carry a tier at all (#3777).
24
+ */
25
+ plan?: string | null;
22
26
  masked?: string;
23
27
  active: boolean;
24
28
  needsReauth?: boolean;
29
+ /** Registered credential that is still excluded from routing until validation completes. */
30
+ validationPending?: boolean;
25
31
  /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
26
32
  priority?: number;
27
33
  quota?: CodexQuotaDto | null;
@@ -237,6 +243,7 @@ interface CodexAccountDto {
237
243
  plan?: string;
238
244
  isMain?: boolean;
239
245
  needsReauth?: boolean;
246
+ health?: { reason?: string };
240
247
  priority?: number;
241
248
  quota?: CodexQuotaDto | null;
242
249
  quotaRefresh?: unknown;
@@ -263,10 +270,12 @@ export async function fetchCodexRows(
263
270
  baseUrl: string,
264
271
  forceRefresh = false,
265
272
  includeQuota = forceRefresh,
273
+ options: { refreshAction?: boolean } = {},
266
274
  ): Promise<FamilyRows> {
267
- const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
275
+ const refreshAction = options.refreshAction === true;
276
+ const accountsPath = `/api/codex-auth/accounts${refreshAction ? "/refresh" : forceRefresh ? "?refresh=1" : ""}`;
268
277
  const [accountsRes, activeRes] = await Promise.all([
269
- apiJson(deps, baseUrl, "GET", accountsPath),
278
+ apiJson(deps, baseUrl, refreshAction ? "POST" : "GET", accountsPath),
270
279
  apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"),
271
280
  ]);
272
281
  if (accountsRes.status !== 0 && accountsRes.status !== 200) {
@@ -300,6 +309,7 @@ export async function fetchCodexRows(
300
309
  plan: a.plan,
301
310
  active: a.id === activeId,
302
311
  needsReauth: a.needsReauth,
312
+ ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}),
303
313
  priority: typeof a.priority === "number" ? a.priority : 0,
304
314
  paused: a.paused === true,
305
315
  ...(includeQuota ? {
@@ -316,6 +326,8 @@ interface OAuthAccountDto {
316
326
  email?: string;
317
327
  active?: boolean;
318
328
  needsReauth?: boolean;
329
+ /** Always sent by the management route; explicitly `null` when the tier is unknown. */
330
+ plan?: string | null;
319
331
  quota?: CodexQuotaDto | null;
320
332
  quotaUnavailable?: boolean;
321
333
  }
@@ -346,6 +358,9 @@ async function fetchOAuthRows(
346
358
  email: a.email,
347
359
  active: a.active ?? a.id === activeId,
348
360
  needsReauth: a.needsReauth,
361
+ // Forward the server's answer verbatim, including `null`. Collapsing null to "absent" here
362
+ // would destroy the one distinction this field exists to make.
363
+ plan: a.plan ?? null,
349
364
  ...(a.quota !== undefined ? { quota: a.quota } : {}),
350
365
  ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}),
351
366
  }));
@@ -154,7 +154,14 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
154
154
  {}, deps,
155
155
  );
156
156
  if (state.status === "done") {
157
- printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
157
+ if (state.validationPending === true) {
158
+ printData({ ...state, recoveryCommand: "ocx gui", recoveryAction: "After quota recovers, click Refresh quotas in the dashboard Codex account pool." }, wantsJson, [
159
+ "Account registered; validation pending (routing disabled).",
160
+ "After quota recovers, open 'ocx gui' and click Refresh quotas to complete validation.",
161
+ ]);
162
+ } else {
163
+ printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
164
+ }
158
165
  if (!wantsJson) warnIfCodexCatalogRefreshPending(state);
159
166
  return;
160
167
  }
@@ -264,6 +264,7 @@ function refreshLine(row: FamilyRows["rows"][number]): string {
264
264
  const quotaText = row.quota ? quotaParts(row.quota).join(" ") : "";
265
265
  parts.push(quotaText.length > 0 ? quotaText : "quota: unknown");
266
266
  if (row.needsReauth) parts.push("needs-reauth");
267
+ if (row.validationPending) parts.push("validation-pending (routing disabled; open 'ocx gui' and click Refresh quotas after recovery)");
267
268
  return parts.filter(Boolean).join(" ");
268
269
  }
269
270
 
@@ -339,7 +340,7 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
339
340
  } else console.log(`no quota report available for ${name}`);
340
341
  return 0;
341
342
  }
342
- const result = await fetchCodexRows(deps, baseUrl, true);
343
+ const result = await fetchCodexRows(deps, baseUrl, true, true, { refreshAction: true });
343
344
  const failed = familyFailure(result, `failed to refresh ${name}`);
344
345
  if (failed !== null) return failed;
345
346
  if (wantsJson) console.log(JSON.stringify({ accounts: result.rows }, null, 2));
@@ -99,6 +99,7 @@ function statusText(row: AccountRow): string {
99
99
  if (row.paused) parts.push("paused");
100
100
  if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
101
101
  if (row.needsReauth) parts.push("needs-reauth");
102
+ if (row.validationPending) parts.push("validation-pending");
102
103
  return parts.join(" ");
103
104
  }
104
105
 
@@ -219,6 +219,18 @@ export const CAPABILITIES: readonly Capability[] = [
219
219
  "`--quota` shows cached Codex windows (including 5h); `--refresh` bypasses the server TTL.",
220
220
  ],
221
221
  },
222
+ {
223
+ command: ["account", "refresh"],
224
+ summary: "Refresh account quotas without model validation; pending Codex accounts require dashboard consent.",
225
+ routes: [
226
+ { method: "POST", path: "/api/codex-auth/accounts/refresh" },
227
+ { method: "GET", path: "/api/provider-quotas" },
228
+ ],
229
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the refresh result as JSON." }],
230
+ mutates: true,
231
+ json: "payload",
232
+ details: ["CLI/admin-token refreshes only observe usage. After quota recovery, a human must click Refresh quotas in the dashboard to authorize model validation. Do not mint a GUI session to work around this consent boundary."],
233
+ },
222
234
  {
223
235
  command: ["usage"],
224
236
  summary: "Token and estimated-cost report over a time range.",
@@ -307,12 +319,13 @@ export const CAPABILITIES: readonly Capability[] = [
307
319
  },
308
320
  {
309
321
  command: ["logs"],
310
- summary: "Recent request log rows, filterable by provider, model, conversation, and status.",
322
+ summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.",
311
323
  routes: [{ method: "GET", path: "/api/logs" }],
312
324
  flags: [
313
325
  { name: "--provider", value: "string", summary: "Restrict to one provider, matching failover attempts too." },
314
326
  { name: "--model", value: "string", summary: "Restrict to one model id, matching failover attempts too." },
315
327
  { name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
328
+ { name: "--account", value: "string", summary: "Restrict to one account log label (`main`, `p<hex6>`, `o<hex6>`), matching failover attempts too." },
316
329
  { name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
317
330
  { name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
318
331
  { name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
@@ -324,6 +337,7 @@ export const CAPABILITIES: readonly Capability[] = [
324
337
  details: [
325
338
  "`--provider` and `--model` both match a failover attempt, so a request is findable by what actually served it, not only by what was asked for.",
326
339
  "Rows print `conv=<id>` when the entry carries one, so a conversation filter can be told apart from an empty result.",
340
+ "Rows print `acct=<label>` when the account is known, so an `--account` filter can be told apart from an empty result.",
327
341
  "`--follow` deduplicates by row id and cannot be combined with `--json`.",
328
342
  ],
329
343
  },
package/src/cli/index.ts CHANGED
@@ -1428,9 +1428,13 @@ async function handleStatus() {
1428
1428
  }
1429
1429
  }
1430
1430
  const { collectOAuthHealthEntriesForCli, oauthLoginSummary } = await import("../oauth");
1431
+ const { emailMaskingEnabled } = await import("../lib/privacy");
1431
1432
  const { formatOAuthHealthForStatus } = await import("./status-oauth");
1432
1433
  console.log(` OAuth logins:`);
1433
- for (const e of oauthLoginSummary()) {
1434
+ // The operator's own `privacy.maskEmails` decision applies to the CLI too: `ocx status` is not
1435
+ // the dashboard, but it reads the same stored addresses, and a flag that only moved one of the
1436
+ // two would leave the operator unable to tell which surface they had configured.
1437
+ for (const e of oauthLoginSummary(emailMaskingEnabled(loadConfig()))) {
1434
1438
  console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`);
1435
1439
  }
1436
1440
  const oauthHealthBlock = formatOAuthHealthForStatus(await collectOAuthHealthEntriesForCli());
@@ -21,7 +21,7 @@ import { MAX_COST4_RATE } from "../usage/expected-prices";
21
21
  import { isValidCost4Rate } from "../usage/user-cost-overlays";
22
22
 
23
23
  const USAGE = `Usage:
24
- ocx models live [--provider <name>] [--json]
24
+ ocx models live [--provider <name>] [--free-only] [--json]
25
25
  ocx models price <provider/model> [--json]
26
26
  ocx models set-price <provider/model> --input N --output N [--cache-read N] [--cache-write N] [--json]
27
27
  ocx models set-price <provider/model> --auto [--json]
@@ -52,17 +52,22 @@ type ModelRow = {
52
52
  custom?: boolean;
53
53
  customId?: string;
54
54
  displayName?: string;
55
+ pricingStatus?: "free" | "paid";
55
56
  };
56
57
 
57
58
  async function live(argv: string[], deps: RuntimeApiDeps): Promise<void> {
58
59
  const args = [...argv];
59
60
  const wantsJson = takeFlag(args, "--json");
60
61
  const provider = takeOption(args, "--provider");
62
+ // Absent pricingStatus means the provider published no usable per-token pair, so it is
63
+ // excluded here for the same fail-closed reason the classifier omits it (#3666).
64
+ const freeOnly = takeFlag(args, "--free-only");
61
65
  rejectArgs(args, USAGE);
62
66
  const rows = await runtimeRequest<ModelRow[]>("/api/models", {}, deps);
63
- const filtered = provider ? rows.filter(row => row.provider === provider) : rows;
67
+ const byProvider = provider ? rows.filter(row => row.provider === provider) : rows;
68
+ const filtered = freeOnly ? byProvider.filter(row => row.pricingStatus === "free") : byProvider;
64
69
  printData(filtered, wantsJson, filtered.map(row => {
65
- const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean);
70
+ const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.pricingStatus === "free" ? "free" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean);
66
71
  return `${row.namespaced ?? `${row.provider}/${row.id}`} [${flags.join(", ")}]`;
67
72
  }));
68
73
  }
@@ -17,7 +17,7 @@ import { redactSecretString } from "../lib/redact";
17
17
 
18
18
  const USAGE = `Usage:
19
19
  ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
20
- [--conversation <id>] [--limit <n>] [--follow] [--json|--jsonl]
20
+ [--conversation <id>] [--account <label>] [--limit <n>] [--follow] [--json|--jsonl]
21
21
  ocx logs explain <request-id> [--json]
22
22
  ocx logs rebuild-index
23
23
  ocx logs index-status
@@ -58,7 +58,14 @@ function formatLog(row: LogEntry): string {
58
58
  const conversation = typeof row.conversationId === "string" && row.conversationId.length > 0
59
59
  ? `conv=${row.conversationId}`
60
60
  : "";
61
- return [time, String(status), route, duration, conversation].filter(Boolean).join(" ");
61
+ // The account label is printed for the same reason, and for one more: it is the answer to
62
+ // "which of my accounts served this?" (#4057). It is only ever the stable non-PII label the
63
+ // proxy already persists (`main`, `p<hex6>`, `o<hex6>`) — never an email, a key, or an
64
+ // upstream account id. Rows from a single-account provider carry no label and print none.
65
+ const account = typeof row.accountLogLabel === "string" && row.accountLogLabel.length > 0
66
+ ? `acct=${row.accountLogLabel}`
67
+ : "";
68
+ return [time, String(status), route, duration, account, conversation].filter(Boolean).join(" ");
62
69
  }
63
70
 
64
71
  async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -72,6 +79,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
72
79
  // Both spellings, because the server accepts both (`request-log.ts:1032`) and an operator
73
80
  // should not have to remember which one this surface wanted.
74
81
  const conversationId = takeOption(args, "--conversation") ?? takeOption(args, "--conversationId");
82
+ // Server-side, so `--limit` caps the rows that MATCHED rather than the rows scanned; a
83
+ // client-side filter after a 200-row cap would silently hide older matches.
84
+ const account = takeOption(args, "--account");
75
85
  const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
76
86
  rejectArgs(args, USAGE);
77
87
  if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
@@ -80,7 +90,7 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
80
90
  }
81
91
  let seen = new Set<string>();
82
92
  do {
83
- const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
93
+ const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, account, limit })}`, {}, deps);
84
94
  const rows = logRows(data);
85
95
  if (!follow && wantsJson) printData(data, true);
86
96
  else {
@@ -1,6 +1,7 @@
1
1
  // ZCode config export.
2
2
  import type { ExportContext, ManagedContribution } from "./contracts";
3
3
  import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata";
4
+ import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
4
5
  import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants";
5
6
 
6
7
 
@@ -15,6 +16,16 @@ export interface ZcodeModelEntry {
15
16
  name?: string;
16
17
  limit?: { context: number; output?: number };
17
18
  modalities: { input: string[]; output: string[] };
19
+ /**
20
+ * On-disk Thought Level block. ZCode 3.x persists `variants`/`defaultVariant`
21
+ * and parses them into in-memory `levels`/`defaultLevel`. Omit the field when
22
+ * the catalog has no selectable ladder, so the picker stays hidden.
23
+ */
24
+ reasoning?: {
25
+ enabled: boolean;
26
+ variants: string[];
27
+ defaultVariant?: string;
28
+ };
18
29
  }
19
30
 
20
31
  export interface ZcodeProviderBlock {
@@ -61,6 +72,19 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig
61
72
  if (context !== undefined) {
62
73
  entry.limit = { context };
63
74
  }
75
+ // `none` is a Codex omit-sentinel, not a ZCode picker option. Keep catalog
76
+ // `ultra` when present: ZCode forwards the selected variant as
77
+ // `reasoning_effort`. Set `defaultVariant` only when it survives that filter.
78
+ const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts)
79
+ ?.filter(effort => effort !== "none");
80
+ if (efforts && efforts.length > 0) {
81
+ const defaultVariant = model.defaultReasoningEffort?.trim().toLowerCase();
82
+ entry.reasoning = {
83
+ enabled: true,
84
+ variants: efforts,
85
+ ...(defaultVariant && efforts.includes(defaultVariant) ? { defaultVariant } : {}),
86
+ };
87
+ }
64
88
  models[model.namespaced] = entry;
65
89
  }
66
90
  return {
@@ -60,6 +60,11 @@ export function isAccountNeedsReauth(id: string): boolean {
60
60
  return true;
61
61
  }
62
62
 
63
- export function clearAccountNeedsReauth(id: string): void {
63
+ export function clearAccountNeedsReauth(id: string, credentialGeneration?: number): void {
64
+ // A model response proves only the credential it used. Keep account-wide
65
+ // quarantine and evidence from another generation intact.
66
+ if (credentialGeneration !== undefined
67
+ && (reauthAccounts.get(id) !== credentialGeneration
68
+ || !isCodexAccountGenerationLive(id, credentialGeneration))) return;
64
69
  reauthAccounts.delete(id);
65
70
  }
@@ -58,7 +58,9 @@ function isCredentialRecord(value: unknown): value is CodexAccountCredentialReco
58
58
  && (value.replacedAt === undefined || typeof value.replacedAt === "number")
59
59
  && (value.lastCodexValidatedAt === undefined || typeof value.lastCodexValidatedAt === "number")
60
60
  && (value.lastCodexValidationStatus === undefined || value.lastCodexValidationStatus === "ok" || value.lastCodexValidationStatus === "failed")
61
- && (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string");
61
+ && (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string")
62
+ && (value.codexValidationPending === undefined || typeof value.codexValidationPending === "boolean")
63
+ && (value.lastCodexValidationTerminal === undefined || typeof value.lastCodexValidationTerminal === "boolean");
62
64
  }
63
65
 
64
66
  export function refreshGrantFingerprintForToken(refreshToken: string): string {
@@ -118,11 +120,21 @@ function persistCredentialMutation(store: CodexAccountStore): void {
118
120
  advanceCodexCredentialMutationEpoch();
119
121
  }
120
122
 
123
+ /**
124
+ * Validation metadata that survives a credential write.
125
+ *
126
+ * `lastCodexValidationTerminal` is deliberately NOT in this list. Every credential write —
127
+ * re-login, the CAS refresh commit, same-grant alias propagation — rebuilds the record from this
128
+ * pick list, so leaving the marker out is what makes a successful refresh or a re-authentication
129
+ * erase a terminal verdict. Both events disprove "the grant was revoked", and a verdict that
130
+ * could only ever be set would brand an account dead forever on one spurious `invalid_grant`.
131
+ */
121
132
  function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
122
133
  CodexAccountCredentialRecord,
123
- "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
134
+ "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError" | "codexValidationPending"
124
135
  > {
125
136
  return {
137
+ ...(record?.codexValidationPending === true ? { codexValidationPending: true } : {}),
126
138
  ...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}),
127
139
  ...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}),
128
140
  ...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}),
@@ -135,8 +147,12 @@ export function getCodexAccountCredential(id: string): CodexAccountCredentials |
135
147
  return record.credential ?? null;
136
148
  }
137
149
 
138
- export function saveCodexAccountCredential(id: string, cred: CodexAccountCredentials): void {
139
- withCredentialMutationLockSync(() => {
150
+ export function saveCodexAccountCredential(
151
+ id: string,
152
+ cred: CodexAccountCredentials,
153
+ options: { validationPending?: boolean } = {},
154
+ ): number {
155
+ return withCredentialMutationLockSync(() => {
140
156
  const store = loadCodexAccountRecordStore();
141
157
  const current = store[id];
142
158
  const refreshGrantFingerprint = current?.credential?.refreshToken === cred.refreshToken
@@ -148,37 +164,84 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
148
164
  refreshGrantFingerprint,
149
165
  replacedAt: current ? Date.now() : undefined,
150
166
  ...preservedValidationMetadata(current),
167
+ ...(options.validationPending ? {
168
+ codexValidationPending: true,
169
+ lastCodexValidatedAt: undefined,
170
+ lastCodexValidationStatus: undefined,
171
+ lastCodexValidationError: undefined,
172
+ } : {}),
151
173
  };
152
174
  persistCredentialMutation(store);
175
+ return store[id].generation;
153
176
  });
154
177
  }
155
178
 
156
- export function markCodexAccountValidated(id: string, atMs: number = Date.now()): void {
179
+ export function markCodexAccountValidated(id: string, atMs: number = Date.now(), generation?: number): void {
157
180
  withCredentialMutationLockSync(() => {
158
181
  const store = loadCodexAccountRecordStore();
159
182
  const current = store[id];
160
183
  if (!current || current.deletedAt != null || !current.credential) return;
184
+ if (current.codexValidationPending && generation === undefined) return;
185
+ if (generation !== undefined && current.generation !== generation) return;
161
186
  store[id] = {
162
187
  ...current,
163
188
  lastCodexValidatedAt: atMs,
164
189
  lastCodexValidationStatus: "ok",
165
190
  lastCodexValidationError: undefined,
191
+ codexValidationPending: undefined,
192
+ // A completed validation is the direct refutation of a terminal verdict, and this
193
+ // spread would otherwise carry the old marker forward.
194
+ lastCodexValidationTerminal: undefined,
166
195
  };
167
- persist(store);
196
+ // Becoming routable invalidates credential-derived caches; a timestamp-only
197
+ // update on an already validated account preserves the existing epoch policy.
198
+ if (current.codexValidationPending) persistCredentialMutation(store);
199
+ else persist(store);
168
200
  });
169
201
  }
170
202
 
171
- export function markCodexAccountValidationFailed(id: string, reason: string): void {
172
- withCredentialMutationLockSync(() => {
203
+ export interface MarkCodexAccountValidationFailedOptions {
204
+ /**
205
+ * Write only while the stored record is still at this generation.
206
+ *
207
+ * A validation attempt is not atomic with the store: an operator can re-authenticate the
208
+ * account, or another writer can commit a refresh, while a probe is still in flight. Without
209
+ * this fence the late failure lands on whatever credential happens to be there and brands a
210
+ * freshly installed one dead. Declining to write is the safe direction — the failure cannot be
211
+ * attributed to a credential the caller never observed.
212
+ */
213
+ expectedGeneration?: number;
214
+ /** The grant itself is revoked or expired; only a re-login clears it. */
215
+ terminal?: boolean;
216
+ }
217
+
218
+ /** Returns whether the verdict was actually persisted (false when the fence declined it). */
219
+ export function markCodexAccountValidationFailed(
220
+ id: string,
221
+ reason: string,
222
+ options: MarkCodexAccountValidationFailedOptions = {},
223
+ ): boolean {
224
+ return withCredentialMutationLockSync(() => {
173
225
  const store = loadCodexAccountRecordStore();
174
226
  const current = store[id];
175
- if (!current || current.deletedAt != null || !current.credential) return;
227
+ if (!current || current.deletedAt != null || !current.credential) return false;
228
+ // Deferred validation is settled only by a caller that names the generation it observed.
229
+ // An unfenced write must never resolve a pending account, whichever verdict it carries.
230
+ if (current.codexValidationPending && options.expectedGeneration === undefined) return false;
231
+ if (options.expectedGeneration !== undefined && current.generation !== options.expectedGeneration) {
232
+ return false;
233
+ }
176
234
  store[id] = {
177
235
  ...current,
178
236
  lastCodexValidationStatus: "failed",
179
237
  lastCodexValidationError: reason,
238
+ // Only ever set here. A transient failure must not clear a terminal marker set earlier,
239
+ // and it must not invent one either, so the flag is written only when the caller proves
240
+ // the grant is dead.
241
+ ...(options.terminal ? { lastCodexValidationTerminal: true } : {}),
180
242
  };
181
243
  persist(store);
244
+ return true;
182
245
  });
183
246
  }
184
247
 
@@ -1,4 +1,4 @@
1
- import { getCodexAccountCredential } from "./account-store";
1
+ import { readCodexAccountRecord } from "./account-store";
2
2
  import { isAccountNeedsReauth } from "./account-runtime-state";
3
3
  import {
4
4
  MAIN_CODEX_ACCOUNT_ID,
@@ -48,5 +48,6 @@ export function isCodexAccountUsable(
48
48
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
49
49
  if (!exists) return false;
50
50
  if (isAccountNeedsReauth(accountId)) return false;
51
- return !!getCodexAccountCredential(accountId);
51
+ const record = readCodexAccountRecord(accountId);
52
+ return !!record?.credential && record.deletedAt == null && !record.codexValidationPending;
52
53
  }