@yansigit/opencodex 2.32.0 → 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 (108) hide show
  1. package/README.md +2 -2
  2. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  3. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +6 -5
  6. package/src/adapters/anthropic.ts +20 -6
  7. package/src/adapters/azure.ts +20 -4
  8. package/src/adapters/base.ts +3 -1
  9. package/src/adapters/command-code.ts +40 -7
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +158 -7
  12. package/src/adapters/cursor/protobuf-request.ts +33 -15
  13. package/src/adapters/cursor/request-builder.ts +4 -3
  14. package/src/adapters/cursor/tool-definitions.ts +27 -1
  15. package/src/adapters/cursor/types.ts +4 -3
  16. package/src/adapters/cursor.ts +9 -0
  17. package/src/adapters/google-antigravity-replay.ts +2 -2
  18. package/src/adapters/google-antigravity-wire.ts +7 -0
  19. package/src/adapters/google-errors.ts +6 -2
  20. package/src/adapters/google-http.ts +30 -7
  21. package/src/adapters/google-truncation.ts +5 -0
  22. package/src/adapters/google-wire-compiler.ts +38 -6
  23. package/src/adapters/google.ts +148 -26
  24. package/src/adapters/kiro-tools.ts +20 -9
  25. package/src/adapters/openai-chat.ts +9 -0
  26. package/src/adapters/openai-responses.ts +1 -1
  27. package/src/bridge.ts +112 -9
  28. package/src/claude/context-windows.ts +16 -9
  29. package/src/cli/doctor.ts +2 -2
  30. package/src/cli/index.ts +9 -2
  31. package/src/cli/provider.ts +6 -0
  32. package/src/cli/status.ts +23 -0
  33. package/src/codex/auth-api.ts +4 -2
  34. package/src/codex/autostart-health.ts +16 -0
  35. package/src/codex/catalog/aggregation.ts +12 -12
  36. package/src/codex/catalog/effort.ts +18 -3
  37. package/src/codex/catalog/metadata.ts +27 -1
  38. package/src/codex/catalog/model-metadata.ts +39 -12
  39. package/src/codex/catalog/parsing.ts +38 -27
  40. package/src/codex/catalog/provider-fetch.ts +198 -133
  41. package/src/codex/catalog/sync.ts +1 -1
  42. package/src/codex/convergence.ts +5 -0
  43. package/src/codex/shim.ts +56 -3
  44. package/src/config/provider-validation.ts +37 -0
  45. package/src/config.ts +78 -2
  46. package/src/generated/compatibility-version.json +120 -96
  47. package/src/images/loop.ts +37 -6
  48. package/src/lib/azure-identity.ts +154 -0
  49. package/src/lib/debug.ts +42 -0
  50. package/src/lib/errors.ts +14 -0
  51. package/src/lib/provider-outbound.ts +45 -33
  52. package/src/lib/provider-tls-profile.ts +309 -0
  53. package/src/lib/proxy-env.ts +49 -0
  54. package/src/lib/redact.ts +10 -1
  55. package/src/oauth/antigravity-routing.ts +282 -236
  56. package/src/oauth/callback-server.ts +22 -2
  57. package/src/oauth/command-code.ts +5 -16
  58. package/src/oauth/google-antigravity.ts +42 -5
  59. package/src/oauth/index.ts +15 -3
  60. package/src/oauth/kimi.ts +9 -1
  61. package/src/oauth/open-browser-choice.ts +26 -0
  62. package/src/oauth/store.ts +6 -0
  63. package/src/providers/antigravity-quota.ts +3 -1
  64. package/src/providers/api-keys.ts +2 -1
  65. package/src/providers/auto-compact-budget.ts +65 -0
  66. package/src/providers/derive.ts +4 -0
  67. package/src/providers/key-failover.ts +5 -1
  68. package/src/providers/openai-tiers.ts +5 -0
  69. package/src/providers/provider-id-rewrite.ts +1 -0
  70. package/src/providers/quota.ts +59 -13
  71. package/src/providers/registry.ts +3 -1
  72. package/src/providers/request-pacing.ts +33 -6
  73. package/src/providers/xai-transport.ts +21 -0
  74. package/src/responses/google-provider-options.ts +36 -0
  75. package/src/responses/namespace-tool-compat.ts +84 -4
  76. package/src/responses/parser.ts +11 -0
  77. package/src/responses/provider-opaque-metadata.ts +3 -3
  78. package/src/responses/schema.ts +37 -0
  79. package/src/responses/state.ts +94 -4
  80. package/src/router.ts +8 -2
  81. package/src/server/auth-cors.ts +28 -0
  82. package/src/server/images.ts +19 -35
  83. package/src/server/management/agent-settings-routes.ts +205 -15
  84. package/src/server/management/combo-routes.ts +6 -0
  85. package/src/server/management/config-routes.ts +31 -5
  86. package/src/server/management/model-rows.ts +4 -0
  87. package/src/server/management/oauth-account-routes.ts +25 -4
  88. package/src/server/management/provider-routes.ts +113 -15
  89. package/src/server/management/routing-profile-routes.ts +3 -0
  90. package/src/server/request-log.ts +21 -0
  91. package/src/server/responses/agent-task-recovery.ts +1 -1
  92. package/src/server/responses/compact.ts +30 -1
  93. package/src/server/responses/core.ts +359 -153
  94. package/src/server/responses/empty-completion-guard.ts +35 -6
  95. package/src/server/responses/fetch-helpers.ts +18 -5
  96. package/src/server/responses/v2-native-parent-override.ts +59 -0
  97. package/src/server/responses/ws-upstream.ts +75 -2
  98. package/src/server/responses-undeclared-tool-guard.ts +90 -8
  99. package/src/service.ts +1 -1
  100. package/src/types/config.ts +16 -1
  101. package/src/types/provider.ts +16 -0
  102. package/src/types/request.ts +28 -0
  103. package/src/types/tools.ts +27 -0
  104. package/src/types.ts +6 -0
  105. package/src/web-search/gemini-executor.ts +6 -4
  106. package/src/web-search/loop.ts +42 -6
  107. package/gui/dist/assets/index-BG43zwVe.js +0 -102
  108. package/gui/dist/assets/index-CiSI-jrP.css +0 -1
@@ -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)) {
@@ -231,6 +236,13 @@ export async function* guardEmptyCompletionEventStream(
231
236
  return;
232
237
  }
233
238
  if (event.type === "error") {
239
+ // A local route/request validation failure from a continuation is already a complete,
240
+ // typed client error. Do not collapse it into the generic empty-completion retry failure.
241
+ if (event.status === 400 && event.errorType === "invalid_request_error") {
242
+ yield* releaseHeld();
243
+ yield withUsage(event);
244
+ return;
245
+ }
234
246
  if (retries > 0 && event.status !== 499) {
235
247
  // The retry failed upstream. Its body cannot reach the client (the
236
248
  // 200 head went out with the first attempt), so state the failure in
@@ -267,8 +279,25 @@ export async function* guardEmptyCompletionEventStream(
267
279
  if (isReasoningEvent(event)) yield { type: "heartbeat" };
268
280
  }
269
281
  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.
282
+ // A terminal-less EOF before text or a tool call is replay-safe: nothing
283
+ // actionable reached the client. Retry once, then surface a stated error
284
+ // instead of letting the bridge reduce the turn to adapter_eof.
285
+ if (!sawContent && !passthrough && retries < maxRetries) {
286
+ retries += 1;
287
+ try {
288
+ source = await options.continuation();
289
+ } catch {
290
+ yield emptyCompletionRetryFailedEvent(usage, true);
291
+ return;
292
+ }
293
+ continue;
294
+ }
295
+ if (!sawContent && retries > 0) {
296
+ yield emptyCompletionRetryFailedEvent(usage, true);
297
+ return;
298
+ }
299
+ // Post-output EOF remains incomplete; replaying could duplicate text or
300
+ // executable tool calls.
272
301
  yield* releaseHeld();
273
302
  return;
274
303
  }
@@ -9,6 +9,7 @@ import type { OcxProviderConfig } from "../../types";
9
9
  import type { WsData } from "../ws-bridge";
10
10
  import { waitForProviderRequestSlot } from "../../providers/request-pacing";
11
11
  import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
12
+ import { providerTlsFetch } from "../../lib/provider-tls-profile";
12
13
 
13
14
  export { withUpstreamHttpVersion };
14
15
 
@@ -64,14 +65,29 @@ export function providerFetch(
64
65
  options: ProviderFetchOptions = {},
65
66
  ): ProviderFetch {
66
67
  const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
68
+ const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
69
+ base.preconnect?.(...args);
70
+ };
71
+ const transport = options.providerName
72
+ ? providerTlsFetch(options.providerName, provider, base)
73
+ : base;
74
+ const httpFetch = Object.assign(
75
+ (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
76
+ transport(input, withUpstreamHttpVersion(input, init, provider)),
77
+ { preconnect },
78
+ ) as typeof globalThis.fetch;
67
79
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
68
80
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
69
81
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
70
82
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
71
83
  if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
72
- return codexWsUpstreamFetch(input, init, base, runtime);
84
+ // The fallback has to be the same HTTP fetch the non-WS branch would have
85
+ // used, protocol pin included: a WS turn that falls back is serving the
86
+ // request over HTTP, and dropping the provider's `upstreamHttpVersion`
87
+ // there would silently negotiate a transport the operator ruled out.
88
+ return codexWsUpstreamFetch(input, init, httpFetch, runtime);
73
89
  }
74
- return base(input, withUpstreamHttpVersion(input, init, provider));
90
+ return httpFetch(input, init);
75
91
  };
76
92
  let pacingSlotAcquired = options.pacingSlotAcquired === true;
77
93
  const waitForPacing = (signal?: AbortSignal) => {
@@ -87,9 +103,6 @@ export function providerFetch(
87
103
  await waitForPacing(init?.signal ?? undefined);
88
104
  return unpaced(input, init);
89
105
  };
90
- const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
91
- base.preconnect?.(...args);
92
- };
93
106
  return Object.assign(wrapped, {
94
107
  preconnect,
95
108
  waitForPacing,
@@ -0,0 +1,59 @@
1
+ import type { OcxConfig, OcxParsedRequest } from "../../types";
2
+ import { routeModel, type RouteResult } from "../../router";
3
+ import type { PolicyRequestEvidence } from "../../routing/evaluator";
4
+ import { isMultiAgentV2Enabled } from "../../codex/features";
5
+ import { collabSurface } from "./collaboration";
6
+ import { isThreadSpawnRequest } from "../effort-policy";
7
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
8
+
9
+ export type V2NativeParentOverrideDecision =
10
+ | { kind: "skip" }
11
+ | { kind: "reject"; message: string; trace?: unknown }
12
+ | { kind: "override"; route: RouteResult };
13
+
14
+ export function decideV2NativeParentOverride(args: {
15
+ kind: "responses" | "compact";
16
+ config: OcxConfig;
17
+ headers: Headers;
18
+ sourceRoute: RouteResult;
19
+ parsed?: OcxParsedRequest;
20
+ comboAttempt?: boolean;
21
+ targetEvidence?: PolicyRequestEvidence;
22
+ }): V2NativeParentOverrideDecision {
23
+ const { config, headers, sourceRoute } = args;
24
+ if (
25
+ args.comboAttempt
26
+ || config.v2NativeParentOverride?.enabled !== true
27
+ || config.multiAgentMode !== "v2"
28
+ || config.keepNativeChatGptOnV1 === true
29
+ || !isMultiAgentV2Enabled()
30
+ ) return { kind: "skip" };
31
+ if (args.kind === "responses" && (
32
+ !args.parsed
33
+ || (collabSurface(args.parsed) !== "v2"
34
+ && !(args.parsed._compactionRequest === true && config.multiAgentMode === "v2"))
35
+ )) {
36
+ return { kind: "skip" };
37
+ }
38
+ if (isThreadSpawnRequest(headers) || headers.has("x-openai-subagent")) return { kind: "skip" };
39
+ if (!isCanonicalOpenAiForwardProvider(sourceRoute.provider)) return { kind: "skip" };
40
+
41
+ const targetModel = config.v2NativeParentOverride.model?.trim();
42
+ if (!targetModel) {
43
+ return { kind: "reject", message: "v2NativeParentOverride requires a configured model" };
44
+ }
45
+ let route: RouteResult;
46
+ try {
47
+ route = routeModel(config, targetModel, args.targetEvidence);
48
+ } catch (error) {
49
+ return {
50
+ kind: "reject",
51
+ message: error instanceof Error ? error.message : String(error),
52
+ trace: (error as { trace?: unknown }).trace,
53
+ };
54
+ }
55
+ if (isCanonicalOpenAiForwardProvider(route.provider)) {
56
+ return { kind: "reject", message: "v2NativeParentOverride target must resolve to a noncanonical provider" };
57
+ }
58
+ return { kind: "override", route };
59
+ }
@@ -28,6 +28,25 @@ const UPGRADE_DEADLINE_MS = 10_000;
28
28
  export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
29
29
  export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
30
30
  export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
31
+ // The backend drops any inbound message of 16 MiB or more: it closes the socket
32
+ // (1009) without a Responses terminal event, which reaches clients as a bare
33
+ // 502 upstream_server_error. Measured against the live endpoint 2026-08-23:
34
+ // 16,777,000 B completed, 16,777,300 B closed in ~1s, every time. The same
35
+ // request body succeeds over HTTP SSE, so the ceiling belongs to this transport
36
+ // alone (see #2426). A full-replay thread reaches it with ~11 pasted
37
+ // screenshots, and then never recovers, because each retry resends the frame.
38
+ export const MAX_CODEX_WS_CREATE_FRAME_BYTES = 16 * 1024 * 1024;
39
+ // Bun frames the payload it is handed, so the send-side budget is the JSON text
40
+ // itself, and nothing is appended between the check and the send. The margin is
41
+ // a conservative cushion, not a computed requirement: it covers RFC 6455 frame
42
+ // overhead in case the backend counts it (14 bytes at this payload size — an
43
+ // 8-byte extended length plus a 4-byte client mask, leaving ~65.5 KiB spare),
44
+ // and it leaves room for a future caller that appends to the frame.
45
+ const CODEX_WS_CREATE_FRAME_MARGIN_BYTES = 64 * 1024;
46
+ export const CODEX_WS_CREATE_FRAME_LIMIT_BYTES =
47
+ MAX_CODEX_WS_CREATE_FRAME_BYTES - CODEX_WS_CREATE_FRAME_MARGIN_BYTES;
48
+ /** Close code the backend uses for an oversized message (RFC 6455 "message too big"). */
49
+ const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
31
50
 
32
51
  export type BunRuntimeIdentity = {
33
52
  version: string;
@@ -102,6 +121,52 @@ export function shouldUseCodexWsUpstream(
102
121
  }
103
122
  }
104
123
 
124
+ const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
125
+
126
+ /**
127
+ * The close code is the only thing that separates "the backend refused this
128
+ * payload" from "the network dropped", and both used to reach the caller as the
129
+ * same bare 502. Naming the oversized case here puts that distinction in the
130
+ * message the client receives.
131
+ *
132
+ * It does NOT reach the request log as a typed code. The eager relay turns any
133
+ * stream error into a generic `upstream_reset` synthetic terminal
134
+ * (`relay.ts`, `relay-eager.ts`) without feeding that frame back through the
135
+ * inspector, so `/api/logs` keeps neither this message nor a specific code —
136
+ * only `streamAborted`. Machine-readable typing would mean changing the error
137
+ * taxonomy, which is deliberately out of scope for this transport fix.
138
+ */
139
+ function closedBeforeTerminalMessage(event: unknown): string {
140
+ const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
141
+ const code = typeof detail?.code === "number" ? detail.code : null;
142
+ const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
143
+ if (code === null) return CLOSED_BEFORE_TERMINAL;
144
+ const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
145
+ if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
146
+ return `codex websocket rejected the request frame as too large (close${suffix});`
147
+ + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
148
+ }
149
+ return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
150
+ }
151
+
152
+ /**
153
+ * True when the `response.create` frame is at or above the backend's inbound
154
+ * message ceiling, so this turn must take the HTTP SSE path instead.
155
+ *
156
+ * Sizing a 16 MiB string should not cost a 16 MiB copy. UTF-8 never encodes
157
+ * below one byte per UTF-16 code unit and never above three, so both tails are
158
+ * settled from the string length alone; only the narrow band between them pays
159
+ * for a real byte count, and `Buffer.byteLength` measures without allocating.
160
+ */
161
+ export function codexWsCreateFrameExceedsLimit(
162
+ frameText: string,
163
+ limitBytes: number = CODEX_WS_CREATE_FRAME_LIMIT_BYTES,
164
+ ): boolean {
165
+ if (frameText.length >= limitBytes) return true;
166
+ if (frameText.length * 3 < limitBytes) return false;
167
+ return Buffer.byteLength(frameText, "utf8") >= limitBytes;
168
+ }
169
+
105
170
  export function codexWsUpstreamFetch(
106
171
  url: string,
107
172
  init: RequestInit,
@@ -127,6 +192,14 @@ export function codexWsUpstreamFetch(
127
192
  return sseFallback(url, init);
128
193
  }
129
194
 
195
+ // Decide before dialing. Once the socket is open the caller already holds a
196
+ // streaming Response, so the oversized close can only be surfaced as a stream
197
+ // error — and a resend at that point could double-generate. Measuring the
198
+ // frame we are about to send keeps the whole failure mode unreachable.
199
+ if (codexWsCreateFrameExceedsLimit(frameText)) {
200
+ return sseFallback(url, init);
201
+ }
202
+
130
203
  const headers: Record<string, string> = {};
131
204
  new Headers(init.headers ?? {}).forEach((value, key) => {
132
205
  // HTTP-body framing headers do not apply to a WS handshake.
@@ -279,7 +352,7 @@ export function codexWsUpstreamFetch(
279
352
  }
280
353
  });
281
354
 
282
- ws.addEventListener("close", () => {
355
+ ws.addEventListener("close", (event: unknown) => {
283
356
  signal?.removeEventListener("abort", onAbort);
284
357
  if (!opened) {
285
358
  if (settledPreOpen) return;
@@ -297,7 +370,7 @@ export function codexWsUpstreamFetch(
297
370
  // here would reach clients with no response.completed/failed at all —
298
371
  // relaySseWithFailedTail() only synthesizes a failed terminal when the
299
372
  // body read THROWS. Error the stream like a reset TCP socket.
300
- try { controller.error(new Error("codex websocket closed before a Responses terminal event")); } catch { /* stream already done */ }
373
+ try { controller.error(new Error(closedBeforeTerminalMessage(event))); } catch { /* stream already done */ }
301
374
  }
302
375
  });
303
376
 
@@ -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");
package/src/service.ts CHANGED
@@ -555,7 +555,7 @@ export function installedServiceListenPort(): number {
555
555
  ?? resolveServiceListenPort();
556
556
  }
557
557
 
558
- export const SERVICE_INSTALL_HEALTH_MS = 20_000;
558
+ export const SERVICE_INSTALL_HEALTH_MS = Number(process.env.OCX_SERVICE_HEALTH_TIMEOUT_MS) || 30_000;
559
559
 
560
560
  /**
561
561
  * Whether a proxy actually answers on the port this install/start just produced.
@@ -259,6 +259,19 @@ export interface OcxConfig {
259
259
  port: number;
260
260
  /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
261
261
  emptyCompletionRetry?: boolean;
262
+ /**
263
+ * Whether a login may open a browser on the machine running the proxy.
264
+ *
265
+ * Absent and `true` both mean "open", which is what every existing install
266
+ * already does. Only an explicit `false` declines — for an operator who wants
267
+ * to paste the authorization URL into a different browser profile, or who is
268
+ * driving the dashboard from a different machine than the proxy.
269
+ *
270
+ * Deliberately a boolean and not an "auto" mode: inferring headlessness from
271
+ * SSH_CONNECTION or a missing DISPLAY breaks a working login silently when
272
+ * the guess is wrong.
273
+ */
274
+ oauthOpenBrowser?: boolean;
262
275
  /** Maximum usage-log bytes read for one management snapshot. */
263
276
  managementUsageMaxReadBytes?: number;
264
277
  providers: Record<string, OcxProviderConfig>;
@@ -455,10 +468,12 @@ export interface OcxConfig {
455
468
  * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92).
456
469
  */
457
470
  keepNativeChatGptOnV1?: boolean;
471
+ /** Experimental routed target for eligible native V2 root parents. */
472
+ v2NativeParentOverride?: { enabled?: boolean; model?: string };
458
473
  /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */
459
474
  agentTaskRecovery?: {
460
475
  enabled?: boolean;
461
- /** ChatGPT model used by the recovery request. Default: gpt-5.6-sol. */
476
+ /** ChatGPT model used by the recovery request. Default: gpt-5.6-luna. */
462
477
  model?: string;
463
478
  /** Recovery request timeout in milliseconds. Default: 45000. */
464
479
  timeoutMs?: number;
@@ -8,6 +8,8 @@ import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode }
8
8
  */
9
9
  export type RefreshPolicy = "proactive" | "lazy-only" | "disabled";
10
10
 
11
+ export type ProviderTlsProfile = "antigravity-browser";
12
+
11
13
  export interface OpenRouterProviderRouting {
12
14
  /** OpenRouter provider slugs to try first, in priority order. */
13
15
  order?: string[];
@@ -68,6 +70,8 @@ export interface RequestPacingRule {
68
70
  requestsPerMinute?: number;
69
71
  /** Minimum delay between request starts. The slower configured value wins. */
70
72
  minIntervalMs?: number;
73
+ /** Positive-only random delay added to each request-start slot. */
74
+ jitterMs?: number;
71
75
  }
72
76
 
73
77
  export interface ProviderRequestPacingConfig extends RequestPacingRule {
@@ -136,6 +140,8 @@ export interface OcxProviderConfig {
136
140
  codexToolMode?: "code_mode_only" | "shell";
137
141
  /** Optional outbound request-start pacing shared by this provider and its model overrides. */
138
142
  requestPacing?: ProviderRequestPacingConfig;
143
+ /** Explicitly acknowledged experimental browser-compatible transport profile. */
144
+ tlsProfile?: ProviderTlsProfile;
139
145
  /** Cursor MCP compatibility bounds; positive integers when configured. */
140
146
  mcpMaxTools?: number;
141
147
  mcpMaxSchemaBytes?: number;
@@ -245,6 +251,11 @@ export interface OcxProviderConfig {
245
251
  */
246
252
  codexAccountMode?: CodexAccountMode;
247
253
  apiKey?: string;
254
+ /** Azure OpenAI identity authentication; mutually exclusive with API-key fields. */
255
+ azureCredential?: {
256
+ type: "default-azure-credential";
257
+ managedIdentityClientId?: string;
258
+ };
248
259
  /**
249
260
  * Key-auth header style for Anthropic-compatible providers.
250
261
  * Defaults to the native Anthropic `x-api-key`; gateways may require
@@ -281,6 +292,11 @@ export interface OcxProviderConfig {
281
292
  modelInputModalities?: Record<string, string[]>;
282
293
  /** Model-specific max input token limits. Values cap auto_compact_token_limit. */
283
294
  modelMaxInputTokens?: Record<string, number>;
295
+ /**
296
+ * Per-model soft compaction budgets. Values may only lower the effective
297
+ * context/max-input envelope; they never raise hard admission limits.
298
+ */
299
+ modelAutoCompactTokenLimits?: Record<string, number>;
284
300
  /**
285
301
  * Provider-wide fallback for chat-completions `max_tokens` when the caller omits
286
302
  * Responses `max_output_tokens`. Adapters still let an explicit request win.
@@ -68,6 +68,8 @@ export interface OcxParsedRequest {
68
68
  _cursorConversationId?: string;
69
69
  /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */
70
70
  _clientThreadId?: string;
71
+ /** True when promptCacheKey is a shared cache cohort rather than a conversation identity. */
72
+ _promptCacheKeyIsSharedCohort?: boolean;
71
73
  /** Conversation/provider/account/model-bound namespace for reasoning replay state. */
72
74
  _reasoningReplayScope?: OcxReasoningReplayScopeRef;
73
75
  /**
@@ -271,6 +273,32 @@ export interface OcxRequestOptions {
271
273
  schema?: Record<string, unknown>;
272
274
  strict?: boolean;
273
275
  };
276
+ providerOptions?: { google?: GoogleProviderOptions };
277
+ }
278
+
279
+ export type GoogleSafetyCategory =
280
+ | "HARM_CATEGORY_HATE_SPEECH"
281
+ | "HARM_CATEGORY_SEXUALLY_EXPLICIT"
282
+ | "HARM_CATEGORY_DANGEROUS_CONTENT"
283
+ | "HARM_CATEGORY_HARASSMENT"
284
+ | "HARM_CATEGORY_CIVIC_INTEGRITY"
285
+ | "HARM_CATEGORY_JAILBREAK";
286
+ export type GoogleSafetyThreshold =
287
+ | "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
288
+ | "BLOCK_LOW_AND_ABOVE"
289
+ | "BLOCK_MEDIUM_AND_ABOVE"
290
+ | "BLOCK_ONLY_HIGH"
291
+ | "BLOCK_NONE"
292
+ | "OFF";
293
+ export interface GoogleSafetySetting {
294
+ category: GoogleSafetyCategory;
295
+ threshold: GoogleSafetyThreshold;
296
+ }
297
+ export interface GoogleProviderOptions {
298
+ thinkingBudget?: number;
299
+ includeThoughts?: boolean;
300
+ safetySettings?: GoogleSafetySetting[];
301
+ cachedContent?: string;
274
302
  }
275
303
 
276
304
  export type OcxMessagePhase = "commentary" | "final_answer";
@@ -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,
@@ -43,6 +44,10 @@ export type {
43
44
  OcxProviderOpaqueToolCallMetadata,
44
45
  OcxAssistantContentPart,
45
46
  OcxRequestOptions,
47
+ GoogleProviderOptions,
48
+ GoogleSafetySetting,
49
+ GoogleSafetyCategory,
50
+ GoogleSafetyThreshold,
46
51
  OcxMessagePhase,
47
52
  OcxProviderContinuationOwner,
48
53
  OcxProviderContinuationState,
@@ -86,6 +91,7 @@ export type {
86
91
 
87
92
  export type {
88
93
  RefreshPolicy,
94
+ ProviderTlsProfile,
89
95
  OpenRouterProviderRouting,
90
96
  ResponsesItemIdRepairConfig,
91
97
  RateLimitRetryPolicy,