-
Notifications
You must be signed in to change notification settings - Fork 79
fix: Cross-provider tool-call ID + thinking-block compatibility #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
e9329c0
02da1c7
e070e41
d9d4898
cf431ad
79ac5cc
1b78e72
1ae50c0
9df3d83
de2c0c0
2a1adcc
3ffe05c
036fb77
4da5365
65ba110
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Filtering out Useful? React with 👍 / 👎. |
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const completionParam: Record<string, any> = { | ||
| role, | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 treatstool_use.idas 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 withtool_resultblocks in multi-tool turns. Consider appending a short deterministic hash suffix before truncation to preserve uniqueness.Useful? React with 👍 / 👎.