@bitkyc08/opencodex 2.32.1 → 2.33.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 (42) hide show
  1. package/gui/dist/assets/{index-8HxacKlZ.js → index-23-Lf7jR.js} +14 -14
  2. package/gui/dist/assets/index-DxJMDyOr.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic.ts +18 -4
  6. package/src/adapters/kiro-tools.ts +20 -9
  7. package/src/bridge.ts +12 -8
  8. package/src/claude/context-windows.ts +16 -9
  9. package/src/cli/doctor.ts +2 -2
  10. package/src/cli/index.ts +9 -2
  11. package/src/cli/status.ts +23 -0
  12. package/src/codex/auth-api.ts +4 -2
  13. package/src/codex/autostart-health.ts +16 -0
  14. package/src/codex/catalog/aggregation.ts +12 -0
  15. package/src/codex/catalog/effort.ts +18 -3
  16. package/src/codex/catalog/metadata.ts +27 -1
  17. package/src/codex/catalog/parsing.ts +38 -27
  18. package/src/codex/catalog/provider-fetch.ts +143 -25
  19. package/src/codex/catalog/sync.ts +1 -1
  20. package/src/codex/convergence.ts +5 -0
  21. package/src/codex/shim.ts +56 -3
  22. package/src/config.ts +24 -0
  23. package/src/generated/compatibility-version.json +43 -35
  24. package/src/oauth/callback-server.ts +22 -2
  25. package/src/oauth/kimi.ts +9 -1
  26. package/src/oauth/open-browser-choice.ts +26 -0
  27. package/src/providers/auto-compact-budget.ts +65 -0
  28. package/src/providers/xai-transport.ts +21 -0
  29. package/src/server/auth-cors.ts +14 -0
  30. package/src/server/management/agent-settings-routes.ts +13 -7
  31. package/src/server/management/config-routes.ts +31 -5
  32. package/src/server/management/model-rows.ts +4 -0
  33. package/src/server/management/oauth-account-routes.ts +10 -4
  34. package/src/server/management/provider-routes.ts +75 -11
  35. package/src/server/responses/core.ts +12 -1
  36. package/src/server/responses/empty-completion-guard.ts +28 -6
  37. package/src/server/responses-undeclared-tool-guard.ts +90 -8
  38. package/src/types/config.ts +13 -0
  39. package/src/types/provider.ts +5 -0
  40. package/src/types/tools.ts +27 -0
  41. package/src/types.ts +1 -0
  42. package/gui/dist/assets/index-DcBbHIAz.css +0 -1
package/src/bridge.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  awaitThoughtSignatureDurability,
20
20
  } from "./responses/thought-signature-replay";
21
21
  import { resolveStallTimeoutSec } from "./stall-timeout";
22
+ import { normalizeDeclaredToolName } from "./types";
22
23
  import { usageDisplayTotalTokens } from "./usage/totals";
23
24
  import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources";
24
25
  import {
@@ -1041,13 +1042,14 @@ export function bridgeToResponsesSSE(
1041
1042
  rememberReasoningForCall(event.id, rawReasoningForNextToolCall, replayCacheScope);
1042
1043
  }
1043
1044
  if (currentToolCall) closeCurrentToolCall();
1044
- const mapped = toolNsMap?.get(event.name);
1045
- const realName = mapped?.name ?? event.name;
1046
- if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) {
1045
+ const effectiveName = normalizeDeclaredToolName(event.name, options?.declaredToolNames);
1046
+ const mapped = toolNsMap?.get(effectiveName);
1047
+ const realName = mapped?.name ?? effectiveName;
1048
+ if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) {
1047
1049
  const failure = responseError(
1048
1050
  502,
1049
1051
  "upstream_error",
1050
- `routed provider emitted undeclared client tool "${event.name}"; only request-declared tools may be called`,
1052
+ `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`,
1051
1053
  );
1052
1054
  emit("response.failed", {
1053
1055
  response: {
@@ -1783,7 +1785,7 @@ function buildResponseJSONWithBudget(
1783
1785
  ));
1784
1786
  }
1785
1787
  break;
1786
- case "tool_call_start":
1788
+ case "tool_call_start": {
1787
1789
  if (currentText) flushText("commentary");
1788
1790
  if (currentSummaryReasoning) flushSummaryReasoning();
1789
1791
  if (currentRawReasoning) flushRawReasoning();
@@ -1791,10 +1793,11 @@ function buildResponseJSONWithBudget(
1791
1793
  rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
1792
1794
  }
1793
1795
  flushToolCall();
1794
- if (options?.declaredToolNames && !options.declaredToolNames.has(e.name)) {
1796
+ const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames);
1797
+ if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) {
1795
1798
  errorEvent = {
1796
1799
  type: "error",
1797
- message: `routed provider emitted undeclared client tool "${e.name}"; only request-declared tools may be called`,
1800
+ message: `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`,
1798
1801
  status: 502,
1799
1802
  errorType: "upstream_error",
1800
1803
  };
@@ -1802,11 +1805,12 @@ function buildResponseJSONWithBudget(
1802
1805
  }
1803
1806
  currentToolCallId = e.id;
1804
1807
  budget?.openCall(e.id);
1805
- currentToolCallName = e.name;
1808
+ currentToolCallName = effectiveName;
1806
1809
  currentToolCallArgs = "";
1807
1810
  currentToolCallArgsBytes = 0;
1808
1811
  currentToolCallProviderMetadata = e.providerMetadata;
1809
1812
  break;
1813
+ }
1810
1814
  case "tool_call_delta":
1811
1815
  {
1812
1816
  ({ value: currentToolCallArgs, bytes: currentToolCallArgsBytes } = appendBatchString(
@@ -118,17 +118,24 @@ export function buildClaudeContextWindows(
118
118
  put(desktop3pAlias("native", slug), window);
119
119
  put(aliasForNative(slug), window);
120
120
  }
121
+ // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
122
+ // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
123
+ // a model that cannot host it. Register anthropic rows only at >=1M.
124
+ const registrable = routedModels.filter(
125
+ m =>
126
+ typeof m.contextWindow === "number" &&
127
+ m.contextWindow > 0 &&
128
+ !(m.provider === "anthropic" && m.contextWindow < ONE_MILLION),
129
+ );
121
130
  // Bare routed ids are registered only when unambiguous across providers (audit
122
- // 021 #5) — natives are registered first, so a native slug always wins the bare key.
131
+ // 021 #5) — natives are registered first, so a native slug always wins the bare
132
+ // key. Counted over the rows that can actually claim the key: a row this loop
133
+ // skips contributes no window, so letting it veto the bare key withholds an
134
+ // answer that was never in doubt.
123
135
  const bareCounts = new Map<string, number>();
124
- for (const m of routedModels) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
125
- for (const m of routedModels) {
126
- const window = m.contextWindow;
127
- if (typeof window !== "number" || window <= 0) continue;
128
- // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
129
- // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
130
- // a model that cannot host it. Register anthropic rows only at >=1M.
131
- if (m.provider === "anthropic" && window < ONE_MILLION) continue;
136
+ for (const m of registrable) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
137
+ for (const m of registrable) {
138
+ const window = m.contextWindow as number;
132
139
  put(`${m.provider}/${m.id}`, window);
133
140
  put(desktop3pAlias(m.provider, m.id), window);
134
141
  put(aliasForRoute(m.provider, m.id), window);
package/src/cli/doctor.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  resolveEffectiveUserIdentity,
43
43
  } from "../codex/user-identity";
44
44
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
45
- import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health";
45
+ import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
46
46
  import {
47
47
  displayCodexRuntimePath,
48
48
  loadLastEffortClamp,
@@ -983,7 +983,7 @@ export async function runDoctor(args: string[] = []): Promise<void> {
983
983
  const startup = collectStartupHealth(doctorConfig);
984
984
  console.log("\nCodex restart safety");
985
985
  console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
986
- console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`);
986
+ console.log(` ${formatStartupRoutingDetail(startup)}`);
987
987
 
988
988
  console.log("\nCodex runtime selection");
989
989
  {
package/src/cli/index.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  writePid,
26
26
  writeRuntimePort,
27
27
  } from "../config/process-state";
28
- import { collectStatus } from "./status";
28
+ import { collectStatus, unusedProxyWarningLines } from "./status";
29
29
 
30
30
  import {
31
31
  discoverStableProxyForRestart,
@@ -46,7 +46,7 @@ import { runCli } from "./root";
46
46
  import { ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
47
47
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
48
48
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
49
- import { startupHealthSummary } from "../codex/autostart-health";
49
+ import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
50
50
  import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
51
51
  import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
52
52
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
@@ -848,6 +848,12 @@ async function handleStatus() {
848
848
  console.log(`❌ Proxy: ${status.proxyLabel}`);
849
849
  }
850
850
  console.log(` Health: ${status.healthLabel}`);
851
+ for (const line of unusedProxyWarningLines({
852
+ proxyUp: Boolean(status.json.proxy.pid || status.json.proxy.health.ok),
853
+ routingKind: status.json.startup.routingKind,
854
+ })) {
855
+ console.log(` ${line}`);
856
+ }
851
857
  if (!(status.json.proxy.pid || status.json.proxy.health.ok)) {
852
858
  console.log(" ↳ Not running — Codex/Claude requests will fail with connection errors.");
853
859
  // The service summary a few lines below already tells a registered-but-not-serving
@@ -867,6 +873,7 @@ async function handleStatus() {
867
873
  console.log(` Default provider: ${status.json.defaultProvider}`);
868
874
  console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
869
875
  console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
876
+ console.log(` ${formatStartupRoutingDetail(status.json.startup)}`);
870
877
  console.log(` Service: ${status.json.service.summary}`);
871
878
  console.log(` ${status.json.codexShim.summary}`);
872
879
  console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
package/src/cli/status.ts CHANGED
@@ -118,6 +118,29 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "
118
118
  : "unreachable";
119
119
  }
120
120
 
121
+ /**
122
+ * `ocx status` greens on process liveness alone, so a proxy that answers
123
+ * /healthz reads healthy even when Codex is not pointed at it and every routed
124
+ * request goes to OpenAI instead (#2411). The proxy line is not wrong — the
125
+ * listener really is up — so it keeps its check, and this supplies the signal
126
+ * that was missing rather than corrupting the one that was already honest.
127
+ *
128
+ * Only `native` warns. `custom-local` and `unknown` are also "this proxy is
129
+ * unused", but startupHealthSummary already renders both as AT RISK with a
130
+ * remedy command, and `custom-remote` is a deliberate operator choice. Warning
131
+ * on all four would teach operators to skip the line that matters.
132
+ */
133
+ export function unusedProxyWarningLines(input: {
134
+ proxyUp: boolean;
135
+ routingKind: StartupHealth["routingKind"];
136
+ }): string[] {
137
+ if (!input.proxyUp || input.routingKind !== "native") return [];
138
+ return [
139
+ "⚠️ Codex routing is native — the running proxy is unused.",
140
+ " Codex requests go to OpenAI, not this proxy. Re-point with: ocx start",
141
+ ];
142
+ }
143
+
121
144
  async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
122
145
  const url = target.healthUrl;
123
146
  const controller = new AbortController();
@@ -1806,7 +1806,7 @@ export async function handleCodexAuthAPI(
1806
1806
  }
1807
1807
 
1808
1808
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
1809
- const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
1809
+ const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean; openBrowser?: unknown };
1810
1810
  const requestedAccountId = body.id?.trim();
1811
1811
  const reauth = body.reauth === true;
1812
1812
  if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
@@ -1840,7 +1840,9 @@ export async function handleCodexAuthAPI(
1840
1840
 
1841
1841
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
1842
1842
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
1843
- if (result.url) {
1843
+ // Both login routes share one resolver so this surface cannot drift from the other.
1844
+ const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
1845
+ if (result.url && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
1844
1846
  const { openUrl } = await import("../lib/open-url");
1845
1847
  openUrl(result.url);
1846
1848
  }
@@ -154,3 +154,19 @@ export function startupHealthSummary(health: StartupHealth): string {
154
154
  if (health.serviceInstalled && !health.serviceViable) return `AT RISK after restart (installed service is disabled, stopped, or unhealthy; run '${command}')`;
155
155
  return `AT RISK after restart (no viable background service; run '${command}')`;
156
156
  }
157
+
158
+ /**
159
+ * The routing/service/shim token `ocx doctor` prints under restart safety.
160
+ * Extracted so `ocx status` can show the same string rather than growing a
161
+ * second copy that drifts (#2411). Two management routes computing the same
162
+ * thing separately is exactly how #2457 happened.
163
+ */
164
+ export function formatStartupRoutingDetail(health: StartupHealth): string {
165
+ const service = health.serviceViable
166
+ ? "viable"
167
+ : health.serviceInstalled ? "installed-but-unhealthy" : "absent";
168
+ const shim = health.shimHealthy
169
+ ? "healthy"
170
+ : health.shimInstalled ? "stale" : "absent";
171
+ return `routing=${health.routingKind}, service=${service}, shim=${shim}`;
172
+ }
@@ -13,6 +13,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
16
17
  import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
17
18
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
19
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -154,8 +155,16 @@ export function deriveComboCatalogModel(
154
155
  // combo would have the same window even without the cap.
155
156
  const contextCapped = limitingMembers.every(member => member.contextCapped === true);
156
157
  const maxInputTokens = Math.min(
158
+ contextWindow,
157
159
  ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
158
160
  );
161
+ const autoCompactTokenLimit = Math.min(
162
+ ...members.map(member => clampAutoCompactTokenLimit(
163
+ member.contextWindow!,
164
+ member.maxInputTokens,
165
+ member.autoCompactTokenLimit,
166
+ )),
167
+ );
159
168
  const defaultReasoningEffort = effectiveComboDefault(
160
169
  combo.defaultEffort,
161
170
  reasoningEfforts,
@@ -167,6 +176,7 @@ export function deriveComboCatalogModel(
167
176
  owned_by: COMBO_NAMESPACE,
168
177
  contextWindow,
169
178
  maxInputTokens,
179
+ autoCompactTokenLimit,
170
180
  ...(hasLimitingContextCapMetadata ? { contextCapped } : {}),
171
181
  inputModalities,
172
182
  reasoningEfforts,
@@ -210,6 +220,7 @@ export function comboCatalogWarningSignature(
210
220
  key,
211
221
  contextWindow: member?.contextWindow ?? null,
212
222
  maxInputTokens: member?.maxInputTokens ?? null,
223
+ autoCompactTokenLimit: member?.autoCompactTokenLimit ?? null,
213
224
  inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
214
225
  reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
215
226
  parallelToolCalls: member?.parallelToolCalls === true,
@@ -299,6 +310,7 @@ export function normalizedOpenAiApiSignature(model: CatalogModel): string {
299
310
  id: model.id,
300
311
  contextWindow: model.contextWindow ?? null,
301
312
  maxInputTokens: model.maxInputTokens ?? null,
313
+ autoCompactTokenLimit: model.autoCompactTokenLimit ?? null,
302
314
  inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
303
315
  reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
304
316
  ownedBy: model.owned_by ?? null,
@@ -13,6 +13,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
16
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
16
17
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
18
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
19
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -128,9 +129,23 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
128
129
  if (typeof resolvedContext === "number" && resolvedContext > 0) {
129
130
  entry.context_window = resolvedContext;
130
131
  entry.max_context_window = resolvedContext;
131
- entry.auto_compact_token_limit = Math.min(
132
- Math.floor(resolvedContext * 0.9),
133
- model.maxInputTokens ?? Number.POSITIVE_INFINITY,
132
+ entry.auto_compact_token_limit = clampAutoCompactTokenLimit(
133
+ resolvedContext,
134
+ model.maxInputTokens,
135
+ model.autoCompactTokenLimit,
136
+ );
137
+ } else if (
138
+ typeof entry.context_window === "number"
139
+ && entry.context_window > 0
140
+ && typeof model.maxInputTokens === "number"
141
+ && model.maxInputTokens > 0
142
+ ) {
143
+ // A conservative routed fallback is not evidence for applying the optional soft policy,
144
+ // but a measured/configured input ceiling is still a hard bound. Compact before that
145
+ // ceiling even when the provider supplied no authoritative context window.
146
+ entry.auto_compact_token_limit = clampAutoCompactTokenLimit(
147
+ entry.context_window,
148
+ model.maxInputTokens,
134
149
  );
135
150
  }
136
151
  if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
@@ -14,6 +14,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
14
14
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
15
15
  import { getProviderRegistryEntry, providerCodexAccountMode } from "../../providers/registry";
16
16
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
17
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
17
18
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
18
19
  import { identifyRoutedModel } from "../../adapters/identity";
19
20
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -204,6 +205,8 @@ export interface NativeContextLimits {
204
205
  readonly providerWindow?: number;
205
206
  /** `providers.openai.modelContextWindows` — per-model, wins over `providerWindow`. */
206
207
  readonly modelWindows?: Readonly<Record<string, number>>;
208
+ /** `providers.openai.modelAutoCompactTokenLimits` — soft, lowering-only budgets. */
209
+ readonly modelAutoCompactTokenLimits?: Readonly<Record<string, number>>;
207
210
  }
208
211
 
209
212
  export type NativeContextLimitsInput = NativeContextLimits | number | undefined;
@@ -227,12 +230,18 @@ export function nativeContextLimits(
227
230
  const window = positiveInt(value);
228
231
  if (window !== undefined) modelWindows[slug] = window;
229
232
  }
233
+ const modelAutoCompactTokenLimits: Record<string, number> = {};
234
+ for (const [slug, value] of Object.entries(provider?.modelAutoCompactTokenLimits ?? {})) {
235
+ const budget = positiveInt(value);
236
+ if (budget !== undefined) modelAutoCompactTokenLimits[slug] = budget;
237
+ }
230
238
  return {
231
239
  ...(positiveInt(providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) !== undefined
232
240
  ? { cap: providerContextCap(config, OPENAI_CODEX_PROVIDER_ID) }
233
241
  : {}),
234
242
  ...(positiveInt(provider?.contextWindow) !== undefined ? { providerWindow: provider!.contextWindow } : {}),
235
243
  ...(Object.keys(modelWindows).length > 0 ? { modelWindows } : {}),
244
+ ...(Object.keys(modelAutoCompactTokenLimits).length > 0 ? { modelAutoCompactTokenLimits } : {}),
236
245
  };
237
246
  }
238
247
 
@@ -277,6 +286,21 @@ export function nativeOpenAiMaxInputTokens(slug: string, limits?: NativeContextL
277
286
  return window === undefined ? narrowed : Math.min(narrowed, window);
278
287
  }
279
288
 
289
+ /** Effective native soft budget after every hard window/input limit is resolved. */
290
+ export function nativeOpenAiAutoCompactTokenLimit(
291
+ slug: string,
292
+ limits?: NativeContextLimitsInput,
293
+ ): number | undefined {
294
+ const contextWindow = nativeOpenAiContextWindow(slug, limits);
295
+ if (contextWindow === undefined) return undefined;
296
+ const configured = positiveInt(asLimits(limits).modelAutoCompactTokenLimits?.[slug]);
297
+ return clampAutoCompactTokenLimit(
298
+ contextWindow,
299
+ nativeOpenAiMaxInputTokens(slug, limits),
300
+ configured,
301
+ );
302
+ }
303
+
280
304
  export function nativeInputModalities(slug: string): string[] {
281
305
  const upstream = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug);
282
306
  if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) {
@@ -387,7 +411,7 @@ export function desktopVisibleNativeSlugs(
387
411
  ]);
388
412
  }
389
413
 
390
- export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps" | "providers">): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number }> {
414
+ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps" | "providers">): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number; autoCompactTokenLimit?: number }> {
391
415
  const disabled = disabledNativeSlugs(config);
392
416
  const shadowed = configuredNativeAliasSlugs(config);
393
417
  // Both user levers, not just the cap: a per-model window set from the dashboard has to show
@@ -403,11 +427,13 @@ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "comb
403
427
  .filter(slug => !shadowed.has(slug)).map(slug => {
404
428
  const contextWindow = nativeOpenAiContextWindow(slug, limits);
405
429
  const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits);
430
+ const autoCompactTokenLimit = nativeOpenAiAutoCompactTokenLimit(slug, limits);
406
431
  return {
407
432
  slug,
408
433
  disabled: disabled.has(slug),
409
434
  ...(contextWindow !== undefined ? { contextWindow } : {}),
410
435
  ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
436
+ ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}),
411
437
  };
412
438
  });
413
439
  }
@@ -31,7 +31,8 @@ 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, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata";
34
+ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, isNativeOpenAiCapabilityAliasModel, nativeMultiAgentVersion, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata";
35
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
35
36
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
36
37
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
37
38
 
@@ -111,6 +112,8 @@ export interface CatalogModel {
111
112
  defaultReasoningEffort?: string;
112
113
  contextWindow?: number;
113
114
  maxInputTokens?: number;
115
+ /** Soft client compaction threshold; hard context/input limits remain authoritative. */
116
+ autoCompactTokenLimit?: number;
114
117
  contextCap?: number;
115
118
  contextCapped?: boolean;
116
119
  inputModalities?: string[];
@@ -292,22 +295,6 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean {
292
295
  return typeof entry.slug === "string" && !entry.slug.includes("/");
293
296
  }
294
297
 
295
- /**
296
- * Auto-compaction threshold for a native row.
297
- *
298
- * The usual rule is 90% of the window, but a row whose input ceiling sits below that has to
299
- * clamp to the ceiling instead — otherwise the client keeps filling until upstream answers
300
- * `context_length_exceeded` and compaction never gets a chance to run. Native GPT-5.6 no
301
- * longer trips this (922,000 window, 829,800 at 90%), but the routed and API-key rows carry
302
- * the same family at a 1,050,000 window where 90% would be 945,000 — past the ceiling.
303
- */
304
- function nativeAutoCompactLimit(contextWindow: number, maxInputTokens: number | undefined, contextCap?: number): number {
305
- const ninety = Math.floor(contextWindow * 0.9);
306
- if (typeof maxInputTokens !== "number" || maxInputTokens <= 0) return ninety;
307
- const cappedMaxInput = applyProviderContextCap(maxInputTokens, contextCap) ?? maxInputTokens;
308
- return Math.min(ninety, cappedMaxInput, contextWindow);
309
- }
310
-
311
298
  /**
312
299
  * Narrow any already-resolved native window by the user levers.
313
300
  *
@@ -333,6 +320,9 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ
333
320
  ?? (isNativeOpenAiEntry(entry) ? entry.slug as string : undefined);
334
321
  if (!nativeSlug) return;
335
322
  const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[nativeSlug];
323
+ // Captured before any override/cap rewrites the row: a retained compaction threshold only
324
+ // describes the window it arrived with.
325
+ const incomingContextWindow = typeof entry.context_window === "number" ? entry.context_window : undefined;
336
326
  if (override) {
337
327
  // Read the effective values through the accessors rather than re-deriving them from the
338
328
  // static table: this function used to apply only the provider cap, so a per-model window
@@ -340,11 +330,6 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ
340
330
  if (typeof override.contextWindow === "number") {
341
331
  const contextWindow = nativeOpenAiContextWindow(nativeSlug, limits) ?? override.contextWindow;
342
332
  entry.context_window = contextWindow;
343
- entry.auto_compact_token_limit = nativeAutoCompactLimit(
344
- contextWindow,
345
- nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override.maxInputTokens,
346
- undefined,
347
- );
348
333
  }
349
334
  if (typeof override.maxContextWindow === "number") {
350
335
  const maxContextWindow = narrowNativeMaxContextWindow(nativeSlug, override.maxContextWindow, limits);
@@ -359,17 +344,43 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ
359
344
  const cappedContext = narrowNativeMaxContextWindow(nativeSlug, currentContext, limits);
360
345
  if (cappedContext !== currentContext && typeof cappedContext === "number") {
361
346
  entry.context_window = cappedContext;
362
- entry.auto_compact_token_limit = nativeAutoCompactLimit(
363
- cappedContext,
364
- nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens,
365
- undefined,
366
- );
367
347
  }
368
348
  const currentMax = typeof entry.max_context_window === "number" ? entry.max_context_window : undefined;
369
349
  const cappedMax = narrowNativeMaxContextWindow(nativeSlug, currentMax, limits);
370
350
  if (cappedMax !== currentMax) {
371
351
  entry.max_context_window = cappedMax;
372
352
  }
353
+ const effectiveContext = typeof entry.context_window === "number" && entry.context_window > 0
354
+ ? entry.context_window
355
+ : undefined;
356
+ if (effectiveContext !== undefined) {
357
+ const derivedAutoCompactTokenLimit = nativeOpenAiAutoCompactTokenLimit(nativeSlug, limits);
358
+ // Only trust a retained threshold that still describes THIS window. When sync corrects the
359
+ // window, the old number is an artifact of the old one: a 115_200 limit retained from a
360
+ // 128k row would pin a corrected 272k model to 42% of its real window and compact every
361
+ // long turn early. Lower-is-policy still holds whenever the window is unchanged.
362
+ const retainedDescribesCurrentContext = incomingContextWindow === undefined
363
+ || incomingContextWindow === effectiveContext;
364
+ const retainedAutoCompactTokenLimit = retainedDescribesCurrentContext
365
+ && isNativeOpenAiEntry(entry)
366
+ && typeof entry.auto_compact_token_limit === "number"
367
+ && Number.isSafeInteger(entry.auto_compact_token_limit)
368
+ && entry.auto_compact_token_limit > 0
369
+ ? entry.auto_compact_token_limit
370
+ : undefined;
371
+ // A smaller threshold retained from Codex is policy evidence too. Configuration may
372
+ // lower it further, but catalog sync must never replace it with a larger default.
373
+ const loweringAutoCompactTokenLimit = retainedAutoCompactTokenLimit === undefined
374
+ ? derivedAutoCompactTokenLimit
375
+ : derivedAutoCompactTokenLimit === undefined
376
+ ? retainedAutoCompactTokenLimit
377
+ : Math.min(retainedAutoCompactTokenLimit, derivedAutoCompactTokenLimit);
378
+ entry.auto_compact_token_limit = clampAutoCompactTokenLimit(
379
+ effectiveContext,
380
+ nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens,
381
+ loweringAutoCompactTokenLimit,
382
+ );
383
+ }
373
384
  }
374
385
 
375
386
  export function ensureStrictCatalogFields(