@bitkyc08/opencodex 2.22.0 → 2.23.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 (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +5 -1
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +86 -74
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-ClEcVlFO.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-rFWrIE11.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DQsMZzI5.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -59,7 +59,7 @@
59
59
  "setup:hooks": "bun scripts/setup-hooks.ts"
60
60
  },
61
61
  "dependencies": {
62
- "@bufbuild/protobuf": "^2.13.0",
62
+ "@bufbuild/protobuf": "^2.14.0",
63
63
  "@modelcontextprotocol/sdk": "^1.30.0",
64
64
  "@napi-rs/keyring": "1.3.0",
65
65
  "bun": "1.3.14",
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMeta, ProviderAdapter } from "./base";
2
+ import { createToolCallIdAllocator, type ToolCallIdAllocator } from "./tool-call-id";
2
3
  import { debugDroppedFrame } from "../lib/debug";
3
4
  import type {
4
5
  AdapterEvent,
@@ -559,7 +560,7 @@ function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name:
559
560
  return { toWire: (name) => name, fromWire: (name) => name };
560
561
  }
561
562
 
562
- function toAnthropicToolResult(msg: OcxToolResultMessage): Record<string, unknown> {
563
+ function toAnthropicToolResult(msg: OcxToolResultMessage, wireCallId: string): Record<string, unknown> {
563
564
  // Anthropic tool_result accepts a string OR content blocks — render images natively
564
565
  // (e.g. Codex view_image output) instead of dropping them.
565
566
  let content: string | unknown[];
@@ -574,12 +575,17 @@ function toAnthropicToolResult(msg: OcxToolResultMessage): Record<string, unknow
574
575
  }
575
576
  return {
576
577
  type: "tool_result",
577
- tool_use_id: msg.toolCallId,
578
+ tool_use_id: wireCallId,
578
579
  content,
579
580
  ...(msg.isError ? { is_error: true } : {}),
580
581
  };
581
582
  }
582
583
 
584
+ function unrepresentableToolCallText(tc: OcxToolCall, wireName: string): string {
585
+ const args = typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments);
586
+ return `[tool_use without a usable id: ${wireName}]\n${args}`;
587
+ }
588
+
583
589
  function orphanToolResultText(msg: OcxToolResultMessage): string {
584
590
  const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId;
585
591
  const content = typeof msg.content === "string"
@@ -592,6 +598,19 @@ function messagesToAnthropicFormat(
592
598
  parsed: OcxParsedRequest,
593
599
  toolNames: { toWire: (name: string) => string },
594
600
  ): { system: string | undefined; messages: unknown[] } {
601
+ // One allocator for the whole request: a tool_result must resolve to the SAME wire id its
602
+ // call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed
603
+ // first so a rewritten id can never squat on an id another call legitimately owns.
604
+ const callIds = createToolCallIdAllocator();
605
+ for (const message of parsed.context.messages) {
606
+ if (message.role === "assistant") {
607
+ for (const part of (message as OcxAssistantMessage).content) {
608
+ if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id);
609
+ }
610
+ } else if (message.role === "toolResult") {
611
+ callIds.reserve((message as OcxToolResultMessage).toolCallId);
612
+ }
613
+ }
595
614
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(
596
615
  parsed.context.tools,
597
616
  parsed.options.toolChoice,
@@ -643,8 +662,17 @@ function messagesToAnthropicFormat(
643
662
  } else if (part.type === "toolCall") {
644
663
  const tc = part as OcxToolCall;
645
664
  const flatName = namespacedToolName(tc.namespace, tc.name);
646
- toolUseIds.push(tc.id);
647
- toolUses.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments });
665
+ // Normalized here, and identically for the matching tool_result above, so a history
666
+ // replayed from another provider path keeps its call/result pairing (#1767).
667
+ // No raw fallback: restoring an empty/unusable id puts a value on the wire Anthropic
668
+ // rejects. An unrepresentable call becomes text instead, and its result follows it there.
669
+ const wireCallId = callIds.allocate(tc.id);
670
+ if (wireCallId === undefined) {
671
+ preface.push({ type: "text", text: unrepresentableToolCallText(tc, toolNames.toWire(flatName)) });
672
+ continue;
673
+ }
674
+ toolUseIds.push(wireCallId);
675
+ toolUses.push({ type: "tool_use", id: wireCallId, name: toolNames.toWire(flatName), input: tc.arguments });
648
676
  }
649
677
  }
650
678
  // Anthropic treats text/thinking after tool_use as ending the tool turn, which makes
@@ -660,9 +688,13 @@ function messagesToAnthropicFormat(
660
688
  let j = i + 1;
661
689
  while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") {
662
690
  const tr = parsed.context.messages[j] as OcxToolResultMessage;
663
- if (requiredIds.has(tr.toolCallId) && !seen.has(tr.toolCallId)) {
664
- resultBlocks.push(toAnthropicToolResult(tr));
665
- seen.add(tr.toolCallId);
691
+ // Match on the WIRE id. requiredIds holds normalized ids, so comparing the raw result id
692
+ // made every rewritten pair lose its result to orphan text and gain a synthetic
693
+ // missing-result block. lookup() never mints an id: a result with no call stays orphan.
694
+ const wireResultId = callIds.lookup(tr.toolCallId);
695
+ if (wireResultId !== undefined && requiredIds.has(wireResultId) && !seen.has(wireResultId)) {
696
+ resultBlocks.push(toAnthropicToolResult(tr, wireResultId));
697
+ seen.add(wireResultId);
666
698
  } else {
667
699
  orphanBlocks.push({ type: "text", text: orphanToolResultText(tr) });
668
700
  }
@@ -191,6 +191,38 @@ export function isCursorWaitTool(tool: Pick<OcxTool, "namespace" | "name">): boo
191
191
  return isCursorResponsesProvider(tool.namespace) && tool.name === CODEX_WAIT_TOOL;
192
192
  }
193
193
 
194
+ /**
195
+ * True for Codex's unified-exec "code mode" tool: a freeform `exec` whose body is JavaScript
196
+ * evaluated in a V8 isolate, not a shell command string.
197
+ */
198
+ export function isCursorCodeModeExecTool(
199
+ tool: Pick<OcxTool, "namespace" | "name" | "freeform">,
200
+ ): boolean {
201
+ return isCursorResponsesProvider(tool.namespace)
202
+ && tool.name === CODEX_UNIFIED_EXEC_TOOL
203
+ && tool.freeform === true;
204
+ }
205
+
206
+ /**
207
+ * Codex code mode advertises ONE freeform `exec` tool and no bare shell bridge. Shell, file
208
+ * edits, and MCP calls are reachable only as nested `tools.<name>(...)` helpers described inside
209
+ * that tool's own description, so a flat catalog scan cannot see them.
210
+ *
211
+ * This matters because the shell-bridge guidance below is written for a flat catalog. Emitting
212
+ * "call \`exec_command\`" into a code-mode turn names a top-level tool that does not exist: the
213
+ * model calls it, gets nothing back, and burns turns rediscovering the real contract from error
214
+ * messages (empty output until \`text()\` is called, \`require is not defined\` because the isolate
215
+ * is not Node, \`apply_patch\` rejected because it too is only a nested helper here).
216
+ */
217
+ export function cursorRequestUsesCodeMode(
218
+ tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
219
+ toolChoice?: OcxRequestOptions["toolChoice"],
220
+ ): boolean {
221
+ const catalog = tools ?? [];
222
+ const visible = catalog.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog));
223
+ return visible.some(isCursorCodeModeExecTool) && !visible.some(isBareCodexShellBridgeTool);
224
+ }
225
+
194
226
  /** @deprecated Prefer isBareCodexShellBridgeTool; kept for older call sites/tests. */
195
227
  function isBareCodexExecCommandTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
196
228
  return isBareCodexShellBridgeTool(tool);
@@ -554,6 +586,14 @@ export function buildCursorToolGuidanceSystemNote(
554
586
  const listedNames = quotedNames(wireNames);
555
587
  const shellBridgeNames = wireNames.filter(isCodexShellBridgeToolName);
556
588
  const hasBareExec = shellBridgeNames.length > 0;
589
+ const codeMode = cursorRequestUsesCodeMode(tools, toolChoice);
590
+ // Code mode describes how the freeform exec tool works; it does not suppress the rest of the
591
+ // catalog. A turn can advertise freeform `exec` AND ordinary top-level tools at once, and
592
+ // telling the model those are "not separate top-level tools" would make it refuse tools that
593
+ // are right there in its catalog. Name the ones that stay callable instead.
594
+ const codeModeOtherTopLevelNames = codeMode
595
+ ? wireNames.filter(name => name !== CODEX_UNIFIED_EXEC_TOOL && !isCodexShellBridgeToolName(name))
596
+ : [];
557
597
  const shellBridgeLabel = quotedNames(shellBridgeNames.length > 0 ? shellBridgeNames : [...CODEX_SHELL_BRIDGE_TOOL_NAMES]);
558
598
  const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice);
559
599
  const structuredEditNames = tools
@@ -572,6 +612,14 @@ export function buildCursorToolGuidanceSystemNote(
572
612
  unavailableNeighborNames.length > 0
573
613
  ? `This turn does not expose neighboring-agent tool names ${quotedNames(unavailableNeighborNames)}; do not call or suggest them unless the catalog lists them.`
574
614
  : undefined,
615
+ // Code mode: shell/edit/MCP live inside freeform `exec` as nested helpers. Without this the
616
+ // model probes for a top-level shell tool that is not there.
617
+ codeMode
618
+ ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.<name>(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description for the exact nested helpers this turn provides. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\`, \`shell_command\`, or \`apply_patch\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.`
619
+ : undefined,
620
+ codeMode
621
+ ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers."
622
+ : undefined,
575
623
  hasBareExec
576
624
  ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.`
577
625
  : undefined,
@@ -1,6 +1,6 @@
1
1
  import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
2
2
  import { debugDroppedFrame } from "../lib/debug";
3
- import { createHash } from "node:crypto";
3
+ import { createToolCallIdAllocator } from "./tool-call-id";
4
4
  import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts";
5
5
  import type {
6
6
  AdapterEvent,
@@ -11,6 +11,7 @@ import type {
11
11
  OcxProviderOpaqueToolCallMetadata,
12
12
  OcxTextContent,
13
13
  OcxToolCall,
14
+ OcxToolResultMessage,
14
15
  OcxUsage,
15
16
  } from "../types";
16
17
  import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
@@ -95,15 +96,9 @@ function vertexReplaySessionId(parsed: OcxParsedRequest): string {
95
96
  * the call/response pairing is preserved. Returns `undefined` for an empty id so the caller omits the
96
97
  * field entirely rather than inventing a non-matching one.
97
98
  */
98
- function geminiToolCallId(rawId: string | undefined): string | undefined {
99
- const raw = rawId ?? "";
100
- if (raw.length === 0) return undefined;
101
- const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_");
102
- if (cleaned === raw) return cleaned;
103
- // Lossy rewrite happened: disambiguate with a deterministic suffix derived from the raw id.
104
- const suffix = createHash("sha256").update(raw).digest("hex").slice(0, 8);
105
- return `${cleaned}_${suffix}`;
106
- }
99
+ // Aliasing the stateless transform here would reintroduce the collision it cannot prevent:
100
+ // a rewritten id can equal a distinct raw id that already conforms. Use a request-scoped
101
+ // allocator, exactly as the Anthropic adapter does, so call/response pairing stays injective.
107
102
 
108
103
  /**
109
104
  * Inline image parts (Gemini `inline_data`) extracted from tool-result content. Only base64 data URLs
@@ -166,6 +161,16 @@ function messagesToGeminiFormat(
166
161
 
167
162
  const contents: unknown[] = [];
168
163
 
164
+ const callIds = createToolCallIdAllocator();
165
+ for (const msg of parsed.context.messages) {
166
+ if (msg.role === "assistant") {
167
+ for (const part of (msg as OcxAssistantMessage).content) {
168
+ if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id);
169
+ }
170
+ } else if (msg.role === "toolResult") {
171
+ callIds.reserve((msg as OcxToolResultMessage).toolCallId);
172
+ }
173
+ }
169
174
  for (const msg of parsed.context.messages) {
170
175
  switch (msg.role) {
171
176
  case "user":
@@ -204,7 +209,7 @@ function messagesToGeminiFormat(
204
209
  // streaming covered by the replay cache. Only forward a REAL upstream signature — the
205
210
  // Responses parser also stashes synthetic item ids (`fc_...`) on this field, and sending
206
211
  // those as a thoughtSignature breaks continuity (the replay cache supplies the real one).
207
- const callId = geminiToolCallId(tc.id);
212
+ const callId = callIds.allocate(tc.id);
208
213
  const functionCall: Record<string, unknown> = { name: namespacedToolName(tc.namespace, tc.name), args: tc.arguments };
209
214
  // Claude-on-Antigravity maps this id to Anthropic `tool_use.id`; without it the upstream
210
215
  // conversion 400s. Gemini accepts the optional id and pairs call/response by it.
@@ -229,7 +234,8 @@ function messagesToGeminiFormat(
229
234
  // functionResponse, but it does accept sibling inline_data parts in the same user turn, so
230
235
  // tool-result screenshots (e.g. Computer Use) ride along as inline_data instead of being
231
236
  // flattened to a "[image]" marker the model can't actually see.
232
- const responseId = geminiToolCallId(msg.toolCallId);
237
+ // lookup(), not allocate(): a response must reuse its call's id and must never mint a new one.
238
+ const responseId = callIds.lookup(msg.toolCallId);
233
239
  const functionResponse: Record<string, unknown> = { name: namespacedToolName(msg.toolNamespace, msg.toolName), response: { result: geminiToolResultText(msg.content) } };
234
240
  // Mirror the matching functionCall id so Claude-on-Antigravity can pair this result with its
235
241
  // `tool_use` block (-> Anthropic `tool_result.tool_use_id`).
@@ -289,10 +289,20 @@ function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "e
289
289
  };
290
290
  }
291
291
 
292
- function invalidToolCallsEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
292
+ function invalidToolCallsEvent(
293
+ rawToolCalls: unknown,
294
+ mode: "stream" | "response",
295
+ usage?: OcxUsage,
296
+ ): Extract<AdapterEvent, { type: "error" }> {
297
+ const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
298
+ const detail = diagnostic
299
+ ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
300
+ : "";
293
301
  return {
294
302
  type: "error",
295
- message: "upstream response contained invalid tool calls",
303
+ status: 502,
304
+ errorType: "upstream_error",
305
+ message: `upstream response contained invalid tool calls${detail}`,
296
306
  ...(usage !== undefined ? { usage } : {}),
297
307
  };
298
308
  }
@@ -333,6 +343,83 @@ type InvalidToolCallReason =
333
343
  | "tool_call_function_name_blank"
334
344
  | "tool_call_function_arguments_invalid";
335
345
 
346
+ type InvalidToolCallDiagnostic = {
347
+ reason: InvalidToolCallReason;
348
+ callIndex?: number;
349
+ valueType: string;
350
+ };
351
+
352
+ type InvalidFieldShape =
353
+ | {
354
+ kind: "object";
355
+ knownKeys: string[];
356
+ knownFieldTypes: Record<string, string>;
357
+ hasUnknownKeys: boolean;
358
+ }
359
+ | {
360
+ kind: "array";
361
+ length: number;
362
+ };
363
+
364
+ const SAFE_TOOL_CALL_SHAPE_KEYS = [
365
+ "name",
366
+ "type",
367
+ "value",
368
+ "function",
369
+ "arguments",
370
+ "id",
371
+ "index",
372
+ ] as const;
373
+ const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set<string>(SAFE_TOOL_CALL_SHAPE_KEYS);
374
+
375
+ function structuralValueType(value: unknown): string {
376
+ return value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
377
+ }
378
+
379
+ function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown {
380
+ if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls;
381
+ if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined;
382
+
383
+ const rawToolCall = rawToolCalls[diagnostic.callIndex];
384
+ if (diagnostic.reason === "tool_call_not_object") return rawToolCall;
385
+ if (!isRecord(rawToolCall)) return undefined;
386
+ if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function;
387
+
388
+ const rawFunction = rawToolCall.function;
389
+ switch (diagnostic.reason) {
390
+ case "tool_call_id_invalid":
391
+ return rawToolCall.id;
392
+ case "tool_call_function_name_invalid":
393
+ return isRecord(rawFunction) ? rawFunction.name : undefined;
394
+ case "tool_call_function_arguments_invalid":
395
+ return isRecord(rawFunction) ? rawFunction.arguments : undefined;
396
+ default:
397
+ return undefined;
398
+ }
399
+ }
400
+
401
+ function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined {
402
+ if (Array.isArray(value)) return { kind: "array", length: value.length };
403
+ if (!isRecord(value)) return undefined;
404
+
405
+ const knownKeys: string[] = [];
406
+ const knownFieldTypes: Record<string, string> = {};
407
+ for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) {
408
+ if (!Object.hasOwn(value, key)) continue;
409
+ knownKeys.push(key);
410
+ knownFieldTypes[key] = structuralValueType(value[key]);
411
+ }
412
+
413
+ let hasUnknownKeys = false;
414
+ for (const key of Object.keys(value)) {
415
+ if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) {
416
+ hasUnknownKeys = true;
417
+ break;
418
+ }
419
+ }
420
+ return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys };
421
+ }
422
+
336
423
  /**
337
424
  * Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
338
425
  * streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
@@ -350,7 +437,7 @@ function isInvalidStreamStringField(value: unknown): boolean {
350
437
  function diagnoseInvalidToolCalls(
351
438
  rawToolCalls: unknown,
352
439
  mode: "stream" | "response",
353
- ): { reason: InvalidToolCallReason; callIndex?: number; valueType: string } | undefined {
440
+ ): InvalidToolCallDiagnostic | undefined {
354
441
  if (!Array.isArray(rawToolCalls)) {
355
442
  return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls };
356
443
  }
@@ -426,8 +513,15 @@ function diagnoseInvalidToolCalls(
426
513
  }
427
514
 
428
515
  function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void {
516
+ if (!isDebugEnabled()) return;
429
517
  const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
430
- if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic });
518
+ if (!diagnostic) return;
519
+ const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
520
+ debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
521
+ mode,
522
+ ...diagnostic,
523
+ ...(fieldShape ? { fieldShape } : {}),
524
+ });
431
525
  }
432
526
 
433
527
  function developerSystemText(message: OcxMessage): string | undefined {
@@ -1184,7 +1278,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1184
1278
 
1185
1279
  buildRequest(parsed: OcxParsedRequest) {
1186
1280
  const { url, headers, hasCredential } = openAIChatTransport(provider);
1187
-
1188
1281
  const messages = messagesToChatFormat(parsed, provider);
1189
1282
  const tools = toolsToChatFormatForProvider(parsed, provider);
1190
1283
  const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
@@ -1479,12 +1572,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1479
1572
  // tolerated as absent because OpenAI-compatible providers may emit it as stream padding.
1480
1573
  if (!Array.isArray(rawToolCalls)) {
1481
1574
  logInvalidToolCalls("stream", rawToolCalls);
1482
- return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1575
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1483
1576
  }
1484
1577
  for (const rawToolCall of rawToolCalls) {
1485
1578
  if (!isRecord(rawToolCall)) {
1486
1579
  logInvalidToolCalls("stream", rawToolCalls);
1487
- return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1580
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1488
1581
  }
1489
1582
  const tc = rawToolCall as {
1490
1583
  index?: number;
@@ -1499,7 +1592,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1499
1592
  if (rawFunction !== undefined && rawFunction !== null) {
1500
1593
  if (!isRecord(rawFunction)) {
1501
1594
  logInvalidToolCalls("stream", rawToolCalls);
1502
- return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1595
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1503
1596
  }
1504
1597
  const rawName = rawFunction.name;
1505
1598
  const rawArguments = rawFunction.arguments;
@@ -1508,12 +1601,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1508
1601
  // non-string value still fails closed before entering the accumulator.
1509
1602
  if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) {
1510
1603
  logInvalidToolCalls("stream", rawToolCalls);
1511
- return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1604
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1512
1605
  }
1513
1606
  }
1514
1607
  if (isInvalidStreamStringField(tc.id)) {
1515
1608
  logInvalidToolCalls("stream", rawToolCalls);
1516
- return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1609
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1517
1610
  }
1518
1611
  const key = typeof tc.index === "number"
1519
1612
  ? `i:${tc.index}`
@@ -1675,12 +1768,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1675
1768
  if (rawToolCalls !== undefined && rawToolCalls !== null) {
1676
1769
  if (!Array.isArray(rawToolCalls)) {
1677
1770
  logInvalidToolCalls("response", rawToolCalls);
1678
- return [invalidToolCallsEvent(usage)];
1771
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
1679
1772
  }
1680
1773
  for (const rawToolCall of rawToolCalls) {
1681
1774
  if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
1682
1775
  logInvalidToolCalls("response", rawToolCalls);
1683
- return [invalidToolCallsEvent(usage)];
1776
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
1684
1777
  }
1685
1778
  const id = rawToolCall.id;
1686
1779
  const name = rawToolCall.function.name;
@@ -1691,7 +1784,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1691
1784
  if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string"
1692
1785
  || name.trim().length === 0) {
1693
1786
  logInvalidToolCalls("response", rawToolCalls);
1694
- return [invalidToolCallsEvent(usage)];
1787
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
1695
1788
  }
1696
1789
  events.push({ type: "tool_call_start", id, name });
1697
1790
  events.push({ type: "tool_call_delta", arguments: args });
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Anthropic rejects a `tool_use.id` longer than this. Collision disambiguation has to fit
5
+ * inside it too, which is why candidates are assembled from parts instead of sliced at the end.
6
+ */
7
+ export const MAX_TOOL_CALL_ID_LENGTH = 64;
8
+
9
+ /** Hex characters of the deterministic tail. Fixed width: it is a discriminator, not a payload. */
10
+ const TOOL_CALL_ID_HASH_WIDTH = 8;
11
+
12
+ const CONFORMING_TOOL_CALL_ID = /^[a-zA-Z0-9_-]+$/;
13
+
14
+ /** An id Anthropic accepts as-is: right character set AND within the length bound. */
15
+ export function isConformingToolCallId(rawId: string): boolean {
16
+ return rawId.length > 0
17
+ && rawId.length <= MAX_TOOL_CALL_ID_LENGTH
18
+ && CONFORMING_TOOL_CALL_ID.test(rawId);
19
+ }
20
+
21
+ /**
22
+ * The parts a rewritten id is built from: the sanitized prefix and a deterministic tail
23
+ * derived from the raw id. Kept separate so collision handling can truncate the prefix
24
+ * without destroying the discriminator.
25
+ */
26
+ function toolCallIdComponents(rawId: string | undefined): { cleaned: string; hash: string } | undefined {
27
+ const raw = rawId ?? "";
28
+ if (raw.length === 0) return undefined;
29
+ const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_");
30
+ const hash = createHash("sha256").update(raw).digest("hex").slice(0, TOOL_CALL_ID_HASH_WIDTH);
31
+ return { cleaned, hash };
32
+ }
33
+
34
+ /** Assemble `prefix_hash`, truncating only the prefix, leaving `reserve` characters spare. */
35
+ function fitToolCallId(cleaned: string, hash: string, reserve = 0): string {
36
+ const tail = `_${hash}`;
37
+ const room = MAX_TOOL_CALL_ID_LENGTH - reserve - tail.length;
38
+ return cleaned.slice(0, Math.max(1, room)) + tail;
39
+ }
40
+
41
+ /**
42
+ * Normalize a tool call id into the character set and length Anthropic accepts.
43
+ *
44
+ * This is the stateless view, kept for callers that only need the shape of one id. It is NOT
45
+ * injective on its own: `anthropicToolCallId("call:a")` returns something like `call_a_1f2e3d4c`,
46
+ * and a raw id that already equals that value is returned unchanged — two distinct sources, one
47
+ * wire id. Anything building a whole request must use {@link createToolCallIdAllocator}, which
48
+ * reserves the conforming ids first and resolves collisions.
49
+ *
50
+ * Returns `undefined` for an empty id. Callers must handle that rather than falling back to the
51
+ * raw value: restoring `""` puts an id on the wire that Anthropic rejects (#1767).
52
+ */
53
+ export function anthropicToolCallId(rawId: string | undefined): string | undefined {
54
+ const raw = rawId ?? "";
55
+ if (raw.length === 0) return undefined;
56
+ if (isConformingToolCallId(raw)) return raw;
57
+ const parts = toolCallIdComponents(raw);
58
+ if (!parts) return undefined;
59
+ return fitToolCallId(parts.cleaned, parts.hash);
60
+ }
61
+
62
+ export type ToolCallIdAllocator = {
63
+ /** Claim an already-conforming source id so no rewrite can be handed the same value. */
64
+ reserve(rawId: string | undefined): void;
65
+ /** Wire id for a raw id, stable within the request. `undefined` means "not representable". */
66
+ allocate(rawId: string | undefined): string | undefined;
67
+ /** Wire id previously allocated for this raw id, without creating one. */
68
+ lookup(rawId: string | undefined): string | undefined;
69
+ };
70
+
71
+ /**
72
+ * Request-scoped raw-id to wire-id mapping.
73
+ *
74
+ * Two properties the stateless transform cannot provide:
75
+ *
76
+ * - **Injective.** Reserve every already-conforming id first, then allocate rewrites around them,
77
+ * appending a numeric suffix when a candidate is taken. Two distinct raw ids never share a wire id,
78
+ * including the case where one raw id already looks like another's normalized form, and including
79
+ * an ordinary 32-bit hash collision.
80
+ * - **Stable.** A tool result asks for the same raw id its call used and gets the same wire id, so
81
+ * call/result pairing survives normalization.
82
+ */
83
+ export function createToolCallIdAllocator(): ToolCallIdAllocator {
84
+ const rawToWire = new Map<string, string>();
85
+ const occupied = new Set<string>();
86
+
87
+ return {
88
+ reserve(rawId) {
89
+ if (!rawId || rawToWire.has(rawId)) return;
90
+ if (!isConformingToolCallId(rawId)) return;
91
+ rawToWire.set(rawId, rawId);
92
+ occupied.add(rawId);
93
+ },
94
+ allocate(rawId) {
95
+ if (!rawId) return undefined;
96
+ const existing = rawToWire.get(rawId);
97
+ if (existing) return existing;
98
+ if (isConformingToolCallId(rawId) && !occupied.has(rawId)) {
99
+ rawToWire.set(rawId, rawId);
100
+ occupied.add(rawId);
101
+ return rawId;
102
+ }
103
+ const parts = toolCallIdComponents(rawId);
104
+ if (!parts) return undefined;
105
+ let candidate = fitToolCallId(parts.cleaned, parts.hash);
106
+ for (let n = 2; occupied.has(candidate); n++) {
107
+ const suffix = `_${n}`;
108
+ candidate = fitToolCallId(parts.cleaned, parts.hash, suffix.length) + suffix;
109
+ }
110
+ rawToWire.set(rawId, candidate);
111
+ occupied.add(candidate);
112
+ return candidate;
113
+ },
114
+ lookup(rawId) {
115
+ if (!rawId) return undefined;
116
+ return rawToWire.get(rawId);
117
+ },
118
+ };
119
+ }
@@ -59,7 +59,10 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
59
59
  return [
60
60
  "Tool contract: use the current tool catalog as ground truth.",
61
61
  `Valid tool names for this turn are exactly ${quoteNames(names)}.`,
62
+ "These listed names are the complete top-level tool-call surface for this turn.",
62
63
  "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
64
+ "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.",
65
+ "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.",
63
66
  unavailableNeighborNames.length > 0
64
67
  ? `Do not use neighboring-agent tool names ${quoteNames(unavailableNeighborNames)} unless this turn's catalog lists those exact names.`
65
68
  : undefined,
package/src/bridge.ts CHANGED
@@ -11,7 +11,10 @@ import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCy
11
11
  import { encodeCompactionSummary } from "./responses/compaction";
12
12
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
13
13
  import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
14
- import { responsesExtraContentFromProviderMetadata } from "./responses/provider-opaque-metadata";
14
+ import {
15
+ rememberAndSerializeExtraContent,
16
+ rememberExtraContentForReplay,
17
+ } from "./responses/thought-signature-replay";
15
18
  import { resolveStallTimeoutSec } from "./stall-timeout";
16
19
  import { usageDisplayTotalTokens } from "./usage/totals";
17
20
  import {
@@ -606,6 +609,9 @@ export function bridgeToResponsesSSE(
606
609
  input: freeformInput(currentToolCall.args),
607
610
  });
608
611
  }
612
+ // Freeform tools serialize as custom_tool_call without extra_content; remember the
613
+ // signature server-side regardless so the replayed call can be re-signed (#1735).
614
+ void rememberExtraContentForReplay(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope);
609
615
  const item = currentToolCall.toolSearch
610
616
  ? {
611
617
  type: "tool_search_call", id: currentToolCall.itemId,
@@ -624,8 +630,9 @@ export function bridgeToResponsesSSE(
624
630
  arguments: argsStr, status: "completed",
625
631
  ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
626
632
  // Provider-opaque metadata (issue #1735) rides the item so a client that replays
627
- // this history can hand the signature back on the part it belongs to.
628
- ...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
633
+ // this history can hand the signature back on the part it belongs to. The proxy
634
+ // also remembers it server-side for clients that never echo extra_content.
635
+ ...(rememberAndSerializeExtraContent(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope).extra ?? {}),
629
636
  };
630
637
  emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
631
638
  retainFinishedItem(item as OutputItem);
@@ -643,6 +650,7 @@ export function bridgeToResponsesSSE(
643
650
  const failCurrentToolCall = () => {
644
651
  if (!currentToolCall) return;
645
652
  const argsStr = currentToolCall.args || "{}";
653
+ void rememberExtraContentForReplay(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope);
646
654
  const item = currentToolCall.toolSearch
647
655
  ? {
648
656
  type: "tool_search_call", id: currentToolCall.itemId,
@@ -663,7 +671,7 @@ export function bridgeToResponsesSSE(
663
671
  // An incomplete call can still be persisted and replayed (max_output_tokens), so it
664
672
  // carries the same metadata as the completed item — otherwise SSE and buffered JSON
665
673
  // would disagree about whether the signature survives.
666
- ...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
674
+ ...(rememberAndSerializeExtraContent(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope).extra ?? {}),
667
675
  };
668
676
  emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
669
677
  retainFinishedItem(item as OutputItem);
@@ -1576,6 +1584,9 @@ function buildResponseJSONWithBudget(
1576
1584
  currentToolCallArgs,
1577
1585
  options?.toolParameterSchemas?.get(currentToolCallName),
1578
1586
  );
1587
+ // Freeform tools serialize as custom_tool_call without extra_content; remember the
1588
+ // signature server-side regardless so the replayed call can be re-signed (#1735).
1589
+ void rememberExtraContentForReplay(currentToolCallId, currentToolCallProviderMetadata, replayCacheScope);
1579
1590
  if (toolSearch) {
1580
1591
  pushOutput({
1581
1592
  type: "tool_search_call", id: `tsc_${uuid()}`,
@@ -1594,7 +1605,7 @@ function buildResponseJSONWithBudget(
1594
1605
  call_id: currentToolCallId, name: realName,
1595
1606
  arguments: coercedArgs || "{}", status,
1596
1607
  ...(ns ? { namespace: ns } : {}),
1597
- ...(responsesExtraContentFromProviderMetadata(currentToolCallProviderMetadata) ?? {}),
1608
+ ...(rememberAndSerializeExtraContent(currentToolCallId, currentToolCallProviderMetadata, replayCacheScope).extra ?? {}),
1598
1609
  });
1599
1610
  }
1600
1611
  budget?.closeCall(currentToolCallId);