@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
@@ -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
- if (authUrl && !deviceCode) {
171
- // Open the browser server-side (the proxy runs on the user's machine) the GUI's
172
- // window.open is popup-blocked because it runs after an await, not a direct click.
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 = providerManagementConfigError(name, next);
712
+ const providerError = canonicalBudgetOnly
713
+ ? canonicalOpenAiBudgetPatchError(next, rawBody, keys, config)
714
+ : providerManagementConfigError(name, next);
657
715
  if (providerError) return jsonResponse({ error: providerError }, 400);
658
- const serviceTierError = providerServiceTierConfigError(name, next);
659
- if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
660
- const resolvedError = await providerDestinationResolvedError(name, next);
661
- if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
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 = providerManagementConfigError(name, replay.next);
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
- const serviceTierError = providerServiceTierConfigError(name, replay.next);
691
- if (serviceTierError) {
692
- replayError = serviceTierError;
693
- return;
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 identical-turn
157
- * retry through `continuation`. Usage is merged across attempts so the bridge
158
- * and request log meter the whole turn, not just the attempt that succeeded.
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
- yield isTerminalEvent(event) ? withUsage(event) : event;
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
- // The source ended without a terminal event (truncated stream). Release
271
- // what was held so the bridge can mark the stream incomplete.
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 (declared.has(name)) return undefined;
206
- if (typeof item.namespace === "string" && declared.has(namespacedToolName(item.namespace, name))) {
207
- return undefined;
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");
@@ -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>;
@@ -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.
@@ -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];
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  export type { OcxTool, OcxToolChoice } from "./types/tools";
5
5
  export {
6
6
  namespacedToolName,
7
+ normalizeDeclaredToolName,
7
8
  toolChoiceAliases,
8
9
  createToolChoiceResolver,
9
10
  toolChoiceCandidates,