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
11 changes: 6 additions & 5 deletions src/anthropic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function convertMessage(m) {
if (m.role === 'assistant') {
return convertAssistantBlocks(m.content);
}
return convertUserBlocks(m.content);
return convertUserBlocks(m.content, m.role);
}

// assistant:text 块拼 content;tool_use 块拼 tool_calls;thinking 块丢弃(上游不消费)
Expand All @@ -157,13 +157,14 @@ function convertAssistantBlocks(blocks) {
return [msg];
}

// user:text 拼一条 user 消息;tool_result 逐个拆成 role:"tool";image 转 data URL
function convertUserBlocks(blocks) {
// 非 assistant 消息(user/system)→ 0..n 条 OpenAI 消息(tool_result 拆成独立 tool 消息)
// role 由调用方传入:字符串 content 的 system 路径与块数组路径保持同一角色,不再降级
function convertUserBlocks(blocks, role = 'user') {
const out = [];
const textParts = [];
const flushText = () => {
if (textParts.length) {
out.push({ role: 'user', content: neutralize(textParts.join('')) });
out.push({ role, content: neutralize(textParts.join('')) });
textParts.length = 0;
}
};
Expand All @@ -185,7 +186,7 @@ function convertUserBlocks(blocks) {
}
}
flushText();
if (!out.length) out.push({ role: 'user', content: '' });
if (!out.length) out.push({ role, content: '' });
return out;
}

Expand Down
8 changes: 7 additions & 1 deletion src/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ export function checkAuth(req) {
? `Bearer ${req.headers['x-api-key']}` : '';
const expected = `Bearer ${config.proxyKey}`;
const candidate = auth || xapiKey;
const ok = candidate.length === expected.length && crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
// 恒时比较要求两串字节等长:客户端头可含非 ASCII(latin1 解码后字节序列更长),
// 字符串长度相等但字节长度不等时 timingSafeEqual 抛 RangeError——try 包住按
// 失败处理(长度不同直接拒绝,不泄露除「长度不等」外的任何时序信息)
let ok = false;
try {
ok = crypto.timingSafeEqual(Buffer.from(candidate, 'utf8'), Buffer.from(expected, 'utf8'));
} catch { /* 字节长度不等 → 认证失败 */ }
if (ok) return { ok: true };

const now = Date.now();
Expand Down
8 changes: 8 additions & 0 deletions src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ if (!config.upstreamUrl) {
console.error('[config] upstream URL is empty: set CNB_REPO_SLUG (org/repo) or UPSTREAM_OVERRIDE. Refusing to start.');
process.exit(1);
}
// 数字环境变量塞非法值时 Number() 得 NaN,setTimeout(fn, NaN) 按 0ms 立即触发 →
// 每条上游流瞬间 abort,全线不可用。fail-fast 在启动时拦下,而不是带病运行。
for (const [key, env] of [['upstreamTimeoutMs', 'PROXY_UPSTREAM_TIMEOUT_MS'], ['idleTimeoutMs', 'PROXY_IDLE_TIMEOUT_MS']]) {
if (!Number.isFinite(config[key]) || config[key] <= 0) {
console.error(`[config] ${env} must be a positive number (ms), got "${process.env[env]}". Refusing to start.`);
process.exit(1);
}
}
23 changes: 18 additions & 5 deletions src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,29 @@ function readBody(req, maxBytes) {
const chunks = [];
let size = 0;
let oversized = false;
let settled = false;
const settle = (fn, v) => { if (!settled) { settled = true; fn(v); } };
req.on('data', (c) => {
if (oversized) return; // 超限后继续丢弃到达的数据,让 413 响应能干净送达
size += c.length;
if (size > maxBytes) {
oversized = true;
chunks.length = 0;
reject(Object.assign(new Error('body too large'), { code: 413 }));
settle(reject, Object.assign(new Error('body too large'), { code: 413 }));
return;
}
chunks.push(c);
});
req.on('end', () => { if (!oversized) resolve(Buffer.concat(chunks).toString('utf8')); });
req.on('error', () => {}); // reject 已发生/客户端断开,静默即可
req.on('end', () => { if (!oversized) { settle(resolve, Buffer.concat(chunks).toString('utf8')); } });
// 客户端 body 未读完就断开(ECONNRESET/半途 FIN):必须 settle 挂起的 Promise,
// 否则 await readJsonBody 的 handler 永久悬挂(闭包泄漏 + finally 访问日志永不落)。
// req.complete 保证正常读完后的 close 不误伤。
const onGone = () => {
if (settled || oversized || req.complete) return;
settle(reject, Object.assign(new Error('client aborted mid-body'), { code: 'CLIENT_ABORTED' }));
};
req.on('error', onGone);
req.on('close', onGone);
});
}

Expand Down Expand Up @@ -60,7 +70,7 @@ async function readJsonBody(req, res, reqId, { envelope = false } = {}) {
raw = await readBody(req, config.maxBodyBytes);
} catch (e) {
if (e.code === 413) send(res, 413, envelope ? anthropicError(413, { error: { message: 'request body exceeds limit' } }) : { error: { message: 'request body exceeds limit', type: 'invalid_request_error' } });
else throw e;
else if (e.code !== 'CLIENT_ABORTED') throw e; // 客户端断开无响应可写,静默终结
return null;
}
let parsed;
Expand Down Expand Up @@ -124,7 +134,10 @@ const server = http.createServer(async (req, res) => {
}

if (req.method === 'POST' && req.url.split('?')[0].endsWith('/chat/completions')) {
return handleChatCompletions(req, res, reqId);
// 必须 await:裸 return 让外层 try/catch 捕不到 handler 内的异常
//(async 函数异常变 unhandledRejection),finally 也会提前执行、ms 记成假数据
await handleChatCompletions(req, res, reqId);
return;
}

// Anthropic Messages 协议(claude code 直连):翻译成上游 OpenAI 端点
Expand Down
16 changes: 16 additions & 0 deletions test/anthropic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,22 @@ test('请求转换:messages 内 system 角色按序透传(真机 claude code
]);
});

test('请求转换:messages 内 system 带块数组 content 保持 system 角色(回归:曾降级为 user)', () => {
// 字符串 content 的内联 system 正确透传,但数组 content 走 convertUserBlocks 时
// role 被硬编码 user —— 同一条消息两种形态一个保留一个降级。修复后 role 由调用方传入。
const out = fromAnthropicRequest({
max_tokens: 50,
messages: [
{ role: 'system', content: [{ type: 'text', text: 'block-array system' }] },
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
],
});
assert.deepEqual(out.messages, [
{ role: 'system', content: 'block-array system' },
{ role: 'user', content: 'hi' },
]);
});

test('请求转换:非法输入抛 ProtocolError(status 400)', () => {
assert.throws(() => fromAnthropicRequest({ messages: [] }), ProtocolError);
assert.throws(() => fromAnthropicRequest({ messages: [{ role: 'tool', content: 'x' }] }), ProtocolError);
Expand Down
63 changes: 57 additions & 6 deletions test/proxy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand Down Expand Up @@ -97,11 +98,13 @@ function startProxy(env = {}) {

// mock 上游地址需要可注入:server.mjs 读 UPSTREAM_OVERRIDE(测试专用)
let proxy;
let serverLog = ''; // 服务端 stdout 日志收集(请求级 reqId 串联,供悬挂回归断言)

test.before(async () => {
rmSync(STATE_PATH, { force: true }); // 清掉同 PID 复用的残留,保证首个用例看到纯回退列表
await new Promise((r) => mockServer.listen(MOCK_PORT, r));
proxy = startProxy();
proxy.stdout.on('data', (b) => (serverLog += b.toString()));
await waitPort(PROXY_PORT);
});

Expand Down Expand Up @@ -152,18 +155,33 @@ test('models: 流式响应同样触发嗅探', async () => {
assert.ok(ids.includes('mock-model'));
});

test('auth: 等字符串长度含非 ASCII 的头 → 401 不崩溃(回归:曾抛 timingSafeEqual RangeError 且路由 return 未 await,单请求打崩进程)', async () => {
// expected="Bearer test-key-12345"(21 字符);candidate 同 JS 长度但含 latin1 高位
// 字节 → Buffer 字节长度不等 → timingSafeEqual 曾抛 RangeError,而 handleChatCompletions
// 被 return(未 await)→ 异常成 unhandledRejection → 进程直接退出
const cand = 'Bearer test-key-1234\u00C3';
const r = await chat({ messages: [] }, { Authorization: cand });
assert.equal(r.status, 401, 'must be 401, not a crash');
// 进程存活:随后正常请求仍 200(不消耗限速窗口:此请求成功不计失败)
const ok = await chat({ model: 'mock-model', messages: [], stream: false });
assert.equal(ok.status, 200, 'proxy must survive the malformed auth header');
});

test('auth: wrong key 401, then rate limited 429', async () => {
// models 也走鉴权:首个失败请求用 models 端点验证 401
// models 也走鉴权:首个失败请求用 models 端点验证 401(阈值前)
const mNoKey = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/models`);
assert.equal(mNoKey.status, 401);
for (let i = 0; i < 9; i++) {
assert.ok([401, 429].includes(mNoKey.status));
// 失败计数跨用例累积(60s 窗口),动态打到 429 为止:阈值前每次失败都应 401
let hit429 = false;
for (let i = 0; i < 12; i++) {
const r = await chat({ messages: [] }, { Authorization: 'Bearer wrong' });
assert.equal(r.status, 401, `attempt ${i + 1} should be 401`);
if (r.status === 429) { hit429 = true; break; }
assert.equal(r.status, 401, `attempt ${i + 1} should be 401 before threshold`);
}
const r = await chat({ messages: [] }, { Authorization: 'Bearer wrong' });
assert.equal(r.status, 429);
assert.ok(hit429, 'wrong-key flood must hit 429 within the window');
});


test('aggregation: content + tool_calls + finish_reason + usage', async () => {
const r = await chat({ model: 'mock-model', messages: [{ role: 'user', content: 'hi' }], stream: false });
assert.equal(r.status, 200);
Expand All @@ -189,6 +207,29 @@ test('invalid json → 400', async () => {
assert.equal(res.status, 400);
});

test('body 未读完客户端断开 → 请求终结不悬挂(回归:曾永久挂起泄漏闭包、访问日志不落)', async () => { // 客户端发大 body 后中途断连(读 body 阶段断开):readBody 的 Promise 曾永不
// settle → handler 悬挂、finally 的访问日志永不写。修复后断开即终结该请求。
// 断开时它是唯一 in-flight 请求 → 其访问日志出现即证明未悬挂。
const raw = net.connect(PROXY_PORT, '127.0.0.1');
await new Promise((r) => raw.once('connect', r));
const body = 'x'.repeat(1024 * 1024); // 1MiB > socket 缓冲,强制分多次写
raw.write(`POST /v1/chat/completions HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${KEY}\r\nContent-Type: application/json\r\nContent-Length: ${body.length + 10}\r\n\r\n`);
await new Promise((r) => setTimeout(r, 50)); // 等服务端进入读 body 阶段
for (let off = 0; off < body.length && !raw.destroyed; off += 64 * 1024) {
raw.write(body.slice(off, Math.min(off + 64 * 1024, body.length)));
await new Promise((r) => setTimeout(r, 10));
}
const logsBefore = serverLog.split('"msg":"request"').length - 1;
raw.destroy(); // 少发 10 字节即断 → 服务端读 body 阶段遭遇客户端断开
// 修复后该请求的 finally 访问日志应在数百 ms 内落盘;悬挂则永不出现
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (serverLog.split('"msg":"request"').length - 1 > logsBefore) return;
await new Promise((r) => setTimeout(r, 100));
}
assert.fail('aborted mid-body request must terminate and log, not hang forever');
});

test('合法 JSON 但非对象 → 400 可解析(回归:null 体曾静默挂死、原始类型曾 TypeError 劣化 502)', async () => {
// 两个历史缺陷同一根因(入口无形状校验):
// ① body=null 曾直接 return 不回包 → 客户端挂死到自身超时;
Expand Down Expand Up @@ -375,3 +416,13 @@ test('reasoning_effort 出站剥除(真机教训:触发上游 thinking 变
assert.equal(upstreamState.lastBody.reasoning_effort, undefined, 'streaming path must strip too');
assert.equal(upstreamState.lastBody.stream, true);
});

test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN 曾使 setTimeout 按 0ms 触发、每条流瞬间 abort)', async () => {
const bad = spawn(process.execPath, ['src/server.mjs'], {
cwd: new URL('..', import.meta.url).pathname,
env: { ...process.env, PROXY_PORT: String(PROXY_PORT + 1), PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', CNB_TOKEN: 'test-token', UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}`, PROXY_IDLE_TIMEOUT_MS: 'abc' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const code = await new Promise((r) => bad.on('exit', (c) => r(c)));
assert.equal(code, 1, 'must refuse to start with a non-numeric timeout');
});
Loading