From 33fa7c4b8800429e8f4abca55d9a4203afd30d00 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 02:16:30 +0800 Subject: [PATCH 1/2] fix(responses): surface backend plain-object errors instead of 500/Object --- CHANGELOG.md | 5 ++ src/proxy.js | 82 +++++++++++++++++-- tests/backend-error-surface.test.js | 117 ++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 tests/backend-error-surface.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c9118b2..1570878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Anthropic Messages API**:新增 `POST /v1/messages`(`max_tokens` 必填,支持 `system`/`tools(input_schema)`/`tool_choice{auto,any,tool,none}`/`thinking→reasoning`/`image`;`tool_use.id` 原样往返;非流式回 `message` 对象,流式回 `message_start/content_block_*/message_delta/message_stop` 无 `[DONE]`;认证同时支持 `x-api-key`;CORS 放行 `x-api-key/anthropic-version`)。新增 `src/converters/anthropic.js` 纯函数转换层与 `tests/messages-anthropic.test.js`(9 例)。附带修复 `EXTERNAL_TOOL_PREFIX` 缺 import 的 latent `ReferenceError`。 +### Fixed + +- **后端错误透出**:`/v1/responses` 在后端 session 失败时曾 `throw` 纯对象,经 `transformUpstreamError` 被洗成 `500 Internal server error / code Object`、真实讯息丢失。新增 `normalizeBackendError`(纯对象→Error,保留 `data.message`/`name`/状态码推断),两处 `throw polled.error` 改用它;`transformUpstreamError` 纵深加固(默认分支改读 `data.message`/`name`,code 默认 `upstream_error` 不再用 `constructor.name`)。现 Credits 类错误正确回 `402 insufficient_quota` 并携带原文。新增 `tests/backend-error-surface.test.js`(4 例复现 LiteLLM 案例,含 messages)。 +- **同族错误路径查漏**:responses 串流 idleTimeout 补 poll 失败检查(不再静默回空 `completed`);messages 非串流改用 transformed `status/type/code`(不再硬编码 `502/api_error`);messages 串流检查 `collected.error` 与 poll 失败(失败即抛正規化错误走 SSE error 事件,不再静默 200);`isTransientUpstreamError` 纳入 `name/code/type` 参与签名匹配(无 message 纯对象不再必判 false)。 + ## [1.5.0] - 2026-04-18 ### Added diff --git a/src/proxy.js b/src/proxy.js index 6d3acd7..7515847 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -48,7 +48,9 @@ import { */ function isTransientUpstreamError(error) { if (!error) return false; - const message = [error.message, error.data?.message] + // Backend failures arrive as plain objects ({name, data:{message}}); include + // name/code/type so signature matching works even without a message string. + const message = [error.message, error.data?.message, error.name, error.code, error.type] .filter((part) => typeof part === 'string') .join(' '); if (!message) return false; @@ -81,17 +83,63 @@ function isTransientUpstreamError(error) { return false; } +/** + * Normalize a backend error into a real Error instance. + * + * The OpenCode backend surfaces failures as plain objects + * ({name, data:{message, statusCode?}}, see SDK types.gen AssistantMessage + * error union), never as Error instances. Throwing one raw makes + * transformUpstreamError fall back to 500/"Internal server error"/code + * "Object" and drops the real message. Normalizing once at the throw + * site (or poll exit) keeps every downstream reader working. + */ +function normalizeBackendError(raw) { + if (raw instanceof Error) return raw; + let message = raw?.data?.message ?? raw?.message ?? raw?.name ?? null; + if (typeof message !== 'string' || !message) { + try { + message = JSON.stringify(raw) ?? String(raw?.name || 'Upstream provider error'); + } catch { + message = String(raw?.name || 'Upstream provider error'); + } + } + let statusCode = raw?.statusCode ?? raw?.data?.statusCode ?? raw?.data?.status ?? null; + if (typeof statusCode !== 'number') { + const m = String(message).match(/\b(\d{3})\s*:/); + if (m) statusCode = Number(m[1]); + } + if (typeof statusCode !== 'number') { + const s = String(message).toLowerCase(); + if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402; + else if (/rate.?limit|too many requests|worker request limit/.test(s)) statusCode = 429; + else if (/invalid api key|unauthorized|authentication/.test(s)) statusCode = 401; + } + const err = new Error(message); + if (typeof statusCode === 'number') err.statusCode = statusCode; + err.code = raw?.code ?? raw?.type ?? raw?.name ?? 'upstream_error'; + err.type = raw?.type ?? raw?.code ?? raw?.name ?? 'upstream_error'; + if (raw?.data !== undefined) err.data = raw.data; + err.cause = raw; + return err; +} + /** * Transform upstream provider errors to OpenAI-compatible format * @param {Error} error - The error from the upstream provider * @returns {{statusCode: number, error: {message: string, type: string, code?: string}}} OpenAI-compatible error response */ function transformUpstreamError(error) { + // Defense in depth: callers may hand us a raw backend plain object + // ({name, data:{message}}) instead of an Error. Normalize first so the + // mapping below sees message/statusCode/code instead of falling back + // to 500/"Internal server error"/code "Object". + const normalized = normalizeBackendError(error); + error = normalized; // Default fallback let statusCode = 500; let message = error.message || 'Internal server error'; let type = 'internal_error'; - let code = error.code || error.constructor.name; + let code = error.code || 'upstream_error'; // Handle timeout errors if (error.message && error.message.includes('Request timeout')) { @@ -2717,11 +2765,12 @@ export function createApp(config) { if (!content && !reasoning) { const polled = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (polled.error && !polled.content && !polled.reasoning) throw polled.error; + if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); if (polled.reasoning) sendResponsesDelta(polled.reasoning, true); if (polled.content) sendResponsesDelta(polled.content, false); } else if (collected && collected.idleTimeout) { const polled = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); const remainingReasoning = polled.reasoning && polled.reasoning.startsWith(rawReasoning) ? polled.reasoning.slice(rawReasoning.length) : polled.reasoning; @@ -2890,7 +2939,7 @@ export function createApp(config) { if (shouldPollForResponses) { const polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); if (polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { - throw polledResponse.error; + throw normalizeBackendError(polledResponse.error); } content = polledResponse.content || content; reasoning = polledResponse.reasoning || reasoning; @@ -2968,7 +3017,7 @@ export function createApp(config) { const failedAt = Math.floor(Date.now() / 1000); res.write(`data: ${JSON.stringify({ type: 'response.failed', - response: { id: `resp_${crypto.randomUUID()}`, object: 'response', created: failedAt, created_at: failedAt, status: 'failed', error: transformed.error.error || transformed.error } + response: { id: `resp_${crypto.randomUUID()}`, object: 'response', created: failedAt, created_at: failedAt, status: 'failed', error: transformed.error } })}\n\n`); res.write('data: [DONE]\n\n'); } catch (writeError) { @@ -3169,7 +3218,14 @@ export function createApp(config) { } if (error && !content && !reasoning) { const t = transformUpstreamError(error); - return res.status(502).json({ type: 'error', error: { type: 'api_error', message: t.error?.error?.message || t.error?.message || 'Upstream error' } }); + return res.status(t.statusCode).json({ + type: 'error', + error: { + type: t.error.type || 'api_error', + message: t.error.message || 'Upstream error', + ...(t.error.code && { code: t.error.code }) + } + }); } let parsed = externalToolRegistry.length > 0 ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) : []; if (parsed.length === 0 && externalToolChoice.mode === 'required') { @@ -3254,8 +3310,11 @@ export function createApp(config) { try { if (!res.destroyed) res.end(); } catch {} return; } + if (collected?.error && !rawContent && !rawReasoning) throw normalizeBackendError(collected.error); if (collected?.__error) { - const { content, reasoning } = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + const polled = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + if (polled.error && !polled.content && !polled.reasoning && !rawContent && !rawReasoning) throw normalizeBackendError(polled.error); + const { content, reasoning } = polled; if (content && !rawContent) sendTextDelta(stripFunctionCallMarkup(content)); if (reasoning && !rawReasoning) sendReasoningDelta(stripFunctionCallMarkup(reasoning)); } @@ -3314,7 +3373,14 @@ export function createApp(config) { } if (!res.headersSent) { const t = transformUpstreamError(error); - return res.status(t.statusCode).json({ type: 'error', error: { type: 'api_error', message: t.error?.error?.message || t.error?.message || error.message } }); + return res.status(t.statusCode).json({ + type: 'error', + error: { + type: t.error.type || 'api_error', + message: t.error.message || error.message || 'Upstream error', + ...(t.error.code && { code: t.error.code }) + } + }); } try { res.write(sseEvent('error', { type: 'error', error: { type: 'api_error', message: error.message } })); diff --git a/tests/backend-error-surface.test.js b/tests/backend-error-surface.test.js new file mode 100644 index 0000000..a872dd7 --- /dev/null +++ b/tests/backend-error-surface.test.js @@ -0,0 +1,117 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +// Regression tests for the LiteLLM 500 case: +// backend `info.error` is a plain object ({name, data:{message}}), never an +// Error. Responses route used to `throw` it raw, and transformUpstreamError +// mangled it into 500 {"message":"Internal server error","code":"Object"}. + +const BACKEND_CREDITS_ERROR = { + name: 'CreditsError', + data: { message: '401: {"message":"Insufficient balance, please recharge","type":"CreditsError"}' } +}; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' }, 'muse-spark-1.3-contributor-free': { name: 'Muse Spark' } } }] } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'err-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [] } })), + sessionMessages: jest.fn(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: BACKEND_CREDITS_ERROR }, parts: [] } + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ + stream: (async function* () { + yield { + type: 'message.updated', + properties: { info: { sessionID: 'err-session', finish: 'stop', error: BACKEND_CREDITS_ERROR } } + }; + })() + })) +}; + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(res); + return { on: jest.fn(), destroy: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(res); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe } + })) +})); + +const { createApp } = await import('../src/proxy.js'); + +describe('POST /v1/responses backend plain-object error', () => { + let app; + beforeEach(() => { + jest.clearAllMocks(); + app = createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false + }).app; + }); + + test('non-stream surfaces backend message as 402, never 500/Object', async () => { + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'muse-spark-1.3-contributor-free', input: 'test from litellm' }); + expect(res.statusCode).toBe(402); + expect(JSON.stringify(res.body)).toContain('Insufficient balance'); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + expect(res.body.error?.message).not.toBe('Internal server error'); + }); + + test('unknown backend error keeps message, no Object code', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'UnknownError', data: { message: 'boom-backend' } } }, parts: [] } + ]); + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(500); + expect(JSON.stringify(res.body)).toContain('boom-backend'); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + }); + + test('stream surfaces backend message in SSE failure, no Object code', async () => { + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi', stream: true }); + expect(res.text).toContain('Insufficient balance'); + expect(res.text).not.toContain('"Object"'); + }); + + test('messages non-stream uses transformed status/type, not hardcoded 502', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(402); + expect(res.body.error?.type).toBe('insufficient_quota'); + expect(JSON.stringify(res.body)).toContain('Insufficient balance'); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + }); +}); From 94bd98790bb53640969c360b102625b59b4cbebb Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 02:20:57 +0800 Subject: [PATCH 2/2] fix(review): preserve error type/code in messages SSE, anchor status regex --- src/proxy.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/proxy.js b/src/proxy.js index 7515847..04fb585 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -71,11 +71,17 @@ function isTransientUpstreamError(error) { if (transientSignatures.some((re) => re.test(message))) return true; // Upstream errors arrive as ": {json}" strings; SDK errors may also - // carry a numeric status on the object itself. - const statusMatch = message.match(/\b(\d{3}):/); - const status = statusMatch - ? Number(statusMatch[1]) - : (error.statusCode || error.data?.status || null); + // carry a numeric status on the object itself. Only accept a leading + // ":" prefix — a loose \b(\d{3}): match misfires on embedded + // numbers like "retry after 300: ...". + let status = error.statusCode ?? error.data?.status ?? null; + if (typeof status !== 'number') { + const statusCandidates = [error.message, error.data?.message].filter((part) => typeof part === 'string'); + for (const candidate of statusCandidates) { + const prefixMatch = candidate.match(/^\s*(\d{3})\s*:/); + if (prefixMatch) { status = Number(prefixMatch[1]); break; } + } + } if (typeof status === 'number') { if (status === 401 || status === 402 || status === 429) return true; if (status >= 500) return true; @@ -105,7 +111,7 @@ function normalizeBackendError(raw) { } let statusCode = raw?.statusCode ?? raw?.data?.statusCode ?? raw?.data?.status ?? null; if (typeof statusCode !== 'number') { - const m = String(message).match(/\b(\d{3})\s*:/); + const m = String(message).match(/^\s*(\d{3})\s*:/); if (m) statusCode = Number(m[1]); } if (typeof statusCode !== 'number') { @@ -3383,7 +3389,8 @@ export function createApp(config) { }); } try { - res.write(sseEvent('error', { type: 'error', error: { type: 'api_error', message: error.message } })); + const t = transformUpstreamError(error); + res.write(sseEvent('error', { type: 'error', error: { type: t.error.type || 'api_error', message: t.error.message, ...(t.error.code && { code: t.error.code }) } })); } catch {} return res.end(); }