Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
65 changes: 58 additions & 7 deletions src/llm/anthropic/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/**
* This util file contains functions for converting LangChain messages to Anthropic messages.
*/
import { createHash } from 'node:crypto';
import {
type BaseMessage,
type SystemMessage,
Expand Down Expand Up @@ -90,6 +91,49 @@ function _formatImage(imageUrl: string) {
);
}

const ANTHROPIC_TOOL_USE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const ANTHROPIC_TOOL_USE_ID_MAX_LENGTH = 64;
const ANTHROPIC_TOOL_USE_ID_HASH_LENGTH = 10;

/**
* Normalize a tool-call ID to satisfy Anthropic's `^[a-zA-Z0-9_-]+$` and 64-char
* constraints. Pure and deterministic — same input always yields the same output,
* so paired `tool_use.id` and `tool_result.tool_use_id` stay matched without
* needing a session map. IDs that already comply pass through unchanged.
*
* For non-compliant inputs we sanitize then append a short SHA-256 prefix of
* the original ID to preserve uniqueness when truncation would otherwise
* collapse distinct IDs to the same value (e.g. two long Responses-style IDs
* sharing a 64-char prefix). The hash is computed against the raw input so
* inputs that differ only after the truncation cutoff still produce distinct
* outputs.
*/
export function normalizeAnthropicToolCallId(id: string): string;
export function normalizeAnthropicToolCallId(
id: string | undefined
): string | undefined;
export function normalizeAnthropicToolCallId(
id: string | undefined
): string | undefined {
if (id == null) {
return id;
}
if (
id.length <= ANTHROPIC_TOOL_USE_ID_MAX_LENGTH &&
ANTHROPIC_TOOL_USE_ID_PATTERN.test(id)
) {
return id;
}
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, '_');
const hash = createHash('sha256')
.update(id)
.digest('hex')
.slice(0, ANTHROPIC_TOOL_USE_ID_HASH_LENGTH);
const prefixMaxLength =
ANTHROPIC_TOOL_USE_ID_MAX_LENGTH - ANTHROPIC_TOOL_USE_ID_HASH_LENGTH - 1;
return `${sanitized.slice(0, prefixMaxLength)}_${hash}`;
}

function _ensureMessageContents(
messages: BaseMessage[]
): (SystemMessage | HumanMessage | AIMessage)[] {
Expand All @@ -109,7 +153,9 @@ function _ensureMessageContents(
(previousMessage.content as MessageContentComplex[]).push({
type: 'tool_result',
content: message.content,
tool_use_id: (message as ToolMessage).tool_call_id,
tool_use_id: normalizeAnthropicToolCallId(
(message as ToolMessage).tool_call_id
),
});
} else {
// If not, we create a new human message with the tool result.
Expand All @@ -119,7 +165,9 @@ function _ensureMessageContents(
{
type: 'tool_result',
content: message.content,
tool_use_id: (message as ToolMessage).tool_call_id,
tool_use_id: normalizeAnthropicToolCallId(
(message as ToolMessage).tool_call_id
),
},
],
})
Expand All @@ -135,7 +183,9 @@ function _ensureMessageContents(
...(message.content != null
? { content: _formatContent(message) }
: {}),
tool_use_id: (message as ToolMessage).tool_call_id,
tool_use_id: normalizeAnthropicToolCallId(
(message as ToolMessage).tool_call_id
),
},
],
})
Expand All @@ -154,11 +204,12 @@ export function _convertLangChainToolCallToAnthropic(
if (toolCall.id === undefined) {
throw new Error('Anthropic requires all tool calls to have an "id".');
}
const isServerTool = toolCall.id.startsWith(
Constants.ANTHROPIC_SERVER_TOOL_PREFIX
);
return {
type: toolCall.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
? 'server_tool_use'
: 'tool_use',
id: toolCall.id,
type: isServerTool ? 'server_tool_use' : 'tool_use',
id: isServerTool ? toolCall.id : normalizeAnthropicToolCallId(toolCall.id),
name: toolCall.name,
input: toolCall.args,
};
Expand Down
171 changes: 171 additions & 0 deletions src/llm/anthropic/utils/tool-id-normalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
import {
_convertLangChainToolCallToAnthropic,
_convertMessagesToAnthropicPayload,
normalizeAnthropicToolCallId,
} from './message_inputs';

describe('normalizeAnthropicToolCallId', () => {
it('returns valid IDs unchanged', () => {
expect(normalizeAnthropicToolCallId('toolu_01ABcdEFgh')).toBe(
'toolu_01ABcdEFgh'
);
expect(normalizeAnthropicToolCallId('call_abc123XYZ')).toBe(
'call_abc123XYZ'
);
expect(normalizeAnthropicToolCallId('a-b_c-d')).toBe('a-b_c-d');
});

it('sanitizes invalid characters and appends a hash suffix', () => {
const out = normalizeAnthropicToolCallId(
'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs'
);
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
expect(out.length).toBeLessThanOrEqual(64);
expect(
out.startsWith('fc_67abc1234def567_call_abc123def456ghi789jkl0mn')
).toBe(true);
// Suffix is `_<10-hex-char hash>`
expect(out).toMatch(/_[0-9a-f]{10}$/);
});

it('produces compliant output for IDs of any length', () => {
const long = 'fc_' + 'a'.repeat(80);
const out = normalizeAnthropicToolCallId(long);
expect(out).toHaveLength(64);
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
});

it('produces uniquely distinguishable outputs for IDs that share a 64-char prefix', () => {
const sharedPrefix = 'fc_' + 'a'.repeat(80);
const idA = sharedPrefix + '|call_unique_A';
const idB = sharedPrefix + '|call_unique_B';

const outA = normalizeAnthropicToolCallId(idA);
const outB = normalizeAnthropicToolCallId(idB);

expect(outA).not.toBe(outB);
expect(outA).toHaveLength(64);
expect(outB).toHaveLength(64);
expect(/^[a-zA-Z0-9_-]+$/.test(outA)).toBe(true);
expect(/^[a-zA-Z0-9_-]+$/.test(outB)).toBe(true);
});

it('disambiguates short IDs that sanitize to the same value', () => {
expect(normalizeAnthropicToolCallId('a|b')).not.toBe(
normalizeAnthropicToolCallId('a.b')
);
});

it('handles combined length and character violations', () => {
const id = 'fc_' + 'x|'.repeat(100);
const out = normalizeAnthropicToolCallId(id);
expect(out).toHaveLength(64);
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
});

it('is deterministic — same input always yields same output', () => {
const id = 'fc_a|b|c';
expect(normalizeAnthropicToolCallId(id)).toBe(
normalizeAnthropicToolCallId(id)
);
});

it('passes through undefined for the optional overload', () => {
expect(normalizeAnthropicToolCallId(undefined)).toBeUndefined();
});
});

describe('_convertMessagesToAnthropicPayload — cross-provider ID normalization', () => {
it('normalizes Responses-style IDs on tool_use AND matching tool_result', () => {
const responsesId = 'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs';

const payload = _convertMessagesToAnthropicPayload([
new HumanMessage('weather?'),
new AIMessage({
content: '',
tool_calls: [
{
id: responsesId,
name: 'get_weather',
args: { location: 'Tokyo' },
type: 'tool_call',
},
],
}),
new ToolMessage({
tool_call_id: responsesId,
content: '{"temp": 21}',
}),
]);

const assistantMsg = payload.messages.find((m) => m.role === 'assistant')!;
const userToolResultMsg = payload.messages.find(
(m) =>
m.role === 'user' &&
Array.isArray(m.content) &&
(m.content as Array<{ type: string }>)[0]?.type === 'tool_result'
)!;

const toolUseBlock = (
assistantMsg.content as Array<{ type: string; id?: string }>
).find((b) => b.type === 'tool_use')!;
const toolResultBlock = (
userToolResultMsg.content as Array<{
type: string;
tool_use_id?: string;
}>
).find((b) => b.type === 'tool_result')!;

const expected = normalizeAnthropicToolCallId(responsesId);
expect(toolUseBlock.id).toBe(expected);
expect(toolResultBlock.tool_use_id).toBe(expected);
expect(toolUseBlock.id).toBe(toolResultBlock.tool_use_id);
expect(/^[a-zA-Z0-9_-]+$/.test(toolUseBlock.id!)).toBe(true);
expect(toolUseBlock.id!.length).toBeLessThanOrEqual(64);
});

it('passes through Anthropic-native IDs unchanged', () => {
const nativeId = 'toolu_01ABcdEFgh23ijKL';

const payload = _convertMessagesToAnthropicPayload([
new HumanMessage('hi'),
new AIMessage({
content: '',
tool_calls: [
{
id: nativeId,
name: 'noop',
args: {},
type: 'tool_call',
},
],
}),
new ToolMessage({
tool_call_id: nativeId,
content: 'ok',
}),
]);

const assistantMsg = payload.messages.find((m) => m.role === 'assistant')!;
const toolUseBlock = (
assistantMsg.content as Array<{ type: string; id?: string }>
).find((b) => b.type === 'tool_use')!;

expect(toolUseBlock.id).toBe(nativeId);
});

it('does not normalize server tool IDs (srvtoolu_ prefix)', () => {
const serverId = 'srvtoolu_01abcXYZ';

const block = _convertLangChainToolCallToAnthropic({
id: serverId,
name: 'web_search',
args: { query: 'x' },
type: 'tool_call',
});

expect(block.type).toBe('server_tool_use');
expect(block.id).toBe(serverId);
});
});
60 changes: 47 additions & 13 deletions src/llm/openai/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,22 +313,52 @@ export function _convertMessagesToOpenAIParams(

let hasAnthropicThinkingBlock: boolean = false;

/**
* When the target is Claude served via an OpenAI-shaped surface (e.g.
* OpenRouter), thinking and redacted_thinking blocks are valid input and
* must pass through verbatim. For native OpenAI, those block types are
* rejected with a 400 — flatten thinking to a `<thinking>...</thinking>`
* text block so the reasoning narrative remains in-band, drop empty
* thinking blocks (some providers reject empty content), and drop
* redacted_thinking entirely (its payload is encrypted and useless to
* a non-Anthropic model).
*/
const isClaudeTarget =
model?.includes('claude') === true ||
model?.includes('anthropic') === true;

const content =
typeof message.content === 'string'
? message.content
: message.content.map((m) => {
if ('type' in m && m.type === 'thinking') {
hasAnthropicThinkingBlock = true;
: message.content
.map((m) => {
if ('type' in m && m.type === 'thinking') {
hasAnthropicThinkingBlock = true;
if (isClaudeTarget) {
return m;
}
const thinking = (m as { thinking?: string }).thinking ?? '';
if (!thinking) {
return null;
}
return {
type: 'text' as const,
text: `<thinking>${thinking}</thinking>`,
};
}
if ('type' in m && m.type === 'redacted_thinking') {
hasAnthropicThinkingBlock = true;
return isClaudeTarget ? m : null;
}
if (isDataContentBlock(m)) {
return convertToProviderContentBlock(
m,
completionsApiContentBlockConverter
);
}
return m;
}
if (isDataContentBlock(m)) {
return convertToProviderContentBlock(
m,
completionsApiContentBlockConverter
);
}
return m;
});
})
.filter(<T>(m: T | null): m is T => m !== null);
Comment on lines +478 to +479
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid emitting empty assistant content arrays

Filtering out thinking/redacted_thinking blocks can leave content as [] for assistant messages that have no tool_calls (for example, a message containing only redacted_thinking or only empty thinking). This value is then forwarded unchanged, but Chat Completions requires assistant content arrays to contain at least one part (text or a single refusal), so this can still trigger a 400 on the OpenAI path. You already guard this case in the tool-calls branch by converting empty arrays to ''; the same normalization is needed for non-tool-call assistant messages.

Useful? React with 👍 / 👎.

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const completionParam: Record<string, any> = {
role,
Expand All @@ -348,7 +378,11 @@ export function _convertMessagesToOpenAIParams(
completionParam.tool_calls = message.tool_calls.map(
convertLangChainToolCallToOpenAI
);
completionParam.content = hasAnthropicThinkingBlock ? content : '';
completionParam.content = hasAnthropicThinkingBlock
? Array.isArray(content) && content.length === 0
? ''
: content
: '';
if (
options?.includeReasoningDetails === true &&
message.additional_kwargs.reasoning_details != null
Expand Down
Loading
Loading