From 4e22b379a815a67244e957d56358f4eccd89b977 Mon Sep 17 00:00:00 2001 From: cita Date: Sun, 1 Mar 2026 15:21:16 +0800 Subject: [PATCH] feat: Add support for OpenAI Responses endpoint and enhance proxy retry logic - Updated proxyRetryPolicy to retry on all 4xx and 5xx status codes. - Introduced 'responses' target format in TestChatRequestPayload. - Implemented extractResponsesContent function to handle responses format in ModelTester. - Enhanced ModelTester to support responses target format and display appropriate labels. - Updated ProxyLogs to extract and display upstream paths from error messages. - Added tests to ensure responses target format is preserved during payload sync. - Created responses proxy route to handle requests to the new OpenAI Responses endpoint. - Updated upstreamEndpoint logic to include responses endpoint handling. - Added modelService tests to validate token route rebuilding logic. --- README.md | 3 +- docs/client-integration.md | 1 + src/server/routes/api/settings.ts | 2 +- src/server/routes/api/stats.ts | 2 +- src/server/routes/api/test.ts | 69 ++- src/server/routes/api/tokens.ts | 2 +- src/server/routes/proxy/chat.stream.test.ts | 431 +++++++++++++++++ src/server/routes/proxy/chat.ts | 106 +++-- src/server/routes/proxy/chatFormats.ts | 58 +++ src/server/routes/proxy/responses.ts | 441 ++++++++++++++++++ src/server/routes/proxy/router.ts | 2 + src/server/routes/proxy/upstreamEndpoint.ts | 345 ++++++++++++++ src/server/services/modelService.test.ts | 123 +++++ src/server/services/modelService.ts | 24 + src/server/services/proxyRetryPolicy.test.ts | 14 +- src/server/services/proxyRetryPolicy.ts | 4 +- src/web/api.ts | 2 +- src/web/pages/ModelTester.tsx | 148 +++++- src/web/pages/ProxyLogs.tsx | 40 +- .../pages/helpers/modelTesterSession.test.ts | 11 + src/web/pages/helpers/modelTesterSession.ts | 22 +- 21 files changed, 1794 insertions(+), 56 deletions(-) create mode 100644 src/server/routes/proxy/responses.ts create mode 100644 src/server/routes/proxy/upstreamEndpoint.ts create mode 100644 src/server/services/modelService.test.ts diff --git a/README.md b/README.md index e708720..d1e260a 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一 ### 🌐 统一代理网关 - 兼容 **OpenAI** 与 **Claude** 下游格式,对接所有主流客户端 -- 支持 Chat Completions / Messages / Completions / Embeddings / Images / Models 全接口 +- 支持 Responses / Chat Completions / Messages / Completions(Legacy)/ Embeddings / Images / Models 全接口 - 完整的 SSE 流式传输支持,自动格式转换(OpenAI ⇄ Claude) ### 🧠 智能路由引擎 @@ -341,6 +341,7 @@ Metapi 对下游暴露标准 OpenAI / Claude 兼容接口: | 接口 | 方法 | 说明 | | -------------------------- | ---- | ---------------------------- | +| `/v1/responses` | POST | OpenAI Responses | | `/v1/chat/completions` | POST | OpenAI Chat Completions | | `/v1/messages` | POST | Claude Messages | | `/v1/completions` | POST | OpenAI Completions(Legacy) | diff --git a/docs/client-integration.md b/docs/client-integration.md index d2ecf2d..5d2d893 100644 --- a/docs/client-integration.md +++ b/docs/client-integration.md @@ -21,6 +21,7 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置 | 接口 | 方法 | 说明 | |------|------|------| +| `/v1/responses` | POST | OpenAI Responses | | `/v1/chat/completions` | POST | OpenAI Chat Completions | | `/v1/messages` | POST | Claude Messages | | `/v1/completions` | POST | OpenAI Completions(Legacy) | diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index cb836d6..9ab8823 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -545,7 +545,7 @@ export async function settingsRoutes(app: FastifyInstance) { successMessage: (currentTask) => { const rebuild = (currentTask.result as any)?.rebuild; if (!rebuild) return '缓存清理后重建路由已完成'; - return `缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; + return `缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},移除旧路由 ${rebuild.removedRoutes ?? 0},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; }, failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`, }, diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 268f983..f219188 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -192,7 +192,7 @@ export async function statsRoutes(app: FastifyInstance) { successMessage: (currentTask) => { const rebuild = (currentTask.result as any)?.rebuild; if (!rebuild) return '模型广场刷新已完成'; - return `模型广场刷新完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; + return `模型广场刷新完成:新增路由 ${rebuild.createdRoutes},移除旧路由 ${rebuild.removedRoutes ?? 0},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; }, failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`, }, diff --git a/src/server/routes/api/test.ts b/src/server/routes/api/test.ts index 72d92a4..6e53ed3 100644 --- a/src/server/routes/api/test.ts +++ b/src/server/routes/api/test.ts @@ -4,7 +4,7 @@ import { fetch } from 'undici'; import { config } from '../../config.js'; type TestChatMessage = { role: string; content: string }; -type TestTargetFormat = 'openai' | 'claude'; +type TestTargetFormat = 'openai' | 'claude' | 'responses'; type TestChatRequestBody = { model?: string; @@ -84,7 +84,11 @@ const validatePayload = ( return null; } - const targetFormat: TestTargetFormat = body.targetFormat === 'claude' ? 'claude' : 'openai'; + const targetFormat: TestTargetFormat = body.targetFormat === 'claude' + ? 'claude' + : body.targetFormat === 'responses' + ? 'responses' + : 'openai'; return { model: body.model, @@ -146,6 +150,56 @@ const convertOpenAiPayloadToClaudeBody = ( return body; }; +const convertOpenAiPayloadToResponsesBody = ( + payload: ValidatedTestChatPayload, + forceStream: boolean, +): Record => { + const systemContents: string[] = []; + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; + + for (const message of payload.messages) { + const role = typeof message.role === 'string' ? message.role : 'user'; + const content = typeof message.content === 'string' ? message.content : ''; + if (!content.trim()) continue; + + if (role === 'system') { + systemContents.push(content); + continue; + } + + messages.push({ + role: role === 'assistant' ? 'assistant' : 'user', + content, + }); + } + + const body: Record = { + model: payload.model, + stream: forceStream, + }; + + if (messages.length === 1 && messages[0].role === 'user' && systemContents.length === 0) { + body.input = messages[0].content; + } else { + body.input = messages; + if (systemContents.length > 0) { + body.instructions = systemContents.join('\n\n'); + } + } + + if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)) { + body.temperature = payload.temperature; + } + if (typeof payload.top_p === 'number' && Number.isFinite(payload.top_p)) { + body.top_p = payload.top_p; + } + body.max_output_tokens = typeof payload.max_tokens === 'number' && Number.isFinite(payload.max_tokens) + ? payload.max_tokens + : 4096; + + return body; +}; + const buildUpstreamRequest = ( payload: ValidatedTestChatPayload, forceStream: boolean, @@ -162,6 +216,17 @@ const buildUpstreamRequest = ( }; } + if (payload.targetFormat === 'responses') { + return { + url: `http://127.0.0.1:${config.port}/v1/responses`, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.proxyToken}`, + }, + body: convertOpenAiPayloadToResponsesBody(payload, forceStream), + }; + } + const { targetFormat: _targetFormat, ...openAiPayload } = payload; return { url: `http://127.0.0.1:${config.port}/v1/chat/completions`, diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index c108dc0..003df12 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -336,7 +336,7 @@ export async function tokensRoutes(app: FastifyInstance) { successMessage: (currentTask) => { const rebuild = (currentTask.result as any)?.rebuild; if (!rebuild) return '刷新模型并重建路由已完成'; - return `刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; + return `刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},移除旧路由 ${rebuild.removedRoutes ?? 0},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; }, failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`, }, diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 2d9c9cb..4efba71 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -10,6 +10,7 @@ const refreshModelsAndRebuildRoutesMock = vi.fn(); const reportProxyAllFailedMock = vi.fn(); const reportTokenExpiredMock = vi.fn(); const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0); +const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise => null); const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ ...usage, estimatedCostFromQuota: 0, @@ -49,6 +50,7 @@ vi.mock('../../services/alertRules.js', () => ({ vi.mock('../../services/modelPricingService.js', () => ({ estimateProxyCost: (arg: any) => estimateProxyCostMock(arg), + fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg), })); vi.mock('../../services/proxyRetryPolicy.js', () => ({ @@ -73,9 +75,11 @@ describe('chat proxy stream behavior', () => { beforeAll(async () => { const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js'); + const { responsesProxyRoute } = await import('./responses.js'); app = Fastify(); await app.register(chatProxyRoute); await app.register(claudeMessagesProxyRoute); + await app.register(responsesProxyRoute); }); beforeEach(() => { @@ -88,6 +92,7 @@ describe('chat proxy stream behavior', () => { reportProxyAllFailedMock.mockReset(); reportTokenExpiredMock.mockReset(); estimateProxyCostMock.mockClear(); + fetchModelPricingCatalogMock.mockReset(); resolveProxyUsageWithSelfLogFallbackMock.mockClear(); dbInsertMock.mockClear(); @@ -100,6 +105,7 @@ describe('chat proxy stream behavior', () => { actualModel: 'upstream-gpt', }); selectNextChannelMock.mockReturnValue(null); + fetchModelPricingCatalogMock.mockResolvedValue(null); }); afterAll(async () => { @@ -141,6 +147,23 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('data: [DONE]'); }); + it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body?.error?.type).toBe('invalid_request_error'); + expect(body?.error?.message).toContain('/v1/responses'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('sets anti-buffering SSE headers for streamed chat responses', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ @@ -314,4 +337,412 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('\"text\":\"hello\"'); expect(response.body).toContain('event: message_stop'); }); + + it('serves /v1/responses via protocol translation when upstream is OpenAI-compatible', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_123', + object: 'response', + output_text: 'hello from responses', + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.object).toBe('response'); + expect(body.output_text).toContain('hello from responses'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/chat/completions'); + const forwarded = JSON.parse(options.body); + expect(forwarded.model).toBe('upstream-gpt'); + }); + + it('passes through /v1/responses SSE payloads', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"hello"}\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/responses', + payload: { + model: 'gpt-5.2', + input: 'hello', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('response.output_text.delta'); + expect(response.body).toContain('[DONE]'); + }); + + it('routes /v1/responses to /v1/messages when upstream catalog is anthropic-only', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['anthropic'], + }, + ], + groupRatio: {}, + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_900', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'hello from anthropic messages upstream' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.object).toBe('response'); + expect(body.output_text).toContain('hello from anthropic messages upstream'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + }); + + it('forces anyrouter platform to prefer /v1/messages even when catalog says openai', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'anyrouter-site', url: 'https://anyrouter.example.com', platform: 'anyrouter' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['openai'], + }, + ], + groupRatio: {}, + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_anyrouter', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'anyrouter prefers messages' }], + 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-haiku-4-5-20251001', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body?.choices?.[0]?.message?.content).toContain('anyrouter prefers messages'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + }); + + it('chooses /v1/messages upstream when catalog indicates messages-only endpoint support', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/messages'], + }, + ], + groupRatio: {}, + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_100', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'hello from messages only' }], + stop_reason: 'end_turn', + usage: { input_tokens: 8, output_tokens: 4, total_tokens: 12 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body?.choices?.[0]?.message?.content).toContain('hello from messages only'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + }); + + it('prefers OpenAI-compatible endpoint when catalog uses generic openai/anthropic labels', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['anthropic', 'openai'], + }, + ], + groupRatio: {}, + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-first', + object: 'chat.completion', + created: 1_706_000_002, + model: 'upstream-gpt', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'hello from openai endpoint' }, + 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-haiku-4-5-20251001', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body?.choices?.[0]?.message?.content).toContain('hello from openai endpoint'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/chat/completions'); + }); + + it('falls back to /v1/messages when catalog only declares openai and chat endpoint fails', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['openai'], + }, + ], + groupRatio: {}, + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'openai_error', + type: 'bad_response_status_code', + code: 'bad_response_status_code', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_fallback_500', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'fallback to messages from openai-only catalog' }], + stop_reason: 'end_turn', + usage: { input_tokens: 12, output_tokens: 7, total_tokens: 19 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body?.choices?.[0]?.message?.content).toContain('fallback to messages from openai-only catalog'); + + 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/chat/completions'); + expect(secondUrl).toContain('/v1/messages'); + }); + + it('downgrades endpoint when upstream returns convert_request_failed/not implemented', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/chat/completions', '/v1/messages'], + }, + ], + groupRatio: {}, + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'not implemented (request id: abc123)', + type: 'new_api_error', + code: 'convert_request_failed', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_200', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'fallback from messages' }], + stop_reason: 'end_turn', + usage: { input_tokens: 11, output_tokens: 6, total_tokens: 17 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body?.choices?.[0]?.message?.content).toContain('fallback from messages'); + + 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/chat/completions'); + expect(secondUrl).toContain('/v1/messages'); + }); + + it('downgrades endpoint when upstream returns openai_error bad_response_status_code', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/chat/completions', '/v1/messages'], + }, + ], + groupRatio: {}, + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'openai_error', + type: 'bad_response_status_code', + code: 'bad_response_status_code', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_300', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'fallback from bad_response_status_code' }], + stop_reason: 'end_turn', + usage: { input_tokens: 9, output_tokens: 5, total_tokens: 14 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body?.choices?.[0]?.message?.content).toContain('fallback from bad_response_status_code'); + + 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/chat/completions'); + expect(secondUrl).toContain('/v1/messages'); + }); }); diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 293ef4f..3c40e37 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -22,9 +22,18 @@ import { serializeFinalResponse, buildSyntheticOpenAiChunks, } from './chatFormats.js'; +import { + buildUpstreamEndpointRequest, + isEndpointDowngradeError, + resolveUpstreamEndpointCandidates, +} from './upstreamEndpoint.js'; const MAX_RETRIES = 2; +function withUpstreamPath(path: string, message: string): string { + return `[upstream:${path}] ${message}`; +} + export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => handleChatProxyRequest(request, reply, 'openai')); @@ -72,55 +81,94 @@ async function handleChatProxyRequest( excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/chat/completions`; - const forwardBody = { - ...upstreamBody, - model: selected.actualModel, - stream: isStream, - }; - const startTime = Date.now(); + const modelName = selected.actualModel || requestedModel; + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + downstreamFormat, + ); + let startTime = Date.now(); try { - const upstream = await fetch(targetUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${selected.tokenValue}`, - }, - body: JSON.stringify(forwardBody), - }); + let upstream: Awaited> | null = null; + let successfulUpstreamPath: string | null = null; + let finalStatus = 0; + let finalErrText = 'unknown error'; - if (!upstream.ok) { - const errText = await upstream.text().catch(() => 'unknown error'); + for (let endpointIndex = 0; endpointIndex < endpointCandidates.length; endpointIndex += 1) { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint: endpointCandidates[endpointIndex], + modelName, + stream: isStream, + tokenValue: selected.tokenValue, + openaiBody: upstreamBody, + }); + + const targetUrl = `${selected.site.url}${endpointRequest.path}`; + startTime = Date.now(); + + const response = await fetch(targetUrl, { + method: 'POST', + headers: endpointRequest.headers, + body: JSON.stringify(endpointRequest.body), + }); + + if (response.ok) { + upstream = response; + successfulUpstreamPath = endpointRequest.path; + break; + } + + const rawErrText = await response.text().catch(() => 'unknown error'); + const errText = withUpstreamPath(endpointRequest.path, rawErrText); + const shouldDowngradeEndpoint = ( + endpointIndex < endpointCandidates.length - 1 + && isEndpointDowngradeError(response.status, rawErrText) + ); + + if (shouldDowngradeEndpoint) { + logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount); + continue; + } + + finalStatus = response.status; + finalErrText = errText; + break; + } + + if (!upstream) { + const status = finalStatus || 502; + const errText = finalErrText || 'unknown error'; tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount); + logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount); - if (isTokenExpiredError({ status: upstream.status, message: errText })) { + if (isTokenExpiredError({ status, message: errText })) { await reportTokenExpired({ accountId: selected.account.id, username: selected.account.username, siteName: selected.site.name, - detail: `HTTP ${upstream.status}`, + detail: `HTTP ${status}`, }); } - if (shouldRetryProxyRequest(upstream.status, errText) && retryCount < MAX_RETRIES) { + if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { retryCount += 1; continue; } await reportProxyAllFailed({ model: requestedModel, - reason: `upstream returned HTTP ${upstream.status}`, + reason: `upstream returned HTTP ${status}`, }); - return reply.code(upstream.status).send({ + return reply.code(status).send({ error: { message: errText, type: 'upstream_error' }, }); } - const modelName = selected.actualModel || requestedModel; - if (isStream) { reply.hijack(); reply.raw.statusCode = 200; @@ -232,6 +280,7 @@ async function handleChatProxyRequest( resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, + successfulUpstreamPath, ); return; } @@ -348,6 +397,7 @@ async function handleChatProxyRequest( resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, + successfulUpstreamPath, ); return; } @@ -407,6 +457,7 @@ async function handleChatProxyRequest( resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, + successfulUpstreamPath, ); return reply.send(downstreamResponse); @@ -446,8 +497,11 @@ function logProxy( completionTokens = 0, totalTokens = 0, estimatedCost = 0, + upstreamPath: string | null = null, ) { try { + const normalizedErrorMessage = errorMessage + || (upstreamPath ? `[upstream:${upstreamPath}]` : null); db.insert(schema.proxyLogs).values({ routeId: selected.channel.routeId, channelId: selected.channel.id, @@ -461,7 +515,7 @@ function logProxy( completionTokens, totalTokens, estimatedCost, - errorMessage, + errorMessage: normalizedErrorMessage, retryCount, }).run(); } catch {} diff --git a/src/server/routes/proxy/chatFormats.ts b/src/server/routes/proxy/chatFormats.ts index c6099a7..a0e0851 100644 --- a/src/server/routes/proxy/chatFormats.ts +++ b/src/server/routes/proxy/chatFormats.ts @@ -247,6 +247,21 @@ function parseClaudeMessageContent(content: unknown): string { return extractTextAndReasoning(content).content; } +function parseResponsesOutputText(payload: Record): string { + const direct = typeof payload.output_text === 'string' ? payload.output_text : ''; + if (direct) return direct; + + const output = Array.isArray(payload.output) ? payload.output : []; + const parts: string[] = []; + for (const item of output) { + if (!isRecord(item)) continue; + const parsed = extractTextAndReasoning(item.content ?? item); + if (parsed.content) parts.push(parsed.content); + } + + return parts.join('\n\n'); +} + function convertClaudeRequestToOpenAiBody(body: Record): { model: string; stream: boolean; @@ -354,6 +369,19 @@ export function parseDownstreamChatRequest( }; } + const hasMessages = Array.isArray(raw.messages) && raw.messages.length > 0; + if (!hasMessages) { + const hint = raw.input !== undefined + ? 'messages is required for /v1/chat/completions. For Responses payload, use /v1/responses.' + : 'messages is required'; + return { + error: { + statusCode: 400, + payload: { error: { message: hint, type: 'invalid_request_error' } }, + }, + }; + } + return { value: { requestedModel: model, @@ -396,6 +424,17 @@ export function normalizeUpstreamFinalResponse( }; } + if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) { + return { + id: isNonEmptyString(payload.id) ? payload.id : fallbackId, + model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, + created: ensureIntegerTimestamp(payload.created, now), + content: parseResponsesOutputText(payload) || fallbackText, + reasoningContent: '', + finishReason: normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop', + }; + } + if (isRecord(payload) && Array.isArray(payload.candidates)) { const candidate = payload.candidates[0] || {}; const parsedCandidate = extractTextAndReasoning(candidate?.content?.parts || candidate?.content); @@ -468,6 +507,25 @@ export function normalizeUpstreamStreamEvent( } const type = typeof payload.type === 'string' ? payload.type : ''; + if (type.startsWith('response.output_text')) { + const deltaText = typeof payload.delta === 'string' + ? payload.delta + : extractTextAndReasoning(payload.delta).content; + return { + contentDelta: deltaText || undefined, + }; + } + + if (type === 'response.completed' && isRecord((payload as any).response)) { + const responsePayload = (payload as any).response as Record; + if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id; + if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model; + return { + finishReason: normalizeStopReason(responsePayload.status) || 'stop', + done: true, + }; + } + const message = isRecord(payload.message) ? payload.message : null; if (message) { diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts new file mode 100644 index 0000000..50ca33c --- /dev/null +++ b/src/server/routes/proxy/responses.ts @@ -0,0 +1,441 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { fetch } from 'undici'; +import { db, schema } from '../../db/index.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { estimateProxyCost } from '../../services/modelPricingService.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js'; +import { normalizeUpstreamFinalResponse } from './chatFormats.js'; +import { + buildUpstreamEndpointRequest, + isEndpointDowngradeError, + resolveUpstreamEndpointCandidates, + type UpstreamEndpoint, +} from './upstreamEndpoint.js'; + +const MAX_RETRIES = 2; + +function withUpstreamPath(path: string, message: string): string { + return `[upstream:${path}] ${message}`; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function normalizeText(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value + .map((item) => normalizeText(item)) + .filter((item) => item.length > 0) + .join('\n'); + } + if (isRecord(value)) { + if (typeof value.text === 'string') return value.text; + if (typeof value.content === 'string') return value.content; + if (typeof value.input_text === 'string') return value.input_text; + if (typeof value.output_text === 'string') return value.output_text; + if (Array.isArray(value.content)) return normalizeText(value.content); + } + return ''; +} + +function convertResponsesBodyToOpenAiBody( + body: Record, + modelName: string, + stream: boolean, +): Record { + const messages: Array<{ role: string; content: string }> = []; + const input = body.input; + + if (typeof input === 'string') { + const text = input.trim(); + if (text) messages.push({ role: 'user', content: text }); + } else if (Array.isArray(input)) { + for (const item of input) { + if (!isRecord(item)) continue; + const role = typeof item.role === 'string' ? item.role : 'user'; + const text = normalizeText(item.content ?? item).trim(); + if (!text) continue; + messages.push({ role: role === 'assistant' ? 'assistant' : (role === 'system' ? 'system' : 'user'), content: text }); + } + } else if (isRecord(input)) { + const text = normalizeText(input).trim(); + if (text) messages.push({ role: 'user', content: text }); + } + + const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : ''; + if (instructions) { + messages.unshift({ role: 'system', content: instructions }); + } + + const payload: Record = { + model: modelName, + stream, + messages, + }; + + if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) { + payload.temperature = body.temperature; + } + if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) { + payload.top_p = body.top_p; + } + if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) { + payload.max_tokens = body.max_output_tokens; + } + if (body.tools !== undefined) payload.tools = body.tools; + if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice; + + return payload; +} + +function toResponsesPayload( + normalized: ReturnType, + usage: { promptTokens: number; completionTokens: number; totalTokens: number }, +): Record { + const normalizedId = typeof normalized.id === 'string' && normalized.id.trim() + ? normalized.id.trim() + : `resp_${Date.now()}`; + const responseId = normalizedId.startsWith('resp_') ? normalizedId : `resp_${normalizedId}`; + const messageId = normalizedId.startsWith('msg_') ? normalizedId : `msg_${normalizedId}`; + + return { + id: responseId, + object: 'response', + created: normalized.created, + status: 'completed', + model: normalized.model, + output: [{ + id: messageId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ + type: 'output_text', + text: normalized.content || '', + }], + }], + output_text: normalized.content || '', + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; +} + +export async function responsesProxyRoute(app: FastifyInstance) { + app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => { + const body = request.body as any; + const requestedModel = typeof body?.model === 'string' ? body.model.trim() : ''; + if (!requestedModel) { + return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); + } + + const isStream = body.stream === true; + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? tokenRouter.selectChannel(requestedModel) + : tokenRouter.selectNextChannel(requestedModel, excludeChannelIds); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = tokenRouter.selectChannel(requestedModel); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + + const modelName = selected.actualModel || requestedModel; + const openAiBody = convertResponsesBodyToOpenAiBody(body, modelName, isStream); + const resolvedCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + 'openai', + ); + const endpointCandidates = resolvedCandidates + .filter((endpoint) => endpoint !== 'responses'); + if (endpointCandidates.length === 0) { + endpointCandidates.push('chat', 'messages'); + } + + const startTime = Date.now(); + + try { + let upstream: Awaited> | null = null; + let successfulUpstreamPath: string | null = null; + let finalStatus = 0; + let finalErrText = 'unknown error'; + + for (let endpointIndex = 0; endpointIndex < endpointCandidates.length; endpointIndex += 1) { + const endpoint = endpointCandidates[endpointIndex] as UpstreamEndpoint; + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: isStream, + tokenValue: selected.tokenValue, + openaiBody: openAiBody, + }); + const targetUrl = `${selected.site.url}${endpointRequest.path}`; + + const response = await fetch(targetUrl, { + method: 'POST', + headers: endpointRequest.headers, + body: JSON.stringify(endpointRequest.body), + }); + + if (response.ok) { + upstream = response; + successfulUpstreamPath = endpointRequest.path; + break; + } + + const rawErrText = await response.text().catch(() => 'unknown error'); + const errText = withUpstreamPath(endpointRequest.path, rawErrText); + const shouldDowngradeEndpoint = ( + endpointIndex < endpointCandidates.length - 1 + && isEndpointDowngradeError(response.status, rawErrText) + ); + + if (shouldDowngradeEndpoint) { + logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount); + continue; + } + + finalStatus = response.status; + finalErrText = errText; + break; + } + + if (!upstream) { + const status = finalStatus || 502; + const errText = finalErrText || 'unknown error'; + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount); + + if (isTokenExpiredError({ status, message: errText })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${status}`, + }); + } + + if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${status}`, + }); + return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } }); + } + + if (isStream) { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const reader = upstream.body?.getReader(); + if (!reader) { + reply.raw.end(); + return; + } + + const decoder = new TextDecoder(); + let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + let sseBuffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + reply.raw.write(chunk); + + sseBuffer += chunk; + const pulled = pullSseDataEvents(sseBuffer); + sseBuffer = pulled.rest; + for (const eventPayload of pulled.events) { + try { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); + } catch {} + } + } + if (sseBuffer.trim().length > 0) { + const pulled = pullSseDataEvents(`${sseBuffer}\n\n`); + for (const eventPayload of pulled.events) { + try { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); + } catch {} + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + + const latency = Date.now() - startTime; + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + let estimatedCost = await estimateProxyCost({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + }); + if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { + estimatedCost = resolvedUsage.estimatedCostFromQuota; + } + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + logProxy( + selected, requestedModel, 'success', 200, latency, null, retryCount, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, + successfulUpstreamPath, + ); + return; + } + + const rawText = await upstream.text(); + let upstreamData: unknown = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(upstreamData); + const normalized = normalizeUpstreamFinalResponse( + upstreamData, + modelName, + rawText, + ); + const downstreamData = toResponsesPayload(normalized, parsedUsage); + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + let estimatedCost = await estimateProxyCost({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + }); + if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { + estimatedCost = resolvedUsage.estimatedCostFromQuota; + } + + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + logProxy( + selected, requestedModel, 'success', 200, latency, null, retryCount, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, + successfulUpstreamPath, + ); + return reply.send(downstreamData); + } catch (err: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: err.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, + }); + } + } + }); +} + +function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, + estimatedCost = 0, + upstreamPath: string | null = null, +) { + try { + const normalizedErrorMessage = errorMessage + || (upstreamPath ? `[upstream:${upstreamPath}]` : null); + db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, + errorMessage: normalizedErrorMessage, + retryCount, + }).run(); + } catch {} +} diff --git a/src/server/routes/proxy/router.ts b/src/server/routes/proxy/router.ts index f169c5b..6eb3bf3 100644 --- a/src/server/routes/proxy/router.ts +++ b/src/server/routes/proxy/router.ts @@ -4,6 +4,7 @@ import { chatProxyRoute, claudeMessagesProxyRoute } from './chat.js'; import { modelsProxyRoute } from './models.js'; import { embeddingsProxyRoute } from './embeddings.js'; import { completionsProxyRoute } from './completions.js'; +import { responsesProxyRoute } from './responses.js'; import { imagesProxyRoute } from './images.js'; export async function proxyRoutes(app: FastifyInstance) { @@ -15,6 +16,7 @@ export async function proxyRoutes(app: FastifyInstance) { await app.register(chatProxyRoute); await app.register(claudeMessagesProxyRoute); await app.register(completionsProxyRoute); + await app.register(responsesProxyRoute); await app.register(modelsProxyRoute); await app.register(embeddingsProxyRoute); await app.register(imagesProxyRoute); diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts new file mode 100644 index 0000000..49ab437 --- /dev/null +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -0,0 +1,345 @@ +import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; +import type { DownstreamFormat } from './chatFormats.js'; + +export type UpstreamEndpoint = 'chat' | 'messages' | 'responses'; + +type ChannelContext = { + site: { + id: number; + url: string; + platform: string; + apiKey?: string | null; + }; + account: { + id: number; + accessToken?: string | null; + apiToken?: string | null; + }; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function normalizeContentText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === 'string') return item; + if (!isRecord(item)) return ''; + const text = asTrimmedString(item.text ?? item.content ?? item.output_text); + return text; + }) + .filter((item) => item.length > 0) + .join('\n'); + } + if (isRecord(content)) { + const text = asTrimmedString(content.text ?? content.content ?? content.output_text); + return text; + } + return ''; +} + +function convertOpenAiBodyToMessagesBody( + openaiBody: Record, + modelName: string, + stream: boolean, +): Record { + const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; + const systemContents: string[] = []; + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; + + for (const item of rawMessages) { + if (!isRecord(item)) continue; + const role = asTrimmedString(item.role) || 'user'; + const content = normalizeContentText(item.content); + if (!content) continue; + + if (role === 'system') { + systemContents.push(content); + continue; + } + + messages.push({ + role: role === 'assistant' ? 'assistant' : 'user', + content, + }); + } + + const body: Record = { + model: modelName, + stream, + messages, + max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096, + }; + + if (systemContents.length > 0) { + body.system = systemContents.join('\n\n'); + } + + const temperature = toFiniteNumber(openaiBody.temperature); + if (temperature !== null) body.temperature = temperature; + + const topP = toFiniteNumber(openaiBody.top_p); + if (topP !== null) body.top_p = topP; + + if (Array.isArray(openaiBody.stop) && openaiBody.stop.length > 0) { + body.stop_sequences = openaiBody.stop; + } + + if (openaiBody.tools !== undefined) body.tools = openaiBody.tools; + if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice; + return body; +} + +function convertOpenAiBodyToResponsesBody( + openaiBody: Record, + modelName: string, + stream: boolean, +): Record { + const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; + const systemContents: string[] = []; + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; + + for (const item of rawMessages) { + if (!isRecord(item)) continue; + const role = asTrimmedString(item.role) || 'user'; + const content = normalizeContentText(item.content); + if (!content) continue; + + if (role === 'system') { + systemContents.push(content); + continue; + } + + messages.push({ + role: role === 'assistant' ? 'assistant' : 'user', + content, + }); + } + + const body: Record = { + model: modelName, + stream, + max_output_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096, + }; + + if (messages.length === 1 && messages[0].role === 'user' && systemContents.length === 0) { + body.input = messages[0].content; + } else { + body.input = messages; + if (systemContents.length > 0) { + body.instructions = systemContents.join('\n\n'); + } + } + + const temperature = toFiniteNumber(openaiBody.temperature); + if (temperature !== null) body.temperature = temperature; + + const topP = toFiniteNumber(openaiBody.top_p); + if (topP !== null) body.top_p = topP; + + if (openaiBody.tools !== undefined) body.tools = openaiBody.tools; + if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice; + return body; +} + +function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { + const raw = asTrimmedString(value).toLowerCase(); + if (!raw) return []; + + const normalized = new Set(); + + if ( + raw.includes('/v1/messages') + || raw === 'messages' + || raw.includes('anthropic') + || raw.includes('claude') + ) { + normalized.add('messages'); + } + + if ( + raw.includes('/v1/responses') + || raw === 'responses' + || raw.includes('response') + ) { + // Treat responses family as OpenAI-compatible for upstream selection. + normalized.add('chat'); + } + + if ( + raw.includes('/v1/chat/completions') + || raw.includes('chat/completions') + || raw === 'chat' + || raw === 'chat_completions' + || raw === 'completions' + || raw.includes('chat') + ) { + normalized.add('chat'); + } + + // Some upstreams return protocol families instead of concrete endpoint paths. + if (raw === 'openai' || raw.includes('openai')) { + normalized.add('chat'); + } + + return Array.from(normalized); +} + +function preferredEndpointOrder(downstreamFormat: DownstreamFormat): UpstreamEndpoint[] { + return downstreamFormat === 'claude' + ? ['messages', 'chat'] + : ['chat', 'messages']; +} + +export async function resolveUpstreamEndpointCandidates( + context: ChannelContext, + modelName: string, + downstreamFormat: DownstreamFormat, +): Promise { + if ((context.site.platform || '').toLowerCase() === 'anyrouter') { + // anyrouter deployments are effectively anthropic-protocol first. + return ['messages', 'chat']; + } + + const preferred = preferredEndpointOrder(downstreamFormat); + + try { + const catalog = await fetchModelPricingCatalog({ + site: { + id: context.site.id, + url: context.site.url, + platform: context.site.platform, + apiKey: context.site.apiKey ?? null, + }, + account: { + id: context.account.id, + accessToken: context.account.accessToken ?? null, + apiToken: context.account.apiToken ?? null, + }, + modelName, + totalTokens: 0, + }); + + if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) { + return preferred; + } + + const matched = catalog.models.find((item) => + asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(), + ); + if (!matched) return preferred; + + const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : []; + const supported = new Set(); + for (const endpoint of supportedRaw) { + const normalizedList = normalizeEndpointTypes(endpoint); + for (const normalized of normalizedList) { + supported.add(normalized); + } + } + + if (supported.size === 0) return preferred; + + const filtered = preferred.filter((endpoint) => supported.has(endpoint)); + if (filtered.length === 0) return preferred; + + // Keep non-catalog endpoints as best-effort fallbacks because some + // upstreams expose incomplete/incorrect endpoint metadata. + const fallback = preferred.filter((endpoint) => !filtered.includes(endpoint)); + return [...filtered, ...fallback]; + } catch { + return preferred; + } +} + +export function buildUpstreamEndpointRequest(input: { + endpoint: UpstreamEndpoint; + modelName: string; + stream: boolean; + tokenValue: string; + openaiBody: Record; +}): { + path: string; + headers: Record; + body: Record; +} { + const commonHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${input.tokenValue}`, + }; + + if (input.endpoint === 'messages') { + return { + path: '/v1/messages', + headers: { + ...commonHeaders, + 'x-api-key': input.tokenValue, + 'anthropic-version': '2023-06-01', + }, + body: convertOpenAiBodyToMessagesBody(input.openaiBody, input.modelName, input.stream), + }; + } + + if (input.endpoint === 'responses') { + return { + path: '/v1/responses', + headers: commonHeaders, + body: convertOpenAiBodyToResponsesBody(input.openaiBody, input.modelName, input.stream), + }; + } + + return { + path: '/v1/chat/completions', + headers: commonHeaders, + body: { + ...input.openaiBody, + model: input.modelName, + stream: input.stream, + }, + }; +} + +export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { + if (status < 400) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (!text) return false; + + let parsedCode = ''; + let parsedType = ''; + let parsedMessage = ''; + try { + const parsed = JSON.parse(upstreamErrorText || '{}') as Record; + const error = (parsed.error && typeof parsed.error === 'object') + ? parsed.error as Record + : parsed; + parsedCode = asTrimmedString(error.code).toLowerCase(); + parsedType = asTrimmedString(error.type).toLowerCase(); + parsedMessage = asTrimmedString(error.message).toLowerCase(); + } catch { + parsedCode = ''; + parsedType = ''; + parsedMessage = ''; + } + + return ( + text.includes('convert_request_failed') + || text.includes('not implemented') + || text.includes('api not implemented') + || text.includes('unsupported legacy protocol') + || parsedCode === 'convert_request_failed' + || parsedCode === 'bad_response_status_code' + || parsedType === 'bad_response_status_code' + || parsedMessage.includes('bad_response_status_code') + ); +} diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts new file mode 100644 index 0000000..b29d8f3 --- /dev/null +++ b/src/server/services/modelService.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { and, eq } from 'drizzle-orm'; + +type DbModule = typeof import('../db/index.js'); +type ModelServiceModule = typeof import('./modelService.js'); + +describe('rebuildTokenRoutesFromAvailability', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let rebuildTokenRoutesFromAvailability: ModelServiceModule['rebuildTokenRoutesFromAvailability']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-model-service-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const modelService = await import('./modelService.js'); + + db = dbModule.db; + schema = dbModule.schema; + rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability; + }); + + beforeEach(() => { + db.delete(schema.routeChannels).run(); + db.delete(schema.tokenRoutes).run(); + db.delete(schema.tokenModelAvailability).run(); + db.delete(schema.modelAvailability).run(); + db.delete(schema.accountTokens).run(); + db.delete(schema.accounts).run(); + db.delete(schema.sites).run(); + }); + + afterAll(() => { + delete process.env.DATA_DIR; + }); + + it('removes stale exact routes and keeps wildcard routes on rebuild', () => { + const site = db.insert(schema.sites).values({ + name: 'site-1', + url: 'https://site-1.example.com', + platform: 'new-api', + }).returning().get(); + + const account = db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-1', + accessToken: 'access-1', + status: 'active', + }).returning().get(); + + const token = db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-test', + source: 'manual', + enabled: true, + isDefault: true, + }).returning().get(); + + db.insert(schema.tokenModelAvailability).values({ + tokenId: token.id, + modelName: 'latest-model', + available: true, + }).run(); + + const staleRoute = db.insert(schema.tokenRoutes).values({ + modelPattern: 'old-model', + enabled: true, + }).returning().get(); + + db.insert(schema.routeChannels).values({ + routeId: staleRoute.id, + accountId: account.id, + tokenId: token.id, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const wildcardRoute = db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-*', + enabled: true, + }).returning().get(); + + db.insert(schema.routeChannels).values({ + routeId: wildcardRoute.id, + accountId: account.id, + tokenId: token.id, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const rebuild = rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + expect(rebuild.removedRoutes).toBe(1); + + const oldRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, staleRoute.id)).get(); + expect(oldRoute).toBeUndefined(); + + const oldChannels = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, staleRoute.id)).all(); + expect(oldChannels).toHaveLength(0); + + const latestRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.modelPattern, 'latest-model')).get(); + expect(latestRoute).toBeDefined(); + const latestChannels = db.select().from(schema.routeChannels) + .where(and(eq(schema.routeChannels.routeId, latestRoute!.id), eq(schema.routeChannels.tokenId, token.id))) + .all(); + expect(latestChannels.length).toBeGreaterThan(0); + + const wildcardRouteAfter = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, wildcardRoute.id)).get(); + expect(wildcardRouteAfter).toBeDefined(); + }); +}); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 0a687e8..0c7aacc 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -16,6 +16,10 @@ function normalizeModels(models: string[]): string[] { return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0))); } +function isExactModelPattern(modelPattern: string): boolean { + return !/[\*\?\[]/.test(modelPattern); +} + async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { let timer: ReturnType | null = null; try { @@ -214,6 +218,7 @@ export function rebuildTokenRoutesFromAvailability() { let createdRoutes = 0; let createdChannels = 0; let removedChannels = 0; + let removedRoutes = 0; for (const [modelName, tokenAccountMap] of modelTokens.entries()) { let route = routes.find((r) => r.modelPattern === modelName); @@ -269,11 +274,30 @@ export function rebuildTokenRoutesFromAvailability() { } } + const latestModelNames = new Set(Array.from(modelTokens.keys())); + for (const route of routes) { + const modelPattern = (route.modelPattern || '').trim(); + if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) { + continue; + } + + const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length; + if (routeChannelCount > 0) { + removedChannels += routeChannelCount; + } + + const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes; + if (deleted > 0) { + removedRoutes += deleted; + } + } + return { models: modelTokens.size, createdRoutes, createdChannels, removedChannels, + removedRoutes, }; } diff --git a/src/server/services/proxyRetryPolicy.test.ts b/src/server/services/proxyRetryPolicy.test.ts index 505d2e1..23e8255 100644 --- a/src/server/services/proxyRetryPolicy.test.ts +++ b/src/server/services/proxyRetryPolicy.test.ts @@ -20,15 +20,21 @@ describe('proxyRetryPolicy', () => { ).toBe(true); }); - it('does not retry on generic client request errors', () => { + it('retries on generic client request errors for cross-channel fallback', () => { expect( shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'), - ).toBe(false); + ).toBe(true); expect( shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'), - ).toBe(false); + ).toBe(true); expect( shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'), - ).toBe(false); + ).toBe(true); + expect( + shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'), + ).toBe(true); + expect( + shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'), + ).toBe(true); }); }); diff --git a/src/server/services/proxyRetryPolicy.ts b/src/server/services/proxyRetryPolicy.ts index 945fdde..f9b0366 100644 --- a/src/server/services/proxyRetryPolicy.ts +++ b/src/server/services/proxyRetryPolicy.ts @@ -21,7 +21,7 @@ function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean { } export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean { - if (status === 429 || status >= 500) return true; - if (status !== 400 && status !== 404 && status !== 422) return false; + if (status >= 400) return true; + if (!upstreamErrorText) return false; return isModelUnsupportedErrorMessage(upstreamErrorText); } diff --git a/src/web/api.ts b/src/web/api.ts index cbe24e1..b23295a 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -87,7 +87,7 @@ async function request(url: string, options: RequestOptions = {}) { type TestChatRequestPayload = { model: string; messages: Array<{ role: string; content: string }>; - targetFormat?: 'openai' | 'claude'; + targetFormat?: 'openai' | 'claude' | 'responses'; stream?: boolean; temperature?: number; top_p?: number; diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index d8c0f65..1ac88dc 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -93,6 +93,78 @@ const extractClaudeMessageContent = (result: any): { content: string; reasoningC }; }; +const extractResponsesContent = (result: any): { content: string; reasoningContent: string } => { + const contentParts: string[] = []; + const reasoningParts: string[] = []; + + const pushContent = (value: unknown) => { + if (typeof value === 'string' && value.length > 0) contentParts.push(value); + }; + const pushReasoning = (value: unknown) => { + if (typeof value === 'string' && value.length > 0) reasoningParts.push(value); + }; + + const directOutputText = result?.output_text; + if (typeof directOutputText === 'string') { + pushContent(directOutputText); + } else if (Array.isArray(directOutputText)) { + for (const item of directOutputText) { + if (typeof item === 'string') { + pushContent(item); + continue; + } + if (item && typeof item === 'object' && typeof (item as any).text === 'string') { + pushContent((item as any).text); + } + } + } + + const outputs = Array.isArray(result?.output) + ? result.output + : (result && typeof result === 'object' && (Array.isArray(result?.content) || typeof result?.type === 'string')) + ? [result] + : []; + for (const item of outputs) { + if (!item || typeof item !== 'object') continue; + const itemType = typeof item.type === 'string' ? item.type : ''; + + if (itemType === 'output_text') { + pushContent(item.text); + continue; + } + + if (itemType === 'reasoning') { + if (typeof item.summary_text === 'string') pushReasoning(item.summary_text); + if (typeof item.reasoning === 'string') pushReasoning(item.reasoning); + if (Array.isArray(item.summary)) { + for (const summaryItem of item.summary) { + if (summaryItem && typeof summaryItem === 'object' && typeof (summaryItem as any).text === 'string') { + pushReasoning((summaryItem as any).text); + } + } + } + } + + const content = Array.isArray((item as any).content) ? (item as any).content : []; + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const blockType = typeof (block as any).type === 'string' ? (block as any).type : ''; + if (blockType === 'output_text' || blockType === 'text') { + pushContent((block as any).text); + continue; + } + if (blockType.includes('reasoning')) { + pushReasoning((block as any).text); + } + } + } + + return { + content: contentParts.join(''), + reasoningContent: reasoningParts.join('\n\n'), + }; +}; + const extractAssistantResult = (result: unknown): { content: string; reasoningContent: string } => { const data = result as any; let content = ''; @@ -113,6 +185,10 @@ const extractAssistantResult = (result: unknown): { content: string; reasoningCo const parsedClaude = extractClaudeMessageContent(data); content = parsedClaude.content; reasoning = parsedClaude.reasoningContent; + } else if (data?.object === 'response' || Array.isArray(data?.output) || typeof data?.output_text === 'string') { + const parsedResponses = extractResponsesContent(data); + content = parsedResponses.content; + reasoning = parsedResponses.reasoningContent; } else if (Array.isArray(data?.candidates)) { const parts = data?.candidates?.[0]?.content?.parts; if (Array.isArray(parts)) { @@ -306,6 +382,71 @@ const parseAnyStreamDelta = (eventPayload: any): { } if (typeof eventPayload.type === 'string') { + if (eventPayload.type === 'response.output_item.added' || eventPayload.type === 'response.output_item.done') { + const parsed = extractResponsesContent(eventPayload.item || eventPayload.output_item || eventPayload.response || eventPayload); + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoningContent || undefined, + }; + } + + if (eventPayload.type === 'response.content_part.added' || eventPayload.type === 'response.content_part.done') { + const part = eventPayload.part || eventPayload.content_part || eventPayload; + const parsed = extractResponsesContent(part); + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoningContent || undefined, + }; + } + + if (eventPayload.type === 'response.content_part.delta') { + const delta = eventPayload.delta; + if (typeof delta === 'string') return { contentDelta: delta || undefined }; + if (delta && typeof delta === 'object') { + const parsed = extractResponsesContent(delta); + if (parsed.content || parsed.reasoningContent) { + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoningContent || undefined, + }; + } + const text = typeof (delta as any).text === 'string' ? (delta as any).text : ''; + return { contentDelta: text || undefined }; + } + } + + if (eventPayload.type === 'response.output_text.delta') { + const text = typeof eventPayload.delta === 'string' + ? eventPayload.delta + : typeof eventPayload.text === 'string' + ? eventPayload.text + : ''; + return { contentDelta: text || undefined }; + } + + if (eventPayload.type === 'response.reasoning_summary_text.delta' || eventPayload.type === 'response.reasoning.delta') { + const text = typeof eventPayload.delta === 'string' + ? eventPayload.delta + : typeof eventPayload.text === 'string' + ? eventPayload.text + : ''; + return { reasoningDelta: text || undefined }; + } + + if (eventPayload.type === 'response.output_text.done') { + const text = typeof eventPayload.text === 'string' ? eventPayload.text : ''; + return { contentDelta: text || undefined }; + } + + if (eventPayload.type === 'response.completed' || eventPayload.type === 'response.failed') { + const parsed = extractResponsesContent(eventPayload.response || eventPayload); + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoningContent || undefined, + done: true, + }; + } + if (eventPayload.type === 'content_block_delta') { const delta = eventPayload.delta || {}; const deltaType = typeof delta.type === 'string' ? delta.type : ''; @@ -649,6 +790,7 @@ export default function ModelTester() { ); const targetFormatOptions = useMemo>(() => ([ { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, + { value: 'responses', label: 'OpenAI Responses (/v1/responses)' }, { value: 'claude', label: 'Claude (/v1/messages)' }, ]), []); @@ -1198,7 +1340,11 @@ export default function ModelTester() {
{(customRequestMode ? '自定义请求' : (inputs.stream ? '流式' : '任务模式'))} {' / '} - {inputs.targetFormat === 'claude' ? 'Claude' : 'OpenAI'} + {inputs.targetFormat === 'claude' + ? 'Claude' + : inputs.targetFormat === 'responses' + ? 'OpenAI Responses' + : 'OpenAI'}
diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index c37312b..645af41 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -48,6 +48,17 @@ function latencyBgColor(ms: number) { return 'color-mix(in srgb, var(--color-success) 12%, transparent)'; } +function extractUpstreamPath(errorMessage?: string): string | null { + if (!errorMessage) return null; + const matched = errorMessage.match(/\[upstream:(\/v1\/[^\]]+)\]/i); + return matched?.[1] || null; +} + +function stripUpstreamPrefix(errorMessage?: string): string { + if (!errorMessage) return ''; + return errorMessage.replace(/^\[upstream:\/v1\/[^\]]+\]\s*/i, ''); +} + export default function ProxyLogs() { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); @@ -174,7 +185,10 @@ export default function ProxyLogs() { - {paged.map(log => ( + {paged.map(log => { + const upstreamPath = extractUpstreamPath(log.errorMessage); + const errorMessageDisplay = stripUpstreamPrefix(log.errorMessage); + return ( setExpanded(expanded === log.id ? null : log.id)} @@ -280,20 +294,24 @@ export default function ProxyLogs() { {/* 请求路径 */}
请求路径 - - /v1/chat/completions - + {upstreamPath ? ( + + {upstreamPath} + + ) : ( + 未记录 + )}
{/* 错误信息 */} - {log.errorMessage && ( + {errorMessageDisplay.trim().length > 0 && (
错误信息 - {log.errorMessage} + {errorMessageDisplay}
)} @@ -301,7 +319,7 @@ export default function ProxyLogs() { )}
- ))} + )})} )} diff --git a/src/web/pages/helpers/modelTesterSession.test.ts b/src/web/pages/helpers/modelTesterSession.test.ts index 300f35a..eecaba1 100644 --- a/src/web/pages/helpers/modelTesterSession.test.ts +++ b/src/web/pages/helpers/modelTesterSession.test.ts @@ -199,6 +199,17 @@ describe('modelTesterSession', () => { expect(syncedJson.messages).toEqual([{ role: 'user', content: 'new' }]); }); + it('keeps responses target format in custom payload sync', () => { + const synced = syncMessagesToCustomRequestBody( + '{"model":"gpt-5.2","targetFormat":"responses"}', + [{ id: '1', role: 'user', content: 'hello', createAt: 1 }], + { ...DEFAULT_INPUTS, model: 'gpt-5.2', targetFormat: 'openai' }, + ); + + const syncedJson = JSON.parse(synced); + expect(syncedJson.targetFormat).toBe('responses'); + }); + it('merges marketplace models with exact enabled route models for tester options', () => { const modelNames = collectModelTesterModelNames( { diff --git a/src/web/pages/helpers/modelTesterSession.ts b/src/web/pages/helpers/modelTesterSession.ts index 43c8fd3..c1b5b69 100644 --- a/src/web/pages/helpers/modelTesterSession.ts +++ b/src/web/pages/helpers/modelTesterSession.ts @@ -14,7 +14,7 @@ export const DEBUG_TABS = { type MessageStatus = typeof MESSAGE_STATUS[keyof typeof MESSAGE_STATUS]; export type DebugTab = typeof DEBUG_TABS[keyof typeof DEBUG_TABS]; type ChatRole = 'user' | 'assistant' | 'system'; -export type TestTargetFormat = 'openai' | 'claude'; +export type TestTargetFormat = 'openai' | 'claude' | 'responses'; export type ChatMessage = { id: string; @@ -256,7 +256,11 @@ const parseInputs = (value: unknown, fallbackModel = ''): ModelTesterInputs => { return { model, - targetFormat: value.targetFormat === 'claude' ? 'claude' : DEFAULT_INPUTS.targetFormat, + targetFormat: value.targetFormat === 'claude' + ? 'claude' + : value.targetFormat === 'responses' + ? 'responses' + : DEFAULT_INPUTS.targetFormat, temperature: toFiniteNumber(value.temperature, DEFAULT_INPUTS.temperature), top_p: toFiniteNumber(value.top_p, DEFAULT_INPUTS.top_p), max_tokens: toFiniteNumber(value.max_tokens, DEFAULT_INPUTS.max_tokens), @@ -298,7 +302,7 @@ const parsePendingPayload = (value: unknown): TestChatPayload | null => { messages: payloadMessages, }; - if (value.targetFormat === 'claude' || value.targetFormat === 'openai') { + if (value.targetFormat === 'claude' || value.targetFormat === 'openai' || value.targetFormat === 'responses') { payload.targetFormat = value.targetFormat; } if (typeof value.stream === 'boolean') payload.stream = value.stream; @@ -385,7 +389,11 @@ const buildFallbackInputsFromLegacy = (value: Record): ModelTes const inputs = parseInputs(value.inputs, legacyModel); if (!value.inputs) { - inputs.targetFormat = value.targetFormat === 'claude' ? 'claude' : inputs.targetFormat; + inputs.targetFormat = value.targetFormat === 'claude' + ? 'claude' + : value.targetFormat === 'responses' + ? 'responses' + : inputs.targetFormat; inputs.temperature = toFiniteNumber(value.temperature, inputs.temperature); } @@ -511,7 +519,11 @@ export const syncMessagesToCustomRequestBody = ( payload.model = typeof payload.model === 'string' && payload.model.trim().length > 0 ? payload.model : inputs.model; - payload.targetFormat = payload.targetFormat === 'claude' ? 'claude' : inputs.targetFormat; + payload.targetFormat = payload.targetFormat === 'claude' + ? 'claude' + : payload.targetFormat === 'responses' + ? 'responses' + : inputs.targetFormat; payload.stream = payload.stream !== undefined ? payload.stream : inputs.stream; payload.messages = toApiMessages(messages);