@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.
- package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +39 -7
- package/src/adapters/cursor/tool-definitions.ts +48 -0
- package/src/adapters/google.ts +18 -12
- package/src/adapters/openai-chat.ts +106 -13
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +3 -0
- package/src/bridge.ts +16 -5
- package/src/chat/inbound.ts +5 -11
- package/src/claude/context-windows.ts +5 -1
- package/src/claude/desktop-3p.ts +11 -6
- package/src/claude/inbound.ts +39 -1
- package/src/claude/model-info.ts +28 -8
- package/src/cli/account-api.ts +5 -1
- package/src/cli/claude-desktop.ts +3 -0
- package/src/cli/config-command.ts +37 -14
- package/src/codex/app-server-restart-service.ts +1 -1
- package/src/codex/auth-api.ts +5 -0
- package/src/codex/auth-context.ts +43 -2
- package/src/codex/catalog/metadata.ts +55 -8
- package/src/codex/catalog/native-models.ts +32 -2
- package/src/codex/catalog/parsing.ts +21 -7
- package/src/codex/catalog/provider-fetch.ts +35 -7
- package/src/codex/catalog/sync.ts +40 -18
- package/src/codex/catalog-refresh-status.ts +21 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +23 -2
- package/src/codex/desired-state.ts +1 -1
- package/src/codex/inject.ts +38 -7
- package/src/codex/injected-marker.ts +28 -0
- package/src/codex/journal.ts +40 -1
- package/src/codex/management-convergence.ts +55 -2
- package/src/codex/quota-rejection.ts +61 -1
- package/src/codex/quota.ts +60 -6
- package/src/codex/routing.ts +30 -3
- package/src/combos/failover.ts +20 -0
- package/src/config.ts +271 -4
- package/src/generated/compatibility-version.json +86 -74
- package/src/grok/sync.ts +3 -1
- package/src/lab/artifacts/sanitize.ts +1 -1
- package/src/lab/live/manifest.ts +1 -1
- package/src/lib/codex-restart-contract.ts +1 -1
- package/src/lib/config-ownership.ts +1 -0
- package/src/lib/errors.ts +9 -0
- package/src/lib/lab-activation.ts +1 -1
- package/src/lib/optional-shutdown-hooks.ts +1 -1
- package/src/providers/quota.ts +10 -4
- package/src/providers/registry.ts +2 -2
- package/src/responses/parser.ts +42 -7
- package/src/responses/provider-opaque-metadata.ts +1 -1
- package/src/responses/thought-signature-replay.ts +261 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/provider-slot.ts +1 -1
- package/src/routing/evaluator.ts +12 -2
- package/src/routing/health.ts +16 -5
- package/src/routing/history/schema.ts +1 -1
- package/src/routing/trace.ts +1 -1
- package/src/server/auth-cors.ts +56 -21
- package/src/server/chat-completions.ts +6 -2
- package/src/server/index.ts +5 -3
- package/src/server/management/agent-settings-routes.ts +26 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/provider-routes.ts +19 -0
- package/src/server/management/shared.ts +3 -3
- package/src/server/management-api.ts +13 -6
- package/src/server/passive-route-linker.ts +1 -1
- package/src/server/relay.ts +16 -0
- package/src/server/responses/compact.ts +10 -3
- package/src/server/responses/core.ts +160 -33
- package/src/server/responses/fetch-helpers.ts +34 -2
- package/src/server/responses/input-admission.ts +17 -9
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/system-env.ts +4 -2
- package/src/service.ts +14 -7
- package/src/types.ts +34 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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,
|
package/src/adapters/google.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
|
|
2
2
|
import { debugDroppedFrame } from "../lib/debug";
|
|
3
|
-
import {
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
):
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
1608
|
+
...(rememberAndSerializeExtraContent(currentToolCallId, currentToolCallProviderMetadata, replayCacheScope).extra ?? {}),
|
|
1598
1609
|
});
|
|
1599
1610
|
}
|
|
1600
1611
|
budget?.closeCall(currentToolCallId);
|