diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index b88c9de..19f6dc7 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -18,6 +18,7 @@ import { createStreamCloser, toUpstreamTimeoutMessage, } from '../shared/upstream-timeout'; +import { extractErrorMessage } from '../shared/http'; import { markServerTool, normalizeToolName, @@ -108,7 +109,7 @@ interface OpenAIChatChoice { } interface OpenAIStreamError { - error?: { message?: string }; + error?: { message?: string; status?: number }; } interface OpenAIUsage { @@ -1044,18 +1045,25 @@ const mapOpenAIStreamToAnthropicSSE = ( let buffer = ''; const rejectStream = ( message = 'Upstream SSE frame exceeds the maximum size', + status?: number, ): void => { streamRejected = true; enqueueEvent({ type: 'error', error: { - // An oversized frame is a malformed stream, but an upstream - // deadline is the server failing — and `api_error` is the type - // clients treat as retryable. Reporting a timeout as - // invalid_request_error would tell them never to retry. - type: message.includes('did not produce output') - ? 'api_error' - : 'invalid_request_error', + // An upstream status names the failure precisely, so it decides + // the type: 429 has to arrive as `rate_limit_error` or a client + // that retries on that type alone stops retrying an exhausted + // quota. Without one, fall back to the message: an oversized frame + // is a malformed stream (`invalid_request_error`), while an + // upstream deadline is the server failing (`api_error`, the type + // clients treat as retryable). + type: + typeof status === 'number' + ? anthropicErrorType(status) + : message.includes('did not produce output') + ? 'api_error' + : 'invalid_request_error', message, }, }); @@ -1137,7 +1145,10 @@ const mapOpenAIStreamToAnthropicSSE = ( const upstreamError = chunk as OpenAIStreamError; if (upstreamError.error?.message) { - rejectStream(upstreamError.error.message); + rejectStream( + upstreamError.error.message, + upstreamError.error.status, + ); return; } processChunk(chunk); @@ -1283,9 +1294,23 @@ const createAnthropicServerToolEventStream = ( } if (!upstreamResponse.ok || !upstreamResponse.body) { + // A rate limit has to arrive as `rate_limit_error`, or a client that + // retries on that type alone will treat an exhausted quota as a + // generic failure and stop retrying — so the upstream status drives + // the event type even though the envelope is already streaming and + // the HTTP status cannot be changed. + const message = upstreamResponse.ok + ? 'Upstream request failed' + : await getUpstreamErrorMessage(upstreamResponse).catch( + () => 'Upstream request failed', + ); + enqueueEvent({ type: 'error', - error: { type: 'api_error', message: 'Upstream request failed' }, + error: { + type: anthropicErrorType(upstreamResponse.status), + message, + }, }); controller.close(); return; @@ -1337,27 +1362,6 @@ const createAnthropicServerToolEventStream = ( }); }; -const extractErrorMessage = (value: unknown): string | null => { - if (typeof value === 'string') { - try { - return extractErrorMessage(JSON.parse(value) as unknown) ?? value; - } catch { - return value; - } - } - if (!value || typeof value !== 'object') return null; - - const payload = value as { - detail?: unknown; - error?: unknown; - message?: unknown; - }; - const detail = extractErrorMessage(payload.detail); - if (detail) return detail; - if (typeof payload.message === 'string') return payload.message; - return extractErrorMessage(payload.error); -}; - const getUpstreamErrorMessage = async (response: Response): Promise => { const text = await response.text(); if (!text) return 'Upstream CodeBuddy request failed'; @@ -1429,26 +1433,28 @@ export const handleMessagesRequest = async ( } }; +export const anthropicErrorType = (status: number): string => + status === 401 + ? 'authentication_error' + : status === 403 + ? 'permission_error' + : status === 404 + ? 'not_found_error' + : status === 413 + ? 'request_too_large' + : status === 429 + ? 'rate_limit_error' + : status === 529 + ? 'overloaded_error' + : status >= 500 + ? 'api_error' + : 'invalid_request_error'; + export const createAnthropicError = ( status: number, message: string, ): Response => { - const type = - status === 401 - ? 'authentication_error' - : status === 403 - ? 'permission_error' - : status === 404 - ? 'not_found_error' - : status === 413 - ? 'request_too_large' - : status === 429 - ? 'rate_limit_error' - : status === 529 - ? 'overloaded_error' - : status >= 500 - ? 'api_error' - : 'invalid_request_error'; + const type = anthropicErrorType(status); return Response.json( { diff --git a/lib/server/proxy/web-search-loop.ts b/lib/server/proxy/web-search-loop.ts index 981db34..11c75cc 100644 --- a/lib/server/proxy/web-search-loop.ts +++ b/lib/server/proxy/web-search-loop.ts @@ -10,6 +10,7 @@ import { runWebFetchResult, runWebSearchResult, } from '../search'; +import { extractErrorMessage } from '../shared/http'; import type { ChatRequestBody } from './codebuddy'; import { @@ -78,33 +79,107 @@ export interface ChatCompletionPayload { message?: ChatCompletionMessage; }>; created?: number; - error?: { message?: string }; + /** + * `status` is the upstream HTTP status, carried so a downstream mapper can + * name the real error type instead of guessing it from the message text. It + * is absent for a payload that already reported an error of its own. + */ + error?: { message?: string; status?: number }; id?: string; model?: string; object?: string; usage?: unknown; } -const readBufferedChatCompletionPayload = async ( +/** + * Rebuilds a failed upstream response so its body can be read again. + * + * A `Response` body can only be consumed once. The loop reads it to decide + * whether the model asked for a server tool, and handing the same object back + * used to leave the route layer — which reads it again to build the answer the + * client actually sees — with a spent body: the second read threw + * "Body already used" and the client got a 500 in place of the real upstream + * status. Draining it here and replaying the bytes in a fresh response keeps + * both reads working and preserves the body verbatim, so an upstream error + * detail that is not valid JSON still reaches the client intact. + * + * `content-length` and `content-encoding` are dropped: the body is re-emitted + * rather than re-encoded, and a stale length would describe bytes the upstream + * compressed before this layer ever saw them. + */ +const buildServerToolFailureResponse = async ( response: Response, -): Promise => { - let payload: ChatCompletionPayload; +): Promise => { + const headers = new Headers(response.headers); + headers.delete('content-length'); + headers.delete('content-encoding'); + headers.set('content-type', 'application/json'); + + return new Response(await response.text(), { + headers, + status: response.status, + statusText: response.statusText, + }); +}; + +/** + * Parses a buffered upstream body, tolerating a failure that is not JSON. + * + * A successful response must be well-formed — anything else is a bug worth + * surfacing — but a failure status already tells the caller everything it + * needs to know, and its body may legitimately be an HTML error page or a + * bare string. Rejecting on those would turn an ordinary outage into an + * unhandled rejection. + */ +const parseBufferedPayload = ( + buffered: string, + ok: boolean, +): ChatCompletionPayload => { try { - payload = (await response.json()) as ChatCompletionPayload; + return JSON.parse(buffered) as ChatCompletionPayload; } catch (error) { - if (response.ok) { + if (ok) { throw error; } - payload = {}; + return {}; } +}; + +const readBufferedChatCompletionPayload = async ( + response: Response, +): Promise => { + // Cloned so the failure path can replay the body verbatim; see + // {@link buildServerToolFailureResponse}. + const buffered = await response.clone().text(); + const payload = parseBufferedPayload(buffered, response.ok); + + if (!response.ok || payload.error) { + const ownMessage = payload.error?.message; + // `extractErrorMessage` digs a nested message out of the payload, so + // `{"error":{"message":"x"}}` reaches the client as "x" rather than as a + // JSON string. The raw body is the fallback: a payload carrying only a + // code has no message to find, and the JSON is still the only record of + // what happened. An empty body says nothing, so it falls all the way + // through to the generic message instead of winning on being non-null. + const detail = buffered.trim(); - if (!response.ok && !payload.error) { return { ...payload, error: { - message: `Upstream request failed with status ${response.status}`, + // The upstream's own explanation — a rate-limit code, a reset + // timestamp — beats the proxy's generic "Upstream CodeBuddy request + // failed", which says only that something failed and leaves the client + // no way to tell what. + // + // `status` travels with the frame so a downstream mapper can name the + // real error type instead of guessing it from the message text. + message: + extractErrorMessage(payload) ?? + ownMessage ?? + (detail || `Upstream request failed with status ${response.status}`), + ...(response.ok ? {} : { status: response.status }), }, }; } @@ -1501,10 +1576,18 @@ export const executeWebSearchLoop = async ({ return { body: loopBody, executions, response }; } - payload = (await response.json()) as ChatCompletionPayload; + // The payload is only needed to detect a tool call or a failure, so read + // the body once and reuse it: the caller reads it again to build the + // client's answer, and a spent body would surface as a 500. + const buffered = await response.clone().text(); + payload = parseBufferedPayload(buffered, response.ok); if (!response.ok || payload.error) { - return { body: loopBody, executions, response }; + return { + body: loopBody, + executions, + response: await buildServerToolFailureResponse(response), + }; } usage = sumUsage(usage, payload.usage); @@ -1652,11 +1735,19 @@ export const executeWebSearchLoop = async ({ }, 'buffer', ); - payload = (await finalResponse.json()) as ChatCompletionPayload; + // Cloned before the read so the failure path can replay the body verbatim + // rather than hand back a spent response the caller cannot read again. + const finalBuffered = await finalResponse.clone().text(); + payload = parseBufferedPayload(finalBuffered, finalResponse.ok); + usage = sumUsage(usage, payload.usage); if (!finalResponse.ok || payload.error) { - return { body: loopBody, executions, response: finalResponse }; + return { + body: loopBody, + executions, + response: await buildServerToolFailureResponse(finalResponse), + }; } return { diff --git a/lib/server/shared/http.ts b/lib/server/shared/http.ts index 24de831..00e4ede 100644 --- a/lib/server/shared/http.ts +++ b/lib/server/shared/http.ts @@ -177,6 +177,35 @@ export const getRequestHeaderMap = ( }, {}); }; +/** + * Digs the human-readable explanation out of an upstream error body. + * + * Upstream shapes nest the real message at different depths — `detail`, + * `error.message`, or a JSON-encoded string standing in for either — so the + * search recurses until it finds text. Returns null when nothing readable is + * there, letting the caller fall back to the raw body. + */ +export const extractErrorMessage = (value: unknown): string | null => { + if (typeof value === 'string') { + try { + return extractErrorMessage(JSON.parse(value) as unknown) ?? value; + } catch { + return value; + } + } + if (!value || typeof value !== 'object') return null; + + const payload = value as { + detail?: unknown; + error?: unknown; + message?: unknown; + }; + const detail = extractErrorMessage(payload.detail); + if (detail) return detail; + if (typeof payload.message === 'string') return payload.message; + return extractErrorMessage(payload.error); +}; + export const createErrorResponse = ( status: number, message: string, diff --git a/tests/server/upstream-error-response.test.ts b/tests/server/upstream-error-response.test.ts new file mode 100644 index 0000000..97a459e --- /dev/null +++ b/tests/server/upstream-error-response.test.ts @@ -0,0 +1,495 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextRequest } from 'next/server'; + +import { updateSettings } from '@/lib/server/domain/config'; +import { isDebugEnabled, updateDebugSettings } from '@/lib/server/domain/debug'; +import { resetWebSearchProviders } from '@/lib/server/search'; +import { handleMessagesRequest } from '@/lib/server/proxy/anthropic'; +import { proxyChatCompletions } from '@/lib/server/proxy/codebuddy'; +import { + addCredential, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { resetUsageStats } from '@/lib/server/domain/stats'; +import { resetStorageRuntime } from '@/lib/server/storage'; + +/** + * An upstream error response has to survive being inspected more than once. + * + * The server-tool loop reads the body to find out whether the model asked for a + * search, `logUpstreamFailure` reads it to record what went wrong, and the + * route layer reads it again to build the answer the client actually sees. A + * `Response` body can only be consumed once, so those reads have to share: + * every one of them after the first used to fail with + * `Body already used`, which surfaced to clients as a 500 instead of the real + * upstream status. + */ + +const tempRootDir = path.join(process.cwd(), '.tmp-test-upstream-error-root'); + +const cleanupTempState = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true }); +}; + +const makeNextRequest = (): NextRequest => + new NextRequest('http://localhost/v1/chat/completions', { method: 'POST' }); + +const makeJsonResponse = ( + payload: Record, + status = 200, +): Response => + new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const makeSseResponse = (...chunks: Record[]): Response => + new Response( + `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('')}data: [DONE]\n\n`, + { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, + ); + +describe('upstream error responses', () => { + beforeEach(async () => { + cleanupTempState(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + process.env.CODEBUDDY_API_KEY = ''; + resetStorageRuntime(); + resetCredentialRuntimeState(); + await resetUsageStats(); + addCredential({ + bearer_token: 'error-path-token', + user_id: 'error-path@example.com', + }); + }); + + afterEach(() => { + cleanupTempState(); + }); + + it('keeps the body readable after the failure has been logged', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeJsonResponse( + { + code: 6004, + msg: '您的使用量已超出频率限制', + }, + 429, + ), + ); + + const response = await proxyChatCompletions( + makeNextRequest(), + { + model: 'glm-5.1', + messages: [{ role: 'user', content: 'hello' }], + }, + undefined, + undefined, + ); + + expect(response.status).toBe(429); + await expect(response.json()).resolves.toMatchObject({ + error: { detail: expect.stringContaining('6004') }, + }); + }); + + it('surfaces the upstream status through the server-tool loop', async () => { + // Reproduces the reported failure: a rate-limited upstream answering a + // request that declared a server web tool. The loop reads the body to + // decide whether the model asked for a search, and the route layer reads + // it again to build the client's answer. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeJsonResponse( + { + code: 6004, + msg: '您的使用量已超出频率限制,将在 2026-09-18 11:35:06 UTC+8 重置', + requestId: '5d3bd1789bec4171a6dbc94664e680a3', + }, + 429, + ), + ); + + const response = await proxyChatCompletions( + makeNextRequest(), + { + model: 'glm-5.1', + messages: [{ role: 'user', content: 'hello' }], + stream: true, + tools: [{ type: 'web_search_20260209', name: 'web_search' }], + }, + undefined, + undefined, + ); + + expect(response.status).toBe(429); + await expect(response.text()).resolves.toContain('6004'); + }); + + it('keeps the body readable when debug tracing snapshots it', async () => { + await updateDebugSettings({ enabled: true }); + expect(await isDebugEnabled()).toBe(true); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeJsonResponse( + { + code: 6004, + msg: '您的使用量已超出频率限制', + }, + 429, + ), + ); + + const response = await proxyChatCompletions( + makeNextRequest(), + { + model: 'glm-5.1', + messages: [{ role: 'user', content: 'hello' }], + }, + undefined, + undefined, + ); + + expect(response.status).toBe(429); + await expect(response.json()).resolves.toMatchObject({ + error: { detail: expect.stringContaining('6004') }, + }); + }); + + it.each([false, true])( + 'reports the upstream rate limit to an Anthropic client (stream: %s)', + async (stream) => { + // Claude Code reads `rate_limit_error` to decide whether to back off, so + // the upstream status has to survive the server-tool bridge on both the + // non-streaming and the streaming path. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeJsonResponse( + { + code: 6004, + msg: '您的使用量已超出频率限制,将在 2026-09-18 11:35:06 UTC+8 重置', + requestId: '5d3bd1789bec4171a6dbc94664e680a3', + }, + 429, + ), + ); + + const response = await handleMessagesRequest( + new NextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ content: 'hello', role: 'user' }], + model: 'claude-sonnet-4.6', + stream, + tools: [{ name: 'web_search', type: 'web_search_20260209' }], + } as never, + ); + + const body = await response.text(); + + if (!stream) { + expect(response.status).toBe(429); + } + + expect(body).toContain('rate_limit_error'); + expect(body).toContain('6004'); + }, + ); + + it('reports the rate limit when it lands after a server tool ran', async () => { + // The first completion succeeds and invokes the tool, so the loop is mid + // turn and the failure arrives as an SSE frame rather than as the initial + // response status. That path bypasses the handler's error branch entirely, + // and used to reach the client as `invalid_request_error` with the + // upstream's detail discarded. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { + content: 'Search result', + title: 'Result', + url: 'https://r.test', + }, + ], + }); + } + + upstreamCalls += 1; + + return upstreamCalls === 1 + ? makeSseResponse({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_search', + index: 0, + function: { + arguments: '{"query":"quota"}', + name: 'web_search', + }, + }, + ], + }, + finish_reason: 'tool_calls', + index: 0, + }, + ], + }) + : makeJsonResponse( + { + code: 6004, + msg: '您的使用量已超出频率限制,将在 2026-09-18 11:35:06 UTC+8 重置', + }, + 429, + ); + }); + + const response = await handleMessagesRequest( + new NextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ content: 'search', role: 'user' }], + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ name: 'web_search', type: 'web_search_20260209' }], + } as never, + ); + + const body = await response.text(); + + expect(upstreamCalls).toBe(2); + expect(body).toContain('web_search_tool_result'); + expect(body).toContain('rate_limit_error'); + expect(body).toContain('6004'); + }); + + it('keeps the raw body when a failing upstream sends no message', async () => { + // A payload carrying only a code has no message to extract, so the raw + // JSON is the only record of what happened — it must not be replaced by + // the generic fallback, which would drop `code`. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results: [] }); + } + + upstreamCalls += 1; + + return upstreamCalls === 1 + ? makeSseResponse({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_search', + index: 0, + function: { + arguments: '{"query":"coded"}', + name: 'web_search', + }, + }, + ], + }, + finish_reason: 'tool_calls', + index: 0, + }, + ], + }) + : makeJsonResponse({ error: { code: 'upstream_error' } }, 502); + }); + + const response = await handleMessagesRequest( + new NextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ content: 'search', role: 'user' }], + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ name: 'web_search', type: 'web_search_20260209' }], + } as never, + ); + + const body = await response.text(); + + expect(body).toContain('upstream_error'); + }); + + it('reports a bodiless upstream failure by its status', async () => { + // With no body there is nothing to explain the failure, so the status has + // to carry it on its own — and 502 must not be flattened into the + // `invalid_request_error` that tells a client never to retry. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results: [] }); + } + + upstreamCalls += 1; + + return upstreamCalls === 1 + ? makeSseResponse({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_search', + index: 0, + function: { + arguments: '{"query":"silent"}', + name: 'web_search', + }, + }, + ], + }, + finish_reason: 'tool_calls', + index: 0, + }, + ], + }) + : new Response(null, { status: 502 }); + }); + + const response = await handleMessagesRequest( + new NextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ content: 'search', role: 'user' }], + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ name: 'web_search', type: 'web_search_20260209' }], + } as never, + ); + + const body = await response.text(); + + expect(body).toContain('api_error'); + expect(body).not.toContain('invalid_request_error'); + }); + + it('surfaces a malformed failure body without throwing', async () => { + // A non-JSON body on a failure status is still the upstream's answer; the + // loop must not reject on it the way it does for a malformed success. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('upstream is on fire', { + headers: { 'Content-Type': 'application/json' }, + status: 502, + }), + ); + + const response = await proxyChatCompletions( + makeNextRequest(), + { + model: 'glm-5.1', + messages: [{ role: 'user', content: 'hello' }], + stream: true, + tools: [{ type: 'web_search_20260209', name: 'web_search' }], + }, + undefined, + undefined, + ); + + expect(response.status).toBe(502); + await expect(response.text()).resolves.toContain('upstream is on fire'); + }); + + it('keeps a malformed failure body on the loop iteration path', async () => { + // Same as above but reached through `executeWebSearchLoop`'s buffered + // iteration rather than the initial probe, where the body is parsed from a + // clone and a non-JSON failure must still survive. + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results: [] }); + } + + upstreamCalls += 1; + + if (upstreamCalls === 1) { + return makeSseResponse({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_search', + index: 0, + function: { + arguments: '{"query":"broken"}', + name: 'web_search', + }, + }, + ], + }, + finish_reason: 'tool_calls', + index: 0, + }, + ], + }); + } + + return new Response('not json at all', { + headers: { 'Content-Type': 'application/json' }, + status: 502, + }); + }); + + const response = await handleMessagesRequest( + new NextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ content: 'search', role: 'user' }], + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ name: 'web_search', type: 'web_search_20260209' }], + } as never, + ); + + await expect(response.text()).resolves.toContain('not json at all'); + }); +}); diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index d25f194..c2e7e35 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -3798,7 +3798,9 @@ describe('chat proxy web search integration', () => { const text = await response.text(); expect(text).toContain('"type":"response.error"'); - expect(text).toContain('Upstream CodeBuddy request failed'); + // The upstream said why it failed, so its own words are passed through + // rather than the proxy's generic failure message. + expect(text).toContain('follow-up failed'); expect(text).not.toContain('"type":"response.completed"'); }); @@ -3807,7 +3809,10 @@ describe('chat proxy web search integration', () => { [ 'message-less object', { code: 'upstream_error' }, - 'Upstream request failed', + // No message exists anywhere in the payload, so the raw JSON is kept: + // it still carries `code`, which the generic fallback would have lost. + // The quotes are escaped because the frame is JSON-encoded for SSE. + '{\\"error\\":{\\"code\\":\\"upstream_error\\"}}', ], ])( 'maps a %s post-tool error payload to a terminal Responses error',