@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
|
@@ -63,6 +63,7 @@ export async function listManagementModelRows(config: OcxConfig): Promise<Manage
|
|
|
63
63
|
disabled: disabled.has(`${selector}/${slug}`) || disabled.has(slug),
|
|
64
64
|
contextWindow: undefined,
|
|
65
65
|
maxInputTokens: undefined,
|
|
66
|
+
autoCompactTokenLimit: undefined,
|
|
66
67
|
})))
|
|
67
68
|
: [];
|
|
68
69
|
const native: ManagementModelRow[] = [...nativeRows, ...accountNativeRows].map(row => {
|
|
@@ -82,6 +83,9 @@ export async function listManagementModelRows(config: OcxConfig): Promise<Manage
|
|
|
82
83
|
// 1.05M). Dropping it here made /api/models describe a native row as if the whole
|
|
83
84
|
// window were usable as input, which is the claim the measurement disproved.
|
|
84
85
|
...(row.maxInputTokens !== undefined ? { maxInputTokens: row.maxInputTokens } : {}),
|
|
86
|
+
...(row.autoCompactTokenLimit !== undefined
|
|
87
|
+
? { autoCompactTokenLimit: row.autoCompactTokenLimit }
|
|
88
|
+
: {}),
|
|
85
89
|
};
|
|
86
90
|
});
|
|
87
91
|
const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => {
|
|
@@ -136,7 +136,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
136
136
|
// the provider's loopback callback server (inside this process) captures the redirect in the
|
|
137
137
|
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
|
|
138
138
|
if (url.pathname === "/api/oauth/login" && req.method === "POST") {
|
|
139
|
-
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
|
|
139
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown };
|
|
140
140
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
141
141
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
142
142
|
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
|
|
@@ -167,9 +167,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
167
167
|
reconcileLiveStateStores();
|
|
168
168
|
},
|
|
169
169
|
});
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
|
171
|
+
// window.open is popup-blocked because it runs after an await, not a direct click.
|
|
172
|
+
//
|
|
173
|
+
// The operator can decline, which is the only way to finish a login in a
|
|
174
|
+
// browser profile other than the OS default, or on a different machine
|
|
175
|
+
// than the proxy. Declining changes nothing else: the URL is still
|
|
176
|
+
// returned below and every login surface renders it with a copy button.
|
|
177
|
+
const { shouldOpenBrowserForLogin } = await import("../../oauth/open-browser-choice");
|
|
178
|
+
if (authUrl && !deviceCode && shouldOpenBrowserForLogin(body.openBrowser, config)) {
|
|
173
179
|
const { openUrl } = await import("../../lib/open-url");
|
|
174
180
|
openUrl(authUrl);
|
|
175
181
|
}
|
|
@@ -36,7 +36,7 @@ import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost,
|
|
|
36
36
|
import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
|
|
37
37
|
import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
|
|
38
38
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
39
|
-
import { deriveProviderPresets } from "../../providers/derive";
|
|
39
|
+
import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive";
|
|
40
40
|
import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry";
|
|
41
41
|
import {
|
|
42
42
|
extractModelEnvelopeRows,
|
|
@@ -54,6 +54,7 @@ import { clearThreadAccountMap } from "../../codex/routing";
|
|
|
54
54
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
55
55
|
import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache";
|
|
56
56
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
57
|
+
import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget";
|
|
57
58
|
import { resolveCodexHomeDir } from "../../codex/home";
|
|
58
59
|
import { readUsageEntries } from "../../usage/log";
|
|
59
60
|
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
@@ -266,6 +267,29 @@ function applyProviderPatchFields(
|
|
|
266
267
|
}
|
|
267
268
|
touched = true;
|
|
268
269
|
}
|
|
270
|
+
if (Object.hasOwn(rawBody, "modelAutoCompactTokenLimits")) {
|
|
271
|
+
const value = rawBody.modelAutoCompactTokenLimits;
|
|
272
|
+
const error = modelAutoCompactTokenLimitsConfigError(value, {
|
|
273
|
+
allowTombstones: true,
|
|
274
|
+
requireNativeIds: name === "openai",
|
|
275
|
+
});
|
|
276
|
+
if (error) return { error };
|
|
277
|
+
if (value === null) {
|
|
278
|
+
delete next.modelAutoCompactTokenLimits;
|
|
279
|
+
} else {
|
|
280
|
+
const budgets: Record<string, number> = Object.assign(
|
|
281
|
+
Object.create(null) as Record<string, number>,
|
|
282
|
+
next.modelAutoCompactTokenLimits ?? {},
|
|
283
|
+
);
|
|
284
|
+
for (const [model, budget] of Object.entries(value as Record<string, number | null>)) {
|
|
285
|
+
if (budget === null) delete budgets[model];
|
|
286
|
+
else budgets[model] = budget;
|
|
287
|
+
}
|
|
288
|
+
if (Object.keys(budgets).length > 0) next.modelAutoCompactTokenLimits = budgets;
|
|
289
|
+
else delete next.modelAutoCompactTokenLimits;
|
|
290
|
+
}
|
|
291
|
+
touched = true;
|
|
292
|
+
}
|
|
269
293
|
if (Object.hasOwn(rawBody, "modelSupportsServiceTier")) {
|
|
270
294
|
const value = rawBody.modelSupportsServiceTier;
|
|
271
295
|
if (value === null) {
|
|
@@ -369,6 +393,28 @@ function applyProviderPatchFields(
|
|
|
369
393
|
return { next, touched, editorTouched, enablingOpenAi, headersTouched };
|
|
370
394
|
}
|
|
371
395
|
|
|
396
|
+
/** Validate the canonical OpenAI soft-budget overlay against a fresh registry seed. */
|
|
397
|
+
function canonicalOpenAiBudgetPatchError(
|
|
398
|
+
provider: OcxProviderConfig,
|
|
399
|
+
rawBody: Record<string, unknown>,
|
|
400
|
+
keys: string[],
|
|
401
|
+
config: OcxConfig,
|
|
402
|
+
): string | null {
|
|
403
|
+
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
404
|
+
return "provider openai must be the canonical built-in provider";
|
|
405
|
+
}
|
|
406
|
+
const entry = getProviderRegistryEntry("openai");
|
|
407
|
+
if (!entry) return "provider openai registry seed is unavailable";
|
|
408
|
+
const seed = providerConfigSeed(entry);
|
|
409
|
+
if (provider.codexAccountMode !== undefined) seed.codexAccountMode = provider.codexAccountMode;
|
|
410
|
+
if (provider.modelAutoCompactTokenLimits !== undefined) {
|
|
411
|
+
seed.modelAutoCompactTokenLimits = { ...provider.modelAutoCompactTokenLimits };
|
|
412
|
+
}
|
|
413
|
+
const applied = applyProviderPatchFields("openai", seed, rawBody, keys, config);
|
|
414
|
+
if ("error" in applied) return applied.error;
|
|
415
|
+
return providerManagementConfigError("openai", applied.next);
|
|
416
|
+
}
|
|
417
|
+
|
|
372
418
|
export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
373
419
|
const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
374
420
|
|
|
@@ -405,6 +451,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
405
451
|
models: p.models ?? [],
|
|
406
452
|
contextWindow: p.contextWindow,
|
|
407
453
|
modelContextWindows: p.modelContextWindows,
|
|
454
|
+
modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
|
|
408
455
|
modelSupportsServiceTier: p.modelSupportsServiceTier,
|
|
409
456
|
noStructuredOutputModels: p.noStructuredOutputModels,
|
|
410
457
|
upstreamHttpVersion: p.upstreamHttpVersion,
|
|
@@ -536,6 +583,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
536
583
|
// call can never fire.
|
|
537
584
|
const submittedContextWindow = Object.hasOwn(prov, "contextWindow");
|
|
538
585
|
const submittedModelContextWindows = Object.hasOwn(prov, "modelContextWindows");
|
|
586
|
+
const submittedModelAutoCompactTokenLimits = Object.hasOwn(prov, "modelAutoCompactTokenLimits");
|
|
539
587
|
const submittedRequestPacing = Object.hasOwn(prov, "requestPacing");
|
|
540
588
|
enrichProviderFromCatalog(name, prov);
|
|
541
589
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
@@ -568,6 +616,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
568
616
|
? { ...existing.modelContextWindows, ...(prov.modelContextWindows ?? {}) }
|
|
569
617
|
: { ...existing.modelContextWindows };
|
|
570
618
|
}
|
|
619
|
+
if (existing?.modelAutoCompactTokenLimits) {
|
|
620
|
+
prov.modelAutoCompactTokenLimits = submittedModelAutoCompactTokenLimits
|
|
621
|
+
? { ...existing.modelAutoCompactTokenLimits, ...(prov.modelAutoCompactTokenLimits ?? {}) }
|
|
622
|
+
: { ...existing.modelAutoCompactTokenLimits };
|
|
623
|
+
}
|
|
571
624
|
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
572
625
|
if (body.setDefault === true) config.defaultProvider = name;
|
|
573
626
|
save(config);
|
|
@@ -591,6 +644,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
591
644
|
const keys = Object.keys(rawBody);
|
|
592
645
|
const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
|
|
593
646
|
const hasSetDefault = Object.hasOwn(rawBody, "setDefault");
|
|
647
|
+
const canonicalBudgetOnly = name === "openai"
|
|
648
|
+
&& keys.length === 1
|
|
649
|
+
&& keys[0] === "modelAutoCompactTokenLimits";
|
|
594
650
|
|
|
595
651
|
// codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map
|
|
596
652
|
// clear, pool prime) and is mutually exclusive with every other patch field.
|
|
@@ -653,12 +709,16 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
653
709
|
|
|
654
710
|
const pacingOnly = keys.every(key => key === "requestPacing");
|
|
655
711
|
if (applied.editorTouched && !pacingOnly) {
|
|
656
|
-
const providerError =
|
|
712
|
+
const providerError = canonicalBudgetOnly
|
|
713
|
+
? canonicalOpenAiBudgetPatchError(next, rawBody, keys, config)
|
|
714
|
+
: providerManagementConfigError(name, next);
|
|
657
715
|
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
716
|
+
if (!canonicalBudgetOnly) {
|
|
717
|
+
const serviceTierError = providerServiceTierConfigError(name, next);
|
|
718
|
+
if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
|
|
719
|
+
const resolvedError = await providerDestinationResolvedError(name, next);
|
|
720
|
+
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
721
|
+
}
|
|
662
722
|
} else if (applied.enablingOpenAi) {
|
|
663
723
|
// Same DNS gate as POST: Clash fake-IP only. Never honor a persisted
|
|
664
724
|
// allowPrivateNetwork on this path — it must not bypass the built-in guard.
|
|
@@ -682,15 +742,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
682
742
|
return;
|
|
683
743
|
}
|
|
684
744
|
if (replay.editorTouched && !pacingOnly) {
|
|
685
|
-
const syncError =
|
|
745
|
+
const syncError = canonicalBudgetOnly
|
|
746
|
+
? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, config)
|
|
747
|
+
: providerManagementConfigError(name, replay.next);
|
|
686
748
|
if (syncError) {
|
|
687
749
|
replayError = syncError;
|
|
688
750
|
return;
|
|
689
751
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
752
|
+
if (!canonicalBudgetOnly) {
|
|
753
|
+
const serviceTierError = providerServiceTierConfigError(name, replay.next);
|
|
754
|
+
if (serviceTierError) {
|
|
755
|
+
replayError = serviceTierError;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
694
758
|
}
|
|
695
759
|
} else if (replay.enablingOpenAi && !isCanonicalOpenAiForwardProvider(replay.next)) {
|
|
696
760
|
replayError = "provider openai must be the canonical built-in provider";
|
|
@@ -191,7 +191,7 @@ import {
|
|
|
191
191
|
rotateProviderTransportOn429,
|
|
192
192
|
} from "../../providers/key-failover";
|
|
193
193
|
import { shouldAttemptImageTierRetry } from "../image-retry";
|
|
194
|
-
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
194
|
+
import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
|
|
195
195
|
import type { WsData } from "../ws-bridge";
|
|
196
196
|
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
197
197
|
import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
|
|
@@ -308,12 +308,14 @@ import {
|
|
|
308
308
|
import {
|
|
309
309
|
collectDeclaredNamelessClientCallTypes,
|
|
310
310
|
collectDeclaredWireToolNames,
|
|
311
|
+
collectProviderExecutedCallTypes,
|
|
311
312
|
createUndeclaredToolCallGuardBlockRewrite,
|
|
312
313
|
currentTurnWireToolCatalogBody,
|
|
313
314
|
hasExplicitWireToolCatalog,
|
|
314
315
|
undeclaredToolCallMessage,
|
|
315
316
|
undeclaredToolCallName,
|
|
316
317
|
undeclaredToolCallNameInResponse,
|
|
318
|
+
type ProviderExecutedCallType,
|
|
317
319
|
} from "../responses-undeclared-tool-guard";
|
|
318
320
|
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
|
|
319
321
|
import { responsesJsonToSseStream } from "../responses-json-events";
|
|
@@ -2942,6 +2944,11 @@ async function handleResponsesInner(
|
|
|
2942
2944
|
const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
|
|
2943
2945
|
clientToolAuthorizationBody,
|
|
2944
2946
|
);
|
|
2947
|
+
// Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a
|
|
2948
|
+
// declaration alone cannot buy the exemption on some other upstream that never serves it.
|
|
2949
|
+
const providerExecutedCallTypes = isXaiResponsesDestination(route.provider)
|
|
2950
|
+
? collectProviderExecutedCallTypes(clientToolAuthorizationBody)
|
|
2951
|
+
: new Set<ProviderExecutedCallType>();
|
|
2945
2952
|
let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
|
|
2946
2953
|
try {
|
|
2947
2954
|
request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
@@ -3061,6 +3068,7 @@ async function handleResponsesInner(
|
|
|
3061
3068
|
payload,
|
|
3062
3069
|
declaredWireToolNames,
|
|
3063
3070
|
declaredNamelessClientCallTypes,
|
|
3071
|
+
providerExecutedCallTypes,
|
|
3064
3072
|
) !== undefined) {
|
|
3065
3073
|
inspectionSawUndeclaredTool = true;
|
|
3066
3074
|
}
|
|
@@ -3074,6 +3082,7 @@ async function handleResponsesInner(
|
|
|
3074
3082
|
response,
|
|
3075
3083
|
declaredWireToolNames,
|
|
3076
3084
|
declaredNamelessClientCallTypes,
|
|
3085
|
+
providerExecutedCallTypes,
|
|
3077
3086
|
) !== undefined
|
|
3078
3087
|
) {
|
|
3079
3088
|
return;
|
|
@@ -3725,6 +3734,7 @@ async function handleResponsesInner(
|
|
|
3725
3734
|
? createUndeclaredToolCallGuardBlockRewrite(
|
|
3726
3735
|
declaredWireToolNames,
|
|
3727
3736
|
declaredNamelessClientCallTypes,
|
|
3737
|
+
providerExecutedCallTypes,
|
|
3728
3738
|
)
|
|
3729
3739
|
: undefined,
|
|
3730
3740
|
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
@@ -3946,6 +3956,7 @@ async function handleResponsesInner(
|
|
|
3946
3956
|
JSON.parse(clientJson),
|
|
3947
3957
|
declaredWireToolNames,
|
|
3948
3958
|
declaredNamelessClientCallTypes,
|
|
3959
|
+
providerExecutedCallTypes,
|
|
3949
3960
|
);
|
|
3950
3961
|
} catch {
|
|
3951
3962
|
return undefined;
|
|
@@ -153,9 +153,10 @@ export interface EmptyCompletionGuardOptions {
|
|
|
153
153
|
* Watch an adapter event stream for the empty-completion failure mode. Events
|
|
154
154
|
* are held until the turn produces content or ends: reasoning and other
|
|
155
155
|
* pre-content events stay buffered (released in order on first content), the
|
|
156
|
-
* terminal is withheld, and an empty terminal triggers one
|
|
157
|
-
* retry through `continuation`. Usage is merged across attempts
|
|
158
|
-
* and request log meter the whole turn, not just the attempt that
|
|
156
|
+
* terminal is withheld, and an empty terminal or pre-output EOF triggers one
|
|
157
|
+
* identical-turn retry through `continuation`. Usage is merged across attempts
|
|
158
|
+
* so the bridge and request log meter the whole turn, not just the attempt that
|
|
159
|
+
* succeeded.
|
|
159
160
|
*
|
|
160
161
|
* Heartbeats always pass through untouched: they feed the bridge's stall
|
|
161
162
|
* watchdog, so holding them behind the content gate would trip false
|
|
@@ -194,7 +195,11 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
194
195
|
if (sawContent || passthrough) {
|
|
195
196
|
// Buffered content is already flowing; everything downstream passes
|
|
196
197
|
// through. Every terminal carries usage merged across every attempt.
|
|
197
|
-
|
|
198
|
+
if (isTerminalEvent(event)) {
|
|
199
|
+
yield withUsage(event);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
yield event;
|
|
198
203
|
continue;
|
|
199
204
|
}
|
|
200
205
|
if (isContentEvent(event)) {
|
|
@@ -267,8 +272,25 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
267
272
|
if (isReasoningEvent(event)) yield { type: "heartbeat" };
|
|
268
273
|
}
|
|
269
274
|
if (!terminalSeen) {
|
|
270
|
-
//
|
|
271
|
-
//
|
|
275
|
+
// A terminal-less EOF before text or a tool call is replay-safe: nothing
|
|
276
|
+
// actionable reached the client. Retry once, then surface a stated error
|
|
277
|
+
// instead of letting the bridge reduce the turn to adapter_eof.
|
|
278
|
+
if (!sawContent && !passthrough && retries < maxRetries) {
|
|
279
|
+
retries += 1;
|
|
280
|
+
try {
|
|
281
|
+
source = await options.continuation();
|
|
282
|
+
} catch {
|
|
283
|
+
yield emptyCompletionRetryFailedEvent(usage, true);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!sawContent && retries > 0) {
|
|
289
|
+
yield emptyCompletionRetryFailedEvent(usage, true);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Post-output EOF remains incomplete; replaying could duplicate text or
|
|
293
|
+
// executable tool calls.
|
|
272
294
|
yield* releaseHeld();
|
|
273
295
|
return;
|
|
274
296
|
}
|
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
import { namespacedToolName } from "../types";
|
|
1
|
+
import { namespacedToolName, normalizeDeclaredToolName } from "../types";
|
|
2
2
|
import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
|
|
3
3
|
|
|
4
4
|
/** Item types the client executes through a request-declared wire name. */
|
|
5
5
|
const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Hosted declarations whose response items the PROVIDER executes, keyed by the request
|
|
9
|
+
* declaration type. These need no client answer, so their names are deliberately absent from
|
|
10
|
+
* the request catalog and must not be read as an undeclared client tool.
|
|
11
|
+
*
|
|
12
|
+
* xAI surfaces hosted `x_search` as `custom_tool_call`. Probed 2026-08-23 against the OAuth CLI
|
|
13
|
+
* destination: its hosted calls use an `xs_call-` call-id prefix. Observed call names were
|
|
14
|
+
* `x_keyword_search`, `x_semantic_search`, and `x_user_search` — three literals for one tool,
|
|
15
|
+
* which is why authorization keys on the declaration, item type, and call-id prefix, never on
|
|
16
|
+
* the name.
|
|
17
|
+
*/
|
|
18
|
+
export type ProviderExecutedCallType = Readonly<{
|
|
19
|
+
itemType: string;
|
|
20
|
+
callIdPrefix: string;
|
|
21
|
+
}>;
|
|
22
|
+
|
|
23
|
+
type ProviderExecutedCallTypes = ReadonlySet<ProviderExecutedCallType>;
|
|
24
|
+
|
|
25
|
+
export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map<string, ProviderExecutedCallType>([
|
|
26
|
+
["x_search", { itemType: "custom_tool_call", callIdPrefix: "xs_call-" }],
|
|
27
|
+
]);
|
|
28
|
+
|
|
7
29
|
/** Nameless declaration kinds whose response items still require client execution. */
|
|
8
30
|
const NAMELESS_CLIENT_DECLARATION_CALL_TYPES = new Map([
|
|
9
31
|
["local_shell", "local_shell_call"],
|
|
@@ -19,6 +41,7 @@ const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([
|
|
|
19
41
|
]);
|
|
20
42
|
|
|
21
43
|
const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet<string> = new Set();
|
|
44
|
+
const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet<ProviderExecutedCallType> = new Set();
|
|
22
45
|
|
|
23
46
|
/** Supported hosted/private declarations that carry no client-executable wire name. */
|
|
24
47
|
const NAMELESS_TOOL_SPEC_TYPES = new Set([
|
|
@@ -127,6 +150,53 @@ function addNamelessClientCallTypes(callTypes: Set<string>, specs: unknown): voi
|
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
|
|
153
|
+
function addProviderExecutedCallTypes(
|
|
154
|
+
callTypes: Set<ProviderExecutedCallType>,
|
|
155
|
+
specs: unknown,
|
|
156
|
+
): void {
|
|
157
|
+
if (!Array.isArray(specs)) return;
|
|
158
|
+
for (const spec of specs) {
|
|
159
|
+
if (!isPlainObject(spec) || typeof spec.type !== "string") continue;
|
|
160
|
+
const callType = PROVIDER_EXECUTED_DECLARATION_CALL_TYPES.get(spec.type);
|
|
161
|
+
if (callType) callTypes.add(callType);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Item types this turn's hosted declarations authorize the PROVIDER to emit unnamed.
|
|
167
|
+
*
|
|
168
|
+
* Caller must gate this on the destination actually being that provider; a declaration alone
|
|
169
|
+
* is not authority, or any upstream could claim a hosted shape it never serves.
|
|
170
|
+
*/
|
|
171
|
+
export function collectProviderExecutedCallTypes(body: unknown): Set<ProviderExecutedCallType> {
|
|
172
|
+
const callTypes = new Set<ProviderExecutedCallType>();
|
|
173
|
+
if (!isPlainObject(body)) return callTypes;
|
|
174
|
+
addProviderExecutedCallTypes(callTypes, body.tools);
|
|
175
|
+
if (Array.isArray(body.input)) {
|
|
176
|
+
for (const item of body.input) {
|
|
177
|
+
if (
|
|
178
|
+
isPlainObject(item)
|
|
179
|
+
&& (item.type === "additional_tools" || item.type === "tool_search_output")
|
|
180
|
+
) addProviderExecutedCallTypes(callTypes, item.tools);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return callTypes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isAuthorizedProviderExecutedCall(
|
|
187
|
+
item: Record<string, unknown>,
|
|
188
|
+
callTypes: ProviderExecutedCallTypes,
|
|
189
|
+
): boolean {
|
|
190
|
+
if (typeof item.call_id !== "string") return false;
|
|
191
|
+
for (const callType of callTypes) {
|
|
192
|
+
if (
|
|
193
|
+
item.type === callType.itemType
|
|
194
|
+
&& item.call_id.startsWith(callType.callIdPrefix)
|
|
195
|
+
) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
130
200
|
/** Nameless client-call item types authorized by supported request tool declarations. */
|
|
131
201
|
export function collectDeclaredNamelessClientCallTypes(body: unknown): Set<string> {
|
|
132
202
|
const callTypes = new Set<string>();
|
|
@@ -190,9 +260,14 @@ function undeclaredNameInItem(
|
|
|
190
260
|
item: unknown,
|
|
191
261
|
declared: ReadonlySet<string>,
|
|
192
262
|
declaredNamelessClientCallTypes: ReadonlySet<string>,
|
|
263
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
193
264
|
): string | undefined {
|
|
194
265
|
if (!isPlainObject(item)) return undefined;
|
|
195
266
|
if (typeof item.type !== "string") return undefined;
|
|
267
|
+
// The provider executes this exact measured shape itself, so there is no client name to
|
|
268
|
+
// authorize. The caller supplies these signatures only for the matching destination and
|
|
269
|
+
// declarations; the item must additionally carry the hosted call-id prefix.
|
|
270
|
+
if (isAuthorizedProviderExecutedCall(item, providerExecutedCallTypes)) return undefined;
|
|
196
271
|
const namelessDisplayName = NAMELESS_CLIENT_CALL_DISPLAY_NAMES.get(item.type);
|
|
197
272
|
if (namelessDisplayName !== undefined) {
|
|
198
273
|
// Only Codex's explicit `execution: "client"` form delegates tool search to the client.
|
|
@@ -202,10 +277,14 @@ function undeclaredNameInItem(
|
|
|
202
277
|
if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined;
|
|
203
278
|
const name = item.name;
|
|
204
279
|
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
280
|
+
if (typeof item.namespace === "string") {
|
|
281
|
+
// Namespaced calls are matched by their full wire name only — never legacy-normalize
|
|
282
|
+
// them, or an undeclared namespaced `exec_command` could slip through as bare `exec`.
|
|
283
|
+
if (declared.has(namespacedToolName(item.namespace, name))) return undefined;
|
|
284
|
+
return name;
|
|
208
285
|
}
|
|
286
|
+
const effectiveName = normalizeDeclaredToolName(name, declared);
|
|
287
|
+
if (declared.has(effectiveName)) return undefined;
|
|
209
288
|
return name;
|
|
210
289
|
}
|
|
211
290
|
|
|
@@ -214,14 +293,15 @@ export function undeclaredToolCallName(
|
|
|
214
293
|
payload: unknown,
|
|
215
294
|
declared: ReadonlySet<string>,
|
|
216
295
|
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
|
|
296
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
217
297
|
): string | undefined {
|
|
218
298
|
if (!isPlainObject(payload)) return undefined;
|
|
219
299
|
if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") {
|
|
220
|
-
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes);
|
|
300
|
+
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
221
301
|
}
|
|
222
302
|
// Sparse gateways skip incremental items and only ever ship the terminal snapshot.
|
|
223
303
|
if (payload.type === "response.completed" || payload.type === "response.incomplete") {
|
|
224
|
-
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes);
|
|
304
|
+
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
225
305
|
}
|
|
226
306
|
return undefined;
|
|
227
307
|
}
|
|
@@ -231,10 +311,11 @@ export function undeclaredToolCallNameInResponse(
|
|
|
231
311
|
response: unknown,
|
|
232
312
|
declared: ReadonlySet<string>,
|
|
233
313
|
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
|
|
314
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
234
315
|
): string | undefined {
|
|
235
316
|
if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined;
|
|
236
317
|
for (const item of response.output) {
|
|
237
|
-
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes);
|
|
318
|
+
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
238
319
|
if (name !== undefined) return name;
|
|
239
320
|
}
|
|
240
321
|
return undefined;
|
|
@@ -274,6 +355,7 @@ function failedBlocks(name: string, newline: string): readonly string[] {
|
|
|
274
355
|
export function createUndeclaredToolCallGuardBlockRewrite(
|
|
275
356
|
declared: ReadonlySet<string>,
|
|
276
357
|
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
|
|
358
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
277
359
|
): SseBlockRewrite {
|
|
278
360
|
let tripped = false;
|
|
279
361
|
return (block: string) => {
|
|
@@ -286,7 +368,7 @@ export function createUndeclaredToolCallGuardBlockRewrite(
|
|
|
286
368
|
} catch {
|
|
287
369
|
return [block];
|
|
288
370
|
}
|
|
289
|
-
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes);
|
|
371
|
+
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
290
372
|
if (name === undefined) return [block];
|
|
291
373
|
tripped = true;
|
|
292
374
|
return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
|
package/src/types/config.ts
CHANGED
|
@@ -243,6 +243,19 @@ export interface OcxConfig {
|
|
|
243
243
|
port: number;
|
|
244
244
|
/** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
|
|
245
245
|
emptyCompletionRetry?: boolean;
|
|
246
|
+
/**
|
|
247
|
+
* Whether a login may open a browser on the machine running the proxy.
|
|
248
|
+
*
|
|
249
|
+
* Absent and `true` both mean "open", which is what every existing install
|
|
250
|
+
* already does. Only an explicit `false` declines — for an operator who wants
|
|
251
|
+
* to paste the authorization URL into a different browser profile, or who is
|
|
252
|
+
* driving the dashboard from a different machine than the proxy.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately a boolean and not an "auto" mode: inferring headlessness from
|
|
255
|
+
* SSH_CONNECTION or a missing DISPLAY breaks a working login silently when
|
|
256
|
+
* the guess is wrong.
|
|
257
|
+
*/
|
|
258
|
+
oauthOpenBrowser?: boolean;
|
|
246
259
|
/** Maximum usage-log bytes read for one management snapshot. */
|
|
247
260
|
managementUsageMaxReadBytes?: number;
|
|
248
261
|
providers: Record<string, OcxProviderConfig>;
|
package/src/types/provider.ts
CHANGED
|
@@ -274,6 +274,11 @@ export interface OcxProviderConfig {
|
|
|
274
274
|
modelInputModalities?: Record<string, string[]>;
|
|
275
275
|
/** Model-specific max input token limits. Values cap auto_compact_token_limit. */
|
|
276
276
|
modelMaxInputTokens?: Record<string, number>;
|
|
277
|
+
/**
|
|
278
|
+
* Per-model soft compaction budgets. Values may only lower the effective
|
|
279
|
+
* context/max-input envelope; they never raise hard admission limits.
|
|
280
|
+
*/
|
|
281
|
+
modelAutoCompactTokenLimits?: Record<string, number>;
|
|
277
282
|
/**
|
|
278
283
|
* Provider-wide fallback for chat-completions `max_tokens` when the caller omits
|
|
279
284
|
* Responses `max_output_tokens`. Adapters still let an explicit request win.
|
package/src/types/tools.ts
CHANGED
|
@@ -31,6 +31,33 @@ export function namespacedToolName(namespace: string | undefined, name: string):
|
|
|
31
31
|
return namespace ? `${namespace}__${name}` : name;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Codex 0.149 unified-exec name normalization.
|
|
36
|
+
*
|
|
37
|
+
* Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own
|
|
38
|
+
* description mentions the nested `await tools.exec_command(...)` helper). Routed models —
|
|
39
|
+
* DeepSeek in particular — sometimes echo that helper name as the tool-call name, emitting
|
|
40
|
+
* `exec_command` instead of the declared `exec`. Accept the legacy shell bridge names only
|
|
41
|
+
* when the request catalog actually declares `exec` and does not itself declare the legacy
|
|
42
|
+
* name (an MCP server may legitimately advertise `exec_command` under its own namespace).
|
|
43
|
+
*/
|
|
44
|
+
const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const;
|
|
45
|
+
|
|
46
|
+
export function normalizeDeclaredToolName(
|
|
47
|
+
name: string,
|
|
48
|
+
declared: ReadonlySet<string> | undefined,
|
|
49
|
+
): string {
|
|
50
|
+
if (!declared || !declared.has("exec")) return name;
|
|
51
|
+
if (declared.has(name)) return name;
|
|
52
|
+
// When the catalog explicitly declares any legacy shell bridge name, the environment
|
|
53
|
+
// genuinely exposes that tool — turn normalization off so a call is never mis-routed
|
|
54
|
+
// to `exec`.
|
|
55
|
+
if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) {
|
|
56
|
+
return name;
|
|
57
|
+
}
|
|
58
|
+
return (LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name) ? "exec" : name;
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
export function toolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): string[] {
|
|
35
62
|
const wireName = namespacedToolName(tool.namespace, tool.name);
|
|
36
63
|
return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName];
|