@bitkyc08/opencodex 2.30.0-preview.20260821 → 2.31.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 (61) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-DkcRs1fL.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +7 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/live-models.ts +21 -26
  10. package/src/adapters/cursor/live-transport.ts +213 -3
  11. package/src/adapters/cursor/native-exec-common.ts +17 -0
  12. package/src/adapters/cursor/native-exec.ts +9 -4
  13. package/src/adapters/cursor/protobuf-events.ts +5 -1
  14. package/src/adapters/cursor/protobuf-request.ts +11 -4
  15. package/src/adapters/cursor/tool-definitions.ts +20 -0
  16. package/src/adapters/cursor/transport.ts +10 -0
  17. package/src/adapters/cursor.ts +23 -5
  18. package/src/adapters/google.ts +16 -3
  19. package/src/adapters/openai-responses.ts +66 -20
  20. package/src/adapters/xai-web-search.ts +185 -0
  21. package/src/cli/agent.ts +2 -1
  22. package/src/cli/dispatch.ts +2 -2
  23. package/src/cli/doctor.ts +89 -0
  24. package/src/cli/help.ts +2 -0
  25. package/src/cli/registry.ts +7 -2
  26. package/src/codex/auth-context.ts +41 -2
  27. package/src/codex/catalog/effort.ts +1 -1
  28. package/src/codex/catalog/parsing.ts +2 -0
  29. package/src/codex/catalog/provider-fetch.ts +20 -5
  30. package/src/codex/coordinator-doctor.ts +332 -0
  31. package/src/codex/inject-coordination.ts +39 -6
  32. package/src/codex/transition-state.ts +12 -12
  33. package/src/generated/compatibility-version.json +74 -50
  34. package/src/lib/errors.ts +8 -2
  35. package/src/oauth/cursor.ts +21 -0
  36. package/src/providers/cursor-pool.ts +72 -0
  37. package/src/providers/derive.ts +3 -0
  38. package/src/providers/fastwire.ts +12 -1
  39. package/src/providers/openai-sidecar.ts +1 -0
  40. package/src/providers/registry.ts +25 -0
  41. package/src/providers/service-tier.ts +22 -7
  42. package/src/responses/custom-tool-compat.ts +24 -8
  43. package/src/responses/namespace-tool-compat.ts +2 -3
  44. package/src/router.ts +3 -0
  45. package/src/server/chat-completions.ts +4 -0
  46. package/src/server/chat-native.ts +20 -0
  47. package/src/server/management/agent-settings-routes.ts +16 -5
  48. package/src/server/management/config-routes.ts +25 -5
  49. package/src/server/management/vision-sidecar-options.ts +54 -19
  50. package/src/server/responses/compact.ts +1 -2
  51. package/src/server/responses/core.ts +54 -13
  52. package/src/service.ts +122 -14
  53. package/src/types/config.ts +9 -3
  54. package/src/types/provider.ts +6 -0
  55. package/src/usage/cost.ts +52 -38
  56. package/src/usage/expected-prices.ts +79 -9
  57. package/src/vision/backends.ts +97 -0
  58. package/src/vision/eligibility.ts +43 -22
  59. package/src/vision/index.ts +73 -5
  60. package/src/vision/routed-describe.ts +175 -0
  61. package/gui/dist/assets/index-eBA05kYB.js +0 -102
@@ -50,7 +50,7 @@ import {
50
50
  recordScreenExec,
51
51
  type CursorNativeToolDeps,
52
52
  } from "./native-exec-tools";
53
- import { clientBytes, execBytes } from "./native-exec-common";
53
+ import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common";
54
54
  import type { McpToolDefinition } from "./gen/agent_pb";
55
55
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
56
56
 
@@ -603,10 +603,15 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
603
603
  }))];
604
604
  }
605
605
  // Unknown exec case — Cursor added a new native exec type that our protobuf definition does not
606
- // include yet. Return an empty reply so the stream stays alive instead of throwing (which kills
607
- // the entire gRPC connection via failAndClear). Same class of bug as #116.
606
+ // include yet. T05 (senpi contract): reply with ExecClientThrow + stream-close so the server
607
+ // unblocks with a known failure. Previously this returned an empty reply (silence), which is
608
+ // the stall class senpi explicitly refused (#116 was about throwing into failAndClear and
609
+ // killing the whole connection; a typed in-band throw does not do that).
608
610
  debugProviderDiagnostic("cursor", "unknown-exec-case", { execCase: execCase ?? "unknown", execId: execMsg.execId });
609
- return [];
611
+ return [
612
+ execThrowBytes(execMsg, "Unknown exec message variant; this client does not implement it."),
613
+ execStreamCloseBytes(execMsg),
614
+ ];
610
615
  }
611
616
 
612
617
 
@@ -11,6 +11,7 @@ import {
11
11
  isCodexShellBridgeToolName,
12
12
  isCursorStructuredEditToolName,
13
13
  normalizeCursorWireName,
14
+ normalizeCursorTextToolMarkers,
14
15
  OCX_RESPONSES_TOOL_PROVIDER,
15
16
  resolveShellBridgeAliasKey,
16
17
  responsesToolNameFromCursorWire,
@@ -1243,7 +1244,10 @@ export function mapCursorProtobufServerMessage(
1243
1244
  const update = serverMessage.message.value.message;
1244
1245
  switch (update.case) {
1245
1246
  case "textDelta":
1246
- return update.value.text ? [{ type: "text", text: update.value.text }] : [];
1247
+ // #2305: fold Cursor display aliases inside textual pseudo tool-call markers back to
1248
+ // the advertised wire name before any client sees the text. Real frames are already
1249
+ // normalized structurally (mcpWireNameFromArgs above).
1250
+ return update.value.text ? [{ type: "text", text: normalizeCursorTextToolMarkers(update.value.text) }] : [];
1247
1251
  case "thinkingDelta":
1248
1252
  return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : [];
1249
1253
  case "toolCallStarted": {
@@ -188,10 +188,12 @@ function assistantRootText(
188
188
  }
189
189
 
190
190
  // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata),
191
- // so prior history including assistant tool calls and tool results must be replayed here or a
192
- // ResumeAction has nothing model-visible to continue from. The active user message is excluded
193
- // because it travels in the action. Tool results are assistant-role text with a [Tool Result]
194
- // or [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Each entry is a SHA-256 blob ID.
191
+ // so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from.
192
+ // The active user message is excluded because it travels in the action. When the continuation cannot
193
+ // rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] /
194
+ // [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Native resume models
195
+ // already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto
196
+ // few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID.
195
197
  function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): {
196
198
  ids: Uint8Array[];
197
199
  byteLength: number;
@@ -212,6 +214,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
212
214
  }
213
215
 
214
216
  const externalModel = isCursorExternalWireModel(request.modelId);
217
+ const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId);
215
218
  const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
216
219
  const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
217
220
 
@@ -243,6 +246,10 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
243
246
  }
244
247
  // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
245
248
  } else if (message.role === "toolResult") {
249
+ // Native resume models already receive the paired MCP result through turns[]. Replaying
250
+ // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto
251
+ // to echo that envelope as chat instead of continuing from the structured result.
252
+ if (!echoToolResultInRoot) continue;
246
253
  // #1920: the prefix must reflect the NORMALIZED error state (an empty
247
254
  // node_repl result is an error even when the runtime said isError=false).
248
255
  const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]";
@@ -336,6 +336,26 @@ export function normalizeCursorWireName(name: string): string {
336
336
  return name.startsWith(CURSOR_MCP_DISPLAY_PREFIX) ? name.slice(CURSOR_MCP_DISPLAY_PREFIX.length) : name;
337
337
  }
338
338
 
339
+ /**
340
+ * #2305: some models emit a TEXTUAL pseudo tool call ("[TOOL_CALL]name[ARGS]{...}")
341
+ * instead of a real frame, using Cursor's display alias as the name. Text-mode clients
342
+ * (Pi) parse that text and then cannot dispatch the undeclared display name. Rewrite the
343
+ * display alias to the advertised wire name ONLY inside the marker pair — prose that
344
+ * merely mentions the alias stays untouched, and the scope guard is the exact
345
+ * `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_` prefix, never generic `mcp_`.
346
+ * Known limit (recorded in devlog 230): a marker split across two streaming deltas is
347
+ * not rewritten; tail-buffering is deferred until a live trace shows split markers.
348
+ */
349
+ const CURSOR_TEXT_TOOL_MARKER = new RegExp(
350
+ String.raw`\[TOOL_CALL\](${CURSOR_MCP_DISPLAY_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\[\]]+)\[ARGS\]`,
351
+ "g",
352
+ );
353
+
354
+ export function normalizeCursorTextToolMarkers(text: string): string {
355
+ if (!text.includes(CURSOR_MCP_DISPLAY_PREFIX)) return text;
356
+ return text.replace(CURSOR_TEXT_TOOL_MARKER, (_match, name: string) => `[TOOL_CALL]${normalizeCursorWireName(name)}[ARGS]`);
357
+ }
358
+
339
359
  export function responsesToolNameFromCursorWire(name: string, cursorToolNameMap?: ReadonlyMap<string, string>): string {
340
360
  const normalized = normalizeCursorWireName(name);
341
361
  if (!cursorToolNameMap) return normalized;
@@ -29,6 +29,16 @@ export interface CursorTransportFactoryInput {
29
29
  firstFrameTimeoutMs?: number;
30
30
  /** Grace (ms) between close() and the force-destroy fallback after a first-frame timeout. Defaults to 1s. */
31
31
  timeoutDestroyGraceMs?: number;
32
+ /**
33
+ * T04 watchdog: maximum inbound decoded-frame silence (ms) after the first frame before the
34
+ * turn is failed. Defaults to 30s.
35
+ */
36
+ streamSilenceFailMs?: number;
37
+ /**
38
+ * T04 watchdog: maximum heartbeat/checkpoint-only traffic (ms) without turn progress before
39
+ * the turn is failed. Defaults to 90s.
40
+ */
41
+ streamHeartbeatOnlyFailMs?: number;
32
42
  /**
33
43
  * Grace window (ms) before a drained client-tool turn is finalized, so a sibling tool call
34
44
  * announced in a later receive chunk can revoke a premature finalize. Defaults to 50ms.
@@ -3,8 +3,8 @@ import type { AdapterEvent, OcxProviderConfig } from "../types";
3
3
  import type { ProviderAdapter } from "./base";
4
4
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
5
5
  import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
6
- import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors";
7
- import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery";
6
+ import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
7
+ import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
8
8
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
9
9
  import { mapCursorServerMessage } from "./cursor/message-mapper";
10
10
  import {
@@ -25,6 +25,7 @@ import {
25
25
  invalidateCursorCheckpoint,
26
26
  } from "./cursor/checkpoint-store";
27
27
  import { debugProviderDiagnostic } from "../lib/debug";
28
+ import { estimateTokens } from "../lib/token-estimate";
28
29
  import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
29
30
  import { runCursorTurnWithRetry } from "./cursor/transport-retry";
30
31
  import {
@@ -53,16 +54,29 @@ export interface CursorAdapterDeps {
53
54
  rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void;
54
55
  }
55
56
 
56
- function safeCursorTransportError(err: unknown): string {
57
+ function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string {
57
58
  if (err instanceof CursorTransportDisabledError) return CURSOR_TRANSPORT_DISABLED_MESSAGE;
58
59
  if (err instanceof CursorMissingCredentialError) {
59
60
  return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN.";
60
61
  }
61
62
  const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined;
62
- if (message) return safeCursorErrorMessage(message);
63
+ if (message) return safeCursorErrorMessage(message, sizeContext);
63
64
  return "Cursor upstream error: transport failed before completion.";
64
65
  }
65
66
 
67
+ /**
68
+ * Size prior for bare resource_exhausted classification (devlog 260): a rough input
69
+ * estimate over the outgoing text vs the model's context window. Only used to keep
70
+ * SMALL requests on the 429 class — unknown/large stays on the overflow mapping.
71
+ */
72
+ function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext {
73
+ const text = [...request.system, ...request.messages.map(message => message.content)].join("\n");
74
+ return {
75
+ estimatedInputTokens: estimateTokens(text, request.modelId),
76
+ contextWindow: inferCursorContextWindow(request.modelId),
77
+ };
78
+ }
79
+
66
80
  export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter {
67
81
  return {
68
82
  name: "cursor",
@@ -88,6 +102,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
88
102
  emit({ type: "error", message: "Cursor turn was aborted before start." });
89
103
  return;
90
104
  }
105
+ // Captured after createCursorRequest so the catch block can apply the bare-RE
106
+ // size prior (devlog 260) even though `request` is scoped inside the try.
107
+ let requestSizeContext: CursorSizeContext | undefined;
91
108
  try {
92
109
  const makeTransport = deps.createTransport ?? createLiveCursorTransport;
93
110
  const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget);
@@ -110,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
110
127
  const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef;
111
128
  const previousConversationId = _parsed._cursorConversationId;
112
129
  let request = createCursorRequest(_parsed);
130
+ requestSizeContext = cursorRequestSizeContext(request);
113
131
  // The builder may derive a stable provider id from the client thread when Responses state
114
132
  // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
115
133
  // and isolated helper/compaction turns must never inherit or donate the parent's usage state.
@@ -292,7 +310,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
292
310
  type: "error",
293
311
  message: isTranslatorBudgetExceededError(err)
294
312
  ? "upstream translation buffer exceeded the safe limit"
295
- : safeCursorTransportError(err),
313
+ : safeCursorTransportError(err, requestSizeContext),
296
314
  ...(isTranslatorBudgetExceededError(err)
297
315
  ? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" }
298
316
  : {}),
@@ -413,6 +413,7 @@ interface GoogleResponsePart {
413
413
  thought?: boolean;
414
414
  thoughtSignature?: string;
415
415
  thought_signature?: string;
416
+ extra_content?: { google?: { thought_signature?: unknown } };
416
417
  functionCall?: unknown;
417
418
  }
418
419
 
@@ -421,6 +422,18 @@ interface GoogleFunctionCall {
421
422
  args?: unknown;
422
423
  }
423
424
 
425
+ /**
426
+ * Read a Gemini/Antigravity thought signature from a response part. Antigravity can place it
427
+ * either directly on the part (`thoughtSignature` / `thought_signature`) or inside the same
428
+ * nested `extra_content.google.thought_signature` shape used on the Responses wire.
429
+ */
430
+ function googlePartThoughtSignature(part: GoogleResponsePart): string | undefined {
431
+ const direct = part.thoughtSignature ?? part.thought_signature;
432
+ if (typeof direct === "string" && direct.length > 0) return direct;
433
+ const nested = part.extra_content?.google?.thought_signature;
434
+ return typeof nested === "string" && nested.length > 0 ? nested : undefined;
435
+ }
436
+
424
437
  /**
425
438
  * Carry a Gemini thought signature with the exact function-call part that produced it. Google
426
439
  * validates the signature against that specific part, so it must ride the individual tool call
@@ -430,7 +443,7 @@ function googleToolCallMetadataFromPart(
430
443
  part: GoogleResponsePart,
431
444
  fallbackSignature?: string,
432
445
  ): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined {
433
- const signature = part.thoughtSignature ?? part.thought_signature ?? fallbackSignature;
446
+ const signature = googlePartThoughtSignature(part) ?? fallbackSignature;
434
447
  if (!isLikelyRealThoughtSignature(signature)) return undefined;
435
448
  return { providerMetadata: { google: { thoughtSignature: signature } } };
436
449
  }
@@ -960,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
960
973
  }
961
974
  if (parts) {
962
975
  for (const part of parts) {
963
- const sig = part.thoughtSignature ?? part.thought_signature;
976
+ const sig = googlePartThoughtSignature(part);
964
977
  if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
965
978
  pendingStreamThoughtSig = sig;
966
979
  }
@@ -1224,7 +1237,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1224
1237
  }
1225
1238
  let pendingThoughtSig: string | undefined;
1226
1239
  for (const part of parts) {
1227
- const sig = part.thoughtSignature ?? part.thought_signature;
1240
+ const sig = googlePartThoughtSignature(part);
1228
1241
  if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
1229
1242
  pendingThoughtSig = sig;
1230
1243
  }
@@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co
19
19
  import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
20
20
  import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
21
21
  import { openaiResponsesUrl } from "./openai-responses-url";
22
+ import { normalizeXaiResponsesWebSearch } from "./xai-web-search";
22
23
  import {
23
24
  createAdapterTierMetadata,
24
25
  } from "../providers/fastwire";
@@ -1503,17 +1504,55 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
1503
1504
  * provider capability metadata; an unclassified upstream keeps the fields.
1504
1505
  */
1505
1506
  const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const;
1506
- export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
1507
- if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
1507
+
1508
+ function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): {
1509
+ tools: unknown[];
1510
+ changed: boolean;
1511
+ } {
1508
1512
  let changed = false;
1509
- const tools = body.tools.map(t => {
1510
- if (!isPlainObject(t) || (t.type !== "web_search" && t.type !== "web_search_preview")) return t;
1511
- if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(t, field))) return t;
1512
- const { external_web_access: _access, search_context_size: _size, ...rest } = t;
1513
+ const stripped = tools.map(tool => {
1514
+ if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) {
1515
+ return tool;
1516
+ }
1517
+ if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool;
1518
+ const { external_web_access: _access, search_context_size: _size, ...rest } = tool;
1513
1519
  changed = true;
1514
1520
  return rest;
1515
1521
  });
1516
- return changed ? { ...body, tools } : body;
1522
+ return { tools: changed ? stripped : tools, changed };
1523
+ }
1524
+
1525
+ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
1526
+ if (!isPlainObject(body)) return body;
1527
+
1528
+ let next: Record<string, unknown> = body;
1529
+ let changed = false;
1530
+ if (Array.isArray(body.tools)) {
1531
+ const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools);
1532
+ if (stripped.changed) {
1533
+ next = { ...next, tools: stripped.tools };
1534
+ changed = true;
1535
+ }
1536
+ }
1537
+
1538
+ if (Array.isArray(body.input)) {
1539
+ let inputChanged = false;
1540
+ const input = body.input.map(item => {
1541
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
1542
+ return item;
1543
+ }
1544
+ const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools);
1545
+ if (!stripped.changed) return item;
1546
+ inputChanged = true;
1547
+ return { ...item, tools: stripped.tools };
1548
+ });
1549
+ if (inputChanged) {
1550
+ next = { ...next, input };
1551
+ changed = true;
1552
+ }
1553
+ }
1554
+
1555
+ return changed ? next : body;
1517
1556
  }
1518
1557
 
1519
1558
  /** Replace every `input_image` part under a routed-compaction body with a short marker. */
@@ -1692,17 +1731,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1692
1731
  // that already recorded a single-query web_search_call replays it every turn, and
1693
1732
  // a strict parser rejects the whole request over it (#930).
1694
1733
  outBody = backfillWebSearchQueries(outBody);
1695
- // Same predicate as the routedCompaction gate in handleResponses(): an
1696
- // authMode check would let a noncanonical custom forward provider skip this
1697
- // rewrite while the server still routes it as a summarizer turn (#422).
1698
- if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
1699
- outBody = buildRoutedCompactionBody(outBody);
1700
- }
1701
1734
  if (!isCanonicalOpenAiForwardProvider(provider)) {
1702
1735
  outBody = promoteClientLoadedTools(outBody);
1703
1736
  }
1704
1737
  if (!isCanonicalOpenAiForwardProvider(provider)) {
1705
- const rewritten = rewriteRoutedCustomToolsForUpstream(outBody);
1738
+ const rewritten = rewriteRoutedCustomToolsForUpstream(
1739
+ outBody,
1740
+ provider.supportsResponsesCustomTools,
1741
+ );
1706
1742
  outBody = rewritten.body;
1707
1743
  convertedRoutedCustomToolNames = rewritten.names;
1708
1744
  }
@@ -1712,12 +1748,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1712
1748
  const rewritten = rewriteRoutedToolSearchForUpstream(outBody);
1713
1749
  outBody = rewritten.body;
1714
1750
  convertedRoutedToolSearchNames = rewritten.names;
1715
- // xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them
1716
- // for OpenAI API-key traffic and unclassified gateways; only an explicit
1717
- // provider capability denial activates the compatibility transform.
1718
- if (provider.supportsOpenAiWebSearchToolFields === false) {
1719
- outBody = stripOpenAiOnlyWebSearchFields(outBody);
1720
- }
1721
1751
  }
1722
1752
  if (!isCanonicalOpenAiForwardProvider(provider)) {
1723
1753
  // Codex 0.147 emits private namespace tool groups, while public/third-party Responses
@@ -1726,9 +1756,25 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1726
1756
  const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody);
1727
1757
  outBody = rewritten.body;
1728
1758
  convertedRoutedNamespaceToolAliases = rewritten.aliases;
1759
+ // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the
1760
+ // generic capability fallback removes the private OpenAI fields.
1761
+ outBody = normalizeXaiResponsesWebSearch(outBody, provider);
1762
+ // xAI and explicitly classified compatible gateways reject these OpenAI web_search
1763
+ // extensions. Keep them for OpenAI API-key traffic and unclassified gateways.
1764
+ if (provider.supportsOpenAiWebSearchToolFields === false) {
1765
+ outBody = stripOpenAiOnlyWebSearchFields(outBody);
1766
+ }
1729
1767
  // Last, so promoted namespace children are also cleared of Codex-private fields.
1730
1768
  outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
1731
1769
  }
1770
+ // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would
1771
+ // let a noncanonical custom forward provider skip this rewrite while the server still routes
1772
+ // it as a summarizer turn (#422). The compaction body build removes the tool surface and must
1773
+ // therefore be the last routed transform: anything before it may depend on the declarations;
1774
+ // anything after it cannot.
1775
+ if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
1776
+ outBody = buildRoutedCompactionBody(outBody);
1777
+ }
1732
1778
  const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
1733
1779
  const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(
1734
1780
  outBody,
@@ -0,0 +1,185 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+
3
+ const CODEX_WEB_SEARCH_TOOL = "web_search";
4
+ const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
5
+ const XAI_API_HOST = "api.x.ai";
6
+
7
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
8
+ return !!value && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+
11
+ function isCodexWebSearchToolType(value: unknown): boolean {
12
+ return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL;
13
+ }
14
+
15
+ /** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */
16
+ function isXaiPublicApi(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
17
+ try {
18
+ const url = new URL(provider.baseUrl);
19
+ return url.protocol === "https:"
20
+ && url.hostname.toLowerCase() === XAI_API_HOST
21
+ && (url.port === "" || url.port === "443");
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ type ToolGroupRewrite = {
28
+ tools: unknown[];
29
+ changed: boolean;
30
+ };
31
+
32
+ /**
33
+ * Translate Codex-private hosted-search fields to xAI's public Responses schema.
34
+ *
35
+ * xAI web search is live-only. A Codex cached/index-only declaration carries
36
+ * `external_web_access: false`; dropping that flag while keeping the tool would silently widen
37
+ * network access, so the whole tool is omitted instead. `true` maps to xAI's ordinary live
38
+ * `{type:"web_search"}` declaration. Requests that omit the private flag are already public-API
39
+ * shaped and retain their live-search behavior.
40
+ */
41
+ function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite {
42
+ const normalized: unknown[] = [];
43
+ let changed = false;
44
+
45
+ for (const tool of tools) {
46
+ if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) {
47
+ normalized.push(tool);
48
+ continue;
49
+ }
50
+
51
+ const hasExternalAccess = Object.hasOwn(tool, "external_web_access");
52
+ if (hasExternalAccess && tool.external_web_access !== true) {
53
+ // xAI has no cached/index-only equivalent. Fail closed instead of turning it into live search.
54
+ changed = true;
55
+ continue;
56
+ }
57
+
58
+ const searchContentTypes = Array.isArray(tool.search_content_types)
59
+ ? tool.search_content_types
60
+ : undefined;
61
+ const enableImageSearch = searchContentTypes?.includes("image") === true;
62
+ const next: Record<string, unknown> = { ...tool, type: CODEX_WEB_SEARCH_TOOL };
63
+ delete next.external_web_access;
64
+ delete next.search_context_size;
65
+ delete next.search_content_types;
66
+ delete next.user_location;
67
+ if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) {
68
+ next.enable_image_search = true;
69
+ }
70
+
71
+ const toolChanged = Object.keys(next).length !== Object.keys(tool).length
72
+ || Object.entries(next).some(([key, value]) => tool[key] !== value);
73
+ changed ||= toolChanged;
74
+ normalized.push(toolChanged ? next : tool);
75
+ }
76
+
77
+ return { tools: changed ? normalized : tools, changed };
78
+ }
79
+
80
+ function hasWebSearchTool(body: Record<string, unknown>): boolean {
81
+ if (Array.isArray(body.tools) && body.tools.some(tool =>
82
+ isPlainObject(tool) && isCodexWebSearchToolType(tool.type)
83
+ )) return true;
84
+ return Array.isArray(body.input) && body.input.some(item =>
85
+ isPlainObject(item)
86
+ && item.type === "additional_tools"
87
+ && Array.isArray(item.tools)
88
+ && item.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type))
89
+ );
90
+ }
91
+
92
+ function hasAnyDeclaredTool(body: Record<string, unknown>): boolean {
93
+ if (Array.isArray(body.tools) && body.tools.length > 0) return true;
94
+ return Array.isArray(body.input) && body.input.some(item =>
95
+ isPlainObject(item)
96
+ && item.type === "additional_tools"
97
+ && Array.isArray(item.tools)
98
+ && item.tools.length > 0
99
+ );
100
+ }
101
+
102
+ /** Remove selectors that would still force a cached-only tool omitted above. */
103
+ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unknown> {
104
+ const choice = body.tool_choice;
105
+ if (choice === undefined) return body;
106
+ const hasSearch = hasWebSearchTool(body);
107
+
108
+ if (isPlainObject(choice) && isCodexWebSearchToolType(choice.type)) {
109
+ if (!hasSearch) return { ...body, tool_choice: "none" };
110
+ return choice.type === CODEX_WEB_SEARCH_TOOL
111
+ ? body
112
+ : { ...body, tool_choice: { ...choice, type: CODEX_WEB_SEARCH_TOOL } };
113
+ }
114
+ if (isPlainObject(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
115
+ let changed = false;
116
+ const tools: unknown[] = [];
117
+ for (const tool of choice.tools) {
118
+ if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) {
119
+ tools.push(tool);
120
+ continue;
121
+ }
122
+ if (!hasSearch) {
123
+ changed = true;
124
+ continue;
125
+ }
126
+ if (tool.type === CODEX_WEB_SEARCH_PREVIEW_TOOL) {
127
+ tools.push({ ...tool, type: CODEX_WEB_SEARCH_TOOL });
128
+ changed = true;
129
+ } else {
130
+ tools.push(tool);
131
+ }
132
+ }
133
+ if (!changed) return body;
134
+ return {
135
+ ...body,
136
+ tool_choice: tools.length > 0 ? { ...choice, tools } : "none",
137
+ };
138
+ }
139
+ if (choice === "required" && !hasAnyDeclaredTool(body)) {
140
+ return { ...body, tool_choice: "none" };
141
+ }
142
+ return body;
143
+ }
144
+
145
+ /**
146
+ * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
147
+ * providers or mutating the caller-owned request body.
148
+ */
149
+ export function normalizeXaiResponsesWebSearch(
150
+ body: unknown,
151
+ provider: Pick<OcxProviderConfig, "baseUrl">,
152
+ ): unknown {
153
+ if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body;
154
+
155
+ let next: Record<string, unknown> = body;
156
+ if (Array.isArray(body.tools)) {
157
+ const rewritten = normalizeToolGroup(body.tools);
158
+ if (rewritten.changed) {
159
+ next = { ...next };
160
+ if (rewritten.tools.length > 0) next.tools = rewritten.tools;
161
+ else delete next.tools;
162
+ }
163
+ }
164
+
165
+ if (Array.isArray(next.input)) {
166
+ let inputChanged = false;
167
+ const input: unknown[] = [];
168
+ for (const item of next.input) {
169
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
170
+ input.push(item);
171
+ continue;
172
+ }
173
+ const rewritten = normalizeToolGroup(item.tools);
174
+ if (!rewritten.changed) {
175
+ input.push(item);
176
+ continue;
177
+ }
178
+ inputChanged = true;
179
+ if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools });
180
+ }
181
+ if (inputChanged) next = { ...next, input };
182
+ }
183
+
184
+ return normalizeToolChoice(next);
185
+ }
package/src/cli/agent.ts CHANGED
@@ -27,7 +27,8 @@ const USAGE = `Usage:
27
27
  ocx agent effort <status|set> [--main <level|->] [--subagent <level|->] [--json]
28
28
  ocx agent subagents <status|set|clear> [model,model...] [--json]
29
29
  ocx agent fallback <status|set|clear> [model,model...] [--poll-ms <5000-600000>] [--json]
30
- ocx agent sidecar <status|web|vision> [--list] [--model <id|->] [--backend <openai|anthropic|xai|gemini|exa|->]
30
+ ocx agent sidecar <status|web|vision> [--list] [--model <id|->]
31
+ [--backend web:<openai|anthropic|xai|gemini|exa|-> vision:<openai|anthropic|routed|->]
31
32
  [--reasoning <level>] [--max-descriptions <n>] [--json]`;
32
33
 
33
34
  function clearable(value: string | undefined): string | null | undefined {
@@ -172,9 +172,9 @@ const commandRunners: Record<string, CommandRunner> = {
172
172
  },
173
173
  doctor: async deps => {
174
174
  const doctorArgs = deps.args.slice(1);
175
- const { runDoctor } = await import("./doctor");
175
+ const { RECOVER_ZERO_BYTE_COORDINATOR_FLAG, runDoctor } = await import("./doctor");
176
176
  await runDoctor(doctorArgs);
177
- if (!doctorArgs.includes("--fix-codex-runtime")) {
177
+ if (!doctorArgs.includes("--fix-codex-runtime") && !doctorArgs.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) {
178
178
  console.log("");
179
179
  const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor");
180
180
  printCodexLogGuardDoctor();