@bitkyc08/opencodex 2.58.0 → 2.59.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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -226,10 +226,12 @@ export function applyReasoningLevels(
226
226
  effortsOverride?: string[],
227
227
  defaultOverride?: string,
228
228
  preserveExact = false,
229
+ suppressSyntheticMax = false,
229
230
  ): void {
230
231
  let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
231
- // Mock top tiers (user decision 260709): every reasoning-capable model advertises `max`
232
- // even when the provider ladder stops lower — subagent spawns pass `max` DIRECTLY
232
+ // Mock top tiers (user decision 260709): reasoning-capable routed models advertise `max`
233
+ // even when the provider ladder stops lower, unless the model opts out of that synthesis.
234
+ // Subagent spawns pass `max` DIRECTLY
233
235
  // (no ultra->max client conversion) and codex-rs validates it by catalog membership,
234
236
  // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest:
235
237
  // routed adapters clamp via clampToSupportedCodexEffort and natives via
@@ -237,7 +239,7 @@ export function applyReasoningLevels(
237
239
  // reasoning-capable, so it must not grow synthetic top rungs.
238
240
  if (!preserveExact && efforts.length > 0 && efforts.some(effort => effort !== "none" && effort !== "minimal")) {
239
241
  const additions: string[] = [];
240
- if (!efforts.includes("max")) additions.push("max");
242
+ if (!suppressSyntheticMax && !efforts.includes("max")) additions.push("max");
241
243
  if (!efforts.includes("ultra")) additions.push("ultra");
242
244
  if (additions.length > 0) efforts = sanitizeCodexReasoningEfforts([...efforts, ...additions]) ?? efforts;
243
245
  }
@@ -258,9 +260,11 @@ export function applyReasoningLevels(
258
260
  }
259
261
  entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride)
260
262
  ? defaultOverride
261
- : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high"
262
- // Sentinels never become the implicit default when real rungs are declared.
263
- : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
263
+ : suppressSyntheticMax && defaultOverride === "max"
264
+ ? clampedDefaultEffort(defaultOverride, efforts)
265
+ : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high"
266
+ // Sentinels never become the implicit default when real rungs are declared.
267
+ : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
264
268
  }
265
269
 
266
270
  /**
@@ -499,6 +499,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
499
499
  inMod: prov.modelInputModalities ?? null,
500
500
  capabilities: prov.modelCapabilities ?? null,
501
501
  re: prov.modelReasoningEfforts ?? null,
502
+ suppressMax: prov.modelSuppressSyntheticMax ?? null,
502
503
  defRe: prov.modelDefaultReasoningEfforts ?? null,
503
504
  rsSum: prov.modelSupportsReasoningSummaries ?? null,
504
505
  verbosity: prov.modelSupportsVerbosity ?? null,
@@ -296,6 +296,7 @@ export function applyProviderConfigHints(
296
296
  inputModalities = base.includes("image") ? [...base] : [...base, "image"];
297
297
  }
298
298
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
299
+ const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) === true;
299
300
  const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
300
301
  const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
301
302
  const supportsVerbosity = configuredVerbositySupport(name, prov, model.id);
@@ -305,6 +306,7 @@ export function applyProviderConfigHints(
305
306
  supportsServiceTier: _staleServiceTier,
306
307
  fastTierDescription: _staleFastTierDescription,
307
308
  providerAlias: _staleProviderAlias,
309
+ suppressSyntheticMax: _staleSuppressSyntheticMax,
308
310
  ...modelWithoutServiceTier
309
311
  } = model;
310
312
  // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
@@ -321,6 +323,7 @@ export function applyProviderConfigHints(
321
323
  ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
322
324
  ...(inputModalities ? { inputModalities } : {}),
323
325
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
326
+ ...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}),
324
327
  ...(configuredMaxInput !== undefined
325
328
  ? {
326
329
  maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
@@ -394,6 +397,31 @@ export function applyConfigHintsToCachedModels(
394
397
  ): CatalogModel[] {
395
398
  return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias));
396
399
  }
400
+
401
+ /** Catalog slugs whose configured model must not gain a missing synthetic max rung. */
402
+ export function suppressedSyntheticMaxCatalogSlugs(
403
+ config: Pick<OcxConfig, "providers">,
404
+ models: readonly CatalogModel[],
405
+ observedEntries: readonly { slug?: unknown }[] = [],
406
+ ): ReadonlySet<string> {
407
+ const slugs = new Set(models
408
+ .filter(model => model.suppressSyntheticMax === true)
409
+ .map(catalogModelSlug));
410
+ for (const [provider, providerConfig] of Object.entries(config.providers)) {
411
+ const configured = providerConfig.modelSuppressSyntheticMax ?? {};
412
+ const encoded = Object.fromEntries(Object.entries(configured)
413
+ .map(([modelId, suppress]) => [routedSlug(provider, modelId).slice(provider.length + 1), suppress]));
414
+ for (const [modelId, suppress] of Object.entries(configured)) {
415
+ if (suppress === true) slugs.add(routedSlug(provider, modelId));
416
+ }
417
+ for (const entry of observedEntries) {
418
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
419
+ if (!slug.startsWith(`${provider}/`)) continue;
420
+ if (modelRecordValue(encoded, slug.slice(provider.length + 1)) === true) slugs.add(slug);
421
+ }
422
+ }
423
+ return slugs;
424
+ }
397
425
  export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
398
426
 
399
427
  export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
@@ -582,10 +610,11 @@ function discoveredPricingRate(value: unknown): number | undefined {
582
610
  /**
583
611
  * Cost class for one discovered row, read from the provider's own `pricing` object (#3666).
584
612
  *
585
- * Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing,
586
- * one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only
587
- * filter. Showing a paid model under a Free filter spends the user's money, while hiding a free
588
- * one costs a click.
613
+ * Fail closed. Any positive numeric component proves the model is paid. Calling it free requires
614
+ * a complete prompt/completion pair and every published pricing component to be a non-negative
615
+ * numeric zero; an unsupported component is "unknown" because it may describe another charge.
616
+ * Showing a paid model under a Free filter spends the user's money, while hiding a free one costs
617
+ * a click.
589
618
  *
590
619
  * Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming
591
620
  * convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on
@@ -598,10 +627,13 @@ function discoveredPricingRate(value: unknown): number | undefined {
598
627
  export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" {
599
628
  const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing);
600
629
  if (!pricing) return "unknown";
630
+ const rates = Object.values(pricing).map(discoveredPricingRate);
631
+ if (rates.some(rate => rate !== undefined && rate > 0)) return "paid";
632
+ if (rates.some(rate => rate === undefined)) return "unknown";
601
633
  const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input);
602
634
  const completion = discoveredPricingRate(pricing.completion ?? pricing.output);
603
635
  if (prompt === undefined || completion === undefined) return "unknown";
604
- return prompt === 0 && completion === 0 ? "free" : "paid";
636
+ return "free";
605
637
  }
606
638
 
607
639
  export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
@@ -112,6 +112,8 @@ export interface CatalogModel {
112
112
  displayName?: string;
113
113
  owned_by?: string;
114
114
  reasoningEfforts?: string[];
115
+ /** Suppress only catalog synthesis of a missing max rung; provider-declared max survives. */
116
+ suppressSyntheticMax?: boolean;
115
117
  defaultReasoningEffort?: string;
116
118
  contextWindow?: number;
117
119
  maxInputTokens?: number;
@@ -255,12 +257,19 @@ export function readCodexCatalogPath(): string {
255
257
  return activeDefaultCatalogPath();
256
258
  }
257
259
 
258
- /** Resolve the configured catalog without consulting ambient CODEX_HOME again. */
259
- export function readCodexCatalogPathForHome(codexHome: string): string {
260
+ /**
261
+ * Resolve the configured catalog without consulting ambient CODEX_HOME again.
262
+ *
263
+ * `configText` is for a caller that has already read that same `config.toml` under
264
+ * its own constraints - the prompt-text probe reads it bounded, on the request
265
+ * thread - so resolving the catalog does not cost a second, unbounded read of the
266
+ * file the caller is holding. Omitting it keeps the original behaviour.
267
+ */
268
+ export function readCodexCatalogPathForHome(codexHome: string, configText?: string): string {
260
269
  try {
261
270
  const configPath = join(codexHome, "config.toml");
262
- if (existsSync(configPath)) {
263
- const toml = readFileSync(configPath, "utf-8");
271
+ if (configText !== undefined || existsSync(configPath)) {
272
+ const toml = configText ?? readFileSync(configPath, "utf-8");
264
273
  const path = readRootTomlString(toml, "model_catalog_json");
265
274
  if (path) return resolve(codexHome, path);
266
275
  }
@@ -284,6 +293,30 @@ export function readConfiguredAutoReviewModel(): string | null {
284
293
  return null;
285
294
  }
286
295
 
296
+ /**
297
+ * Read the root `model` pin from Codex's config.toml (issue #4646).
298
+ *
299
+ * Codex starts every new session on this id, and nothing in opencodex checks that the id is one
300
+ * the proxy actually exposes: the pin lives in Codex's config, while exposure is decided here by
301
+ * `disabledModels`, provider `selectedModels`, and account entitlements. When the two disagree
302
+ * every turn fails and no surface says why, which is what the `ocx doctor` section added for
303
+ * #4646 reports.
304
+ *
305
+ * Read-only, and deliberately the same shape and the same swallow-and-return-null error policy as
306
+ * `readConfiguredAutoReviewModel` above: a diagnostic must degrade to "unknown" on an unreadable
307
+ * or absent config rather than throw out of the surface that called it.
308
+ */
309
+ export function readConfiguredDefaultModel(): string | null {
310
+ try {
311
+ const configPath = activeCodexConfigPath();
312
+ if (existsSync(configPath)) {
313
+ const toml = readFileSync(configPath, "utf-8");
314
+ return readRootTomlString(toml, "model");
315
+ }
316
+ } catch { /* ignore */ }
317
+ return null;
318
+ }
319
+
287
320
  export function parseCatalogJson(raw: string): RawCatalog | null {
288
321
  try {
289
322
  const cat = JSON.parse(raw);
@@ -627,6 +660,14 @@ export interface MultiAgentModeOptions {
627
660
  * so they can still spawn Grok/Claude — ChatGPT encrypts v2 NEW_TASK bodies.
628
661
  */
629
662
  keepNativeChatGptOnV1?: boolean;
663
+ /**
664
+ * Pristine installed-catalog pins keyed by bare native slug. When provided, the
665
+ * backup — not the bundled snapshot — is authoritative for the rows it contains,
666
+ * and a preserved live/native row outside it keeps the pin it already carries:
667
+ * an absent baseline entry cannot distinguish a stale forced stamp from a
668
+ * legitimate user- or provider-preserved pin, so the non-destructive read wins.
669
+ */
670
+ nativeDefaults?: ReadonlyMap<string, string | null>;
630
671
  }
631
672
 
632
673
  /** Catalog rows that run on the ChatGPT backend (encrypt v2 child tasks). */
@@ -707,13 +748,34 @@ export function applyMultiAgentMode(
707
748
  && hasNativeOpenAiCapabilityMetadata(routedNativeSlug)
708
749
  ? routedNativeSlug
709
750
  : undefined;
751
+ const accountBoundNativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
752
+ const nativeLookupSlug = accountBoundNativeSlug ?? slug;
753
+ // The baseline is built from bare native slugs only, so "absent from the
754
+ // baseline" is not evidence about a routed row — it is guaranteed. Only a
755
+ // native row can carry a pin the baseline legitimately failed to mention;
756
+ // a routed row keeps the documented default-mode normalization.
757
+ const isNativeCatalogEntry = accountBoundNativeSlug !== undefined || !slug.includes("/");
758
+ const hasNativeDefault = !nativeAlias
759
+ && codexForwardCapabilityAlias === undefined
760
+ && options.nativeDefaults?.has(nativeLookupSlug) === true;
710
761
  const upstreamPin = nativeAlias
711
762
  ? nativeMultiAgentVersion(slug)
712
763
  : codexForwardCapabilityAlias
713
764
  ? nativeMultiAgentVersion(codexForwardCapabilityAlias)
714
- : UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug)?.multi_agent_version;
765
+ : hasNativeDefault
766
+ ? options.nativeDefaults?.get(nativeLookupSlug)
767
+ : options.nativeDefaults === undefined
768
+ ? UPSTREAM_NATIVE_ENTRIES.get(nativeLookupSlug)?.multi_agent_version
769
+ : undefined;
715
770
  if (typeof upstreamPin === "string") {
716
771
  entry.multi_agent_version = upstreamPin;
772
+ } else if (options.nativeDefaults !== undefined
773
+ && !nativeAlias
774
+ && codexForwardCapabilityAlias === undefined
775
+ && isNativeCatalogEntry
776
+ && !hasNativeDefault
777
+ && typeof entry.multi_agent_version === "string") {
778
+ continue;
717
779
  } else if (v2FeatureEnabled) {
718
780
  entry.multi_agent_version = "v2";
719
781
  } else {
@@ -888,3 +950,19 @@ export function readNativeBaseline(catalogPath: string): Map<string, number> {
888
950
  }
889
951
  return out;
890
952
  }
953
+
954
+ /**
955
+ * Extract the pristine baseline's per-slug multi-agent pins. A bare native row that
956
+ * carried no pin maps to null so "baseline says unpinned" stays distinguishable
957
+ * from "baseline never contained this row".
958
+ */
959
+ export function nativeMultiAgentDefaults(
960
+ models: readonly Readonly<Record<string, unknown>>[] | null | undefined,
961
+ ): Map<string, string | null> {
962
+ const out = new Map<string, string | null>();
963
+ for (const entry of models ?? []) {
964
+ if (typeof entry.slug !== "string" || entry.slug.includes("/")) continue;
965
+ out.set(entry.slug, typeof entry.multi_agent_version === "string" ? entry.multi_agent_version : null);
966
+ }
967
+ return out;
968
+ }
@@ -0,0 +1,96 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { isEffectiveCodexDesktopAuthless } from "../loopback-target";
3
+ import { NATIVE_RESERVE_MODEL } from "./native-models";
4
+
5
+ /**
6
+ * #4811: why an opted-in install still gets no Luna Reserve row.
7
+ *
8
+ * The Reserve projection is account-qualified — its slug is `<selector>/gpt-reserve` — so it
9
+ * cannot be written without a selector that targets the main Codex account. On a fresh authless
10
+ * install `codexAccountNamespaces` is empty, the account picker is therefore off, no selectors
11
+ * exist, and `createReserveCatalogProjection` returns `undefined`. Nothing downstream can
12
+ * explain that: an omitted row has no place to carry a reason, which is exactly the shape of
13
+ * #4212 for account-gated natives, and the same answer applies — say it once, here, while the
14
+ * inputs that produced the omission are still in scope.
15
+ *
16
+ * Deliberately scoped to an operator who asked for the feature. A default install has
17
+ * `codexDesktopAuthless` unset, wants no Reserve row, and must not be told about one. An install
18
+ * that stores the flag on a bind where it cannot take effect is a different silence, already
19
+ * reported as `inertReason` by `describeCodexDesktopSwitches`, and is not repeated here.
20
+ */
21
+ export type ReserveCatalogSuppressionReason =
22
+ | "no-canonical-openai-provider"
23
+ | "account-picker-disabled"
24
+ | "no-account-namespaces"
25
+ | "no-main-account-selector";
26
+
27
+ type ReserveSuppressionConfig = Pick<
28
+ OcxConfig,
29
+ | "runtimeRole"
30
+ | "hostname"
31
+ | "unauthenticatedLoopbackListener"
32
+ | "codexDesktopAuthless"
33
+ | "codexAccountNamespaces"
34
+ | "codexAccountPickerEnabled"
35
+ >;
36
+
37
+ export interface ReserveSuppressionInput {
38
+ /** Already resolved by the caller; passed in so this module stays off the metadata graph. */
39
+ includeAccountBoundNativeOpenAi: boolean;
40
+ /** Selectors that resolved to the main Codex account. A non-empty list means no suppression. */
41
+ mainSelectors: readonly string[];
42
+ }
43
+
44
+ export function reserveCatalogSuppressionReason(
45
+ config: ReserveSuppressionConfig,
46
+ input: ReserveSuppressionInput,
47
+ ): ReserveCatalogSuppressionReason | undefined {
48
+ if (!isEffectiveCodexDesktopAuthless(config) || input.mainSelectors.length > 0) return undefined;
49
+ if (!input.includeAccountBoundNativeOpenAi) return "no-canonical-openai-provider";
50
+ // Checked before the empty-map case: when an operator has explicitly turned the picker off,
51
+ // naming the empty map would send them to hand-author selectors that the opt-in generates.
52
+ if (config.codexAccountPickerEnabled === false) return "account-picker-disabled";
53
+ if (Object.keys(config.codexAccountNamespaces ?? {}).length === 0) return "no-account-namespaces";
54
+ return "no-main-account-selector";
55
+ }
56
+
57
+ const SUPPRESSION_TEXT: Readonly<Record<ReserveCatalogSuppressionReason, {
58
+ cause: string;
59
+ action: string;
60
+ }>> = {
61
+ "no-canonical-openai-provider": {
62
+ cause: "no enabled canonical OpenAI provider can serve account-qualified native routes",
63
+ action: "Enable the built-in \"openai\" provider with forwarded ChatGPT auth.",
64
+ },
65
+ "account-picker-disabled": {
66
+ cause: "the Codex account picker is turned off (codexAccountPickerEnabled is false)",
67
+ action: "Set codexAccountPickerEnabled to true to generate the default account selectors.",
68
+ },
69
+ "no-account-namespaces": {
70
+ cause: "no Codex account selectors are configured (codexAccountNamespaces is empty)",
71
+ action: "Enable the Codex account picker once to generate the default account selectors.",
72
+ },
73
+ "no-main-account-selector": {
74
+ cause: "no configured selector in codexAccountNamespaces targets the main Codex account",
75
+ action: "Map one selector to the main Codex account.",
76
+ },
77
+ };
78
+
79
+ const warnedReserveSuppression = new Set<string>();
80
+
81
+ /** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */
82
+ export function resetReserveSuppressionWarningsForTests(): void {
83
+ warnedReserveSuppression.clear();
84
+ }
85
+
86
+ export function warnReserveSuppressedOnce(reason: ReserveCatalogSuppressionReason): void {
87
+ if (warnedReserveSuppression.has(reason)) return;
88
+ warnedReserveSuppression.add(reason);
89
+ const text = SUPPRESSION_TEXT[reason];
90
+ console.warn(
91
+ `[opencodex] catalog sync: authless Codex Desktop routing is on, but the Luna Reserve row `
92
+ + `(<selector>/${NATIVE_RESERVE_MODEL}) is not being written because ${text.cause}. `
93
+ + `The Reserve projection is account-qualified and needs a selector for the main Codex `
94
+ + `account. ${text.action}`,
95
+ );
96
+ }
@@ -33,6 +33,7 @@ import {
33
33
  readCodexCatalogPath,
34
34
  readCodexCatalogPathForHome,
35
35
  readNativeBaseline,
36
+ nativeMultiAgentDefaults,
36
37
  } from "./parsing";
37
38
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
38
39
  import {
@@ -50,6 +51,7 @@ import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
50
51
  import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
51
52
  import { isMultiAgentV2Enabled } from "../features";
52
53
  import { clampCatalogModelsToCodexSupport } from "./effort";
54
+ import { suppressedSyntheticMaxCatalogSlugs } from "./model-hints";
53
55
  import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
54
56
  import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
55
57
  import {
@@ -75,6 +77,7 @@ import {
75
77
  import { finishUpstreamNativeEntry } from "./derive-entry";
76
78
  import { finalizeAutoReviewModelOverride } from "./auto-review";
77
79
  import { gatedNativeAccountLabel, gatedNativeReauthSuppressionReason, warnGatedNativeSuppressedOnce } from "./gated-native-warn";
80
+ import { reserveCatalogSuppressionReason, warnReserveSuppressedOnce } from "./reserve-warn";
78
81
 
79
82
  interface RetainedCatalogSyncRead {
80
83
  readonly catalogPath: string;
@@ -320,6 +323,11 @@ function writeRetainedCatalogSync({
320
323
  const enabledGo = filterCatalogVisibleModels(goModels, config);
321
324
  const featured = config.subagentModels ?? [];
322
325
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
326
+ const suppressedSyntheticMaxSlugs = suppressedSyntheticMaxCatalogSlugs(
327
+ config,
328
+ orderedGoModels,
329
+ catalogModelsForMerge,
330
+ );
323
331
  const modelPickerOrder = config.modelPickerOrder ?? [];
324
332
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
325
333
  const exactComboSlugs = exactComboCatalogSlugs(config);
@@ -376,6 +384,14 @@ function writeRetainedCatalogSync({
376
384
  const accountTargets = new Map(codexAccountNamespaceEntries(config));
377
385
  const reserveMainSelectors = accountSelectors.filter(selector =>
378
386
  isMainCodexAccountTarget(accountTargets.get(selector) ?? ""));
387
+ // #4811: an omitted Reserve row carries no reason, so the explanation has to be emitted here,
388
+ // where the selector inputs that produced the omission are still in scope. Silent for every
389
+ // install that did not opt into authless Codex Desktop routing.
390
+ const reserveSuppression = reserveCatalogSuppressionReason(config, {
391
+ includeAccountBoundNativeOpenAi,
392
+ mainSelectors: reserveMainSelectors,
393
+ });
394
+ if (reserveSuppression) warnReserveSuppressedOnce(reserveSuppression);
379
395
  // The active file can own a bare source even when the bundled catalog is the build base.
380
396
  // A previously clamped qualified projection must not shorten a retained genuine ladder.
381
397
  const reserveObservations = [
@@ -443,6 +459,7 @@ function writeRetainedCatalogSync({
443
459
  // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
444
460
  const baselineCatalog = readCatalogBackup(catalogPath);
445
461
  const baseline = readNativeBaseline(catalogPath);
462
+ const nativePinBaseline = nativeMultiAgentDefaults(baselineCatalog?.models);
446
463
  const gatheredProviderNames = new Set(
447
464
  Object.entries(config.providers ?? {})
448
465
  .filter(([, prov]) => prov.disabled !== true)
@@ -512,8 +529,10 @@ function writeRetainedCatalogSync({
512
529
  includeNativeOpenAi,
513
530
  accountBoundEntries,
514
531
  suppressedBareNativeSlugs,
532
+ suppressedSyntheticMaxSlugs,
515
533
  openaiContextCap,
516
534
  nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames,
535
+ nativeMultiAgentDefaults: nativePinBaseline,
517
536
  policy: {
518
537
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
519
538
  nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
@@ -850,6 +850,26 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows(
850
850
  ];
851
851
  }
852
852
 
853
+ /**
854
+ * Add generated-registry rows the live provider list did not return, and backfill a
855
+ * published context window onto a live row that arrived without one.
856
+ *
857
+ * The backfill is the reason this function has a merge branch at all. It used to skip every
858
+ * id the live list returned, which is correct for a row that already knows its window and
859
+ * wrong for the normal OpenCode Go case, where discovery returns an id with no context
860
+ * field at all. The serialized Codex catalog was unaffected, because
861
+ * `applyCatalogMetadata` writes `context_window` onto the entry straight from the generated
862
+ * table; the absence was only visible to consumers that read `CatalogModel.contextWindow`
863
+ * (#4971). Those are not cosmetic: `buildClaudeContextWindows` drops a routed row with no
864
+ * window from the map that decides the `[1m]` marker, so a published 1M model could not be
865
+ * recognized as one, and the Grok config writer and several client exporters omit the field
866
+ * entirely rather than emit a known value.
867
+ *
868
+ * It is a missing-value fill, never an override: a live positive window still wins, because
869
+ * the upstream is the authority on its own model. The seeded row is re-hinted so operator
870
+ * precedence is unchanged — a configured window still lowers it and `providerContextCaps`
871
+ * still caps it, exactly as for an appended row.
872
+ */
853
873
  export function augmentRoutedModelsWithMetadata(
854
874
  models: CatalogModel[],
855
875
  providerNames: string[],
@@ -858,7 +878,7 @@ export function augmentRoutedModelsWithMetadata(
858
878
  metadataModelIdCaseFoldByProvider?: ReadonlyMap<string, boolean>,
859
879
  ): CatalogModel[] {
860
880
  const out = [...models];
861
- const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
881
+ const indexByKey = new Map(out.map((model, index) => [`${model.provider}/${model.id}`, index]));
862
882
  for (const provider of providerNames) {
863
883
  if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
864
884
  if (providers?.[provider]?.liveModels === false) continue;
@@ -866,9 +886,28 @@ export function augmentRoutedModelsWithMetadata(
866
886
  if (!jawcodeProvider) continue;
867
887
  for (const meta of listModelMetadata(jawcodeProvider)) {
868
888
  const key = `${provider}/${meta.id}`;
869
- if (seen.has(key)) continue;
870
- seen.add(key);
871
889
  const contextCap = caps ? providerContextCap(caps, provider) : undefined;
890
+ const existingIndex = indexByKey.get(key);
891
+ if (existingIndex !== undefined) {
892
+ const existing = out[existingIndex]!;
893
+ const publishedWindow = typeof meta.contextWindow === "number" && meta.contextWindow > 0
894
+ ? meta.contextWindow
895
+ : undefined;
896
+ const liveWindow = typeof existing.contextWindow === "number" && existing.contextWindow > 0;
897
+ if (liveWindow || publishedWindow === undefined) continue;
898
+ const seeded: CatalogModel = { ...existing, contextWindow: publishedWindow };
899
+ out[existingIndex] = providers?.[provider]
900
+ ? applyProviderConfigHints(
901
+ provider,
902
+ providers[provider],
903
+ seeded,
904
+ contextCap,
905
+ metadataModelIdCaseFoldByProvider?.get(provider),
906
+ )
907
+ : seeded;
908
+ continue;
909
+ }
910
+ indexByKey.set(key, out.length);
872
911
  const model: CatalogModel = {
873
912
  provider,
874
913
  id: meta.id,