mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 04:15:52 +00:00
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:
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user