Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
51 changes: 44 additions & 7 deletions src/llm/anthropic/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ function _formatImage(imageUrl: string) {
);
}

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

/**
* 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.
*/
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;
}
return id
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, ANTHROPIC_TOOL_USE_ID_MAX_LENGTH);
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 Preserve uniqueness when normalizing truncated tool-call IDs

The normalization strategy can collapse distinct tool-call IDs into the same 64-char value because it only replaces invalid characters and truncates (slice(0, 64)) without adding any disambiguator. Anthropic treats tool_use.id as a unique identifier, so two long IDs with a shared prefix can become duplicates and either be rejected (tool_use ids must be unique) or mis-associated with tool_result blocks in multi-tool turns. Consider appending a short deterministic hash suffix before truncation to preserve uniqueness.

Useful? React with 👍 / 👎.

}

function _ensureMessageContents(
messages: BaseMessage[]
): (SystemMessage | HumanMessage | AIMessage)[] {
Expand All @@ -109,7 +139,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 +151,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 +169,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 +190,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
146 changes: 146 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,146 @@
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('replaces invalid characters with underscores', () => {
expect(
normalizeAnthropicToolCallId(
'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs'
)
).toBe('fc_67abc1234def567_call_abc123def456ghi789jkl0mnopqrs');
expect(normalizeAnthropicToolCallId('a.b@c#d')).toBe('a_b_c_d');
});

it('truncates IDs longer than 64 characters', () => {
const long = 'fc_' + 'a'.repeat(80);
const out = normalizeAnthropicToolCallId(long);
expect(out).toHaveLength(64);
expect(out.startsWith('fc_aaa')).toBe(true);
});

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