From 5cebc1554fe063d67bd6de3e6205a8001d1083bf Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Tue, 3 Mar 2026 00:03:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=B5=81=E8=A1=8C=E4=B8=BA=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=92=8C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/routes/proxy/chat.stream.test.ts | 207 +++++++++++++++++- src/server/routes/proxy/chatFormats.ts | 17 ++ .../routes/proxy/upstreamEndpoint.test.ts | 45 +++- src/server/routes/proxy/upstreamEndpoint.ts | 207 ++++++++++++------ 4 files changed, 408 insertions(+), 68 deletions(-) diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 6d739ed..b18c906 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1096,6 +1096,105 @@ describe('chat proxy stream behavior', () => { 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 () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -1359,6 +1458,71 @@ describe('chat proxy stream behavior', () => { 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 () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ @@ -1493,6 +1657,47 @@ describe('chat proxy stream behavior', () => { 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({ + 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 () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ @@ -1569,7 +1774,7 @@ describe('chat proxy stream behavior', () => { method: 'POST', url: '/v1/chat/completions', payload: { - model: 'claude-haiku-4-5-20251001', + model: 'gpt-4o-mini', stream: false, tools: [{ type: 'function', diff --git a/src/server/routes/proxy/chatFormats.ts b/src/server/routes/proxy/chatFormats.ts index 94b515c..1d16ea5 100644 --- a/src/server/routes/proxy/chatFormats.ts +++ b/src/server/routes/proxy/chatFormats.ts @@ -161,6 +161,10 @@ export function normalizeStopReason(raw: unknown): string | null { const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; if (!value) return null; + if (value === 'failed' || value === 'error') { + return 'error'; + } + if ( value === 'end_turn' || 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)) { const item = (payload as any).item as Record; if (item.type === 'function_call') { diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 52911b6..a0b87d2 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -6,7 +6,7 @@ vi.mock('../../services/modelPricingService.js', () => ({ fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg), })); -import { resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js'; +import { isEndpointDowngradeError, resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js'; const baseContext = { site: { @@ -104,7 +104,7 @@ describe('resolveUpstreamEndpointCandidates', () => { 'gpt-5.3', 'openai', ); - expect(openaiOrder).toEqual(['chat', 'responses']); + expect(openaiOrder).toEqual(['chat', 'responses', 'messages']); const openaiResponsesOrder = await resolveUpstreamEndpointCandidates( { @@ -114,7 +114,17 @@ describe('resolveUpstreamEndpointCandidates', () => { 'gpt-5.3', '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( { @@ -127,6 +137,29 @@ describe('resolveUpstreamEndpointCandidates', () => { 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 () => { const openaiOrder = await resolveUpstreamEndpointCandidates( { @@ -148,4 +181,10 @@ describe('resolveUpstreamEndpointCandidates', () => { ); 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); + }); }); diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 10458d4..cb2b04c 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -394,7 +394,63 @@ function convertOpenAiBodyToMessagesBody( content: string | Array>; }> = []; - for (const item of rawMessages) { + const appendAssistantMessage = (item: Record) => { + const role = asTrimmedString(item.role).toLowerCase() || 'assistant'; + if (role !== 'assistant') return; + + const contentBlocks: Array> = []; + + const textContent = normalizeContentText(item.content).trim(); + if (textContent) { + contentBlocks.push({ + type: 'text', + text: textContent, + }); + } + + const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; + for (let index = 0; index < rawToolCalls.length; index += 1) { + const toolCall = rawToolCalls[index]; + if (!isRecord(toolCall)) continue; + const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; + + const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`; + const name = ( + asTrimmedString(functionPart.name) + || asTrimmedString(toolCall.name) + || `tool_${index}` + ); + const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments); + + contentBlocks.push({ + type: 'tool_use', + id, + name, + input, + }); + } + + if (contentBlocks.length <= 0) return; + + if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') { + const singleText = asTrimmedString(contentBlocks[0].text); + if (singleText) { + messages.push({ + role: 'assistant', + content: singleText, + }); + } + return; + } + + messages.push({ + role: 'assistant', + 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'; @@ -406,73 +462,59 @@ function convertOpenAiBodyToMessagesBody( } if (role === 'tool') { - const toolUseId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); - if (!toolUseId) continue; + const toolResultBlocks: Array> = []; + 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 toolResultContent = normalizeToolMessageContent(item.content).trim(); - if (!toolResultContent) continue; + 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; + } - messages.push({ - role: 'user', - content: [{ - type: 'tool_result', - tool_use_id: toolUseId, - content: toolResultContent, - }], - }); + 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') { - const contentBlocks: Array> = []; - - const textContent = normalizeContentText(item.content).trim(); - if (textContent) { - contentBlocks.push({ - type: 'text', - text: textContent, - }); - } - - const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; - for (let index = 0; index < rawToolCalls.length; index += 1) { - const toolCall = rawToolCalls[index]; - if (!isRecord(toolCall)) continue; - const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; - - const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`; - const name = ( - asTrimmedString(functionPart.name) - || asTrimmedString(toolCall.name) - || `tool_${index}` - ); - const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments); - - contentBlocks.push({ - type: 'tool_use', - id, - name, - input, - }); - } - - if (contentBlocks.length <= 0) continue; - - if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') { - const singleText = asTrimmedString(contentBlocks[0].text); - if (singleText) { - messages.push({ - role: 'assistant', - content: singleText, - }); - } - continue; - } - - messages.push({ - role: 'assistant', - content: contentBlocks, - }); + appendAssistantMessage(item); continue; } @@ -698,9 +740,14 @@ function preferredEndpointOrder( } 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' - ? ['responses', 'chat'] - : ['chat', 'responses']; + ? ['responses', 'chat', 'messages'] + : ['chat', 'responses', 'messages']; } if (platform === 'claude') { @@ -781,6 +828,14 @@ export async function resolveUpstreamEndpointCandidates( ); if (!matched) return preferred; + const shouldIgnoreCatalogOrderingForClaudeMessages = ( + preferMessagesForClaudeModel + && downstreamFormat !== 'responses' + ); + if (shouldIgnoreCatalogOrderingForClaudeMessages) { + return preferred; + } + const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : []; const normalizedSupportedRaw = supportedRaw .map((item) => asTrimmedString(item).toLowerCase()) @@ -864,6 +919,7 @@ export function buildUpstreamEndpointRequest(input: { } if (sitePlatform === 'openai') { + if (endpoint === 'messages') return '/v1/messages'; if (endpoint === 'responses') return '/v1/responses'; return '/v1/chat/completions'; } @@ -961,6 +1017,7 @@ export function buildUpstreamEndpointRequest(input: { export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { if (status < 400) return false; const text = (upstreamErrorText || '').toLowerCase(); + if (status === 404 || status === 405 || status === 501) return true; if (!text) return false; let parsedCode = ''; @@ -982,12 +1039,34 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str return ( 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('api not implemented') || text.includes('unsupported legacy protocol') || parsedCode === 'convert_request_failed' + || parsedCode === 'not_found' + || parsedCode === 'endpoint_not_found' + || parsedCode === 'unknown_endpoint' + || parsedCode === 'unsupported_endpoint' || parsedCode === 'bad_response_status_code' + || parsedType === 'not_found_error' + || parsedType === 'invalid_request_error' + || parsedType === 'unsupported_endpoint' + || parsedType === 'unsupported_path' || 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') ); }