@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.
- package/gui/dist/assets/{index-8HxacKlZ.js → index-23-Lf7jR.js} +14 -14
- package/gui/dist/assets/index-DxJMDyOr.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +18 -4
- package/src/adapters/kiro-tools.ts +20 -9
- package/src/bridge.ts +12 -8
- package/src/claude/context-windows.ts +16 -9
- package/src/cli/doctor.ts +2 -2
- package/src/cli/index.ts +9 -2
- package/src/cli/status.ts +23 -0
- package/src/codex/auth-api.ts +4 -2
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +12 -0
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/parsing.ts +38 -27
- package/src/codex/catalog/provider-fetch.ts +143 -25
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/convergence.ts +5 -0
- package/src/codex/shim.ts +56 -3
- package/src/config.ts +24 -0
- package/src/generated/compatibility-version.json +43 -35
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/xai-transport.ts +21 -0
- package/src/server/auth-cors.ts +14 -0
- package/src/server/management/agent-settings-routes.ts +13 -7
- package/src/server/management/config-routes.ts +31 -5
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/oauth-account-routes.ts +10 -4
- package/src/server/management/provider-routes.ts +75 -11
- package/src/server/responses/core.ts +12 -1
- package/src/server/responses/empty-completion-guard.ts +28 -6
- package/src/server/responses-undeclared-tool-guard.ts +90 -8
- package/src/types/config.ts +13 -0
- package/src/types/provider.ts +5 -0
- package/src/types/tools.ts +27 -0
- package/src/types.ts +1 -0
- 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
|
|
1045
|
-
const
|
|
1046
|
-
|
|
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 "${
|
|
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
|
-
|
|
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 "${
|
|
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 =
|
|
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
|
|
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
|
|
125
|
-
for (const m of
|
|
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(`
|
|
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();
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
132
|
-
|
|
133
|
-
model.maxInputTokens
|
|
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(
|