Skip to content
Closed
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
44 changes: 44 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Test

on:
push:
# 只在这些长期分支上跑 push;其余分支靠 pull_request 触发,避免同一提交跑两遍
branches: [master, release]
pull_request:
workflow_dispatch:

# 同一分支的新推送取消旧运行,避免排队浪费
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# engines 下限是 18;Dockerfile 用 22;中间放 20 覆盖 LTS 跨度
node: ['18', '20', '22']
name: node ${{ matrix.node }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}

- name: Show Node version
run: node --version

- name: Syntax check
run: node --check proxy.mjs

- name: Run tests
run: npm test
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"start": "node proxy.mjs",
"dev": "node --watch proxy.mjs",
"test": "node --test test/*.test.mjs",
"docker:build": "docker build -t commandcode-proxy:latest .",
"docker:build:multi": "docker buildx build --platform linux/amd64,linux/arm64 -t commandcode-proxy:latest ."
},
Expand Down
103 changes: 103 additions & 0 deletions test/endpoints.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 端点契约:四个路由在正常路径下的行为。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setup } from './helpers.mjs';

test('POST /v1/chat/completions 流式:返回 OpenAI SSE 且内容正确', async () => {
const s = await setup();
try {
const r = await s.proxy.post('/v1/chat/completions',
{ model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }], stream: true },
{ Authorization: 'Bearer user_test' });
assert.equal(r.status, 200);
const text = await r.text();
assert.match(text, /data: /);
assert.ok(text.includes('hello'), 'SSE 应包含上游 text-delta 的内容');
assert.match(text, /\[DONE\]/, '流应以 [DONE] 结束');
} finally { await s.close(); }
});

test('POST /v1/chat/completions 非流式:返回 chat.completion 对象', async () => {
const s = await setup();
try {
const r = await s.proxy.post('/v1/chat/completions',
{ model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }] },
{ Authorization: 'Bearer user_test' });
assert.equal(r.status, 200);
const j = await r.json();
assert.equal(j.object, 'chat.completion');
assert.equal(j.choices[0].message.content, 'hello');
assert.ok(j.usage, 'usage 必须存在');
} finally { await s.close(); }
});

test('POST /v1/messages 流式:返回 Anthropic SSE 事件序列', async () => {
const s = await setup();
try {
const r = await s.proxy.post('/v1/messages',
{ model: 'deepseek/deepseek-v4-flash', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], stream: true },
{ 'x-api-key': 'user_test' });
assert.equal(r.status, 200);
const text = await r.text();
for (const ev of ['message_start', 'content_block_start', 'message_stop']) {
assert.ok(text.includes(ev), 'Anthropic SSE 应包含 ' + ev);
}
} finally { await s.close(); }
});

test('POST /v1/responses 流式:返回具名 SSE 事件', async () => {
const s = await setup();
try {
const r = await s.proxy.post('/v1/responses',
{ model: 'deepseek/deepseek-v4-flash', input: 'hi', stream: true },
{ Authorization: 'Bearer user_test' });
assert.equal(r.status, 200);
const text = await r.text();
assert.ok(text.includes('response.completed'), '应包含 response.completed');
// 规范要求每个事件都带 sequence_number
assert.ok(text.includes('sequence_number'), '每个事件都必须带 sequence_number');
} finally { await s.close(); }
});

test('GET /v1/models 走 /provider/v1/models', async () => {
const s = await setup({ env: { CC_USE_PROVIDER_MODELS: 'true' }, onRequest: (req, res) => {
if (req.url === '/provider/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'test/model-1', object: 'model' }] }));
}
}});
try {
const r = await s.proxy.get('/v1/models', { headers: { Authorization: 'Bearer user_test' } });
assert.equal(r.status, 200);
const j = await r.json();
assert.ok(Array.isArray(j.data));
assert.equal(j.data[0].id, 'test/model-1');
} finally { await s.close(); }
});

test('GET /health 与 / 返回存活状态', async () => {
const s = await setup();
try {
for (const path of ['/health', '/']) {
const r = await s.proxy.get(path);
assert.equal(r.status, 200, path + ' 应返回 200');
}
} finally { await s.close(); }
});

test('未知路由返回 404', async () => {
const s = await setup();
try {
const r = await s.proxy.get('/nope');
assert.equal(r.status, 404);
} finally { await s.close(); }
});

test('缺少 API key 返回 401', async () => {
const s = await setup();
try {
const r = await s.proxy.post('/v1/chat/completions',
{ model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }] });
assert.equal(r.status, 401);
} finally { await s.close(); }
});
143 changes: 143 additions & 0 deletions test/errors-limits.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// 错误映射 + 请求体上限 + 在途上限。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setup } from './helpers.mjs';

const AUTH = { Authorization: 'Bearer user_test' };
const CHAT = { model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }] };

// CC_STATUS_MAP 的语义契约(逐条来自 proxy.mjs 的映射表):
// 400/401/404 原样透传;422 -> 400;403 -> 401;402 -> 429(payment → rate limit)
// 500/502 -> 502;503 -> 503;未列出的状态 -> 502 upstream_error
const MAPPING = [
[400, 400], [401, 401], [404, 404],
[422, 400], [403, 401], [402, 429],
[500, 502], [502, 502], [503, 503],
[418, 502], // 未在表中 → upstream_error
];

for (const [upstream, expected] of MAPPING) {
test('状态映射:上游 ' + upstream + ' -> 下游 ' + expected, async () => {
const s = await setup({ status: upstream, errorBody: JSON.stringify({ error: { message: 'mock' } }) });
try {
const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH);
assert.equal(r.status, expected, '上游 ' + upstream + ' 应映射为 ' + expected);
const j = await r.json();
assert.ok(j.error && j.error.type, '响应体应含 error.type');
} finally { await s.close(); }
});
}

test('上游 402(payment required)映射为 429 而非 402', async () => {
const s = await setup({ status: 402, errorBody: JSON.stringify({ error: { message: 'insufficient credits' } }) });
try {
const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH);
assert.equal(r.status, 429, '402 是 CC 的余额语义,下游按限流处理');
const j = await r.json();
assert.match(j.error.message, /insufficient credits/, '上游错误体应透传进 message');
} finally { await s.close(); }
});

test('上游 429 映射为 rate_limit_error 并带 retry_after', async () => {
const s = await setup({ status: 429, errorBody: JSON.stringify({ error: { message: 'slow down' } }) });
try {
const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH);
const j = await r.json();
assert.equal(r.status, 429);
assert.equal(j.error.type, 'rate_limit_error');
} finally { await s.close(); }
});

test('上游 5xx 映射为 502/503 类服务端错误', async () => {
const s = await setup({ status: 500, errorBody: JSON.stringify({ error: { message: 'boom' } }) });
try {
const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH);
assert.ok(r.status >= 500, '上游 500 应映射成 5xx,实际 ' + r.status);
} finally { await s.close(); }
});

test('Anthropic 端点:上游错误以 Anthropic 错误体返回', async () => {
const s = await setup({ status: 429, errorBody: JSON.stringify({ error: { message: 'slow down' } }) });
try {
const r = await s.proxy.post('/v1/messages',
{ model: 'm', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'hi' }] },
{ 'x-api-key': 'user_test' });
const j = await r.json();
assert.ok(j.error || j.type, 'Anthropic 错误体应有 error 或 type 字段');
} finally { await s.close(); }
});

test('非法 JSON 请求体返回 400', async () => {
const s = await setup();
try {
const r = await s.proxy.post('/v1/chat/completions', '{not json', AUTH);
assert.equal(r.status, 400);
} finally { await s.close(); }
});

test('超过 CC_MAX_BODY_MB 的请求返回 413,且之后小请求仍可用', async () => {
const s = await setup({ env: { CC_MAX_BODY_MB: '1' } });
try {
const big = JSON.stringify({ model: 'm', stream: true,
messages: [{ role: 'user', content: 'x'.repeat(2 * 1024 * 1024) }] });
const r1 = await s.proxy.post('/v1/chat/completions', big, AUTH);
assert.equal(r1.status, 413, '超限请求应返回 413(而非直接 reset)');

// issue #7:超限后连接必须保持可排空,后续请求不受影响
const r2 = await s.proxy.post('/v1/chat/completions',
{ model: 'm', stream: true, messages: [{ role: 'user', content: 'small' }] }, AUTH);
assert.equal(r2.status, 200, '超限拒绝后小请求仍应正常');
await r2.text();
} finally { await s.close(); }
});

test('默认上限放行多模态量级的请求(issue #7 场景,~9MB)', async () => {
const s = await setup();
try {
const body = JSON.stringify({ model: 'm', stream: true,
messages: [{ role: 'user', content: 'x'.repeat(9 * 1024 * 1024) }] });
const r = await s.proxy.post('/v1/chat/completions', body, AUTH);
assert.equal(r.status, 200, '默认 100MB 上限必须容纳 #7 的多模态长会话');
await r.text();
} finally { await s.close(); }
});

test('CC_MAX_INFLIGHT 限流:超限返回 503 + Retry-After,且名额会释放', async () => {
// 让上游把请求挂住,以便观察在途名额
const s = await setup({ env: { CC_MAX_INFLIGHT: '1' },
onRequest: async (req, res) => { if (req.url === '/alpha/generate') await new Promise(r => setTimeout(r, 1200)); } });
try {
const first = s.proxy.post('/v1/chat/completions', CHAT, AUTH);
await new Promise(r => setTimeout(r, 400)); // 确保第一个已占住名额

const r2 = await s.proxy.post('/v1/chat/completions', CHAT, AUTH);
assert.equal(r2.status, 503, '超出在途上限应返回 503');
assert.equal(r2.headers.get('retry-after'), '5', '应带 Retry-After');
const j = await r2.json();
assert.equal(j.error.type, 'server_busy');

const r1 = await first;
assert.equal(r1.status, 200, '已占住名额的请求应正常完成');
await r1.text();

// 名额释放后新请求应通行
const r3 = await s.proxy.post('/v1/chat/completions', CHAT, AUTH);
assert.equal(r3.status, 200, '名额释放后应恢复通行');
await r3.text();
} finally { await s.close(); }
});

test('CC_MAX_INFLIGHT 不限制探活端点', async () => {
const s = await setup({ env: { CC_MAX_INFLIGHT: '1' },
onRequest: async (req, res) => { if (req.url === '/alpha/generate') await new Promise(r => setTimeout(r, 1200)); } });
try {
const first = s.proxy.post('/v1/chat/completions', CHAT, AUTH);
await new Promise(r => setTimeout(r, 400));
// 探活端点被占满时仍必须可用,否则编排系统会误判容器已死
for (const path of ['/health', '/']) {
const r = await s.proxy.get(path);
assert.equal(r.status, 200, path + ' 不应受在途上限影响');
}
const r1 = await first; await r1.text();
} finally { await s.close(); }
});
Loading