From 809c303117e53fbb4db701e60baf3c1a1fe9fcc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= <51266541+404oops@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:14:28 +0200 Subject: [PATCH] fix(ai): treat a gateway 4xx with no upstream error body as a route failure (#4301) Infron (a new-api gateway) answers "bad response status code N" with the upstream's status when the upstream replied without a JSON error, e.g. a model the upstream no longer serves. That 4xx was classified as the caller's bad request, so the request skipped fallback, the dead route stayed in rotation, and the caller got upstream_bad_request. It now counts as an upstream failure: fallback runs, the route is marked unhealthy, and an exhausted chain reports upstream_provider_unavailable. A gateway error that carries the vendor's own message still stays request-level. Co-authored-by: Claude Opus 5.5 --- .../ChatCompletionDriver.edges.test.ts | 54 +++++++++++++++++++ .../drivers/ai-chat/ChatCompletionDriver.ts | 9 ++++ 2 files changed, 63 insertions(+) diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts index 3e96f421a..b1a5f132c 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts @@ -633,6 +633,40 @@ describe('ChatCompletionDriver exhausted-chain classification', () => { expect(err.message).toBe('unsupported parameter: top_k'); }); + it('maps a gateway 4xx with no upstream error body to 400 upstream_provider_unavailable', async () => { + const err = await errorFor({ + status: 400, + message: '400 bad response status code 400', + error: { + type: 'upstream_error', + message: 'bad response status code 400', + code: 400, + }, + }); + expect(err).toMatchObject({ + statusCode: 400, + legacyCode: 'upstream_provider_unavailable', + }); + expect(err.message).toBe('AI provider unavailable'); + }); + + it('keeps a gateway 4xx that carries the vendor message as the caller’s upstream_bad_request', async () => { + const err = await errorFor({ + status: 400, + message: + '400 bad response status code 400, message: max_tokens too large, body: {}', + error: { + type: 'upstream_error', + message: + 'bad response status code 400, message: max_tokens too large, body: {}', + }, + }); + expect(err).toMatchObject({ + statusCode: 400, + legacyCode: 'upstream_bad_request', + }); + }); + it('maps a completion with no choices to a 400, not an internal 500', async () => { vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementation( () => @@ -809,6 +843,26 @@ describe('ChatCompletionDriver cross-provider fallback', () => { expect(line).toContain('first 422'); }); + it('falls back past a gateway 4xx with no upstream error body and marks the route unhealthy', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue( + Object.assign(new Error('400 bad response status code 400'), { + status: 400, + error: { type: 'upstream_error' }, + }), + ); + vi.spyOn(OpenAiChatProvider.prototype, 'complete').mockResolvedValue({ + message: { role: 'assistant', content: 'from the fallback' }, + usage: { prompt_tokens: 1, completion_tokens: 1 }, + } as never); + + const result = (await completeShared()) as { + message: { content: string }; + }; + expect(result.message.content).toBe('from the fallback'); + expect(isRouteUnhealthy('azure-openai', SHARED_MODEL)).toBe(true); + }); + it('surfaces the primary route-level 4xx after a non-strict fallback also fails', async () => { vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue( Object.assign(new Error('unsupported parameter: frobnicate'), { diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 7877553ee..1296a9a75 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -323,8 +323,17 @@ const isAuthFailure = (a: ProviderAttempt) => a.status === 403 || /unauthorized|forbidden|invalid api key/i.test(a.error)); +/** + * A new-api gateway's (Infron's) wrapper for an upstream that answered without + * a JSON error body. It mirrors the upstream's status, so the 4xx says nothing + * about the caller's request; a real vendor rejection carries its own message. + */ +const isOpaqueGatewayFailure = (a: ProviderAttempt) => + /bad response status code \d+(?:,\s*body:|$)/i.test(a.error); + const isUpstream5xx = (a: ProviderAttempt) => (a.status !== undefined && a.status >= 500) || + isOpaqueGatewayFailure(a) || /provider returned error|internal server error|service unavailable|bad gateway/i.test( a.error, );