feat: 增强聊天代理流行为,优化错误处理和工具调用逻辑

This commit is contained in:
ciat-777
2026-03-03 00:03:34 +08:00
parent e2fce12e20
commit 5cebc1554f
4 changed files with 408 additions and 68 deletions
+206 -1
View File
@@ -1096,6 +1096,105 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/messages'); expect(targetUrl).toContain('/v1/messages');
}); });
it('prefers /v1/messages on openai platform for claude-family models on /v1/chat/completions', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'claude-opus-4-6',
supportedEndpointTypes: ['/v1/chat/completions', 'openai'],
},
],
groupRatio: {},
});
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-openai',
actualModel: 'claude-opus-4-6',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_openai_platform_claude',
type: 'message',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'messages endpoint selected' }],
stop_reason: 'end_turn',
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-opus-4-6',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/messages');
});
it('falls back from /v1/messages to /v1/chat/completions on openai platform when messages endpoint is unavailable', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-openai',
actualModel: 'claude-opus-4-6',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'Not Found', type: 'not_found_error' },
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'chatcmpl_openai_fallback_chat',
object: 'chat.completion',
created: 1_706_000_777,
model: 'claude-opus-4-6',
choices: [{
index: 0,
message: { role: 'assistant', content: 'fallback to chat completed' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-opus-4-6',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toContain('/v1/messages');
expect(secondUrl).toContain('/v1/chat/completions');
});
it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => { it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => {
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, channel: { id: 11, routeId: 22 },
@@ -1359,6 +1458,71 @@ describe('chat proxy stream behavior', () => {
expect(forwardedBody.tool_choice).toEqual({ type: 'tool', name: 'Glob' }); expect(forwardedBody.tool_choice).toEqual({ type: 'tool', name: 'Glob' });
}); });
it('groups consecutive tool messages into one anthropic user turn when routing /v1/chat/completions to /v1/messages', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-claude',
actualModel: 'claude-sonnet-4-5-20250929',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_claude_grouped_tool_results',
type: 'message',
model: 'claude-sonnet-4-5-20250929',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-sonnet-4-5-20250929',
stream: false,
messages: [
{
role: 'assistant',
tool_calls: [
{
id: 'call_one',
type: 'function',
function: { name: 'Glob', arguments: '{"pattern":"README*"}' },
},
{
id: 'call_two',
type: 'function',
function: { name: 'Read', arguments: '{"file":"README.md"}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_one', content: '{"matches":["README.md"]}' },
{ role: 'tool', tool_call_id: 'call_two', content: '{"content":"hello"}' },
{ role: 'user', content: 'continue' },
],
},
});
expect(response.statusCode).toBe(200);
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(Array.isArray(forwardedBody.messages)).toBe(true);
const userMessages = forwardedBody.messages.filter((item: any) => item?.role === 'user');
expect(userMessages.length).toBe(1);
expect(Array.isArray(userMessages[0]?.content)).toBe(true);
expect(userMessages[0].content.filter((part: any) => part?.type === 'tool_result').length).toBe(2);
expect(userMessages[0].content.some((part: any) => part?.type === 'tool_result' && part?.tool_use_id === 'call_one')).toBe(true);
expect(userMessages[0].content.some((part: any) => part?.type === 'tool_result' && part?.tool_use_id === 'call_two')).toBe(true);
expect(userMessages[0].content.some((part: any) => part?.type === 'text' && part?.text === 'continue')).toBe(true);
});
it('converts /v1/responses function_call SSE to OpenAI tool_calls on /v1/chat/completions', async () => { it('converts /v1/responses function_call SSE to OpenAI tool_calls on /v1/chat/completions', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({ fetchModelPricingCatalogMock.mockResolvedValue({
models: [ models: [
@@ -1493,6 +1657,47 @@ describe('chat proxy stream behavior', () => {
expect(matches.length).toBe(1); expect(matches.length).toBe(1);
}); });
it('emits finish_reason error when /v1/chat/completions receives response.failed from /v1/responses upstream', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'upstream-gpt',
supportedEndpointTypes: ['/v1/responses'],
},
],
groupRatio: {},
});
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_fail_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.failed\ndata: {"type":"response.failed","response":{"id":"resp_fail_1","model":"upstream-gpt","status":"failed","error":{"message":"tool execution failed"}}}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-haiku-4-5-20251001',
stream: true,
messages: [{ role: 'user', content: 'find readme' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"finish_reason":"error"');
expect(response.body).toContain('[DONE]');
});
it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => { it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({ fetchModelPricingCatalogMock.mockResolvedValue({
models: [ models: [
@@ -1569,7 +1774,7 @@ describe('chat proxy stream behavior', () => {
method: 'POST', method: 'POST',
url: '/v1/chat/completions', url: '/v1/chat/completions',
payload: { payload: {
model: 'claude-haiku-4-5-20251001', model: 'gpt-4o-mini',
stream: false, stream: false,
tools: [{ tools: [{
type: 'function', type: 'function',
+17
View File
@@ -161,6 +161,10 @@ export function normalizeStopReason(raw: unknown): string | null {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (!value) return null; if (!value) return null;
if (value === 'failed' || value === 'error') {
return 'error';
}
if ( if (
value === 'end_turn' value === 'end_turn'
|| value === 'stop' || value === 'stop'
@@ -803,6 +807,19 @@ export function normalizeUpstreamStreamEvent(
}; };
} }
if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') {
const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null;
const finishReason = normalizeStopReason(
(responsePayload as any)?.status
?? (payload as any).status
?? (payload as any).type,
) || 'error';
return {
finishReason,
done: true,
};
}
if (type === 'response.output_item.added' && isRecord((payload as any).item)) { if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
const item = (payload as any).item as Record<string, unknown>; const item = (payload as any).item as Record<string, unknown>;
if (item.type === 'function_call') { if (item.type === 'function_call') {
@@ -6,7 +6,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg), fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
})); }));
import { resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js'; import { isEndpointDowngradeError, resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js';
const baseContext = { const baseContext = {
site: { site: {
@@ -104,7 +104,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
'gpt-5.3', 'gpt-5.3',
'openai', 'openai',
); );
expect(openaiOrder).toEqual(['chat', 'responses']); expect(openaiOrder).toEqual(['chat', 'responses', 'messages']);
const openaiResponsesOrder = await resolveUpstreamEndpointCandidates( const openaiResponsesOrder = await resolveUpstreamEndpointCandidates(
{ {
@@ -114,7 +114,17 @@ describe('resolveUpstreamEndpointCandidates', () => {
'gpt-5.3', 'gpt-5.3',
'responses', 'responses',
); );
expect(openaiResponsesOrder).toEqual(['responses', 'chat']); expect(openaiResponsesOrder).toEqual(['responses', 'chat', 'messages']);
const openaiClaudeOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'openai' },
},
'claude-opus-4-6',
'openai',
);
expect(openaiClaudeOrder).toEqual(['messages', 'chat', 'responses']);
const claudeOrder = await resolveUpstreamEndpointCandidates( const claudeOrder = await resolveUpstreamEndpointCandidates(
{ {
@@ -127,6 +137,29 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(claudeOrder).toEqual(['messages']); expect(claudeOrder).toEqual(['messages']);
}); });
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'claude-opus-4-6',
supportedEndpointTypes: ['/v1/chat/completions', 'openai'],
},
],
groupRatio: {},
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'openai' },
},
'claude-opus-4-6',
'openai',
);
expect(order).toEqual(['messages', 'chat', 'responses']);
});
it('keeps anyrouter messages-first special case', async () => { it('keeps anyrouter messages-first special case', async () => {
const openaiOrder = await resolveUpstreamEndpointCandidates( const openaiOrder = await resolveUpstreamEndpointCandidates(
{ {
@@ -148,4 +181,10 @@ describe('resolveUpstreamEndpointCandidates', () => {
); );
expect(responsesOrder).toEqual(['responses', 'messages', 'chat']); expect(responsesOrder).toEqual(['responses', 'messages', 'chat']);
}); });
it('treats endpoint-not-found responses as downgrade candidates', () => {
expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true);
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true);
});
}); });
+112 -33
View File
@@ -394,36 +394,10 @@ function convertOpenAiBodyToMessagesBody(
content: string | Array<Record<string, unknown>>; content: string | Array<Record<string, unknown>>;
}> = []; }> = [];
for (const item of rawMessages) { const appendAssistantMessage = (item: Record<string, unknown>) => {
if (!isRecord(item)) continue; const role = asTrimmedString(item.role).toLowerCase() || 'assistant';
const role = asTrimmedString(item.role).toLowerCase() || 'user'; if (role !== 'assistant') return;
if (role === 'system' || role === 'developer') {
const content = normalizeContentText(item.content);
if (!content) continue;
systemContents.push(content);
continue;
}
if (role === 'tool') {
const toolUseId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
if (!toolUseId) continue;
const toolResultContent = normalizeToolMessageContent(item.content).trim();
if (!toolResultContent) continue;
messages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: toolResultContent,
}],
});
continue;
}
if (role === 'assistant') {
const contentBlocks: Array<Record<string, unknown>> = []; const contentBlocks: Array<Record<string, unknown>> = [];
const textContent = normalizeContentText(item.content).trim(); const textContent = normalizeContentText(item.content).trim();
@@ -456,7 +430,7 @@ function convertOpenAiBodyToMessagesBody(
}); });
} }
if (contentBlocks.length <= 0) continue; if (contentBlocks.length <= 0) return;
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') { if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
const singleText = asTrimmedString(contentBlocks[0].text); const singleText = asTrimmedString(contentBlocks[0].text);
@@ -466,13 +440,81 @@ function convertOpenAiBodyToMessagesBody(
content: singleText, content: singleText,
}); });
} }
continue; return;
} }
messages.push({ messages.push({
role: 'assistant', role: 'assistant',
content: contentBlocks, content: contentBlocks,
}); });
};
for (let messageIndex = 0; messageIndex < rawMessages.length; messageIndex += 1) {
const item = rawMessages[messageIndex];
if (!isRecord(item)) continue;
const role = asTrimmedString(item.role).toLowerCase() || 'user';
if (role === 'system' || role === 'developer') {
const content = normalizeContentText(item.content);
if (!content) continue;
systemContents.push(content);
continue;
}
if (role === 'tool') {
const toolResultBlocks: Array<Record<string, unknown>> = [];
let cursor = messageIndex;
while (cursor < rawMessages.length) {
const toolCandidate = rawMessages[cursor];
if (!isRecord(toolCandidate)) break;
const toolRole = asTrimmedString(toolCandidate.role).toLowerCase();
if (toolRole !== 'tool') break;
const toolUseId = (
asTrimmedString(toolCandidate.tool_call_id)
|| asTrimmedString(toolCandidate.id)
);
const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim();
if (toolUseId && toolResultContent) {
toolResultBlocks.push({
type: 'tool_result',
tool_use_id: toolUseId,
content: toolResultContent,
});
}
cursor += 1;
}
if (toolResultBlocks.length > 0 && cursor < rawMessages.length) {
const nextItem = rawMessages[cursor];
if (isRecord(nextItem)) {
const nextRole = asTrimmedString(nextItem.role).toLowerCase();
if (nextRole === 'user') {
const userText = normalizeContentText(nextItem.content).trim();
if (userText) {
toolResultBlocks.push({
type: 'text',
text: userText,
});
}
cursor += 1;
}
}
}
if (toolResultBlocks.length > 0) {
messages.push({
role: 'user',
content: toolResultBlocks,
});
}
messageIndex = cursor - 1;
continue;
}
if (role === 'assistant') {
appendAssistantMessage(item);
continue; continue;
} }
@@ -698,9 +740,14 @@ function preferredEndpointOrder(
} }
if (platform === 'openai') { if (platform === 'openai') {
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
// Keep chat/responses as fallbacks when messages is unavailable.
return ['messages', 'chat', 'responses'];
}
return downstreamFormat === 'responses' return downstreamFormat === 'responses'
? ['responses', 'chat'] ? ['responses', 'chat', 'messages']
: ['chat', 'responses']; : ['chat', 'responses', 'messages'];
} }
if (platform === 'claude') { if (platform === 'claude') {
@@ -781,6 +828,14 @@ export async function resolveUpstreamEndpointCandidates(
); );
if (!matched) return preferred; if (!matched) return preferred;
const shouldIgnoreCatalogOrderingForClaudeMessages = (
preferMessagesForClaudeModel
&& downstreamFormat !== 'responses'
);
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
return preferred;
}
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : []; const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
const normalizedSupportedRaw = supportedRaw const normalizedSupportedRaw = supportedRaw
.map((item) => asTrimmedString(item).toLowerCase()) .map((item) => asTrimmedString(item).toLowerCase())
@@ -864,6 +919,7 @@ export function buildUpstreamEndpointRequest(input: {
} }
if (sitePlatform === 'openai') { if (sitePlatform === 'openai') {
if (endpoint === 'messages') return '/v1/messages';
if (endpoint === 'responses') return '/v1/responses'; if (endpoint === 'responses') return '/v1/responses';
return '/v1/chat/completions'; return '/v1/chat/completions';
} }
@@ -961,6 +1017,7 @@ export function buildUpstreamEndpointRequest(input: {
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false; if (status < 400) return false;
const text = (upstreamErrorText || '').toLowerCase(); const text = (upstreamErrorText || '').toLowerCase();
if (status === 404 || status === 405 || status === 501) return true;
if (!text) return false; if (!text) return false;
let parsedCode = ''; let parsedCode = '';
@@ -982,12 +1039,34 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
return ( return (
text.includes('convert_request_failed') text.includes('convert_request_failed')
|| text.includes('not found')
|| text.includes('unknown endpoint')
|| text.includes('unsupported endpoint')
|| text.includes('unsupported path')
|| text.includes('unrecognized request url')
|| text.includes('no route matched')
|| text.includes('does not exist')
|| (status === 400 && text.includes('unsupported'))
|| text.includes('not implemented') || text.includes('not implemented')
|| text.includes('api not implemented') || text.includes('api not implemented')
|| text.includes('unsupported legacy protocol') || text.includes('unsupported legacy protocol')
|| parsedCode === 'convert_request_failed' || parsedCode === 'convert_request_failed'
|| parsedCode === 'not_found'
|| parsedCode === 'endpoint_not_found'
|| parsedCode === 'unknown_endpoint'
|| parsedCode === 'unsupported_endpoint'
|| parsedCode === 'bad_response_status_code' || parsedCode === 'bad_response_status_code'
|| parsedType === 'not_found_error'
|| parsedType === 'invalid_request_error'
|| parsedType === 'unsupported_endpoint'
|| parsedType === 'unsupported_path'
|| parsedType === 'bad_response_status_code' || parsedType === 'bad_response_status_code'
|| parsedMessage.includes('unknown endpoint')
|| parsedMessage.includes('unsupported endpoint')
|| parsedMessage.includes('unsupported path')
|| parsedMessage.includes('unrecognized request url')
|| parsedMessage.includes('no route matched')
|| parsedMessage.includes('does not exist')
|| parsedMessage.includes('bad_response_status_code') || parsedMessage.includes('bad_response_status_code')
); );
} }