Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 87 additions & 14 deletions src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -69,29 +71,81 @@ function isTransientUpstreamError(error) {
if (transientSignatures.some((re) => re.test(message))) return true;

// Upstream errors arrive as "<status>: {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
// "<status>:" 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;
}
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(/^\s*(\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')) {
Expand Down Expand Up @@ -2717,11 +2771,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;
Expand Down Expand Up @@ -2890,7 +2945,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;
Expand Down Expand Up @@ -2968,7 +3023,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) {
Expand Down Expand Up @@ -3169,7 +3224,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') {
Expand Down Expand Up @@ -3254,8 +3316,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));
}
Expand Down Expand Up @@ -3314,10 +3379,18 @@ 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 } }));
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();
}
Expand Down
117 changes: 117 additions & 0 deletions tests/backend-error-surface.test.js
Original file line number Diff line number Diff line change
@@ -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"');
});
});
Loading