@bitkyc08/opencodex 2.29.0 → 2.31.0-preview.20260822

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 (68) hide show
  1. package/README.md +5 -5
  2. package/gui/dist/assets/index-DyWYnr-t.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +3 -3
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +29 -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/images.ts +704 -0
  10. package/src/adapters/cursor/live-models.ts +21 -26
  11. package/src/adapters/cursor/live-transport.ts +239 -8
  12. package/src/adapters/cursor/native-exec-common.ts +17 -0
  13. package/src/adapters/cursor/native-exec.ts +9 -4
  14. package/src/adapters/cursor/protobuf-events.ts +5 -1
  15. package/src/adapters/cursor/protobuf-request.ts +46 -9
  16. package/src/adapters/cursor/request-builder.ts +29 -14
  17. package/src/adapters/cursor/tool-definitions.ts +20 -0
  18. package/src/adapters/cursor/transport.ts +10 -0
  19. package/src/adapters/cursor/types.ts +8 -1
  20. package/src/adapters/cursor.ts +23 -5
  21. package/src/adapters/google.ts +16 -3
  22. package/src/adapters/openai-responses.ts +66 -20
  23. package/src/adapters/xai-web-search.ts +185 -0
  24. package/src/cli/agent.ts +2 -1
  25. package/src/cli/dispatch.ts +2 -2
  26. package/src/cli/doctor.ts +89 -0
  27. package/src/cli/help.ts +2 -0
  28. package/src/cli/registry.ts +7 -2
  29. package/src/codex/auth-context.ts +41 -2
  30. package/src/codex/catalog/effort.ts +1 -1
  31. package/src/codex/catalog/parsing.ts +2 -0
  32. package/src/codex/catalog/provider-fetch.ts +20 -5
  33. package/src/codex/coordinator-doctor.ts +332 -0
  34. package/src/codex/features.ts +58 -0
  35. package/src/codex/inject-coordination.ts +39 -6
  36. package/src/codex/transition-state.ts +12 -12
  37. package/src/generated/compatibility-version.json +86 -58
  38. package/src/lib/bun-stream-caps.ts +7 -4
  39. package/src/lib/errors.ts +8 -2
  40. package/src/oauth/cursor.ts +21 -0
  41. package/src/providers/command-code-efforts.ts +7 -0
  42. package/src/providers/cursor-pool.ts +72 -0
  43. package/src/providers/derive.ts +3 -0
  44. package/src/providers/fastwire.ts +12 -1
  45. package/src/providers/openai-sidecar.ts +1 -0
  46. package/src/providers/quota.ts +98 -25
  47. package/src/providers/registry.ts +115 -10
  48. package/src/providers/service-tier.ts +22 -7
  49. package/src/responses/custom-tool-compat.ts +24 -8
  50. package/src/responses/namespace-tool-compat.ts +2 -3
  51. package/src/router.ts +3 -0
  52. package/src/server/chat-completions.ts +4 -0
  53. package/src/server/chat-native.ts +20 -0
  54. package/src/server/management/agent-settings-routes.ts +16 -5
  55. package/src/server/management/config-routes.ts +25 -5
  56. package/src/server/management/vision-sidecar-options.ts +54 -19
  57. package/src/server/responses/compact.ts +1 -2
  58. package/src/server/responses/core.ts +54 -13
  59. package/src/service.ts +122 -14
  60. package/src/types/config.ts +9 -3
  61. package/src/types/provider.ts +6 -0
  62. package/src/usage/cost.ts +52 -38
  63. package/src/usage/expected-prices.ts +79 -9
  64. package/src/vision/backends.ts +97 -0
  65. package/src/vision/eligibility.ts +43 -22
  66. package/src/vision/index.ts +73 -5
  67. package/src/vision/routed-describe.ts +175 -0
  68. package/gui/dist/assets/index-BNESwCzn.js +0 -102
@@ -31,6 +31,7 @@ import {
31
31
  type CursorCheckpointInvalidationReason,
32
32
  type CursorCheckpointSnapshot,
33
33
  } from "./checkpoint-store";
34
+ import { extractCursorImageUrls } from "./images";
34
35
 
35
36
  /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */
36
37
  export const CURSOR_TOOL_COUNT_LIMIT = 330;
@@ -211,15 +212,8 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri
211
212
  case "thinking":
212
213
  return part.thinking;
213
214
  case "image":
214
- // User-message images are still flattened here: this path builds the plain-text prompt, and
215
- // the schema slot that could carry them (UserMessage.selectedContext.selectedImages) is not
216
- // populated by this adapter. The tool-result ENCODER does build real McpImageContent
217
- // (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no
218
- // longer true of the encoder — but note that nothing reaches Cursor today either way:
219
- // every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar
220
- // describes or strips images before this adapter runs. Kept the same length to avoid
221
- // shifting any byte-budgeted prompt path.
222
- return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`;
215
+ // Images ride UserMessage.selected_context (SelectedImage) instead of text.
216
+ return undefined;
223
217
  case "toolCall":
224
218
  // Cursor does not accept OpenAI Responses assistant tool-call parts as native history here.
225
219
  // Rendering them as visible "[tool_call]" text leaks synthetic protocol markers back into
@@ -252,9 +246,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined {
252
246
  switch (message.role) {
253
247
  case "user":
254
248
  case "developer":
255
- return { role: message.role, content: contentToText(message.content) };
249
+ {
250
+ const content = contentToText(message.content);
251
+ // Image-only turns survive as empty content; the encoder keeps them userMessageAction.
252
+ if (content.length === 0 && extractCursorImageUrls(message.content).length === 0) {
253
+ return undefined;
254
+ }
255
+ return { role: message.role, content };
256
+ }
256
257
  case "assistant":
257
- return { role: "assistant", content: contentToText(message.content) };
258
+ {
259
+ const content = contentToText(message.content);
260
+ return content.length > 0 ? { role: "assistant", content } : undefined;
261
+ }
258
262
  case "toolResult":
259
263
  return {
260
264
  role: "tool",
@@ -263,6 +267,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined {
263
267
  }
264
268
  }
265
269
 
270
+ /**
271
+ * Rebuild the text `messages` channel from prepared `rawMessages` so omission markers
272
+ * and JPEG-rewritten parts stay visible to activePromptText after image preparation.
273
+ */
274
+ export function cursorRequestMessagesFromRaw(
275
+ messages: readonly OcxMessage[] | undefined,
276
+ ): CursorRequestMessage[] {
277
+ if (!messages?.length) return [];
278
+ return messages
279
+ .map(requestMessage)
280
+ .filter((message): message is CursorRequestMessage => !!message);
281
+ }
282
+
266
283
  export function generatedCursorConversationId(): string {
267
284
  return `cursor_${crypto.randomUUID().replace(/-/g, "")}`;
268
285
  }
@@ -409,9 +426,7 @@ export function createCursorRequest(
409
426
  parsed: OcxParsedRequest,
410
427
  options: CreateCursorRequestOptions = {},
411
428
  ): CursorRunRequest {
412
- const messages = parsed.context.messages
413
- .map(requestMessage)
414
- .filter((message): message is CursorRequestMessage => !!message && message.content.length > 0);
429
+ const messages = cursorRequestMessagesFromRaw(parsed.context.messages);
415
430
  const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? "";
416
431
  const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
417
432
  const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
@@ -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.
@@ -2,6 +2,7 @@ import type { OcxUsage } from "../../types";
2
2
  import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types";
3
3
  import type { CursorRoutingLevel } from "./discovery";
4
4
  import type { CursorCheckpointInvalidationReason } from "./checkpoint-store";
5
+ import type { ResolvedCursorImage } from "./images";
5
6
 
6
7
  export interface CursorRequestedModelParameter {
7
8
  id: string;
@@ -17,7 +18,13 @@ export interface CursorRunRequest {
17
18
  conversationId: string;
18
19
  system: string[];
19
20
  messages: CursorRequestMessage[];
20
- rawMessages?: OcxMessage[];
21
+ rawMessages?: readonly OcxMessage[];
22
+ /**
23
+ * Images for the active user/developer turn. Encoded as SelectedImage blobIdWithData refs under
24
+ * UserMessage.selected_context (bytes live in the request-scoped KV store for getBlobArgs
25
+ * hydration). History stays text-only. data: URLs only in this slice.
26
+ */
27
+ selectedImages?: readonly ResolvedCursorImage[];
21
28
  tools?: OcxTool[];
22
29
  toolChoice?: OcxRequestOptions["toolChoice"];
23
30
  parallelToolCalls?: boolean;
@@ -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();