mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +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
@@ -116,13 +116,63 @@ describe('openai proxy messages endpoint', () => {
|
||||
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
tool_choice?: unknown;
|
||||
tools?: Array<{ type: string; function: { name: string } }>;
|
||||
};
|
||||
expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' });
|
||||
expect(parsedUpstream.tool_choice).toBe('auto');
|
||||
expect(parsedUpstream.tools?.[0]?.type).toBe('function');
|
||||
expect(parsedUpstream.tools?.[0]?.function.name).toBe('search');
|
||||
});
|
||||
|
||||
it('preserves tool schemas and forwards explicit tool_choice semantics upstream', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Search docs' }] }],
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search docs',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', pattern: '^[a-z]+$' },
|
||||
},
|
||||
required: ['q'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: 'search',
|
||||
disable_parallel_tool_use: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const parsedUpstream = upstreamBody as {
|
||||
tool_choice?: unknown;
|
||||
parallel_tool_calls?: boolean;
|
||||
tools?: Array<{ type: string; function: { parameters: Record<string, unknown> } }>;
|
||||
};
|
||||
|
||||
expect(parsedUpstream.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
function: { name: 'search' },
|
||||
});
|
||||
expect(parsedUpstream.parallel_tool_calls).toBe(false);
|
||||
expect(parsedUpstream.tools?.[0]?.function.parameters).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', pattern: '^[a-z]+$' },
|
||||
},
|
||||
required: ['q'],
|
||||
additionalProperties: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Anthropic JSON for non-streaming requests', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
@@ -155,6 +205,24 @@ describe('openai proxy messages endpoint', () => {
|
||||
expect(body.error?.message).toContain('Invalid JSON');
|
||||
});
|
||||
|
||||
it('returns invalid_request_error for orphan tool_result blocks', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_orphan', content: 'orphan' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const body = (await response.json()) as { error?: { type?: string; message?: string } };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error?.type).toBe('invalid_request_error');
|
||||
expect(body.error?.message).toContain('tool_result requires a preceding assistant tool_use');
|
||||
});
|
||||
|
||||
it('rejects requests without the local proxy auth token', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -214,4 +214,75 @@ describe('openai proxy request routing', () => {
|
||||
expect(hits).toEqual(['thinker']);
|
||||
expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' });
|
||||
});
|
||||
|
||||
it('routes adaptive thinking requests through the configured think scenario', async () => {
|
||||
const primaryPort = await getPort();
|
||||
const thinkPort = await getPort();
|
||||
const hits: string[] = [];
|
||||
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||
await startMockUpstream(primaryPort, 'primary', hits, bodies);
|
||||
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
|
||||
|
||||
const primarySettings = writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'hf_token',
|
||||
ANTHROPIC_MODEL: 'hf-default',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
const thinkSettings = writeSettings('thinker', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${thinkPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'think_token',
|
||||
ANTHROPIC_MODEL: 'deepseek-reasoner',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, '.ccs', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles: { hf: primarySettings, thinker: thinkSettings },
|
||||
proxy: {
|
||||
routing: {
|
||||
think: 'thinker:deepseek-reasoner',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile: OpenAICompatProfileConfig = {
|
||||
profileName: 'hf',
|
||||
settingsPath: primarySettings,
|
||||
baseUrl: `http://127.0.0.1:${primaryPort}`,
|
||||
apiKey: 'hf_token',
|
||||
provider: 'generic-chat-completion-api',
|
||||
model: 'hf-default',
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-default',
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'max' },
|
||||
messages: [{ role: 'user', content: 'think adaptively' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
content: [{ type: 'text', text: 'Reply from thinker' }],
|
||||
});
|
||||
expect(hits).toEqual(['thinker']);
|
||||
expect(bodies[0]?.body).toMatchObject({
|
||||
model: 'deepseek-reasoner',
|
||||
reasoning_effort: 'high',
|
||||
reasoning: { enabled: true, effort: 'high' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user