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
3 changes: 2 additions & 1 deletion src/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export async function forward({ reqId, payload, wantStream, res, transform }) {
return { handled: false, status: 504, body: { error: { message: 'upstream connect timeout' } } };
}
log.error(reqId, 'upstream fetch failed', { err: String(e).slice(0, 200) });
return { handled: false, status: 502, body: { error: { message: `upstream connect failed: ${e.cause || e}` } } };
// 错误详情只进日志:内部异常串(含 cause/payload 片段)不外泄给客户端
return { handled: false, status: 502, body: { error: { message: 'upstream connect failed' } } };
}
clearTimeout(timer);

Expand Down
11 changes: 10 additions & 1 deletion src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,19 @@ async function readJsonBody(req, res, reqId, { envelope = false } = {}) {
else throw e;
return null;
}
try { return JSON.parse(raw); } catch {
let parsed;
try { parsed = JSON.parse(raw); } catch {
send(res, 400, envelope ? anthropicError(400, { error: { message: 'request body is not valid JSON' } }) : { error: { message: 'request body is not valid JSON', type: 'invalid_request_error' } });
return null;
}
// 合法 JSON ≠ 合法请求体:null/数字/字符串/数组都是合法 JSON,但流入下游会
// 退化(null 体会静默挂起不回包、原始类型触发内部 TypeError 变误导性 502),
// 在唯一解析点统一 400,三条路由(chat/messages/count_tokens)一并兜住
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
send(res, 400, envelope ? anthropicError(400, { error: { message: 'request body must be a JSON object' } }) : { error: { message: 'request body must be a JSON object', type: 'invalid_request_error' } });
return null;
}
return parsed;
}

async function handleChatCompletions(req, res, reqId) {
Expand Down
45 changes: 45 additions & 0 deletions test/proxy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const mockServer = http.createServer((req, res) => {
// 连接后不回任何字节 → 代理应在上游连接超时后回 504(而不是挂死客户端)
return;
}
if (parsed.model === 'mock-reset') {
// 收到请求后直接断连 → 代理应回 502,且错误消息不泄漏内部异常细节
res.socket.destroy();
return;
}
if (parsed.model === 'mock-stall') {
// 出响应头后停滞 → 代理流空闲看门狗应 abort 并结束响应
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
Expand Down Expand Up @@ -184,6 +189,32 @@ test('invalid json → 400', async () => {
assert.equal(res.status, 400);
});

test('合法 JSON 但非对象 → 400 可解析(回归:null 体曾静默挂死、原始类型曾 TypeError 劣化 502)', async () => {
// 两个历史缺陷同一根因(入口无形状校验):
// ① body=null 曾直接 return 不回包 → 客户端挂死到自身超时;
// ② body=123/"str" 曾在 stripReasoningTriggers 的 in 操作符上抛 TypeError → 误导性 502 计入 errors 看板
for (const body of ['null', '123', '"str"', '[1,2]']) {
const res = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body,
});
assert.equal(res.status, 400, `body=${body} must be 400`);
const j = await res.json(); // 可解析 = 响应确实写出了(挂死回归即在此断言失败)
assert.equal(j.error.type, 'invalid_request_error');
}
// /v1/messages 同样兜住,且包 Anthropic envelope
const m = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/messages`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' },
body: 'null',
});
assert.equal(m.status, 400);
const mj = await m.json();
assert.equal(mj.type, 'error');
assert.equal(mj.error.type, 'invalid_request_error');
});

test('body over 4MiB → 413', async () => {
const big = JSON.stringify({ messages: [{ role: 'user', content: 'a'.repeat(5 * 1024 * 1024) }] });
const res = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/chat/completions`, {
Expand All @@ -210,6 +241,14 @@ test('upstream 500 → transparent pass-through', async () => {
assert.equal(j.error.message, 'mock upstream boom');
});

test('upstream 断连 → 502 且消息不外泄内部异常细节', async () => {
// mock-reset 直接触发 fetch 异常(非超时路径):错误详情只进日志,客户端消息固定
const r = await chat({ model: 'mock-reset', messages: [] });
assert.equal(r.status, 502);
const j = await r.json();
assert.equal(j.error.message, 'upstream connect failed');
});

test('client abort cancels upstream (stop burning tokens)', async () => {
const ac = new AbortController();
const r = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/chat/completions`, {
Expand Down Expand Up @@ -329,4 +368,10 @@ test('reasoning_effort 出站剥除(真机教训:触发上游 thinking 变
await chat({ model: 'mock-model', messages: [], stream: false, enable_thinking: true, thinking: { type: 'enabled' } });
assert.equal(upstreamState.lastBody.enable_thinking, undefined);
assert.equal(upstreamState.lastBody.thinking, undefined);

// 流式路径同一剥除(真实 CPA 渠道链走的是流式):共享同一 fetch 出站点,
// 断言钉住流式分支防未来重构把剥除挪进分支
await chat({ model: 'mock-model', messages: [], stream: true, reasoning_effort: 'high' });
assert.equal(upstreamState.lastBody.reasoning_effort, undefined, 'streaming path must strip too');
assert.equal(upstreamState.lastBody.stream, true);
});
Loading