@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -3,15 +3,213 @@ import { readBoundedResponseBody } from "../lib/bounded-body";
3
3
  import type { OcxConfig } from "../types";
4
4
  import { isSelectableCodexPoolAccount } from "./account-id";
5
5
  import { getValidCodexToken, readCodexAccountRecord } from "./account-store";
6
- import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account";
6
+ import {
7
+ getMainAccountToken,
8
+ getValidMainAccountToken,
9
+ MAIN_CODEX_ACCOUNT_ID,
10
+ type NativeMainRefreshDependencies,
11
+ } from "./main-account";
7
12
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
13
+ import { loadPersistedCodexRuntime } from "./runtime";
14
+ import { codexRuntimeStateEpoch } from "./runtime";
15
+ import upstreamModelsSnapshot from "./data/upstream-models.json";
8
16
 
9
- const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=0.0.0";
17
+ const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
18
+
19
+ /**
20
+ * Upstream filters this roster by the client version it is told, and `client_version` is a
21
+ * required parameter — a measured `0.60.0` returns zero models where `0.142.2` returns five
22
+ * (devlog/_fin/260817_native_gpt56_1m_context/001_measurement_evidence.md). Asking as
23
+ * `0.0.0` therefore describes what a prehistoric client may use, and treating that as the
24
+ * account's entitlement hides models the account genuinely owns (#2886).
25
+ *
26
+ * A version must be supplied by the caller. There is deliberately no default: inventing one
27
+ * either manufactures a confirmed negative (the defect) or advertises models the installed
28
+ * runtime cannot drive (#2548, from the opposite side).
29
+ */
30
+ function codexModelsUrl(clientVersion: string): string {
31
+ return `${CODEX_MODELS_ENDPOINT}?client_version=${encodeURIComponent(clientVersion)}`;
32
+ }
33
+
34
+ /** A version string upstream can filter on. Rejects empty and the `0.0.0` placeholder. */
35
+ export function isUsableCodexClientVersion(value: string | null | undefined): value is string {
36
+ if (typeof value !== "string") return false;
37
+ const trimmed = value.trim();
38
+ if (!trimmed || trimmed === "0.0.0") return false;
39
+ // Bounded: the value reaches an outbound URL, and no real client version is this long.
40
+ if (trimmed.length > 64) return false;
41
+ if (!/^\d+(\.\d+)*([-+][0-9A-Za-z.-]+)?$/.test(trimmed)) return false;
42
+ // An all-zero numeric core is the same claim `0.0.0` makes — a client predating every gated
43
+ // model — so `0`, `0.0`, `00.0.0`, and `0.0.0-dev` must fail for the same reason, by value
44
+ // rather than by exact string.
45
+ const core = trimmed.split(/[-+]/, 1)[0]!;
46
+ if (core.split(".").every(segment => Number(segment) === 0)) return false;
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Highest `minimal_client_version` this build's bundled roster records for the models that
52
+ * are account-gated. Derived, not hardcoded: if the snapshot is refreshed with a model that
53
+ * requires a newer client, the floor follows it, so the two can never drift apart.
54
+ *
55
+ * Used only as the last tier of version precedence, for background work that has no request
56
+ * and no resolved runtime to speak for. It answers "what does this build believe the gated
57
+ * models need?", which is a claim the repository can actually substantiate.
58
+ */
59
+ export function deriveGatedClientVersionFloor(
60
+ rows: ReadonlyArray<Record<string, unknown>>,
61
+ gatedSlugs: ReadonlySet<string> = ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
62
+ ): string | null {
63
+ const floors = rows
64
+ .filter(row => typeof row.slug === "string" && gatedSlugs.has(row.slug))
65
+ .map(row => (typeof row.minimal_client_version === "string" ? row.minimal_client_version : null))
66
+ .filter((value): value is string => isUsableCodexClientVersion(value));
67
+ return floors.reduce<string | null>(
68
+ (best, candidate) => (best === null || compareClientVersions(candidate, best) > 0 ? candidate : best),
69
+ null,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Fallback when the snapshot records no usable gated floor.
75
+ *
76
+ * Not every gated slug carries a `minimal_client_version` — `gpt-daybreak-blue-latest` has no
77
+ * row in the current snapshot at all — so the derivation can legitimately come back empty as the
78
+ * gated set changes. This is the last resort behind it, and it is still a version upstream can
79
+ * filter on rather than the placeholder that caused #2886.
80
+ */
81
+ const GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK = "0.142.2";
82
+
83
+ export const GATED_MODEL_CLIENT_VERSION_FLOOR: string =
84
+ deriveGatedClientVersionFloor(
85
+ (upstreamModelsSnapshot as { models?: Array<Record<string, unknown>> }).models ?? [],
86
+ ) ?? GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK;
87
+
88
+ /** Numeric-segment comparison. Only used to pick the highest floor in a known-good set. */
89
+ function compareClientVersions(left: string, right: string): number {
90
+ const l = left.split(/[.+-]/).map(Number);
91
+ const r = right.split(/[.+-]/).map(Number);
92
+ for (let i = 0; i < Math.max(l.length, r.length); i += 1) {
93
+ const a = Number.isFinite(l[i]) ? l[i]! : 0;
94
+ const b = Number.isFinite(r[i]) ? r[i]! : 0;
95
+ if (a !== b) return a - b;
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ /**
101
+ * Version authority, in precedence order:
102
+ *
103
+ * 1. the inbound request's own `client_version` — the only value certainly describing the
104
+ * client being answered;
105
+ * 2. the selected Codex runtime version, for background sync where no request exists.
106
+ * Retained sync refreshes runtime evidence before discovery, which is what makes this
107
+ * usable here; the persisted file itself carries no freshness guarantee.
108
+ * 3. the floor this build's own bundled roster records for the models being gated
109
+ * (`GATED_MODEL_CLIENT_VERSION_FLOOR`).
110
+ *
111
+ * Tier 3 exists because background catalog sync has no request and, on a host where Codex
112
+ * has never been resolved, no persisted runtime either — yet it is exactly the path that
113
+ * publishes account-confirmed native rows. Skipping discovery there suppressed the rows this
114
+ * fix is meant to restore. The floor is not invented: it is the version this build's own
115
+ * snapshot states the gated models require, so asking under it is the narrowest question
116
+ * that can still return them.
117
+ *
118
+ * There is deliberately no `0.0.0`-style fallback. A placeholder describes a client that
119
+ * predates every gated model, which is what made upstream answer with an empty roster and
120
+ * turned absent evidence into a manufactured confirmed negative (#2886).
121
+ */
122
+ export function resolveCodexEntitlementClientVersion(
123
+ inbound?: string | null,
124
+ loadRuntime: () => { selectedVersion?: string | null } | null = loadPersistedCodexRuntime,
125
+ options: { readonly bypassRuntimeMemo?: boolean; readonly now?: number } = {},
126
+ ): string {
127
+ if (isUsableCodexClientVersion(inbound)) return inbound.trim();
128
+ // The memo describes the real runtime file, so a caller supplying a different loader is asking
129
+ // a different question and must not be answered from it. Detected rather than left to the
130
+ // caller, because forgetting the flag would silently cross-answer.
131
+ const bypass = options.bypassRuntimeMemo === true || loadRuntime !== loadPersistedCodexRuntime;
132
+ const selected = bypass
133
+ ? readRuntimeVersion(loadRuntime)
134
+ : memoizedPersistedRuntimeVersion(loadRuntime, options.now ?? Date.now());
135
+ return selected ?? GATED_MODEL_CLIENT_VERSION_FLOOR;
136
+ }
10
137
  const MODEL_ROSTER_TTL_MS = 5 * 60_000;
138
+
139
+ /**
140
+ * How long a persisted-runtime version read is reused.
141
+ *
142
+ * Tier 2 of the version chain reads `codex-runtime.json` from disk, and it is consulted on
143
+ * every gated Direct authorization and every `/v1/models` resolution — including when the
144
+ * five-minute roster cache is hot, so the entitlement answer needs no I/O at all. Left
145
+ * unmemoized that is a synchronous `readFileSync` on the request path under concurrent gated
146
+ * traffic. `persistCodexRuntime` is the only writer, and it clears the runtime's own resolve
147
+ * cache; this window is short enough that a runtime switch is picked up promptly either way.
148
+ */
149
+ const RUNTIME_VERSION_MEMO_MS = 5_000;
150
+
151
+ let runtimeVersionMemo: { at: number; epoch: number; version: string | null } | null = null;
152
+
153
+ function readRuntimeVersion(
154
+ loadRuntime: () => { selectedVersion?: string | null } | null,
155
+ ): string | null {
156
+ try {
157
+ const value = loadRuntime()?.selectedVersion;
158
+ return isUsableCodexClientVersion(value) ? value.trim() : null;
159
+ } catch {
160
+ // An unreadable or malformed runtime file is an absent version, not a failure worth
161
+ // propagating into entitlement resolution.
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function memoizedPersistedRuntimeVersion(
167
+ loadRuntime: () => { selectedVersion?: string | null } | null,
168
+ now: number,
169
+ ): string | null {
170
+ const epoch = codexRuntimeStateEpoch();
171
+ // Time bounds staleness; the epoch makes a runtime switch invalidate this immediately, so the
172
+ // window can never answer under the version that was just replaced.
173
+ if (
174
+ runtimeVersionMemo
175
+ && runtimeVersionMemo.epoch === epoch
176
+ && now - runtimeVersionMemo.at < RUNTIME_VERSION_MEMO_MS
177
+ ) {
178
+ return runtimeVersionMemo.version;
179
+ }
180
+ const selected = readRuntimeVersion(loadRuntime);
181
+ runtimeVersionMemo = { at: now, epoch, version: selected };
182
+ return selected;
183
+ }
11
184
  const MODEL_ROSTER_FAILURE_TTL_MS = 15_000;
12
185
  const MODEL_ROSTER_TIMEOUT_MS = 8_000;
13
186
  const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024;
14
187
  const MODEL_ROSTER_CACHE_MAX = 64;
188
+
189
+ /**
190
+ * Versions retained per account.
191
+ *
192
+ * `client_version` arrives on the inbound `/v1/models` request, so making it part of the cache
193
+ * key handed callers a knob on key cardinality. With a flat per-key budget, one account cycling
194
+ * 64 versions filled its whole class and pushed other accounts' confirmed grants out — the same
195
+ * fail-closed catalog flapping the two-class budget exists to prevent, reached by a different
196
+ * axis. A handful of versions per account is all any real deployment needs (a client, a runtime,
197
+ * the floor), and the budget below counts ACCOUNTS so one noisy account cannot spend another's
198
+ * share.
199
+ */
200
+ const MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX = 4;
201
+
202
+ /**
203
+ * Concurrent roster requests allowed per account.
204
+ *
205
+ * The cache is bounded on write, but an in-flight request is not a cache entry: distinct
206
+ * `client_version` values miss the flight key by design, so a caller cycling versions could open
207
+ * arbitrarily many concurrent upstream requests, each holding an eight-second timer. This bounds
208
+ * the concurrency itself. Exceeding it is reported as unconfirmed — the same fail-closed answer a
209
+ * discovery failure produces, and cheaper than either queueing or serving another version's
210
+ * roster.
211
+ */
212
+ const MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX = 4;
15
213
  const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:";
16
214
 
17
215
  export interface CodexModelEntitlementCredentialSnapshot {
@@ -24,6 +222,12 @@ export interface CodexModelEntitlementCredentialSnapshot {
24
222
 
25
223
  interface CachedAccountModels {
26
224
  readonly credentialIdentity: string;
225
+ /**
226
+ * Client version this roster was fetched under. Upstream filters by it, so a roster is
227
+ * only an answer for that version — an entry fetched under one must never satisfy a read
228
+ * for another (#2886).
229
+ */
230
+ readonly clientVersion: string;
27
231
  readonly expiresAt: number;
28
232
  readonly models: ReadonlySet<string>;
29
233
  readonly confirmed: boolean;
@@ -37,7 +241,20 @@ export interface CodexModelEntitlementSnapshot {
37
241
 
38
242
  export interface CodexModelEntitlementResolveOptions {
39
243
  readonly fetcher?: typeof fetch;
244
+ /**
245
+ * Client version to ask upstream about. Absent means no trustworthy version was
246
+ * available, and discovery is skipped rather than asked under a placeholder.
247
+ */
248
+ readonly clientVersion?: string | null;
249
+ /**
250
+ * Test-only seam for the persisted-runtime half of the version precedence chain, so a case
251
+ * can reach the no-trustworthy-version branch without depending on the host's own runtime
252
+ * state file.
253
+ */
254
+ readonly loadPersistedRuntime?: () => { selectedVersion?: string | null } | null;
255
+ readonly nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
40
256
  readonly now?: number;
257
+ readonly signal?: AbortSignal;
41
258
  /** Test-only credential seam; production callers enumerate local main + Pool credentials. */
42
259
  readonly credentials?: readonly CodexModelEntitlementCredentialSnapshot[];
43
260
  /** Test-only seam for proving lifecycle exclusions happen before credential reads. */
@@ -49,6 +266,22 @@ export interface CodexModelEntitlementResolveOptions {
49
266
  const accountModelsCache = new Map<string, CachedAccountModels>();
50
267
  const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
51
268
 
269
+ /**
270
+ * Cache key. The roster is version-specific, so the version has to be part of the identity —
271
+ * with an account-only key, two versions in flight for one account race to overwrite each
272
+ * other, and the unversioned projection readers in `catalog/metadata.ts` then publish
273
+ * whichever landed last.
274
+ */
275
+ function cacheKeyFor(accountId: string, clientVersion: string): string {
276
+ return `${accountId}\u0000${clientVersion}`;
277
+ }
278
+
279
+ /** Account component of a cache key, for eviction budgets and per-account invalidation. */
280
+ function accountIdOfCacheKey(key: string): string {
281
+ const separator = key.indexOf("\u0000");
282
+ return separator === -1 ? key : key.slice(0, separator);
283
+ }
284
+
52
285
  /**
53
286
  * Direct-caller entries are evicted separately from main/Pool entries.
54
287
  *
@@ -60,23 +293,38 @@ const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
60
293
  * from erasing the other's evidence.
61
294
  */
62
295
  function boundedCacheSet(accountId: string, value: CachedAccountModels): void {
63
- accountModelsCache.delete(accountId);
64
- accountModelsCache.set(accountId, value);
65
- const isDirect = (key: string): boolean => key.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX);
296
+ const key = cacheKeyFor(accountId, value.clientVersion);
297
+ accountModelsCache.delete(key);
298
+ accountModelsCache.set(key, value);
299
+ const isDirect = (candidate: string): boolean =>
300
+ accountIdOfCacheKey(candidate).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX);
301
+
302
+ // First, bound THIS account's versions, so a caller cycling client_version evicts only its
303
+ // own older entries and never reaches another account's evidence.
304
+ const ownKeys = [...accountModelsCache.keys()].filter(k => accountIdOfCacheKey(k) === accountId);
305
+ for (const stale of ownKeys.slice(0, Math.max(0, ownKeys.length - MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX))) {
306
+ accountModelsCache.delete(stale);
307
+ }
308
+
309
+ // Then bound the class by DISTINCT ACCOUNTS. Counting keys would let one account's versions
310
+ // consume the budget; counting accounts keeps each account's share independent of how many
311
+ // versions any other account is using.
66
312
  const evictClass = (direct: boolean): void => {
67
- let count = 0;
68
- for (const key of accountModelsCache.keys()) if (isDirect(key) === direct) count += 1;
69
- while (count > MODEL_ROSTER_CACHE_MAX) {
70
- let oldest: string | undefined;
71
- for (const key of accountModelsCache.keys()) {
72
- if (isDirect(key) === direct) { oldest = key; break; }
313
+ const accountsInOrder: string[] = [];
314
+ for (const key of accountModelsCache.keys()) {
315
+ if (isDirect(key) !== direct) continue;
316
+ const account = accountIdOfCacheKey(key);
317
+ if (!accountsInOrder.includes(account)) accountsInOrder.push(account);
318
+ }
319
+ // Never evict the account just written, even if it is the least-recently-inserted.
320
+ for (const victim of accountsInOrder.slice(0, Math.max(0, accountsInOrder.length - MODEL_ROSTER_CACHE_MAX))) {
321
+ if (victim === accountId) continue;
322
+ for (const key of [...accountModelsCache.keys()]) {
323
+ if (accountIdOfCacheKey(key) === victim) accountModelsCache.delete(key);
73
324
  }
74
- if (oldest === undefined) break;
75
- accountModelsCache.delete(oldest);
76
- count -= 1;
77
325
  }
78
326
  };
79
- evictClass(isDirect(accountId));
327
+ evictClass(accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX));
80
328
  }
81
329
 
82
330
  function currentCredentialIdentity(accountId: string): string | undefined {
@@ -92,9 +340,15 @@ function currentCredentialIdentity(accountId: string): string | undefined {
92
340
  return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
93
341
  }
94
342
 
95
- async function accountCredentialSnapshot(accountId: string): Promise<CodexModelEntitlementCredentialSnapshot | null> {
343
+ async function accountCredentialSnapshot(
344
+ accountId: string,
345
+ options: Pick<CodexModelEntitlementResolveOptions, "nativeMainRefreshDependencies" | "signal"> = {},
346
+ ): Promise<CodexModelEntitlementCredentialSnapshot | null> {
96
347
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
97
- const token = getMainAccountToken();
348
+ const token = await getValidMainAccountToken({
349
+ signal: options.signal,
350
+ ...(options.nativeMainRefreshDependencies ?? {}),
351
+ });
98
352
  return token
99
353
  ? {
100
354
  accountId,
@@ -137,6 +391,7 @@ async function fetchAccountModels(
137
391
  credential: CodexModelEntitlementCredentialSnapshot,
138
392
  fetcher: typeof fetch,
139
393
  now: number,
394
+ clientVersion: string,
140
395
  ): Promise<CachedAccountModels> {
141
396
  const controller = new AbortController();
142
397
  const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS);
@@ -146,7 +401,7 @@ async function fetchAccountModels(
146
401
  Accept: "application/json",
147
402
  });
148
403
  if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId);
149
- const response = await fetcher(CODEX_MODELS_URL, {
404
+ const response = await fetcher(codexModelsUrl(clientVersion), {
150
405
  headers,
151
406
  redirect: "error",
152
407
  signal: controller.signal,
@@ -161,6 +416,7 @@ async function fetchAccountModels(
161
416
  : null;
162
417
  return {
163
418
  credentialIdentity: credential.credentialIdentity,
419
+ clientVersion,
164
420
  expiresAt: now + (models ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS),
165
421
  models: models ?? new Set(),
166
422
  confirmed: models !== null,
@@ -168,6 +424,7 @@ async function fetchAccountModels(
168
424
  } catch {
169
425
  return {
170
426
  credentialIdentity: credential.credentialIdentity,
427
+ clientVersion,
171
428
  expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS,
172
429
  models: new Set(),
173
430
  confirmed: false,
@@ -199,18 +456,34 @@ async function modelsForCredential(
199
456
  credential: CodexModelEntitlementCredentialSnapshot,
200
457
  fetcher: typeof fetch,
201
458
  now: number,
459
+ clientVersion: string,
202
460
  ): Promise<CachedAccountModels> {
203
- const cached = accountModelsCache.get(credential.accountId);
461
+ const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
204
462
  if (
205
463
  cached
206
464
  && cached.credentialIdentity === credential.credentialIdentity
207
465
  && cached.expiresAt > now
208
466
  ) return cached;
209
467
 
210
- const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}`;
468
+ const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`;
211
469
  const existing = accountModelsFlights.get(flightKey);
212
470
  if (existing) return existing;
213
- const flight = fetchAccountModels(credential, fetcher, now)
471
+
472
+ // Bound concurrency per account before opening another upstream request.
473
+ let liveForAccount = 0;
474
+ for (const key of accountModelsFlights.keys()) {
475
+ if (accountIdOfCacheKey(key) === credential.accountId) liveForAccount += 1;
476
+ }
477
+ if (liveForAccount >= MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX) {
478
+ return {
479
+ credentialIdentity: credential.credentialIdentity,
480
+ clientVersion,
481
+ expiresAt: now,
482
+ models: new Set(),
483
+ confirmed: false,
484
+ };
485
+ }
486
+ const flight = fetchAccountModels(credential, fetcher, now, clientVersion)
214
487
  .then(result => {
215
488
  if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) {
216
489
  boundedCacheSet(credential.accountId, result);
@@ -256,16 +529,20 @@ export async function resolveCodexModelEntitlements(
256
529
  ): Promise<CodexModelEntitlementSnapshot> {
257
530
  const now = options.now ?? Date.now();
258
531
  const fetcher = options.fetcher ?? fetch;
532
+ const clientVersion = resolveCodexEntitlementClientVersion(
533
+ options.clientVersion,
534
+ options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
535
+ );
259
536
  const allowedAccountIds = candidateAccountIds(config)
260
537
  .filter(accountId => !options.excludeAccountIds?.has(accountId));
261
538
  const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
262
539
  const credentials = options.credentials
263
540
  ? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId))
264
- : (await Promise.all(allowedAccountIds.map(credentialSnapshot)))
541
+ : (await Promise.all(allowedAccountIds.map(accountId => credentialSnapshot(accountId, options))))
265
542
  .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
266
543
  const results = await Promise.all(credentials.map(async credential => ({
267
544
  credential,
268
- result: await modelsForCredential(credential, fetcher, now),
545
+ result: await modelsForCredential(credential, fetcher, now, clientVersion),
269
546
  })));
270
547
  return {
271
548
  modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])),
@@ -278,15 +555,17 @@ export async function resolveCodexModelEntitlements(
278
555
  export async function isDirectCallerEntitledToCodexModel(
279
556
  headers: Headers,
280
557
  modelId: string,
281
- options: Pick<CodexModelEntitlementResolveOptions, "fetcher" | "now"> = {},
558
+ options: Pick<CodexModelEntitlementResolveOptions, "fetcher" | "now" | "clientVersion"> = {},
282
559
  ): Promise<boolean> {
283
560
  if (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return true;
284
561
  const credential = directCallerCredential(headers);
285
562
  if (!credential) return false;
563
+ const clientVersion = resolveCodexEntitlementClientVersion(options.clientVersion);
286
564
  const result = await modelsForCredential(
287
565
  credential,
288
566
  options.fetcher ?? fetch,
289
567
  options.now ?? Date.now(),
568
+ clientVersion,
290
569
  );
291
570
  return result.confirmed && result.models.has(modelId);
292
571
  }
@@ -318,11 +597,22 @@ export function availableAccountGatedNativeModels(
318
597
  export function cachedAvailableAccountGatedNativeModels(
319
598
  now = Date.now(),
320
599
  eligibleAccountIds?: ReadonlySet<string>,
600
+ clientVersion?: string | null,
321
601
  ): ReadonlySet<string> {
602
+ // Entries fetched under a different client version answer a different question, so a caller
603
+ // that knows which version it is projecting for must say so — otherwise a newer client's
604
+ // roster leaks into an older client's projection (#2548 from the opposite direction).
605
+ //
606
+ // Omitting the argument deliberately does NOT filter. This is a synchronous read of
607
+ // whatever discovery already proved, and its callers (catalog metadata) are not
608
+ // request-scoped: resolving a version here would silently discard every entry fetched under
609
+ // a different one, which is a suppression this function has no evidence to justify.
610
+ const version = isUsableCodexClientVersion(clientVersion) ? clientVersion.trim() : null;
322
611
  return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => (
323
612
  [...accountModelsCache].some(([accountId, entry]) => (
324
- (!eligibleAccountIds || eligibleAccountIds.has(accountId))
325
- && !accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
613
+ (!eligibleAccountIds || eligibleAccountIds.has(accountIdOfCacheKey(accountId)))
614
+ && !accountIdOfCacheKey(accountId).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
615
+ && (version === null || entry.clientVersion === version)
326
616
  && entry.confirmed
327
617
  && entry.expiresAt > now
328
618
  && entry.models.has(modelId)
@@ -338,21 +628,43 @@ export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntit
338
628
  }
339
629
 
340
630
  export function invalidateCodexModelEntitlementsForAccount(accountId: string | null | undefined): void {
341
- if (accountId) accountModelsCache.delete(accountId);
631
+ if (!accountId) return;
632
+ // Every version's entry for this account, since a credential change invalidates the
633
+ // account's entitlement evidence regardless of which client version asked for it.
634
+ for (const key of [...accountModelsCache.keys()]) {
635
+ if (accountIdOfCacheKey(key) === accountId) accountModelsCache.delete(key);
636
+ }
342
637
  }
343
638
 
344
639
  export function resetCodexModelEntitlementCacheForTests(): void {
345
640
  accountModelsCache.clear();
346
641
  accountModelsFlights.clear();
642
+ runtimeVersionMemo = null;
643
+ }
644
+
645
+ /**
646
+ * Test-only seam for the memoized tier-2 read.
647
+ *
648
+ * The memo is deliberately reachable only through the DEFAULT loader (a supplied loader is
649
+ * auto-bypassed, so it cannot be cross-answered), which leaves no way to observe memo behavior
650
+ * from a test without either touching the real state file or exposing this.
651
+ */
652
+ export function memoizeRuntimeVersionForTests(
653
+ loadRuntime: () => { selectedVersion?: string | null } | null,
654
+ now: number,
655
+ ): string | null {
656
+ return memoizedPersistedRuntimeVersion(loadRuntime, now);
347
657
  }
348
658
 
349
659
  export function seedCodexModelEntitlementsForTests(
350
660
  accountId: string,
351
661
  models: readonly string[],
352
662
  now = Date.now(),
663
+ clientVersion = "0.146.0",
353
664
  ): void {
354
665
  boundedCacheSet(accountId, {
355
666
  credentialIdentity: `test:${accountId}`,
667
+ clientVersion,
356
668
  expiresAt: now + MODEL_ROSTER_TTL_MS,
357
669
  models: new Set(models),
358
670
  confirmed: true,