@bitkyc08/opencodex 2.48.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -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 {
@@ -204,7 +204,8 @@ async function presets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
204
204
  const rows = Array.isArray(result) ? result : result.providers ?? [];
205
205
  printData(result, wantsJson, rows.map(row => {
206
206
  const record = row as Record<string, unknown>;
207
- return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}`.trimEnd();
207
+ const sponsor = record.sponsor ? ` (sponsor: ${String(record.sponsor)})` : "";
208
+ return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}${sponsor}`.trimEnd();
208
209
  }));
209
210
  }
210
211
 
@@ -36,8 +36,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
36
36
  },
37
37
  {
38
38
  name: "recover-history",
39
- usage: "ocx recover-history --legacy-openai --yes",
40
- summary: "Force all user-message opencodex rows to OpenAI for legacy recovery.",
39
+ usage: "ocx recover-history (--legacy-openai | --ocx-compaction <thread-id>) --yes",
40
+ summary: "Recover legacy provider metadata or one OpenCodeX-compacted thread for native replay.",
41
41
  },
42
42
  {
43
43
  name: "uninstall",
@@ -14,7 +14,7 @@ import {
14
14
  const USAGE = `Usage:
15
15
  ocx system [status] [--json]
16
16
  ocx system settings [--auto-start <on|off>] [--stream-mode <auto|legacy-tee|eager-relay>]
17
- [--desktop-authless <on|off>] [--json]
17
+ [--desktop-authless <on|off>] [--client-compaction <on|off>] [--json]
18
18
  ocx system startup <health|install-service|install-shim> [--json]
19
19
  ocx system diagnostics [--json]
20
20
  ocx system sync [--json]
@@ -23,7 +23,11 @@ const USAGE = `Usage:
23
23
  ocx system codex-cli-update check [--json]
24
24
  ocx system update check [--channel <latest|preview>] [--json]
25
25
  ocx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
26
- ocx system update status <job-id> [--json]`;
26
+ ocx system update status <job-id> [--json]
27
+
28
+ --client-compaction favors native replay portability for future compactions while
29
+ keeping OpenCodeX routing active; the configured provider may process summaries
30
+ and consume its quota.`;
27
31
 
28
32
  async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
29
33
  const args = [...argv];
@@ -44,8 +48,10 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
44
48
  const autoStart = takeBooleanOption(args, "--auto-start");
45
49
  const streamMode = takeOption(args, "--stream-mode");
46
50
  const desktopAuthless = takeBooleanOption(args, "--desktop-authless");
51
+ const clientCompaction = takeBooleanOption(args, "--client-compaction");
47
52
  rejectArgs(args, USAGE);
48
- if (autoStart === undefined && streamMode === undefined && desktopAuthless === undefined) {
53
+ if (autoStart === undefined && streamMode === undefined
54
+ && desktopAuthless === undefined && clientCompaction === undefined) {
49
55
  const result = await runtimeRequest("/api/settings", {}, deps);
50
56
  printData(result, wantsJson, summaryLines(result));
51
57
  return;
@@ -54,6 +60,7 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
54
60
  ...(autoStart !== undefined ? { codexAutoStart: autoStart } : {}),
55
61
  ...(streamMode !== undefined ? { streamMode } : {}),
56
62
  ...(desktopAuthless !== undefined ? { codexDesktopAuthless: desktopAuthless } : {}),
63
+ ...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}),
57
64
  };
58
65
  const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps);
59
66
  printData(result, wantsJson, ["System settings updated."]);
@@ -59,8 +59,11 @@ interface UsageReportInput {
59
59
 
60
60
  const MAX_MODEL_ROWS = 10;
61
61
 
62
- function terminalText(value: string): string {
63
- return value.replace(/[\x00-\x1f\x7f-\x9f]/g, character => {
62
+ function terminalText(value: unknown): string {
63
+ const text = typeof value === "string" ? value
64
+ : value === null || value === undefined ? ""
65
+ : typeof value === "number" || typeof value === "boolean" ? String(value) : "[invalid]";
66
+ return text.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, character => {
64
67
  const code = character.charCodeAt(0);
65
68
  return code <= 0x7f
66
69
  ? `\\x${code.toString(16).padStart(2, "0")}`
@@ -69,7 +72,8 @@ function terminalText(value: string): string {
69
72
  }
70
73
 
71
74
  function count(value: number | undefined): string {
72
- return (value ?? 0).toLocaleString("en-US");
75
+ if (value === undefined || value === null) return "0";
76
+ return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "—";
73
77
  }
74
78
 
75
79
  /**
@@ -111,7 +115,7 @@ export function formatUsageReport(data: UsageReportInput): string[] {
111
115
  .filter(Boolean).join(" and ");
112
116
  lines.push(`No usage recorded for ${terminalText(what)} in this range.`);
113
117
  lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
114
- return lines;
118
+ return lines.map(terminalText);
115
119
  }
116
120
 
117
121
  const tokenSplit = [
@@ -185,5 +189,5 @@ export function formatUsageReport(data: UsageReportInput): string[] {
185
189
 
186
190
  lines.push("");
187
191
  lines.push("Not a billing receipt. Subscription usage or provider credits may apply instead.");
188
- return lines;
192
+ return lines.map(terminalText);
189
193
  }
@@ -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 {
@@ -7,12 +7,13 @@ import {
7
7
  } from "../config";
8
8
  import { removeCodexAccountCredential } from "./account-store";
9
9
  import { clearAccountNeedsReauth } from "./account-runtime-state";
10
- import { getMainChatgptAccountId } from "./auth-collision";
10
+ import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision";
11
11
  import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
12
12
  import { clearAccountQuota } from "./quota";
13
13
  import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
14
14
  import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
15
- import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache";
15
+ import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache";
16
+ import { extractAccountIdClaims } from "../oauth/chatgpt";
16
17
  import { forgetCodexAccountPause } from "./account-pause";
17
18
  import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
18
19
  import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
@@ -75,6 +76,38 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
75
76
  return true;
76
77
  }
77
78
 
79
+ /**
80
+ * Rebuild the memory-only policy binding from a startup-owned, recovered auth path.
81
+ * The caller holds the native owner and exclusive claim; an incoming bearer is never evidence.
82
+ * A failed read creates no binding and cannot revoke a prior verified observation or its block.
83
+ * Only a valid replacement observation or confirmed account transition supersedes that evidence.
84
+ */
85
+ export function initializeMainAccountPolicyBinding(authPath: string): boolean {
86
+ // Startup observes the pinned owned path inside the exclusive claim: bound the read so a
87
+ // replaced non-regular or oversized file cannot stall startup inside that claim.
88
+ const result = readCodexTokensResult(authPath, { bounded: true });
89
+ if (result.status !== "ok") return false;
90
+ const { tokens } = result;
91
+ if (typeof tokens.access_token !== "string" || !tokens.access_token
92
+ || typeof tokens.account_id !== "string" || !tokens.account_id) return false;
93
+ if (tokens.id_token != null && typeof tokens.id_token !== "string") return false;
94
+ const accountId = tokens.account_id;
95
+ // An owned file may contain an opaque bearer, but every decoded identity must agree —
96
+ // including the two account-id encodings within a single token.
97
+ const idTokenClaims = extractAccountIdClaims(tokens.id_token);
98
+ const accessTokenClaims = extractAccountIdClaims(tokens.access_token);
99
+ if (idTokenClaims.conflict || accessTokenClaims.conflict) return false;
100
+ const idTokenAccountId = idTokenClaims.accountId;
101
+ const accessTokenAccountId = accessTokenClaims.accountId;
102
+ if ((idTokenAccountId !== undefined && idTokenAccountId !== accountId)
103
+ || (accessTokenAccountId !== undefined && accessTokenAccountId !== accountId)) return false;
104
+ const previousAccountId = observedMainChatgptAccountId;
105
+ observedMainChatgptAccountId = accountId;
106
+ if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
107
+ observeMainQuotaIdentity(accountId);
108
+ return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
109
+ }
110
+
78
111
  /**
79
112
  * Apply a transaction-confirmed physical native-login change without waiting for
80
113
  * a later auth.json observation. The caller owns credential commit/rollback.
@@ -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
  }