Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IncomingMeta, ProviderAdapter } from "./base";
import { createToolCallIdAllocator, type ToolCallIdAllocator } from "./tool-call-id";
import { debugDroppedFrame } from "../lib/debug";
import type {
AdapterEvent,
Expand Down Expand Up @@ -559,7 +560,7 @@ function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name:
return { toWire: (name) => name, fromWire: (name) => name };
}

function toAnthropicToolResult(msg: OcxToolResultMessage): Record<string, unknown> {
function toAnthropicToolResult(msg: OcxToolResultMessage, wireCallId: string): Record<string, unknown> {
// Anthropic tool_result accepts a string OR content blocks — render images natively
// (e.g. Codex view_image output) instead of dropping them.
let content: string | unknown[];
Expand All @@ -574,12 +575,17 @@ function toAnthropicToolResult(msg: OcxToolResultMessage): Record<string, unknow
}
return {
type: "tool_result",
tool_use_id: msg.toolCallId,
tool_use_id: wireCallId,
content,
...(msg.isError ? { is_error: true } : {}),
};
}

function unrepresentableToolCallText(tc: OcxToolCall, wireName: string): string {
const args = typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments);
return `[tool_use without a usable id: ${wireName}]\n${args}`;
}

function orphanToolResultText(msg: OcxToolResultMessage): string {
const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId;
const content = typeof msg.content === "string"
Expand All @@ -592,6 +598,19 @@ function messagesToAnthropicFormat(
parsed: OcxParsedRequest,
toolNames: { toWire: (name: string) => string },
): { system: string | undefined; messages: unknown[] } {
// One allocator for the whole request: a tool_result must resolve to the SAME wire id its
// call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed
// first so a rewritten id can never squat on an id another call legitimately owns.
const callIds = createToolCallIdAllocator();
for (const message of parsed.context.messages) {
if (message.role === "assistant") {
for (const part of (message as OcxAssistantMessage).content) {
if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id);
}
} else if (message.role === "toolResult") {
callIds.reserve((message as OcxToolResultMessage).toolCallId);
}
}
const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(
parsed.context.tools,
parsed.options.toolChoice,
Expand Down Expand Up @@ -643,8 +662,17 @@ function messagesToAnthropicFormat(
} else if (part.type === "toolCall") {
const tc = part as OcxToolCall;
const flatName = namespacedToolName(tc.namespace, tc.name);
toolUseIds.push(tc.id);
toolUses.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments });
// Normalized here, and identically for the matching tool_result above, so a history
// replayed from another provider path keeps its call/result pairing (#1767).
// No raw fallback: restoring an empty/unusable id puts a value on the wire Anthropic
// rejects. An unrepresentable call becomes text instead, and its result follows it there.
const wireCallId = callIds.allocate(tc.id);
if (wireCallId === undefined) {
preface.push({ type: "text", text: unrepresentableToolCallText(tc, toolNames.toWire(flatName)) });
continue;
}
toolUseIds.push(wireCallId);
toolUses.push({ type: "tool_use", id: wireCallId, name: toolNames.toWire(flatName), input: tc.arguments });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
// Anthropic treats text/thinking after tool_use as ending the tool turn, which makes
Expand All @@ -660,9 +688,13 @@ function messagesToAnthropicFormat(
let j = i + 1;
while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") {
const tr = parsed.context.messages[j] as OcxToolResultMessage;
if (requiredIds.has(tr.toolCallId) && !seen.has(tr.toolCallId)) {
resultBlocks.push(toAnthropicToolResult(tr));
seen.add(tr.toolCallId);
// Match on the WIRE id. requiredIds holds normalized ids, so comparing the raw result id
// made every rewritten pair lose its result to orphan text and gain a synthetic
// missing-result block. lookup() never mints an id: a result with no call stays orphan.
const wireResultId = callIds.lookup(tr.toolCallId);
if (wireResultId !== undefined && requiredIds.has(wireResultId) && !seen.has(wireResultId)) {
resultBlocks.push(toAnthropicToolResult(tr, wireResultId));
seen.add(wireResultId);
} else {
orphanBlocks.push({ type: "text", text: orphanToolResultText(tr) });
}
Expand Down
30 changes: 18 additions & 12 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
import { debugDroppedFrame } from "../lib/debug";
import { createHash } from "node:crypto";
import { createToolCallIdAllocator } from "./tool-call-id";
import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts";
import type {
AdapterEvent,
Expand All @@ -11,6 +11,7 @@ import type {
OcxProviderOpaqueToolCallMetadata,
OcxTextContent,
OcxToolCall,
OcxToolResultMessage,
OcxUsage,
} from "../types";
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
Expand Down Expand Up @@ -95,15 +96,9 @@ function vertexReplaySessionId(parsed: OcxParsedRequest): string {
* the call/response pairing is preserved. Returns `undefined` for an empty id so the caller omits the
* field entirely rather than inventing a non-matching one.
*/
function geminiToolCallId(rawId: string | undefined): string | undefined {
const raw = rawId ?? "";
if (raw.length === 0) return undefined;
const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_");
if (cleaned === raw) return cleaned;
// Lossy rewrite happened: disambiguate with a deterministic suffix derived from the raw id.
const suffix = createHash("sha256").update(raw).digest("hex").slice(0, 8);
return `${cleaned}_${suffix}`;
}
// Aliasing the stateless transform here would reintroduce the collision it cannot prevent:
// a rewritten id can equal a distinct raw id that already conforms. Use a request-scoped
// allocator, exactly as the Anthropic adapter does, so call/response pairing stays injective.

/**
* Inline image parts (Gemini `inline_data`) extracted from tool-result content. Only base64 data URLs
Expand Down Expand Up @@ -166,6 +161,16 @@ function messagesToGeminiFormat(

const contents: unknown[] = [];

const callIds = createToolCallIdAllocator();
for (const msg of parsed.context.messages) {
if (msg.role === "assistant") {
for (const part of (msg as OcxAssistantMessage).content) {
if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id);
}
} else if (msg.role === "toolResult") {
callIds.reserve((msg as OcxToolResultMessage).toolCallId);
}
}
for (const msg of parsed.context.messages) {
switch (msg.role) {
case "user":
Expand Down Expand Up @@ -204,7 +209,7 @@ function messagesToGeminiFormat(
// streaming covered by the replay cache. Only forward a REAL upstream signature — the
// Responses parser also stashes synthetic item ids (`fc_...`) on this field, and sending
// those as a thoughtSignature breaks continuity (the replay cache supplies the real one).
const callId = geminiToolCallId(tc.id);
const callId = callIds.allocate(tc.id);
const functionCall: Record<string, unknown> = { name: namespacedToolName(tc.namespace, tc.name), args: tc.arguments };
// Claude-on-Antigravity maps this id to Anthropic `tool_use.id`; without it the upstream
// conversion 400s. Gemini accepts the optional id and pairs call/response by it.
Expand All @@ -229,7 +234,8 @@ function messagesToGeminiFormat(
// functionResponse, but it does accept sibling inline_data parts in the same user turn, so
// tool-result screenshots (e.g. Computer Use) ride along as inline_data instead of being
// flattened to a "[image]" marker the model can't actually see.
const responseId = geminiToolCallId(msg.toolCallId);
// lookup(), not allocate(): a response must reuse its call's id and must never mint a new one.
const responseId = callIds.lookup(msg.toolCallId);
const functionResponse: Record<string, unknown> = { name: namespacedToolName(msg.toolNamespace, msg.toolName), response: { result: geminiToolResultText(msg.content) } };
// Mirror the matching functionCall id so Claude-on-Antigravity can pair this result with its
// `tool_use` block (-> Anthropic `tool_result.tool_use_id`).
Expand Down
119 changes: 119 additions & 0 deletions src/adapters/tool-call-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { createHash } from "node:crypto";

/**
* Anthropic rejects a `tool_use.id` longer than this. Collision disambiguation has to fit
* inside it too, which is why candidates are assembled from parts instead of sliced at the end.
*/
export const MAX_TOOL_CALL_ID_LENGTH = 64;

/** Hex characters of the deterministic tail. Fixed width: it is a discriminator, not a payload. */
const TOOL_CALL_ID_HASH_WIDTH = 8;

const CONFORMING_TOOL_CALL_ID = /^[a-zA-Z0-9_-]+$/;

/** An id Anthropic accepts as-is: right character set AND within the length bound. */
export function isConformingToolCallId(rawId: string): boolean {
return rawId.length > 0
&& rawId.length <= MAX_TOOL_CALL_ID_LENGTH
&& CONFORMING_TOOL_CALL_ID.test(rawId);
}

/**
* The parts a rewritten id is built from: the sanitized prefix and a deterministic tail
* derived from the raw id. Kept separate so collision handling can truncate the prefix
* without destroying the discriminator.
*/
function toolCallIdComponents(rawId: string | undefined): { cleaned: string; hash: string } | undefined {
const raw = rawId ?? "";
if (raw.length === 0) return undefined;
const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_");
const hash = createHash("sha256").update(raw).digest("hex").slice(0, TOOL_CALL_ID_HASH_WIDTH);
return { cleaned, hash };
}

/** Assemble `prefix_hash`, truncating only the prefix, leaving `reserve` characters spare. */
function fitToolCallId(cleaned: string, hash: string, reserve = 0): string {
const tail = `_${hash}`;
const room = MAX_TOOL_CALL_ID_LENGTH - reserve - tail.length;
return cleaned.slice(0, Math.max(1, room)) + tail;
}

/**
* Normalize a tool call id into the character set and length Anthropic accepts.
*
* This is the stateless view, kept for callers that only need the shape of one id. It is NOT
* injective on its own: `anthropicToolCallId("call:a")` returns something like `call_a_1f2e3d4c`,
* and a raw id that already equals that value is returned unchanged — two distinct sources, one
* wire id. Anything building a whole request must use {@link createToolCallIdAllocator}, which
* reserves the conforming ids first and resolves collisions.
*
* Returns `undefined` for an empty id. Callers must handle that rather than falling back to the
* raw value: restoring `""` puts an id on the wire that Anthropic rejects (#1767).
*/
export function anthropicToolCallId(rawId: string | undefined): string | undefined {
const raw = rawId ?? "";
if (raw.length === 0) return undefined;
if (isConformingToolCallId(raw)) return raw;
const parts = toolCallIdComponents(raw);
if (!parts) return undefined;
return fitToolCallId(parts.cleaned, parts.hash);
}

export type ToolCallIdAllocator = {
/** Claim an already-conforming source id so no rewrite can be handed the same value. */
reserve(rawId: string | undefined): void;
/** Wire id for a raw id, stable within the request. `undefined` means "not representable". */
allocate(rawId: string | undefined): string | undefined;
/** Wire id previously allocated for this raw id, without creating one. */
lookup(rawId: string | undefined): string | undefined;
};

/**
* Request-scoped raw-id to wire-id mapping.
*
* Two properties the stateless transform cannot provide:
*
* - **Injective.** Reserve every already-conforming id first, then allocate rewrites around them,
* appending a numeric suffix when a candidate is taken. Two distinct raw ids never share a wire id,
* including the case where one raw id already looks like another's normalized form, and including
* an ordinary 32-bit hash collision.
* - **Stable.** A tool result asks for the same raw id its call used and gets the same wire id, so
* call/result pairing survives normalization.
*/
export function createToolCallIdAllocator(): ToolCallIdAllocator {
const rawToWire = new Map<string, string>();
const occupied = new Set<string>();

return {
reserve(rawId) {
if (!rawId || rawToWire.has(rawId)) return;
if (!isConformingToolCallId(rawId)) return;
rawToWire.set(rawId, rawId);
occupied.add(rawId);
},
allocate(rawId) {
if (!rawId) return undefined;
const existing = rawToWire.get(rawId);
if (existing) return existing;
if (isConformingToolCallId(rawId) && !occupied.has(rawId)) {
rawToWire.set(rawId, rawId);
occupied.add(rawId);
return rawId;
}
const parts = toolCallIdComponents(rawId);
if (!parts) return undefined;
let candidate = fitToolCallId(parts.cleaned, parts.hash);
for (let n = 2; occupied.has(candidate); n++) {
const suffix = `_${n}`;
candidate = fitToolCallId(parts.cleaned, parts.hash, suffix.length) + suffix;
}
rawToWire.set(rawId, candidate);
occupied.add(candidate);
return candidate;
},
lookup(rawId) {
if (!rawId) return undefined;
return rawToWire.get(rawId);
},
};
}
Loading
Loading