@bitkyc08/opencodex 2.8.2-preview.20260731 → 2.9.1

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 (164) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-CuVjugeE.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +86 -5
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +89 -14
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/doctor.ts +12 -0
  42. package/src/cli/export-command.ts +187 -0
  43. package/src/cli/help.ts +11 -0
  44. package/src/cli/index.ts +13 -3
  45. package/src/cli/opencode.ts +36 -151
  46. package/src/cli/star-prompt.ts +13 -4
  47. package/src/cli/status-oauth.ts +12 -2
  48. package/src/clients/config-export.ts +377 -0
  49. package/src/codex/account-runtime-state.ts +19 -1
  50. package/src/codex/account-store.ts +162 -82
  51. package/src/codex/auth-api.ts +467 -159
  52. package/src/codex/auth-context.ts +15 -2
  53. package/src/codex/catalog/aggregation.ts +15 -0
  54. package/src/codex/catalog/provider-fetch.ts +29 -0
  55. package/src/codex/catalog/sync.ts +7 -1
  56. package/src/codex/inject.ts +5 -5
  57. package/src/codex/main-account-cache.ts +8 -1
  58. package/src/codex/model-cache.ts +81 -2
  59. package/src/codex/pool-rotation.ts +39 -0
  60. package/src/codex/project-config-warnings.ts +12 -1
  61. package/src/codex/quota.ts +35 -3
  62. package/src/codex/routing.ts +46 -1
  63. package/src/codex/shim.ts +10 -4
  64. package/src/codex/subagent-model-fallback.ts +12 -0
  65. package/src/codex/websocket-registry.ts +27 -0
  66. package/src/combos/failover.ts +31 -1
  67. package/src/combos/request.ts +9 -0
  68. package/src/combos/resolve.ts +60 -4
  69. package/src/combos/types.ts +12 -0
  70. package/src/config.ts +510 -55
  71. package/src/github/star-state.ts +13 -1
  72. package/src/images/fulfill.ts +39 -1
  73. package/src/images/loop.ts +52 -12
  74. package/src/lib/admission.ts +83 -0
  75. package/src/lib/app-owned-memory-stores.ts +173 -0
  76. package/src/lib/app-owned-memory.ts +265 -0
  77. package/src/lib/bun-stream-caps.ts +31 -7
  78. package/src/lib/config-ownership.ts +33 -0
  79. package/src/lib/crash-guard.ts +65 -5
  80. package/src/lib/debug-log-buffer.ts +47 -6
  81. package/src/lib/errors.ts +3 -0
  82. package/src/lib/gcp-adc.ts +40 -2
  83. package/src/lib/injection-debug-log.ts +26 -2
  84. package/src/lib/sidecar-tracker.ts +5 -2
  85. package/src/lib/sse-decoder.ts +257 -37
  86. package/src/lib/state-store-registrations.ts +109 -0
  87. package/src/lib/state-store-sweeper.ts +184 -0
  88. package/src/lib/translator-budget.ts +356 -0
  89. package/src/lib/windows-secret-acl.ts +33 -12
  90. package/src/lib/winsw.ts +8 -1
  91. package/src/oauth/anthropic-routing.ts +31 -7
  92. package/src/oauth/google-antigravity.ts +2 -1
  93. package/src/oauth/health.ts +30 -12
  94. package/src/oauth/index.ts +127 -23
  95. package/src/oauth/kiro.ts +10 -2
  96. package/src/oauth/store.ts +165 -18
  97. package/src/oauth/token-guardian.ts +43 -4
  98. package/src/oauth/types.ts +2 -1
  99. package/src/providers/base-url-choices.ts +10 -0
  100. package/src/providers/derive.ts +12 -0
  101. package/src/providers/key-failover.ts +12 -0
  102. package/src/providers/openai-sidecar.ts +4 -1
  103. package/src/providers/quota.ts +68 -7
  104. package/src/providers/registry.ts +279 -3
  105. package/src/responses/parser.ts +5 -1
  106. package/src/responses/spill-store.ts +394 -0
  107. package/src/responses/state.ts +520 -102
  108. package/src/router.ts +18 -1
  109. package/src/server/adapter-resolve.ts +20 -3
  110. package/src/server/auth-cors.ts +121 -28
  111. package/src/server/chat-completions.ts +57 -12
  112. package/src/server/claude-messages.ts +85 -13
  113. package/src/server/index.ts +239 -97
  114. package/src/server/lifecycle.ts +155 -25
  115. package/src/server/management/agent-settings-routes.ts +75 -32
  116. package/src/server/management/api-key-usage.ts +167 -0
  117. package/src/server/management/body.ts +35 -0
  118. package/src/server/management/combo-routes.ts +5 -1
  119. package/src/server/management/config-routes.ts +37 -12
  120. package/src/server/management/logs-usage-routes.ts +41 -21
  121. package/src/server/management/model-routes.ts +173 -53
  122. package/src/server/management/oauth-account-routes.ts +115 -26
  123. package/src/server/management/provider-routes.ts +56 -6
  124. package/src/server/management/sidebar-routes.ts +50 -1
  125. package/src/server/management/system-restart.ts +13 -6
  126. package/src/server/management/system-routes.ts +15 -3
  127. package/src/server/management/usage-summary-cache.ts +86 -0
  128. package/src/server/management-api.ts +39 -5
  129. package/src/server/management-auth.ts +65 -14
  130. package/src/server/port-reclaim.ts +58 -12
  131. package/src/server/ports.ts +2 -0
  132. package/src/server/proxy-liveness.ts +51 -12
  133. package/src/server/relay-eager.ts +20 -4
  134. package/src/server/relay.ts +548 -154
  135. package/src/server/request-decompress.ts +51 -4
  136. package/src/server/request-log.ts +134 -15
  137. package/src/server/responses/collaboration.ts +15 -4
  138. package/src/server/responses/compact.ts +3 -0
  139. package/src/server/responses/core.ts +241 -66
  140. package/src/server/responses-image-gen-repair.ts +19 -5
  141. package/src/server/responses-item-id-repair.ts +23 -5
  142. package/src/server/sse-payload-rewrite.ts +71 -12
  143. package/src/server/startup-health-cache.ts +14 -1
  144. package/src/server/system-env.ts +8 -1
  145. package/src/server/windows-tcp-drop.ts +15 -5
  146. package/src/server/ws-bridge.ts +25 -0
  147. package/src/service.ts +23 -3
  148. package/src/storage/policy-job.ts +93 -23
  149. package/src/storage/policy-worker.ts +6 -0
  150. package/src/storage/restore-job.ts +62 -16
  151. package/src/storage/restore-worker.ts +6 -0
  152. package/src/storage/storage-mutation-coordinator.ts +36 -6
  153. package/src/storage/worker-lifecycle.ts +181 -47
  154. package/src/tray/windows.ts +45 -18
  155. package/src/types.ts +34 -6
  156. package/src/update/index.ts +24 -5
  157. package/src/update/job.ts +598 -73
  158. package/src/usage/log.ts +115 -17
  159. package/src/usage/summary.ts +67 -2
  160. package/src/vision/index.ts +112 -22
  161. package/src/web-search/loop.ts +38 -6
  162. package/src/web-search/progress-stream.ts +14 -3
  163. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
  164. package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  CodexCredentialGenerationConflictError,
3
3
  CodexCredentialRefreshLockTimeoutError,
4
+ CodexCredentialRefreshBusyError,
5
+ CodexCredentialRefreshStaleError,
4
6
  getValidCodexToken,
5
7
  isCodexAccountGenerationLive,
6
8
  } from "./account-store";
9
+ import { ConfigMutationLockError } from "../config";
7
10
  import { markAccountNeedsReauth } from "./account-runtime-state";
8
11
  import { isCodexAccountUsable } from "./account-usability";
9
12
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
@@ -24,12 +27,14 @@ import { formatErrorResponse } from "../bridge";
24
27
  import { getAccountQuota } from "./quota";
25
28
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
26
29
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
30
+ import { captureConfigGeneration } from "../lib/state-store-sweeper";
27
31
 
28
32
  export type CodexAuthContext =
29
33
  | { kind: "main"; accountId: null }
30
34
  | {
31
35
  kind: "pool";
32
36
  accountId: string;
37
+ writerGeneration: number;
33
38
  generation: number;
34
39
  accessToken: string;
35
40
  chatgptAccountId: string;
@@ -49,6 +54,7 @@ export type CodexAuthContext =
49
54
  // (Option A). Distinct from "main" (passthrough fallback that forwards the client token).
50
55
  kind: "main-pool";
51
56
  accountId: string;
57
+ writerGeneration: number;
52
58
  accessToken: string;
53
59
  chatgptAccountId: string;
54
60
  /** See `pool.probeLeaseId`. */
@@ -180,7 +186,11 @@ export class CodexThreadAffinityExpiredError extends Error {
180
186
  }
181
187
 
182
188
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
183
- return !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError);
189
+ return !(cause instanceof CodexCredentialGenerationConflictError)
190
+ && !(cause instanceof CodexCredentialRefreshLockTimeoutError)
191
+ && !(cause instanceof CodexCredentialRefreshBusyError)
192
+ && !(cause instanceof CodexCredentialRefreshStaleError)
193
+ && !(cause instanceof ConfigMutationLockError);
184
194
  }
185
195
 
186
196
  export interface ResolveCodexAuthContextOptions {
@@ -195,6 +205,7 @@ export async function resolveCodexAuthContext(
195
205
  mode: CodexAccountMode,
196
206
  options: ResolveCodexAuthContextOptions = {},
197
207
  ): Promise<CodexAuthContext> {
208
+ const writerGeneration = captureConfigGeneration();
198
209
  if (mode === "direct") {
199
210
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
200
211
  return { kind: "main", accountId: null };
@@ -254,6 +265,7 @@ export async function resolveCodexAuthContext(
254
265
  return {
255
266
  kind: "main-pool",
256
267
  accountId,
268
+ writerGeneration,
257
269
  accessToken: token.accessToken,
258
270
  chatgptAccountId: token.chatgptAccountId,
259
271
  ...(quotaScope ? { quotaScope } : {}),
@@ -267,6 +279,7 @@ export async function resolveCodexAuthContext(
267
279
  return {
268
280
  kind: "pool",
269
281
  accountId,
282
+ writerGeneration,
270
283
  generation: token.generation,
271
284
  accessToken: token.accessToken,
272
285
  chatgptAccountId: token.chatgptAccountId,
@@ -278,7 +291,7 @@ export async function resolveCodexAuthContext(
278
291
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
279
292
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
280
293
  if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
281
- markAccountNeedsReauth(accountId);
294
+ markAccountNeedsReauth(accountId, writerGeneration);
282
295
  }
283
296
  throw new CodexAuthContextError(accountId, cause);
284
297
  }
@@ -281,6 +281,21 @@ export function resetOpenAiApiCatalogWarningStateForTests(): void {
281
281
  export const slugAliasCollisionWarnings = new Set<string>();
282
282
 
283
283
  export const comboMasqueradeCollisionWarnings = new Set<string>();
284
+ let lastWarningReconciledGeneration = 0;
285
+
286
+ export function reconcileCatalogWarningMemos(generation: number): number {
287
+ if (generation <= lastWarningReconciledGeneration) return 0;
288
+ const removed = openAiApiCollisionWarnings.size
289
+ + comboCatalogWarningSignatures.size
290
+ + slugAliasCollisionWarnings.size
291
+ + comboMasqueradeCollisionWarnings.size;
292
+ openAiApiCollisionWarnings.clear();
293
+ comboCatalogWarningSignatures.clear();
294
+ slugAliasCollisionWarnings.clear();
295
+ comboMasqueradeCollisionWarnings.clear();
296
+ lastWarningReconciledGeneration = generation;
297
+ return removed;
298
+ }
284
299
 
285
300
  export function warnComboMasqueradeCollisionOnce(slug: string): void {
286
301
  if (comboMasqueradeCollisionWarnings.has(slug)) return;
@@ -53,6 +53,7 @@ import {
53
53
  type ProviderModelsApiItem,
54
54
  } from "../../providers/model-discovery";
55
55
  import upstreamModelsSnapshot from "../data/upstream-models.json";
56
+ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
56
57
 
57
58
 
58
59
  import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
@@ -69,6 +70,21 @@ interface GatherFlightResult {
69
70
  }
70
71
 
71
72
  const gatherInflight = new Map<string, Promise<GatherFlightResult>>();
73
+ const MAX_CONCURRENT_CATALOG_GATHERS = 8;
74
+ const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
75
+
76
+ export class CatalogGatherBusyError extends ResourceAdmissionError {
77
+ override readonly code = "catalog_busy";
78
+ readonly retryAfterSeconds = 1;
79
+ constructor() {
80
+ super("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
81
+ this.name = "CatalogGatherBusyError";
82
+ }
83
+ }
84
+
85
+ export function catalogGatherAdmissionMetrics(): AdmissionMetrics {
86
+ return gatherGate.metrics();
87
+ }
72
88
 
73
89
  function stableJson(value: unknown): string {
74
90
  return JSON.stringify(value, (_key, nested) => {
@@ -217,6 +233,15 @@ export function isDatedVariantId(liveId: string, configuredId: string): boolean
217
233
  }
218
234
 
219
235
  export const lastDropWarnSignature = new Map<string, string>();
236
+ let lastWarningReconciledGeneration = 0;
237
+
238
+ export function reconcileProviderFetchWarnings(generation: number): number {
239
+ if (generation <= lastWarningReconciledGeneration) return 0;
240
+ const removed = lastDropWarnSignature.size;
241
+ lastDropWarnSignature.clear();
242
+ lastWarningReconciledGeneration = generation;
243
+ return removed;
244
+ }
220
245
 
221
246
  export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
222
247
 
@@ -345,6 +370,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
345
370
  const contextWindow =
346
371
  positiveSafeInteger(
347
372
  limits?.max_context_length,
373
+ metadata?.context_length,
348
374
  item.context_length,
349
375
  item.context_size,
350
376
  item.max_model_len,
@@ -648,10 +674,13 @@ export async function gatherRoutedModels(
648
674
  const key = gatherFlightKey(config);
649
675
  let promise = gatherInflight.get(key);
650
676
  if (!promise) {
677
+ const lease = gatherGate.tryAcquire();
678
+ if (!lease) throw new CatalogGatherBusyError();
651
679
  // Claim the slot synchronously before any await so same-key callers join this flight.
652
680
  // Distinct keys keep their own entries — a second config must not evict the first.
653
681
  const flight = gatherRoutedModelsUncached(config).finally(() => {
654
682
  if (gatherInflight.get(key) === flight) gatherInflight.delete(key);
683
+ lease.release();
655
684
  });
656
685
  gatherInflight.set(key, flight);
657
686
  promise = flight;
@@ -492,6 +492,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
492
492
  const catalog = loadCatalogForSync(catalogPath);
493
493
  if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
494
494
 
495
+ // The bundled catalog is a reliable native template on the default path, but it is not the
496
+ // merge source. Preservation must inspect the file that this sync is about to overwrite;
497
+ // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
498
+ const onDiskCatalog = readCatalog(catalogPath);
499
+ const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
500
+
495
501
  const template = findNativeTemplate(catalog);
496
502
 
497
503
  const comboOmissions: ComboCatalogOmission[] = [];
@@ -534,7 +540,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
534
540
  // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
535
541
  // providers are configured yet (fresh install / catalog bootstrap tests).
536
542
  const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
537
- catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
543
+ catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
538
544
  clampCatalogModelsToCodexSupport(catalog.models);
539
545
 
540
546
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
@@ -296,10 +296,10 @@ function stripExistingModelProvider(content: string): string {
296
296
  }
297
297
 
298
298
  /**
299
- * Drop ROOT-level `model_context_window` / `model_auto_compact_token_limit` overrides (keys before
300
- * the first table header). Codex treats these root keys as a global override that wins over the
301
- * per-model catalog values, so a stale `model_context_window = 1000000` makes every model (e.g.
302
- * gpt-5.5) report a 1M window. Stripping them on (re)injection lets the catalog drive context size.
299
+ * Drop ROOT-level `model_context_window` overrides (keys before the first table header). Codex
300
+ * treats this root key as a global override that wins over the per-model catalog values, so a stale
301
+ * `model_context_window = 1000000` makes every model (e.g. gpt-5.5) report a 1M window. User-owned
302
+ * compaction limits do not alter the advertised context window and must survive reinjection.
303
303
  */
304
304
  export function stripRootContextWindowOverrides(content: string): string {
305
305
  const lines = content.split("\n");
@@ -307,7 +307,7 @@ export function stripRootContextWindowOverrides(content: string): string {
307
307
  return lines
308
308
  .filter((line, i) => {
309
309
  const isRoot = firstTable === -1 || i < firstTable;
310
- return !isRoot || !/^\s*model_(?:context_window|auto_compact_token_limit)\s*=/.test(line);
310
+ return !isRoot || !/^\s*model_context_window\s*=/.test(line);
311
311
  })
312
312
  .join("\n");
313
313
  }
@@ -1,4 +1,7 @@
1
1
  import type { StoredAccountQuota } from "./quota";
2
+ import { truncateRetainedUtf8 } from "../lib/admission";
3
+
4
+ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
2
5
 
3
6
  export interface MainAccountInfo {
4
7
  email: string | null;
@@ -17,7 +20,11 @@ export function getMainAccountInfoCache(): CachedMainAccountInfo | null {
17
20
  }
18
21
 
19
22
  export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
20
- cachedMainAccountInfo = value;
23
+ cachedMainAccountInfo = {
24
+ ...value,
25
+ email: value.email === null ? null : truncateRetainedUtf8(value.email, MAX_DIAGNOSTIC_VALUE_BYTES),
26
+ plan: value.plan === null ? null : truncateRetainedUtf8(value.plan, MAX_DIAGNOSTIC_VALUE_BYTES),
27
+ };
21
28
  }
22
29
 
23
30
  export function clearMainAccountInfoCache(): void {
@@ -8,6 +8,8 @@
8
8
  * restarts, so an in-memory cache is sufficient here (no SQLite layer needed).
9
9
  */
10
10
  import type { CatalogModel } from "./catalog";
11
+ import type { GenerationContext } from "../lib/state-store-sweeper";
12
+ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
11
13
 
12
14
  /** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */
13
15
  export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -15,6 +17,7 @@ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
15
17
  interface CacheEntry {
16
18
  models: CatalogModel[];
17
19
  fetchedAt: number;
20
+ sizeBytes: number;
18
21
  }
19
22
 
20
23
  export type ProviderModelDiscoveryFailureReason =
@@ -40,6 +43,29 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends
40
43
  : never;
41
44
 
42
45
  const cache = new Map<string, CacheEntry>();
46
+ let cacheBytes = 0;
47
+ let oldestCachedProvider: string | undefined;
48
+ let oldestCachedAt: number | null = null;
49
+ const modelCacheEncoder = new TextEncoder();
50
+
51
+ function recomputeOldestCachedProvider(): void {
52
+ oldestCachedProvider = undefined;
53
+ oldestCachedAt = null;
54
+ for (const [provider, entry] of cache) {
55
+ if (oldestCachedAt !== null && entry.fetchedAt >= oldestCachedAt) continue;
56
+ oldestCachedProvider = provider;
57
+ oldestCachedAt = entry.fetchedAt;
58
+ }
59
+ }
60
+
61
+ function deleteCachedProvider(provider: string): number {
62
+ const entry = cache.get(provider);
63
+ if (!entry) return 0;
64
+ cache.delete(provider);
65
+ cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
66
+ if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
67
+ return entry.sizeBytes;
68
+ }
43
69
 
44
70
  /** Cooldown after a failed live `/models` fetch, so a dead/unreachable provider doesn't re-pay
45
71
  * the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */
@@ -54,6 +80,7 @@ const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
54
80
  * custom-only, and the provider's configured fallback would wrongly stay authoritative.
55
81
  */
56
82
  const liveModelCounts = new Map<string, number>();
83
+ let lastReconciledGeneration = 0;
57
84
 
58
85
  export function markModelsFetchFailure(provider: string, now = Date.now()): void {
59
86
  failureAt.set(provider, now);
@@ -129,20 +156,72 @@ export function getStaleCached(provider: string): CatalogModel[] | null {
129
156
  }
130
157
 
131
158
  export function setCached(provider: string, models: CatalogModel[], now = Date.now()): void {
132
- cache.set(provider, { models, fetchedAt: now });
159
+ deleteCachedProvider(provider);
160
+ const sizeBytes = modelCacheEncoder.encode(provider).byteLength
161
+ + modelCacheEncoder.encode(JSON.stringify(models)).byteLength;
162
+ cache.set(provider, { models, fetchedAt: now, sizeBytes });
163
+ cacheBytes += sizeBytes;
164
+ if (oldestCachedAt === null || now < oldestCachedAt) {
165
+ oldestCachedProvider = provider;
166
+ oldestCachedAt = now;
167
+ }
168
+ enforceAppOwnedMemoryBudget();
133
169
  }
134
170
 
135
171
  /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
136
172
  export function clearModelCache(provider?: string): void {
137
173
  if (provider) {
138
- cache.delete(provider);
174
+ deleteCachedProvider(provider);
139
175
  failureAt.delete(provider);
140
176
  discoveryStatus.delete(provider);
141
177
  liveModelCounts.delete(provider);
142
178
  } else {
143
179
  cache.clear();
180
+ cacheBytes = 0;
181
+ oldestCachedProvider = undefined;
182
+ oldestCachedAt = null;
144
183
  failureAt.clear();
145
184
  discoveryStatus.clear();
146
185
  liveModelCounts.clear();
147
186
  }
148
187
  }
188
+
189
+ export function reconcileModelCacheProviders(
190
+ validProviders: ReadonlySet<string>,
191
+ generation = lastReconciledGeneration + 1,
192
+ ): number {
193
+ if (generation <= lastReconciledGeneration) return 0;
194
+ const removedProviders = new Set<string>();
195
+ for (const store of [failureAt, discoveryStatus, liveModelCounts]) {
196
+ for (const provider of store.keys()) {
197
+ if (validProviders.has(provider)) continue;
198
+ store.delete(provider);
199
+ removedProviders.add(provider);
200
+ }
201
+ }
202
+ for (const provider of cache.keys()) {
203
+ if (validProviders.has(provider)) continue;
204
+ deleteCachedProvider(provider);
205
+ removedProviders.add(provider);
206
+ }
207
+ lastReconciledGeneration = generation;
208
+ return removedProviders.size;
209
+ }
210
+
211
+ export function reconcileModelCacheGeneration(context: GenerationContext): number {
212
+ return reconcileModelCacheProviders(context.providerNames, context.generation);
213
+ }
214
+
215
+ export function modelCacheRetainedStoreSnapshot(): RetainedStoreSnapshot {
216
+ return {
217
+ count: cache.size,
218
+ bytes: cacheBytes,
219
+ evictableBytes: cacheBytes,
220
+ pinnedBytes: 0,
221
+ oldestAt: oldestCachedAt,
222
+ };
223
+ }
224
+
225
+ export function evictOldestModelCacheForBudget(): number {
226
+ return oldestCachedProvider === undefined ? 0 : deleteCachedProvider(oldestCachedProvider);
227
+ }
@@ -1,4 +1,5 @@
1
1
  import type { OcxAccountPoolRotationStrategy } from "../types";
2
+ import type { GenerationContext } from "../lib/state-store-sweeper";
2
3
 
3
4
  export const POOL_KEY_CODEX = "codex";
4
5
  export const POOL_KEY_ANTHROPIC = "anthropic";
@@ -10,6 +11,7 @@ interface SelectionState {
10
11
  }
11
12
 
12
13
  const selectionState = new Map<string, SelectionState>();
14
+ let lastReconciledGeneration = 0;
13
15
 
14
16
  const DEFAULT_STICKY_LIMIT = 1;
15
17
  const MIN_STICKY_LIMIT = 1;
@@ -184,3 +186,40 @@ export function clearPoolRotationState(poolKey?: string): void {
184
186
  }
185
187
  selectionState.delete(poolKey);
186
188
  }
189
+
190
+ export function reconcilePoolRotationState(context: GenerationContext): number {
191
+ if (context.generation <= lastReconciledGeneration) return 0;
192
+ const anthropicIds = new Set<string>();
193
+ for (const key of context.oauthAccountKeys) {
194
+ const separator = key.indexOf("\0");
195
+ if (separator > 0 && key.slice(0, separator) === "anthropic") {
196
+ anthropicIds.add(key.slice(separator + 1));
197
+ }
198
+ }
199
+ let removed = 0;
200
+ for (const [poolKey, state] of selectionState) {
201
+ const valid = poolKey === POOL_KEY_ANTHROPIC
202
+ ? anthropicIds
203
+ : poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`)
204
+ ? context.codexAccountIds
205
+ : null;
206
+ if (!valid) continue;
207
+ if (valid.size === 0) {
208
+ selectionState.delete(poolKey);
209
+ removed += 1;
210
+ continue;
211
+ }
212
+ if (state.activeKey && !valid.has(state.activeKey)) {
213
+ delete state.activeKey;
214
+ state.successes = 0;
215
+ removed += 1;
216
+ }
217
+ for (const accountId of state.currentWeights.keys()) {
218
+ if (valid.has(accountId)) continue;
219
+ state.currentWeights.delete(accountId);
220
+ removed += 1;
221
+ }
222
+ }
223
+ lastReconciledGeneration = context.generation;
224
+ return removed;
225
+ }
@@ -3,9 +3,11 @@ import path, { dirname, join, resolve } from "node:path";
3
3
  import { expandUserPath } from "../config";
4
4
  import { defaultCodexHome } from "./home";
5
5
  import { readRootTomlString } from "./paths";
6
+ import { truncateRetainedUtf8 } from "../lib/admission";
6
7
 
7
8
  const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
8
9
  const DIAGNOSTICS_CACHE_TTL_MS = 30_000;
10
+ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
9
11
 
10
12
  function resolveCodexConfigPath(): string {
11
13
  const raw = process.env.CODEX_HOME?.trim();
@@ -309,7 +311,16 @@ export function getCachedProjectConfigDiagnostics(): {
309
311
  } {
310
312
  const now = Date.now();
311
313
  if (!diagnosticsCache || now - diagnosticsCache.at > DIAGNOSTICS_CACHE_TTL_MS) {
312
- diagnosticsCache = { at: now, warnings: collectProjectCodexConfigWarnings() };
314
+ diagnosticsCache = {
315
+ at: now,
316
+ warnings: collectProjectCodexConfigWarnings().map(warning => ({
317
+ ...warning,
318
+ path: truncateRetainedUtf8(warning.path, MAX_DIAGNOSTIC_VALUE_BYTES),
319
+ detail: truncateRetainedUtf8(warning.detail, MAX_DIAGNOSTIC_VALUE_BYTES),
320
+ ...(warning.profileName === undefined ? {} : { profileName: truncateRetainedUtf8(warning.profileName, MAX_DIAGNOSTIC_VALUE_BYTES) }),
321
+ message: truncateRetainedUtf8(warning.message, MAX_DIAGNOSTIC_VALUE_BYTES),
322
+ })),
323
+ };
313
324
  }
314
325
  const warnings = diagnosticsCache.warnings;
315
326
  return { warnings, grouped: groupProjectCodexConfigWarningsByPath(warnings) };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
4
5
 
5
6
  export type StoredAccountQuota = {
6
7
  weeklyPercent?: number;
@@ -49,8 +50,16 @@ const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
49
50
  const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
50
51
 
51
52
  const accountQuota = new Map<string, StoredAccountQuota>();
53
+ let lastReconciledGeneration = 0;
54
+ let liveAccountIds = new Set<string>();
52
55
 
53
- export const CODEX_UNKNOWN_USAGE_SCORE = 100;
56
+ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boolean {
57
+ return writerGeneration >= lastReconciledGeneration || liveAccountIds.has(accountId);
58
+ }
59
+
60
+ // Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
61
+ // actually exhausted account is still eligible for threshold rotation.
62
+ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
54
63
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
55
64
 
56
65
  export function isCodexQuotaExhausted(
@@ -125,8 +134,10 @@ function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean
125
134
  export function setAccountQuotaFromParsed(
126
135
  accountId: string,
127
136
  quota: Omit<StoredAccountQuota, "updatedAt"> | null,
137
+ writerGeneration = captureConfigGeneration(),
128
138
  ): void {
129
139
  if (!quota) return;
140
+ if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
130
141
  const existing = accountQuota.get(accountId);
131
142
  const next: StoredAccountQuota = { updatedAt: Date.now() };
132
143
  const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
@@ -214,10 +225,14 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
214
225
  return hasKnownQuotaValue(quota) ? quota : null;
215
226
  }
216
227
 
217
- export function applyAccountQuotaFromUpstreamHeaders(accountId: string, headers: Headers): void {
228
+ export function applyAccountQuotaFromUpstreamHeaders(
229
+ accountId: string,
230
+ headers: Headers,
231
+ writerGeneration = captureConfigGeneration(),
232
+ ): void {
218
233
  const quota = parseUpstreamQuotaHeaders(headers);
219
234
  if (!quota) return;
220
- setAccountQuotaFromParsed(accountId, quota);
235
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration);
221
236
  }
222
237
 
223
238
  export function updateAccountQuota(
@@ -227,7 +242,9 @@ export function updateAccountQuota(
227
242
  monthly?: unknown,
228
243
  monthlyResetAt?: unknown,
229
244
  resetCredits?: number,
245
+ writerGeneration = captureConfigGeneration(),
230
246
  ): void {
247
+ if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
231
248
  const existing = accountQuota.get(accountId);
232
249
  const nextWeekly = normalizeUsagePercent(weekly);
233
250
  const nextMonthly = normalizeUsagePercent(monthly);
@@ -325,6 +342,21 @@ export function clearAccountQuota(accountId?: string): void {
325
342
  }
326
343
  }
327
344
 
345
+ export function reconcileCodexQuotaAccounts(context: GenerationContext): number {
346
+ if (context.generation <= lastReconciledGeneration) return 0;
347
+ hydrateAccountQuotasFromDisk();
348
+ let removed = 0;
349
+ for (const accountId of accountQuota.keys()) {
350
+ if (context.codexAccountIds.has(accountId)) continue;
351
+ accountQuota.delete(accountId);
352
+ removed += 1;
353
+ }
354
+ liveAccountIds = new Set(context.codexAccountIds);
355
+ lastReconciledGeneration = context.generation;
356
+ if (removed > 0) schedulePersistAccountQuotas();
357
+ return removed;
358
+ }
359
+
328
360
  export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
329
361
  const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number"
330
362
  ? data.rate_limit_reset_credits.available_count
@@ -19,6 +19,9 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
19
19
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
20
20
  import { isSelectableCodexPoolAccount } from "./account-id";
21
21
  import type { OcxConfig } from "../types";
22
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
23
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
24
+ import { retainedUtf8Bytes } from "../lib/admission";
22
25
 
23
26
  type ThreadAffinityEntry = {
24
27
  accountId: string;
@@ -104,6 +107,7 @@ const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
104
107
  ] as const;
105
108
  export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
106
109
  export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
110
+ const MAX_AFFINITY_COMPONENT_BYTES = 512;
107
111
  // Min interval between quota threshold re-evaluations for a single bound thread.
108
112
  // Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
109
113
  export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
@@ -115,6 +119,8 @@ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
115
119
  * from account-wide Retry-After/default throttles and transient health.
116
120
  */
117
121
  const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
122
+ let lastReconciledGeneration = 0;
123
+ let liveHealthAccountIds = new Set<string>();
118
124
 
119
125
  export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
120
126
  export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
@@ -185,6 +191,8 @@ export type CodexUpstreamOutcomeMeta = {
185
191
  * again (which would advance a round-robin ring twice).
186
192
  */
187
193
  promoteAccountId?: string;
194
+ /** Generation captured when this routed account was selected. */
195
+ writerGeneration?: number;
188
196
  };
189
197
 
190
198
  function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
@@ -193,6 +201,15 @@ function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean
193
201
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
194
202
  }
195
203
 
204
+ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
205
+ const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
206
+ const openai = config.providers.openai;
207
+ if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
208
+ ids.add(MAIN_CODEX_ACCOUNT_ID);
209
+ }
210
+ return ids;
211
+ }
212
+
196
213
  export function clearThreadAccountMap(): void {
197
214
  threadAccountMap.clear();
198
215
  }
@@ -217,6 +234,24 @@ export function clearCodexUpstreamHealthForAccount(accountId: string): void {
217
234
  quotaScopedHealth.delete(accountId);
218
235
  }
219
236
 
237
+ export function reconcileCodexRoutingHealth(context: GenerationContext): number {
238
+ if (context.generation <= lastReconciledGeneration) return 0;
239
+ let removed = 0;
240
+ for (const accountId of upstreamHealth.keys()) {
241
+ if (context.codexAccountIds.has(accountId)) continue;
242
+ upstreamHealth.delete(accountId);
243
+ removed += 1;
244
+ }
245
+ for (const accountId of quotaScopedHealth.keys()) {
246
+ if (context.codexAccountIds.has(accountId)) continue;
247
+ quotaScopedHealth.delete(accountId);
248
+ removed += 1;
249
+ }
250
+ liveHealthAccountIds = new Set(context.codexAccountIds);
251
+ lastReconciledGeneration = context.generation;
252
+ return removed;
253
+ }
254
+
220
255
  export function getCodexUpstreamHealth(
221
256
  accountId: string,
222
257
  ): CodexUpstreamHealth | null {
@@ -600,11 +635,17 @@ function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope
600
635
  return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
601
636
  }
602
637
 
638
+ function admissibleAffinityComponent(value: string): boolean {
639
+ return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
640
+ }
641
+
603
642
  function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
643
+ if (!admissibleAffinityComponent(threadId)) return undefined;
604
644
  return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
605
645
  }
606
646
 
607
647
  function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
648
+ if (!admissibleAffinityComponent(threadId)) return;
608
649
  const affinities = threadAccountMap.get(threadId);
609
650
  if (!affinities) return;
610
651
  affinities.delete(threadAffinityScope(quotaScope));
@@ -613,6 +654,7 @@ function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): v
613
654
 
614
655
  /** Remove only the matching failed account's affinities for one thread. */
615
656
  function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
657
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
616
658
  const affinities = threadAccountMap.get(threadId);
617
659
  if (!affinities) return;
618
660
  for (const [scope, entry] of affinities) {
@@ -671,6 +713,7 @@ function bindThreadAffinity(
671
713
  now: number,
672
714
  quotaScope?: CodexQuotaScope,
673
715
  ): void {
716
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
674
717
  const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
675
718
  if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
676
719
  pruneExpiredThreadAffinities(now);
@@ -1204,6 +1247,8 @@ export function recordCodexUpstreamOutcome(
1204
1247
  meta: CodexUpstreamOutcomeMeta = {},
1205
1248
  ): void {
1206
1249
  if (!accountId) return;
1250
+ const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1251
+ if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1207
1252
  const now = meta.now ?? Date.now();
1208
1253
  const outcomeClass = classifyCodexUpstreamOutcome(outcome);
1209
1254
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
@@ -1275,7 +1320,7 @@ export function recordCodexUpstreamOutcome(
1275
1320
  lastFailureAt: now,
1276
1321
  });
1277
1322
  quotaScopedHealth.delete(accountId);
1278
- markAccountNeedsReauth(accountId);
1323
+ markAccountNeedsReauth(accountId, writerGeneration);
1279
1324
  clearThreadAccountMapForAccount(accountId);
1280
1325
  return;
1281
1326
  }