From 5f744c338290202a5ffcd0cccaaf8425495a0b5d Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 19:15:53 +0800 Subject: [PATCH] fix responses endpoint memory --- src/server/routes/proxy/chat.stream.test.ts | 81 +++++++++++++++++++ .../routes/proxy/upstreamEndpoint.test.ts | 42 ++++++++++ src/server/routes/proxy/upstreamEndpoint.ts | 28 ++++++- 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 0c1a124..9d487df 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -2032,6 +2032,87 @@ describe('chat proxy stream behavior', () => { expect(targetUrl).toContain('/v1/messages'); }); + it('does not stick generic /v1/responses traffic to /v1/messages after a fallback success', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'Gateway time-out', type: 'upstream_error' }, + }), { + status: 504, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'Bad gateway', type: 'upstream_error' }, + }), { + status: 502, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_fallback_1', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'ok via messages fallback' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_recovered_1', + object: 'response', + model: 'upstream-gpt', + status: 'completed', + output_text: 'ok via recovered responses', + usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello', + }, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json().output_text).toContain('ok via messages fallback'); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello again', + }, + }); + + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.json().output_text).toContain('ok via recovered responses'); + expect(fetchMock).toHaveBeenCalledTimes(4); + + const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; + const [thirdUrl] = fetchMock.mock.calls[2] as [string, any]; + const [fourthUrl] = fetchMock.mock.calls[3] as [string, any]; + expect(firstUrl).toContain('/v1/responses'); + expect(secondUrl).toContain('/v1/chat/completions'); + expect(thirdUrl).toContain('/v1/messages'); + expect(fourthUrl).toContain('/v1/responses'); + }); + it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 0d4ce8e..d5e7e2c 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -216,6 +216,48 @@ describe('resolveUpstreamEndpointCandidates', () => { expect(order).toEqual(['responses', 'chat', 'messages']); }); + it('does not remember messages fallback success for generic /v1/responses requests', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'messages', + downstreamFormat: 'responses', + modelName: 'gpt-5.3', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('does not block generic /v1/responses endpoints on transient upstream errors', async () => { + recordUpstreamEndpointFailure({ + siteId: baseContext.site.id, + endpoint: 'responses', + downstreamFormat: 'responses', + modelName: 'gpt-5.3', + status: 504, + errorText: '{"error":{"message":"Gateway time-out","type":"upstream_error"}}', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + it('learns a better endpoint from explicit upstream protocol errors', async () => { recordUpstreamEndpointFailure({ siteId: baseContext.site.id, diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 87c5b90..b97579a 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -714,15 +714,37 @@ function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEnd function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean { if (isEndpointDispatchDeniedError(status, errorText)) return true; - if (isEndpointDowngradeError(status, errorText)) return true; + if (status === 404 || status === 405 || status === 415 || status === 501) return true; + if (isUnsupportedMediaTypeError(status, errorText)) return true; + const text = (errorText || '').toLowerCase(); return ( - text.includes('unsupported legacy protocol') + text.includes('convert_request_failed') + || text.includes('endpoint_not_found') + || text.includes('unknown_endpoint') + || text.includes('unsupported_endpoint') + || text.includes('unsupported_path') + || text.includes('not_found_error') + || text.includes('unsupported legacy protocol') || text.includes('please use /v1/') || text.includes('does not allow /v1/') + || 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') ); } +function shouldRememberSuccessfulEndpoint(input: { + endpoint: UpstreamEndpoint; + downstreamFormat: EndpointPreference; +}): boolean { + if (input.downstreamFormat !== 'responses') return true; + return input.endpoint === 'responses'; +} + export function resetUpstreamEndpointRuntimeState(): void { endpointRuntimeStates.clear(); } @@ -739,6 +761,8 @@ export function recordUpstreamEndpointSuccess(input: { wantsNativeResponsesReasoning?: boolean; }; }): void { + if (!shouldRememberSuccessfulEndpoint(input)) return; + const nowMs = Date.now(); const key = buildEndpointRuntimeStateKey({ siteId: input.siteId,