@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) 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-u5eFOv2y.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 +126 -10
  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 +143 -17
  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/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -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;
@@ -109,19 +109,29 @@ export function catalogEntryEfforts(entry: RawEntry): string[] {
109
109
 
110
110
  export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS];
111
111
 
112
- export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
112
+ export function applyCatalogModelMetadata(
113
+ entry: RawEntry,
114
+ model?: CatalogModel,
115
+ fallbackDisplayName?: string,
116
+ ): void {
113
117
  if (!model) return;
114
118
  // This marker survives strict catalog normalization and lets sync distinguish a stale
115
119
  // bare combo alias from a genuine native model row.
116
120
  if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE;
117
121
  // displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing
118
- // slug, alias, or provider. deriveEntry already stamped the slug as display_name; a
119
- // configured displayName overrides just the label. The `/` separator is rejected at every
120
- // input boundary (CLI `ocx models add`, management API), so the catalog trusts its source.
121
- // Combos carry no displayName, and natives never reach here (no CatalogModel), so genuine
122
- // upstream marketing names and combo alias labels are preserved untouched.
122
+ // slug, alias, or provider. A configured displayName wins. Otherwise physical routed rows
123
+ // use the final segment of their native id, keeping provider/vendor namespaces out of the
124
+ // narrow picker label. buildCatalogEntries supplies a longer fallback only when that basename
125
+ // would collide with another visible row. Aliased combos keep their public alias. Natives never
126
+ // reach here (no CatalogModel), so genuine upstream marketing names are preserved untouched.
123
127
  const displayName = typeof model.displayName === "string" ? model.displayName.trim() : "";
124
128
  if (displayName) entry.display_name = displayName;
129
+ else if (!model.alias) {
130
+ const nativeId = model.id.trim();
131
+ const finalSegment = nativeId.slice(nativeId.lastIndexOf("/") + 1).trim();
132
+ const fallback = fallbackDisplayName?.trim() || finalSegment;
133
+ if (fallback) entry.display_name = fallback;
134
+ }
125
135
  if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
126
136
  entry.context_window = model.contextWindow;
127
137
  entry.max_context_window = model.contextWindow;
@@ -123,6 +123,12 @@ export function visibleNativeSlugs(config: Pick<OcxConfig, "disabledModels">): s
123
123
  return nativeOpenAiSlugs().filter(slug => !disabled.has(slug));
124
124
  }
125
125
 
126
+ /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */
127
+ export function desktopVisibleNativeSlugs(config: Pick<OcxConfig, "claudeCode" | "disabledModels">): string[] {
128
+ if (config.claudeCode?.desktopNativeModels === false) return [];
129
+ return visibleNativeSlugs(config);
130
+ }
131
+
126
132
  export function nativeModelRows(config: Pick<OcxConfig, "disabledModels">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
127
133
  const disabled = disabledNativeSlugs(config);
128
134
  return NATIVE_OPENAI_MODELS.map(slug => {
@@ -92,7 +92,9 @@ export interface CatalogModel {
92
92
  /**
93
93
  * Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never
94
94
  * affects the routing slug, alias-collision order, native marketing-name precedence, or provider
95
- * behavior. When unset, the entry falls back to its Codex-facing slug (the historical behavior).
95
+ * behavior. When unset, routed entries use the final segment of the native model id so the
96
+ * provider namespace does not crowd the picker; basename collisions retain enough route context
97
+ * to stay distinguishable, and aliased combo rows keep their public alias.
96
98
  * Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path
97
99
  * which carries no CatalogModel, so a configured displayName can never override a native name.
98
100
  */
@@ -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;
@@ -166,6 +166,7 @@ export function deriveEntry(
166
166
  priority: number,
167
167
  model?: CatalogModel,
168
168
  exactComboSlugs: ReadonlySet<string> = new Set(),
169
+ fallbackDisplayName?: string,
169
170
  ): RawEntry {
170
171
  const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
171
172
  const isRouted = model !== undefined;
@@ -202,7 +203,7 @@ export function deriveEntry(
202
203
  applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
203
204
  normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
204
205
  if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
205
- applyCatalogModelMetadata(e, model);
206
+ applyCatalogModelMetadata(e, model, fallbackDisplayName);
206
207
  } else {
207
208
  applyNativeOpenAiContextOverride(e);
208
209
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
@@ -239,7 +240,7 @@ export function deriveEntry(
239
240
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
240
241
  }
241
242
  if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
242
- applyCatalogModelMetadata(entry, model);
243
+ applyCatalogModelMetadata(entry, model, fallbackDisplayName);
243
244
  if (!isRouted) applyNativeOpenAiContextOverride(entry);
244
245
  return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
245
246
  preserveExactInputModalities: preserveExact,
@@ -247,6 +248,50 @@ export function deriveEntry(
247
248
  });
248
249
  }
249
250
 
251
+ function configuredDisplayName(model: CatalogModel): string {
252
+ return typeof model.displayName === "string" ? model.displayName.trim() : "";
253
+ }
254
+
255
+ function defaultPickerLabel(model: CatalogModel): string {
256
+ const nativeId = model.id.trim();
257
+ return nativeId.slice(nativeId.lastIndexOf("/") + 1).trim();
258
+ }
259
+
260
+ /**
261
+ * Keep the common picker path compact, but retain only as much route context as needed when two
262
+ * emitted rows would otherwise have the same label. Explicit display names and combo aliases are
263
+ * author-owned and never rewritten. If distinct native ids share a basename, the native id is
264
+ * enough; if the same native id is exposed by multiple providers, append the provider as well.
265
+ */
266
+ function routedFallbackDisplayNames(models: readonly CatalogModel[]): Map<CatalogModel, string> {
267
+ const intendedLabel = (model: CatalogModel): string =>
268
+ configuredDisplayName(model) || model.alias?.trim() || defaultPickerLabel(model);
269
+ const labelCounts = new Map<string, number>();
270
+ const nativeIdCounts = new Map<string, number>();
271
+ for (const model of models) {
272
+ const label = intendedLabel(model);
273
+ if (!label) continue;
274
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
275
+ const nativeKey = `${label}\u0000${model.id.trim()}`;
276
+ nativeIdCounts.set(nativeKey, (nativeIdCounts.get(nativeKey) ?? 0) + 1);
277
+ }
278
+
279
+ const out = new Map<CatalogModel, string>();
280
+ for (const model of models) {
281
+ if (configuredDisplayName(model) || model.alias) continue;
282
+ const label = defaultPickerLabel(model);
283
+ if (!label) continue;
284
+ if ((labelCounts.get(label) ?? 0) <= 1) {
285
+ out.set(model, label);
286
+ continue;
287
+ }
288
+ const nativeId = model.id.trim();
289
+ const sameNativeIdCount = nativeIdCounts.get(`${label}\u0000${nativeId}`) ?? 0;
290
+ out.set(model, sameNativeIdCount > 1 ? `${nativeId} (${model.provider})` : nativeId);
291
+ }
292
+ return out;
293
+ }
294
+
250
295
  export function buildCatalogEntries(
251
296
  template: RawEntry | null,
252
297
  gptSlugs: string[],
@@ -271,22 +316,28 @@ export function buildCatalogEntries(
271
316
  if (rank.has(slug)) e.priority = rank.get(slug)!;
272
317
  out.push(e);
273
318
  }
274
- for (const m of goModels) {
275
- if (collisionSkipped.has(m)) continue;
319
+ const routedModels = goModels.filter(m => {
320
+ if (collisionSkipped.has(m)) return false;
276
321
  const slug = catalogModelSlug(m);
277
322
  if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
278
323
  warnComboMasqueradeCollisionOnce(slug);
279
- continue;
324
+ return false;
280
325
  }
326
+ return true;
327
+ });
328
+ const fallbackDisplayNames = routedFallbackDisplayNames(routedModels);
329
+ for (const m of routedModels) {
330
+ const slug = catalogModelSlug(m);
281
331
  // Provider rows use the one-slash slug codec; combo aliases intentionally override that
282
332
  // public slug and may be bare.
283
333
  const e = deriveEntry(
284
334
  template,
285
335
  slug,
286
- `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`,
336
+ `Routed via opencodex → ${slug} (${m.owned_by ?? m.provider}).`,
287
337
  5,
288
338
  m,
289
339
  exactComboSlugs,
340
+ fallbackDisplayNames.get(m),
290
341
  );
291
342
  // Featured picks may be stored raw (legacy) or encoded — honor both.
292
343
  const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
@@ -492,6 +543,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
492
543
  const catalog = loadCatalogForSync(catalogPath);
493
544
  if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
494
545
 
546
+ // The bundled catalog is a reliable native template on the default path, but it is not the
547
+ // merge source. Preservation must inspect the file that this sync is about to overwrite;
548
+ // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
549
+ const onDiskCatalog = readCatalog(catalogPath);
550
+ const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
551
+
495
552
  const template = findNativeTemplate(catalog);
496
553
 
497
554
  const comboOmissions: ComboCatalogOmission[] = [];
@@ -534,7 +591,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
534
591
  // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
535
592
  // providers are configured yet (fresh install / catalog bootstrap tests).
536
593
  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);
594
+ catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
538
595
  clampCatalogModelsToCodexSupport(catalog.models);
539
596
 
540
597
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
@@ -1,8 +1,8 @@
1
1
  // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules.
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
- export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
3
+ export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
5
+ export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
@@ -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) };