fix(proxy): harden Anthropic request transformation semantics

- enforce strict tool_result ordering and pairing against assistant tool_use ids
- reject tool_result image payloads that cannot map to OpenAI tool messages
- preserve raw tool schemas on the /v1/messages proxy path instead of silently tightening them
- forward Anthropic tool_choice semantics and cover adaptive routing plus upstream payload checks
This commit is contained in:
Tam Nhu Tran committed 2026-04-18 19:39:13 -04:00
1 parent 22ab58b02e
commit ebc92194bb
4 files changed
+422 -48

No files matched your search

@@ -74,8 +74,114 @@ describe('ProxyRequestTransformer regressions', () => {
).toThrow('tool_result requires user role');
});
it('translates url images and error tool results while coalescing repeated turns', () => {
it('rejects orphaned, incomplete, or mixed-order tool_result blocks', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'orphan' }],
},
],
})
).toThrow('tool_result requires a preceding assistant tool_use');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'docs' } },
{ type: 'tool_use', id: 'toolu_2', name: 'open', input: { url: 'https://example.com' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'partial' }],
},
],
})
).toThrow('must provide tool_result blocks for all pending tool_use ids');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: [
{ type: 'text', text: 'Here you go' },
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'result' },
],
},
],
})
).toThrow('tool_result blocks must come before other user content');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: 'plain follow-up',
},
],
})
).toThrow('must start with tool_result blocks for pending tool_use ids');
});
it('rejects tool_result content that cannot be represented as OpenAI tool text', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } }],
},
],
},
],
})
).toThrow('type "image" is not supported in tool_result content');
});
it('rejects unsupported assistant blocks instead of silently dropping them', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'server_tool_use', id: 'srv_1' }],
},
],
})
).toThrow('type "server_tool_use" is not supported');
});
it('translates url images and tool_choice while coalescing repeated turns', () => {
const result = new ProxyRequestTransformer().transform({
tool_choice: {
type: 'tool',
name: 'vision',
disable_parallel_tool_use: true,
},
tools: [{ name: 'vision', description: 'Inspect image', input_schema: { type: 'object' } }],
messages: [
{
role: 'user',
@@ -97,16 +203,19 @@ describe('ProxyRequestTransformer regressions', () => {
type: 'tool_result',
tool_use_id: 'toolu_1',
is_error: true,
content: [
{ type: 'text', text: 'fetch failed' },
{ type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } },
],
content: [{ type: 'text', text: 'fetch failed' }],
},
],
},
],
});
expect(result.tool_choice).toEqual({
type: 'function',
function: { name: 'vision' },
});
expect(result.parallel_tool_calls).toBe(false);
expect(result.messages[0]).toEqual({
role: 'user',
content: [
@@ -131,10 +240,16 @@ describe('ProxyRequestTransformer regressions', () => {
expect(result.messages[2]).toEqual({
role: 'tool',
tool_call_id: 'toolu_1',
content: [
{ type: 'text', text: 'Error: fetch failed' },
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } },
],
content: 'Error: fetch failed',
});
});
it('defaults tools to auto tool_choice when none is specified', () => {
const result = new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
});
expect(result.tool_choice).toBe('auto');
});
});