@bitkyc08/opencodex 2.23.0-preview.20260816 → 2.24.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 (84) hide show
  1. package/gui/dist/assets/index-CFqJKF2L.js +102 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +20 -4
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +135 -12
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +61 -13
  25. package/src/codex/catalog/provider-fetch.ts +37 -7
  26. package/src/codex/catalog/sync.ts +49 -25
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +90 -78
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/lib/pinned-http.ts +7 -2
  50. package/src/lib/windows-elevation.ts +3 -3
  51. package/src/providers/quota.ts +10 -4
  52. package/src/providers/registry.ts +2 -2
  53. package/src/responses/parser.ts +42 -7
  54. package/src/responses/provider-opaque-metadata.ts +1 -1
  55. package/src/responses/thought-signature-replay.ts +261 -0
  56. package/src/router.ts +6 -1
  57. package/src/routing/compatibility/provider-slot.ts +1 -1
  58. package/src/routing/evaluator.ts +12 -2
  59. package/src/routing/health.ts +16 -5
  60. package/src/routing/history/schema.ts +1 -1
  61. package/src/routing/trace.ts +1 -1
  62. package/src/server/auth-cors.ts +96 -21
  63. package/src/server/chat-completions.ts +6 -2
  64. package/src/server/chat-native.ts +32 -6
  65. package/src/server/index.ts +5 -3
  66. package/src/server/management/agent-settings-routes.ts +26 -4
  67. package/src/server/management/config-routes.ts +79 -2
  68. package/src/server/management/context.ts +1 -1
  69. package/src/server/management/model-rows.ts +5 -0
  70. package/src/server/management/native-integration-routes.ts +4 -1
  71. package/src/server/management/provider-routes.ts +19 -0
  72. package/src/server/management/shared.ts +3 -3
  73. package/src/server/management-api.ts +13 -6
  74. package/src/server/passive-route-linker.ts +1 -1
  75. package/src/server/relay.ts +16 -0
  76. package/src/server/responses/compact.ts +10 -3
  77. package/src/server/responses/core.ts +160 -33
  78. package/src/server/responses/fetch-helpers.ts +34 -2
  79. package/src/server/responses/input-admission.ts +17 -9
  80. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  81. package/src/server/system-env.ts +4 -2
  82. package/src/service.ts +22 -7
  83. package/src/types.ts +35 -1
  84. package/gui/dist/assets/index-Ch-YtWdA.js +0 -102
@@ -99,16 +99,54 @@ export function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
99
99
  return /^(?:gpt|codex)-/.test(slug);
100
100
  }
101
101
 
102
- export const NATIVE_GPT56_CONTEXT_WINDOW = 372_000;
102
+ /**
103
+ * Advertised context for the Codex-login native GPT-5.6 family.
104
+ *
105
+ * This is an OPERATING CAP, not the hard ceiling — the same shape upstream uses. The live
106
+ * catalog reports `context_window: 272000` against a `max_context_window: 872000` for these
107
+ * slugs, and gpt-5.4 runs 272,000 against 1,000,000: the advertised window is always well
108
+ * inside what the model can take.
109
+ *
110
+ * The hard ceiling here was measured on 2026-08-17 against a real Codex-login account:
111
+ * `POST /backend-api/codex/responses` admitted 921,508 input tokens and refused 922,013 with
112
+ * `error.code: context_length_exceeded` on sol, terra and luna alike.
113
+ *
114
+ * Codex spends `context_window * effective_context_window_percent`, which defaults to 95%
115
+ * (codex-rs `openai_models.rs` / `turn_context.rs`). So this value yields a 875,900-token
116
+ * budget and leaves ~46k of headroom under the measured ceiling. An earlier release shipped
117
+ * 1,050,000 here, which spent 997,500 — past what the upstream accepts.
118
+ *
119
+ * Do NOT back-solve this from the ceiling (970,000 would land the budget at 921,500, inside
120
+ * the 1,840-token gap between the last success and the first refusal). The 95% is a safety
121
+ * margin to keep, not a discount to cancel out.
122
+ *
123
+ * Evidence: devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md
124
+ * and 014_final_922k_with_margin.md.
125
+ */
126
+ export const NATIVE_GPT56_CONTEXT_WINDOW = 922_000;
103
127
 
104
- export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
128
+ /**
129
+ * Hard ceiling: the largest input the native GPT-5.6 family actually accepts (measured).
130
+ *
131
+ * Equal to the advertised window above rather than below it, because that window is already
132
+ * capped under this ceiling. The clamp stays because routed and API-key rows carry the same
133
+ * family at a 1,050,000 window, where 90% (945,000) WOULD overshoot this limit.
134
+ */
135
+ export const NATIVE_GPT56_MAX_INPUT_TOKENS = 922_000;
136
+
137
+ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number; maxInputTokens?: number }> = {
105
138
  "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
106
139
  "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
107
140
  "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 },
108
- "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
109
- "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
110
- "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
111
- [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
141
+ "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
142
+ "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
143
+ "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
144
+ // Daybreak Blue borrows Sol's capability metadata and rides the same family contract.
145
+ // Unlike sol/terra/luna its window was NOT measured here: this account cannot reach it
146
+ // (`400 "The 'gpt-daybreak-blue-latest' model is not supported when using Codex with a
147
+ // ChatGPT account."`), so the promotion rests on a report from an account that has
148
+ // access rather than on a probe. Treat it as the weaker evidence of the four.
149
+ [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
112
150
  };
113
151
 
114
152
  const PINNED_UPSTREAM_MODELS: Map<string, RawEntry> = new Map(
@@ -132,12 +170,89 @@ const PINNED_NATIVE_CAPABILITY_ENTRIES: Map<string, RawEntry> = new Map(
132
170
  }),
133
171
  );
134
172
 
135
- export function nativeOpenAiContextWindow(slug: string, contextCap?: number): number | undefined {
173
+ /**
174
+ * The user-owned levers that narrow a native window, carried together.
175
+ *
176
+ * Both only ever lower: the authoritative window is measured against what the upstream
177
+ * accepts, so a user value above it would re-create the over-advertising this unit fixed.
178
+ *
179
+ * This travels as an ARGUMENT rather than module state on purpose. `grok/sync.ts` runs in
180
+ * the `ocx ensure` parent process, outside the server, so an injected global would never
181
+ * reach it — that failure is recorded in
182
+ * devlog/_plan/260817_native_gpt56_1m_context/006_root_cause_replan.md. Every call site
183
+ * already holds a config or a cap, so passing one more field costs nothing.
184
+ *
185
+ * A bare number is still accepted for the many call sites that only know the cap.
186
+ */
187
+ export interface NativeContextLimits {
188
+ /** `providerContextCaps.openai` */
189
+ readonly cap?: number;
190
+ /** `providers.openai.contextWindow` — a floor-wide user override. */
191
+ readonly providerWindow?: number;
192
+ /** `providers.openai.modelContextWindows` — per-model, wins over `providerWindow`. */
193
+ readonly modelWindows?: Readonly<Record<string, number>>;
194
+ }
195
+
196
+ export type NativeContextLimitsInput = NativeContextLimits | number | undefined;
197
+
198
+ function positiveInt(value: unknown): number | undefined {
199
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
200
+ }
201
+
202
+ function asLimits(input: NativeContextLimitsInput): NativeContextLimits {
203
+ if (input === undefined) return {};
204
+ return typeof input === "number" ? { cap: input } : input;
205
+ }
206
+
207
+ /** Read both levers out of a config once, for call sites that hold one. */
208
+ export function nativeContextLimits(
209
+ config: Pick<OcxConfig, "providers" | "providerContextCaps">,
210
+ ): NativeContextLimits {
211
+ const provider = config.providers?.[OPENAI_CODEX_PROVIDER_ID];
212
+ const modelWindows: Record<string, number> = {};
213
+ for (const [slug, value] of Object.entries(provider?.modelContextWindows ?? {})) {
214
+ const window = positiveInt(value);
215
+ if (window !== undefined) modelWindows[slug] = window;
216
+ }
217
+ return {
218
+ ...(positiveInt(providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) !== undefined
219
+ ? { cap: providerContextCap(config, OPENAI_CODEX_PROVIDER_ID) }
220
+ : {}),
221
+ ...(positiveInt(provider?.contextWindow) !== undefined ? { providerWindow: provider!.contextWindow } : {}),
222
+ ...(Object.keys(modelWindows).length > 0 ? { modelWindows } : {}),
223
+ };
224
+ }
225
+
226
+ /** Apply the user levers to an authoritative value. Lowering only, in a fixed order. */
227
+ function narrowToLimits(raw: number | undefined, slug: string, input: NativeContextLimitsInput): number | undefined {
228
+ if (raw === undefined) return undefined;
229
+ const limits = asLimits(input);
230
+ const overlay = positiveInt(limits.modelWindows?.[slug]) ?? positiveInt(limits.providerWindow);
231
+ const narrowed = overlay === undefined ? raw : Math.min(raw, overlay);
232
+ return applyProviderContextCap(narrowed, limits.cap) ?? narrowed;
233
+ }
234
+
235
+ export function nativeOpenAiContextWindow(slug: string, limits?: NativeContextLimitsInput): number | undefined {
136
236
  const raw = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow
137
237
  ?? (typeof PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)?.context_window === "number"
138
238
  ? PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)!.context_window as number
139
239
  : undefined);
140
- return applyProviderContextCap(raw, contextCap) ?? raw;
240
+ return narrowToLimits(raw, slug, limits);
241
+ }
242
+
243
+ /**
244
+ * Largest input a native slug accepts, or undefined when no separate limit is known
245
+ * (the caller then falls back to the context window).
246
+ *
247
+ * A provider context cap lowers this too: a capped 272k window must not keep advertising a
248
+ * 922k input ceiling, or the cap would be cosmetic on every input-side surface.
249
+ */
250
+ export function nativeOpenAiMaxInputTokens(slug: string, limits?: NativeContextLimitsInput): number | undefined {
251
+ const raw = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.maxInputTokens;
252
+ if (raw === undefined) return undefined;
253
+ const window = nativeOpenAiContextWindow(slug, limits);
254
+ const narrowed = narrowToLimits(raw, slug, limits) ?? raw;
255
+ return window === undefined ? narrowed : Math.min(narrowed, window);
141
256
  }
142
257
 
143
258
  export function nativeInputModalities(slug: string): string[] {
@@ -250,13 +365,21 @@ export function desktopVisibleNativeSlugs(
250
365
  ]);
251
366
  }
252
367
 
253
- export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
368
+ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps" | "providers">): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number }> {
254
369
  const disabled = disabledNativeSlugs(config);
255
370
  const shadowed = configuredNativeAliasSlugs(config);
256
- const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID);
371
+ // Both user levers, not just the cap: a per-model window set from the dashboard has to show
372
+ // up on the row the dashboard itself renders.
373
+ const limits = nativeContextLimits(config);
257
374
  return NATIVE_OPENAI_MODELS.filter(slug => !shadowed.has(slug)).map(slug => {
258
- const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap);
259
- return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) };
375
+ const contextWindow = nativeOpenAiContextWindow(slug, limits);
376
+ const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits);
377
+ return {
378
+ slug,
379
+ disabled: disabled.has(slug),
380
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
381
+ ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
382
+ };
260
383
  });
261
384
  }
262
385
 
@@ -12,7 +12,19 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
12
12
  [NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol",
13
13
  });
14
14
 
15
- /** Account-scoped native ids that may inherit metadata but never enter the bare allowlist. */
15
+ /**
16
+ * Native ids whose capability metadata is inherited from another pinned native row.
17
+ *
18
+ * Membership here is about METADATA INHERITANCE only, and is independent of whether the
19
+ * slug is also globally allowlisted in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest`
20
+ * is now in BOTH: it inherits Sol's capability shape AND ships as a globally supported
21
+ * native row (owner decision, devlog 260816_codexrs_multiagent_v2_and_history_perf/011).
22
+ *
23
+ * The maps that consume the union of these two lists (`PINNED_NATIVE_CAPABILITY_ENTRIES`,
24
+ * `UPSTREAM_NATIVE_ENTRIES`) are keyed by slug, so an overlapping id collapses to one
25
+ * entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS` alone, so it still emits
26
+ * exactly one bare row and one row per account selector.
27
+ */
16
28
  export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze(
17
29
  Object.keys(NATIVE_OPENAI_CAPABILITY_SOURCES),
18
30
  );
@@ -25,10 +37,28 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string {
25
37
  return NATIVE_OPENAI_CAPABILITY_SOURCES[slug] ?? slug;
26
38
  }
27
39
 
28
- /** Native OpenAI model ids that this release can route and restore with authoritative metadata. */
40
+ /**
41
+ * Native OpenAI model ids that this release can route and restore with authoritative metadata.
42
+ *
43
+ * `gpt-daybreak-blue-latest` is entitlement-gated upstream: it is absent from codex-rs's
44
+ * bundled catalog and reaches a client only through an authenticated `/models` response.
45
+ * It is listed here by explicit owner decision so the row exists without waiting for an
46
+ * observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds
47
+ * a `StaticModelsManager` whose refresh is a no-op — an entitled account had no way to
48
+ * discover it on a clean install.
49
+ *
50
+ * Accepted tradeoff: an UNENTITLED account also sees the row. Catalog sync still succeeds;
51
+ * selecting the model reaches the canonical OpenAI provider and the backend answers 400
52
+ * "model not supported for this account", which is relayed (a bare pooled route may first
53
+ * retry one alternate account on that exact body; a selector-qualified route is fixed and
54
+ * relays immediately). `disabledModels` hides the row but is NOT a runtime routing denial.
55
+ *
56
+ * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
57
+ */
29
58
  export const NATIVE_OPENAI_MODELS = [
30
59
  "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
31
60
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
61
+ NATIVE_DAYBREAK_BLUE_MODEL,
32
62
  ];
33
63
 
34
64
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
@@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
 
33
33
 
34
- import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, isNativeOpenAiCapabilityAliasModel, nativeMultiAgentVersion } from "./metadata";
34
+ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, isNativeOpenAiCapabilityAliasModel, nativeMultiAgentVersion, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata";
35
35
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
36
36
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
37
37
 
@@ -266,32 +266,81 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean {
266
266
  return typeof entry.slug === "string" && !entry.slug.includes("/");
267
267
  }
268
268
 
269
- export function applyNativeOpenAiContextOverride(entry: RawEntry, contextCap?: number): void {
269
+ /**
270
+ * Auto-compaction threshold for a native row.
271
+ *
272
+ * The usual rule is 90% of the window, but a row whose input ceiling sits below that has to
273
+ * clamp to the ceiling instead — otherwise the client keeps filling until upstream answers
274
+ * `context_length_exceeded` and compaction never gets a chance to run. Native GPT-5.6 no
275
+ * longer trips this (922,000 window, 829,800 at 90%), but the routed and API-key rows carry
276
+ * the same family at a 1,050,000 window where 90% would be 945,000 — past the ceiling.
277
+ */
278
+ function nativeAutoCompactLimit(contextWindow: number, maxInputTokens: number | undefined, contextCap?: number): number {
279
+ const ninety = Math.floor(contextWindow * 0.9);
280
+ if (typeof maxInputTokens !== "number" || maxInputTokens <= 0) return ninety;
281
+ const cappedMaxInput = applyProviderContextCap(maxInputTokens, contextCap) ?? maxInputTokens;
282
+ return Math.min(ninety, cappedMaxInput, contextWindow);
283
+ }
284
+
285
+ /**
286
+ * Narrow any already-resolved native window by the user levers.
287
+ *
288
+ * Used for the fields the accessors do not own (`max_context_window`, and preserved rows
289
+ * that carry no static override) so every field on a row lands at the same width.
290
+ */
291
+ function narrowNativeMaxContextWindow(
292
+ slug: string,
293
+ value: number | undefined,
294
+ limits?: NativeContextLimitsInput,
295
+ ): number | undefined {
296
+ if (typeof value !== "number" || value <= 0) return value;
297
+ const resolved = nativeOpenAiContextWindow(slug, limits);
298
+ const authoritative = nativeOpenAiContextWindow(slug);
299
+ // The accessor pair tells us how far the levers moved this slug; apply the same delta to a
300
+ // field the accessor does not model, without ever raising it.
301
+ if (resolved === undefined || authoritative === undefined) return value;
302
+ return Math.min(value, Math.max(resolved, 1));
303
+ }
304
+
305
+ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: NativeContextLimitsInput): void {
270
306
  const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry)
271
307
  ?? (isNativeOpenAiEntry(entry) ? entry.slug as string : undefined);
272
308
  if (!nativeSlug) return;
273
309
  const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[nativeSlug];
274
310
  if (override) {
311
+ // Read the effective values through the accessors rather than re-deriving them from the
312
+ // static table: this function used to apply only the provider cap, so a per-model window
313
+ // the dashboard had already accepted was silently written back at full width here.
275
314
  if (typeof override.contextWindow === "number") {
276
- const contextWindow = applyProviderContextCap(override.contextWindow, contextCap) ?? override.contextWindow;
315
+ const contextWindow = nativeOpenAiContextWindow(nativeSlug, limits) ?? override.contextWindow;
277
316
  entry.context_window = contextWindow;
278
- entry.auto_compact_token_limit = Math.floor(contextWindow * 0.9);
317
+ entry.auto_compact_token_limit = nativeAutoCompactLimit(
318
+ contextWindow,
319
+ nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override.maxInputTokens,
320
+ undefined,
321
+ );
279
322
  }
280
323
  if (typeof override.maxContextWindow === "number") {
281
- entry.max_context_window = applyProviderContextCap(override.maxContextWindow, contextCap) ?? override.maxContextWindow;
324
+ const maxContextWindow = narrowNativeMaxContextWindow(nativeSlug, override.maxContextWindow, limits);
325
+ entry.max_context_window = maxContextWindow;
282
326
  }
283
327
  }
284
328
  // providerContextCaps.openai is a ceiling for native OpenAI rows regardless of where the
285
329
  // advertised window came from (#1430): preserved rows without a hardcoded override (e.g.
286
330
  // gpt-5.4-mini) must stay under the cap too, and auto-compaction follows the capped window.
331
+ // The per-model window narrows the same rows for the same reason.
287
332
  const currentContext = typeof entry.context_window === "number" ? entry.context_window : undefined;
288
- const cappedContext = applyProviderContextCap(currentContext, contextCap);
333
+ const cappedContext = narrowNativeMaxContextWindow(nativeSlug, currentContext, limits);
289
334
  if (cappedContext !== currentContext && typeof cappedContext === "number") {
290
335
  entry.context_window = cappedContext;
291
- entry.auto_compact_token_limit = Math.floor(cappedContext * 0.9);
336
+ entry.auto_compact_token_limit = nativeAutoCompactLimit(
337
+ cappedContext,
338
+ nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens,
339
+ undefined,
340
+ );
292
341
  }
293
342
  const currentMax = typeof entry.max_context_window === "number" ? entry.max_context_window : undefined;
294
- const cappedMax = applyProviderContextCap(currentMax, contextCap);
343
+ const cappedMax = narrowNativeMaxContextWindow(nativeSlug, currentMax, limits);
295
344
  if (cappedMax !== currentMax) {
296
345
  entry.max_context_window = cappedMax;
297
346
  }
@@ -462,16 +511,15 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
462
511
  // tool_search round-trip (upstream codex-rs code_mode suite; live canary 2026-08-13: routed
463
512
  // kimi/k3 called tools.mcp__node_repl__js → isError:false). Stamping false here instead forces
464
513
  // every MCP declaration into exec.description — a measured 2.7x turn-1 payload regression
465
- // (96,699 → 258,929 chars; devlog/_plan/260813_tool_catalog_deferral/010). So non-Cursor routed
466
- // rows advertise deferred discovery; the #1522 reachability concern is covered by the code-mode
467
- // path, not by paying the full-catalog tax. Cursor stays false: its runTurn transport bypasses
468
- // the web-search sidecar and has no proven deferred path.
514
+ // (96,699 → 258,929 chars; devlog/_plan/260813_tool_catalog_deferral/010). So every routed
515
+ // code-mode row advertises deferred discovery. Cursor still omits hosted web-search metadata below,
516
+ // but disabling this separate exposure bit can inflate `exec` past Cursor's 120 KB wire cap (#1830).
469
517
  if (isCursorEntry) {
470
518
  delete entry.web_search_tool_type;
471
519
  } else {
472
520
  entry.web_search_tool_type = "text_and_image";
473
521
  }
474
- entry.supports_search_tool = !isCursorEntry;
522
+ entry.supports_search_tool = true;
475
523
  // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
476
524
  // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
477
525
  // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
@@ -74,7 +74,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr
74
74
 
75
75
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
76
76
  import type { CatalogModel } from "./parsing";
77
- import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
77
+ import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
78
78
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
79
79
  import type { ComboCatalogOmission } from "./aggregation";
80
80
  import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
@@ -703,6 +703,8 @@ const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000;
703
703
 
704
704
  interface ComboCatalogMemberFallback {
705
705
  readonly contextWindow?: number;
706
+ /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */
707
+ readonly maxInputTokens?: number;
706
708
  readonly inputModalities?: readonly string[];
707
709
  readonly reasoningEfforts?: readonly string[];
708
710
  }
@@ -744,7 +746,11 @@ export function resolveComboCatalogMember(
744
746
  if (!addMaxInput && !addModalities && !addReasoning) return member;
745
747
  return {
746
748
  ...member,
747
- ...(addMaxInput ? { maxInputTokens: contextWindow } : {}),
749
+ // Never claim a larger input budget than the window, and prefer the model's own
750
+ // measured ceiling when the fallback carries one.
751
+ ...(addMaxInput
752
+ ? { maxInputTokens: Math.min(fallback.maxInputTokens ?? contextWindow!, contextWindow!) }
753
+ : {}),
748
754
  ...(addModalities ? { inputModalities: [...fallback.inputModalities!] } : {}),
749
755
  ...(addReasoning ? { reasoningEfforts: [...fallback.reasoningEfforts!] } : {}),
750
756
  };
@@ -765,7 +771,7 @@ export function resolveComboCatalogMember(
765
771
  }
766
772
  const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0
767
773
  ? Math.min(existing.maxInputTokens, capped)
768
- : capped;
774
+ : Math.min(fallback?.maxInputTokens ?? capped, capped);
769
775
  return withFallbackMetadata({
770
776
  ...existing,
771
777
  contextWindow: capped,
@@ -790,6 +796,10 @@ export function resolveComboCatalogMember(
790
796
  : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0
791
797
  ? base.maxInputTokens
792
798
  : undefined);
799
+ // Kept OUT of knownMaxInput on purpose: that value doubles as a context-window fallback
800
+ // below, and a native alias whose input ceiling (922k) is lower than its window (1.05M)
801
+ // would otherwise shrink the advertised window to the input limit.
802
+ const fallbackMaxInput = existing || prov ? fallback?.maxInputTokens : undefined;
793
803
  // Real discovery/config values win. A native alias is the next fallback tier.
794
804
  // The generic 128k/text synthesis from #1305 remains the final fallback.
795
805
  const fallbackContext = existing || prov ? fallback?.contextWindow : undefined;
@@ -814,8 +824,11 @@ export function resolveComboCatalogMember(
814
824
  ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined)
815
825
  ?? base.reasoningEfforts
816
826
  ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined);
817
- const maxInputTokens = knownMaxInput !== undefined
818
- ? Math.min(knownMaxInput, contextWindow)
827
+ // The model's own measured input ceiling still applies when discovery gave us nothing:
828
+ // GPT-5.6 advertises a 1.05M window but refuses input past 922k.
829
+ const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput;
830
+ const maxInputTokens = effectiveMaxInput !== undefined
831
+ ? Math.min(effectiveMaxInput, contextWindow)
819
832
  : contextWindow;
820
833
 
821
834
  return {
@@ -1712,7 +1725,11 @@ async function gatherRoutedModelsUncached(
1712
1725
  id: slug,
1713
1726
  owned_by: "openai",
1714
1727
  contextWindow,
1715
- maxInputTokens: contextWindow,
1728
+ // Input limit, not the total window. These coincide for native GPT-5.6 today (the
1729
+ // advertised 922,000 window is already capped at its measured ceiling), but the two
1730
+ // stay separate fields because routed/API rows of the same family run a wider window.
1731
+ // Falls back to the window for slugs with no separate ceiling.
1732
+ maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow),
1716
1733
  inputModalities: nativeInputModalities(slug),
1717
1734
  reasoningEfforts: nativeReasoningEfforts(slug),
1718
1735
  ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
@@ -1730,11 +1747,15 @@ async function gatherRoutedModelsUncached(
1730
1747
  const combo = getCombo(config, id);
1731
1748
  if (!combo) continue;
1732
1749
  const nativeContextWindow = combo.nativeAlias && combo.alias
1733
- ? nativeOpenAiContextWindow(combo.alias)
1750
+ ? nativeOpenAiContextWindow(combo.alias, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))
1751
+ : undefined;
1752
+ const nativeAliasMaxInput = combo.nativeAlias && combo.alias
1753
+ ? nativeOpenAiMaxInputTokens(combo.alias, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))
1734
1754
  : undefined;
1735
1755
  const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined
1736
1756
  ? {
1737
1757
  contextWindow: nativeContextWindow,
1758
+ ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: Math.min(nativeAliasMaxInput, nativeContextWindow) } : {}),
1738
1759
  inputModalities: nativeInputModalities(combo.alias),
1739
1760
  reasoningEfforts: nativeReasoningEfforts(combo.alias),
1740
1761
  }
@@ -1788,6 +1809,14 @@ async function gatherRoutedModelsUncached(
1788
1809
  ? Math.min(cm.contextWindow, nativeAliasContextWindow)
1789
1810
  : cm.contextWindow
1790
1811
  : nativeAliasContextWindow;
1812
+ // Input ceiling for a native capability alias, clamped to whatever window we settled on
1813
+ // above. A custom row that lowered the window must not keep the full native input budget.
1814
+ const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias
1815
+ ? nativeOpenAiMaxInputTokens(cm.modelId, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))
1816
+ : undefined;
1817
+ const customMaxInputTokens = nativeAliasMaxInputTokens !== undefined && customContextWindow !== undefined
1818
+ ? Math.min(nativeAliasMaxInputTokens, customContextWindow)
1819
+ : nativeAliasMaxInputTokens;
1791
1820
  const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias
1792
1821
  ? nativeDefaultReasoningEffort(cm.modelId)
1793
1822
  : undefined;
@@ -1804,6 +1833,7 @@ async function gatherRoutedModelsUncached(
1804
1833
  ? { displayName: cm.displayName }
1805
1834
  : codexForwardNativeCapabilityAlias ? { displayName: "Daybreak Blue" } : {}),
1806
1835
  ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}),
1836
+ ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}),
1807
1837
  ...(cm.inputModalities
1808
1838
  ? { inputModalities: cm.inputModalities }
1809
1839
  : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}),
@@ -35,7 +35,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../accou
35
35
 
36
36
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
37
37
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
38
- import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
38
+ import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
39
39
  import {
40
40
  bundledCatalogCacheState,
41
41
  loadBundledCodexCatalog,
@@ -90,21 +90,36 @@ export type SubagentRosterExclusionReason =
90
90
  /**
91
91
  * Whether a catalog entry may be offered as a V2 subagent model.
92
92
  *
93
- * Upstream (codex-rs 92938d880) requires `multi_agent_version === "v2"` exactly,
94
- * because upstream assumes a single backend serves every model. opencodex routes
95
- * many providers, so that equality would reject the cross-provider spawns this
96
- * proxy exists to enable.
93
+ * Upstream changed this rule in codex-rs `6d4d9442c` ("Support leaf models in
94
+ * multi-agent v2"). `model_supports_multi_agent_backend`
95
+ * (core/src/tools/handlers/multi_agents_common.rs:36-42) now admits EVERY model
96
+ * except one explicitly marked `disabled`; the older `== Some(V2)` equality that
97
+ * `92938d880` introduced is gone.
97
98
  *
98
- * Decision (option B, devlog 260730_codex_rs_upstream_v2_live_handoff/060): any
99
- * model opencodex actually routes is eligible. An entry pinned to a DIFFERENT
100
- * multi-agent backend (`v1`) stays excluded, because that pin is a real capability
101
- * statement rather than an absence of information. An unpinned entry (null or
102
- * absent) is a routed or unpinned-native model and is allowed. The three-way
103
- * distinction is the substance; do not flatten it into a truthiness check.
99
+ * The field no longer answers "may I be a delegation target". It answers "does the
100
+ * CHILD get collaboration tools": `collab_tools_enabled`
101
+ * (core/src/tools/spec_plan.rs:599-610) grants a child recursive tools only when its
102
+ * own catalog value is exactly `Some(V2)`. The three-way distinction survives, but it
103
+ * now means eligible-recursive / eligible-LEAF / excluded:
104
+ *
105
+ * - `"v2"` -> eligible, and the child may itself delegate.
106
+ * - `"v1"` -> eligible LEAF worker. This is upstream's pin for `gpt-5.6-luna`
107
+ * (models-manager/models.json); excluding it here is exactly what
108
+ * kept Luna out of opencodex's roster.
109
+ * - absent/null -> eligible LEAF worker (routed or unpinned-native model).
110
+ * - `"disabled"` -> the sole capability-based exclusion.
111
+ *
112
+ * This is the roster filter only. Catalog STAMPING is a separate concern owned by
113
+ * `applyMultiAgentMode`, including the `keepNativeChatGptOnV1` policy (#1728) that
114
+ * keeps ChatGPT-native rows on `v1` so a native parent can still spawn a routed child
115
+ * despite backend-encrypted NEW_TASK bodies (#92). Recognizing those `v1` rows as
116
+ * eligible leaves here is what makes that policy usable, not a contradiction of it.
117
+ *
118
+ * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 (C1), superseding the
119
+ * option-B decision in 260730_codex_rs_upstream_v2_live_handoff/060.
104
120
  */
105
121
  export function isEligibleV2SubagentEntry(entry: RawEntry): boolean {
106
- const pinned = entry.multi_agent_version;
107
- return pinned === "v2" || pinned === null || pinned === undefined;
122
+ return entry.multi_agent_version !== "disabled";
108
123
  }
109
124
 
110
125
  export interface EffectiveSubagentModel {
@@ -211,7 +226,7 @@ export function effectiveSubagentRoster(
211
226
  return { candidates, advertised, excluded };
212
227
  }
213
228
 
214
- export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: number): RawEntry {
229
+ export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry {
215
230
  if (priority !== 9) clone.priority = priority;
216
231
  applyNativeOpenAiContextOverride(clone, contextCap);
217
232
  // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra).
@@ -264,7 +279,7 @@ export function deriveEntry(
264
279
  priority: number,
265
280
  model?: CatalogModel,
266
281
  exactComboSlugs: ReadonlySet<string> = new Set(),
267
- contextCap?: number,
282
+ contextCap?: NativeContextLimitsInput,
268
283
  ): RawEntry {
269
284
  const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
270
285
  const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true
@@ -343,10 +358,9 @@ export function deriveEntry(
343
358
  });
344
359
  }
345
360
  // Fallback when no template is available (best-effort; strict parser may need more).
346
- // Cursor fallback rows mirror normalizeRoutedCatalogEntry: no deferred discovery, no hosted
347
- // web-search metadata (runTurn transport bypasses the sidecar). Non-Cursor routed fallbacks
348
- // advertise deferred discovery code mode keeps deferred MCP callable (devlog
349
- // 260813_tool_catalog_deferral/010+020); search=false costs a measured 2.7x turn-1 payload.
361
+ // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog
362
+ // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
363
+ // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar.
350
364
  const isCursorFallback = isRouted && model?.provider === "cursor";
351
365
  const entry: RawEntry = {
352
366
  slug, display_name: routedDisplayName(slug), description: desc,
@@ -354,7 +368,7 @@ export function deriveEntry(
354
368
  priority, base_instructions: "You are a helpful coding assistant.",
355
369
  ...(isRouted
356
370
  ? isCursorFallback
357
- ? { supports_search_tool: false }
371
+ ? { supports_search_tool: true }
358
372
  : { web_search_tool_type: "text_and_image", supports_search_tool: true }
359
373
  : {}),
360
374
  };
@@ -391,7 +405,7 @@ export interface ObservedCatalogEntryBuildInput {
391
405
  readonly disabledNativeAccountSlugs: ReadonlySet<string>;
392
406
  readonly multiAgentV2Enabled: boolean;
393
407
  readonly keepNativeChatGptOnV1?: boolean;
394
- readonly openaiContextCap?: number;
408
+ readonly openaiContextCap?: NativeContextLimitsInput;
395
409
  /** Additional native ids to clone under account selectors, without creating bare rows. */
396
410
  readonly accountNativeSlugs?: readonly string[];
397
411
  /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */
@@ -410,7 +424,7 @@ export function buildCatalogEntries(
410
424
  accountSelectors: readonly string[] = [],
411
425
  suppressedBareNativeSlugs: ReadonlySet<string> = new Set(),
412
426
  disabledNativeAccountSlugs: ReadonlySet<string> = new Set(),
413
- contextCap?: number,
427
+ contextCap?: NativeContextLimitsInput,
414
428
  accountNativeSlugs?: readonly string[],
415
429
  accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>,
416
430
  keepNativeChatGptOnV1 = false,
@@ -746,7 +760,7 @@ export interface ObservedCatalogMergeInput {
746
760
  readonly accountBoundEntries: readonly RawEntry[];
747
761
  readonly suppressedBareNativeSlugs?: ReadonlySet<string>;
748
762
  readonly policy: ObservedCatalogMergePolicy;
749
- readonly openaiContextCap?: number;
763
+ readonly openaiContextCap?: NativeContextLimitsInput;
750
764
  }
751
765
 
752
766
  /**
@@ -1410,7 +1424,9 @@ function writeRetainedCatalogSync({
1410
1424
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
1411
1425
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
1412
1426
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
1413
- const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID);
1427
+ // Both user levers. Passing only the cap here is what let a per-model window the dashboard
1428
+ // had accepted get written back at full width in the on-disk catalog.
1429
+ const openaiContextCap = nativeContextLimits(config);
1414
1430
  const accountSelectors = includeAccountBoundNativeOpenAi
1415
1431
  ? visibleCodexAccountSelectors(config)
1416
1432
  : [];
@@ -1672,8 +1688,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
1672
1688
  export function restoreCodexCatalogWithPermit(
1673
1689
  permit: CatalogWritePermit,
1674
1690
  owningCodexHome: string,
1691
+ /**
1692
+ * The catalog this injection actually wrote, when it is known (#1798).
1693
+ *
1694
+ * Re-resolving from the CURRENT config is wrong after a Codex app rewrite that dropped
1695
+ * `model_catalog_json`: that sends restore to the default catalog while the routed file we
1696
+ * really wrote is left untouched. The recorded path is the file whose routing is ours.
1697
+ */
1698
+ injectedCatalogPath?: string | null,
1675
1699
  ): { removed: number; kept: number; path: string } {
1676
- const catalogPath = readCodexCatalogPath();
1700
+ const catalogPath = injectedCatalogPath ?? readCodexCatalogPath();
1677
1701
  const catalog = readCatalog(catalogPath);
1678
1702
  if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
1679
1703
  const disabledModels = currentDisabledModelsForRestore();