Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,14 +484,14 @@ The Anthropic SDK authenticates via the `x-api-key` header — supported by the

## Anti-Detection

Aligned line-by-line against the official npm package source (`command-code@1.53.1`; `dist/cli.mjs` is minified but **not obfuscated**). Newer npm releases only raise a drift **warning** — the proxy never silently bumps the version it claims:
Aligned line-by-line against the official npm package source (originally `command-code@1.53.1`, re-verified against `1.88.2` on 2026-10-30; `dist/cli.mjs` is minified but **not obfuscated**). Newer npm releases only raise a drift **warning** — the proxy never silently bumps the version it claims:

| Mechanism | Implementation |
|-----------|---------------|
| **Device Fingerprint** | `POST /alpha/fingerprint/record` before first request per key; signal values (Windows MachineGuid shape, real-shaped MACs, `DESKTOP-xxxxxx` hostname) are **derived deterministically from the API key** and hashed exactly like the CLI, so one key always reports the same device — across restarts, memory reclamation and multiple instances (bulk reset via `CC_FINGERPRINT_SALT`) |
| **Lifecycle Events** | `POST /alpha/lifecycle-events` (`cli_session_exists`, metadata `{sessionId, cliVersion, mode, os}`) sent in parallel with the fingerprint on key init, using the same `User-Agent: cli` as generate |
| **Session ID** | Taken from the client header when present (`x-session-id` / `x-claude-code-session-id` / `session_id` / `prompt_cache_key`); otherwise **derived deterministically from the request** — `sha1(apiKey + model + system + user text up to the first non-user message)` formatted as a UUID. |
| **Version** | `x-command-code-version` reports the **protocol version actually implemented** (currently `1.53.1`); newer npm releases only raise a drift **warning**, never a silent version bump |
| **Version** | `x-command-code-version` reports the **protocol version actually implemented** (currently `1.88.2`); newer npm releases only raise a drift **warning**, never a silent version bump |
| **CLI Envelope** | 9 keys: `config / memory / taste / skills / permissionMode / threadId / mode / promptCache / params` |
| **OpenTelemetry** | `traceparent` (W3C Trace Context) |
| **Environment** | `x-cli-environment: production`, `x-taste-learning: "false"`, `User-Agent: cli` |
Expand Down
4 changes: 2 additions & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

将 Command Code API 转换为 OpenAI / Anthropic 兼容接口的反代代理。单文件,零外部依赖。

逐条对齐官方 npm 包源码(`command-code@1.53.1`;`dist/cli.mjs` 只是压缩、**没有混淆**)。上游 npm 走到更高版本时代理只打**漂移告警**,不会静默改版本号(见[反检测](#反检测))。
逐条对齐官方 npm 包源码(最初对齐 `command-code@1.53.1`,2026-10-30 已对照 `1.88.2` 逐段复核;`dist/cli.mjs` 只是压缩、**没有混淆**)。上游 npm 走到更高版本时代理只打**漂移告警**,不会静默改版本号(见[反检测](#反检测))。

**完整功能**:OpenAI Chat Completions / **Responses API(`/v1/responses`)** + Anthropic Messages API | 流式/非流式输出 | 工具调用 (tool_use) | 多模态图片输入 | 推理强度 (reasoning_effort) | 动态模型列表 | 缓存命中指标 | 设备指纹伪装(per-key 绑定、自动刷新)| `x-api-key` 鉴权(Anthropic SDK)| 客户端断连检测(上游中止)| 零输出防护(非流式 429 / 流式 response.failed)| 连续超时 → 429 自动重试 | 隐私保护日志

Expand Down Expand Up @@ -486,7 +486,7 @@ Anthropic SDK 通过 `x-api-key` 头鉴权——代理已原生支持(无需 `
| **设备指纹** | 每个 Key 首次请求前发送 `POST /alpha/fingerprint/record`;信号值(Windows MachineGuid 形状、真实形状的 MAC、`DESKTOP-xxxxxx` 主机名)由 API key **确定性派生**,并按 CLI 的算法哈希 —— 同一个 key 永远报告同一台设备:重启、内存回收、多实例都一致(用 `CC_FINGERPRINT_SALT` 成批换身份)|
| **生命周期声明** | Key 初始化时与指纹并行发送 `POST /alpha/lifecycle-events`(`cli_session_exists`,metadata `{sessionId, cliVersion, mode, os}`),与生成请求共用 `User-Agent: cli` |
| **Session ID** | 客户端带了 session 类 header 时直接用(`x-session-id` / `x-claude-code-session-id` / `session_id` / `prompt_cache_key`);否则**按请求内容确定性派生** —— `sha1(apiKey + model + system + 首个非 user 消息之前的连续 user 文本)` 格式化成 UUID。|
| **协议版本号** | `x-command-code-version` 报**实际实现的协议版本**(当前 `1.53.1`);npm 上有新版本只打**漂移告警**,不会静默改版本号 |
| **协议版本号** | `x-command-code-version` 报**实际实现的协议版本**(当前 `1.88.2`);npm 上有新版本只打**漂移告警**,不会静默改版本号 |
| **CLI 信封格式** | 9 键:`config / memory / taste / skills / permissionMode / threadId / mode / promptCache / params` |
| **OpenTelemetry** | `traceparent` (W3C Trace Context) |
| **环境标识** | `x-cli-environment: production`、`x-taste-learning: "false"`、`User-Agent: cli` |
Expand Down
49 changes: 32 additions & 17 deletions proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function loadConfig() {

const CFG = loadConfig();

// ── 设备指纹(形态与哈希逐字对齐官方 CLI;1.53.1 对齐,1.54.0 复核未变) ──
// ── 设备指纹(形态与哈希逐字对齐官方 CLI;1.53.1 对齐,1.54.0 复核未变,1.88.2 复核仍未变) ──
// CPU 型号与核数对应表(仅 Windows x64)。
// ⚠️ 上网的 components.cpuCount 必须是 **threads** 而不是 cores ——
// CLI 的 gatherRawSignals 取的是 `os.cpus().length`,即**逻辑处理器数**。
Expand Down Expand Up @@ -207,11 +207,11 @@ function generateFingerprint(apiKey) {
};
}

// 本代理**实际实现**的 wire 协议版本(对齐 command-code@1.53.1 源码)。
// 本代理**实际实现**的 wire 协议版本(对齐 command-code@1.88.2 源码)。
// 真机发的永远是「形状 + 版本号」自洽的组合;如果版本号跟着 npm 走而形状没变,
// 就变成「自称最新版、却说旧方言」—— 这比版本号过期更容易被行为分析挑出来。
// 因此这里报的是协议版本,npm 上更新了只告警、不自动改。
const CC_PROTOCOL_VERSION = '1.53.1';
const CC_PROTOCOL_VERSION = '1.88.2';
let CC_VERSION = CC_PROTOCOL_VERSION;
const CC_VERSION_REFRESH_MS = 24 * 60 * 60 * 1000; // 24h — 检查一次是否发生漂移

Expand Down Expand Up @@ -1008,7 +1008,7 @@ function createSseTranslator(model, completionId, created) {
break;
}

case 'reasoning-end': case 'provider-metadata': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end': case 'tool-error': case 'text-end':
case 'reasoning-end': case 'provider-metadata': case 'cache-write-tokens': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end': case 'tool-error': case 'text-end':
// Silent - no user-visible content
break;
default:
Expand Down Expand Up @@ -1897,7 +1897,7 @@ async function handleChatCompletions(req, res) {
// 于是线上刷屏。它们本身不携带内容(内容在 text-delta),纯粹是噪音。
case 'text-start': case 'text-end': case 'start': case 'start-step':
case 'reasoning-start': case 'reasoning-end':
case 'provider-metadata': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end':
case 'provider-metadata': case 'cache-write-tokens': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end':
case 'tool-error':
// Silent - no user-visible content
break;
Expand Down Expand Up @@ -2497,7 +2497,7 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) {
break;
}

case 'reasoning-end': case 'provider-metadata': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end': case 'tool-error': case 'text-end':
case 'reasoning-end': case 'provider-metadata': case 'cache-write-tokens': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end': case 'tool-error': case 'text-end':
// Silent - no user-visible content
break;
default:
Expand Down Expand Up @@ -2832,7 +2832,7 @@ async function handleMessages(req, res) {
// 于是线上刷屏。它们本身不携带内容(内容在 text-delta),纯粹是噪音。
case 'text-start': case 'text-end': case 'start': case 'start-step':
case 'reasoning-start': case 'reasoning-end':
case 'provider-metadata': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end':
case 'provider-metadata': case 'cache-write-tokens': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end':
case 'tool-error':
// Silent - no user-visible content
break;
Expand Down Expand Up @@ -2925,6 +2925,10 @@ async function handleMessages(req, res) {

let dynamicModels = null;
let modelsLastFetch = 0;
// 1.88.2 CLI 的目录刷新(createModelCatalogManager.fetchOnce)用裸 fetch、**不带认证**,
// 只发 accept + if-none-match(`"<catalogVersion>"`);304 表示版本未变、沿用当前目录。
// 这里对齐该可观测形状:catalogVersion 取自响应体,首次请求无版本可带。
let catalogVersion = '';

async function fetchModels(apiKey) {
const now = Date.now();
Expand All @@ -2935,24 +2939,35 @@ async function fetchModels(apiKey) {
try {
if (!apiKey || !CFG.useProviderModels) throw new Error('Provider models disabled');

const response = await upstreamFetch(`${CFG.apiBase}/provider/v1/models`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'x-cli-environment': 'production',
'x-command-code-version': CC_VERSION,
},
const headers = { 'accept': 'application/json' };
if (catalogVersion) headers['if-none-match'] = `"${catalogVersion}"`;
let response = await upstreamFetch(`${CFG.apiBase}/provider/v1/models?view=client`, {
headers,
signal: AbortSignal.timeout(10000),
});

if (response.status === 304) {
if (dynamicModels) {
modelsLastFetch = now; // 版本未变:缓存继续有效
return dynamicModels;
}
// 无缓存可复用(正常流程不可达,但缓存被清空后会到达):清掉版本号并无条件重取一次。
catalogVersion = '';
response = await upstreamFetch(`${CFG.apiBase}/provider/v1/models?view=client`, {
headers: { 'accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
}
if (response.ok) {
const data = await response.json();
if (Array.isArray(data.data)) {
if (Array.isArray(data?.data)) {
if (typeof data.catalogVersion === 'string') catalogVersion = data.catalogVersion;
dynamicModels = data.data.map(m => ({
id: m.id,
name: m.id,
name: m.name || m.id,
}));
modelsLastFetch = now;
log('info', 'Fetched models from Provider API', { count: dynamicModels.length });
log('info', 'Fetched models from Provider API', { count: dynamicModels.length, catalogVersion: catalogVersion || null });
return dynamicModels;
}
}
Expand Down Expand Up @@ -3831,7 +3846,7 @@ async function handleResponses(req, res) {
// 'Unknown CC event type',线上刷屏、把真正的错误淹掉。
case 'text-start': case 'text-end': case 'start': case 'start-step':
case 'reasoning-start': case 'reasoning-end':
case 'provider-metadata': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end':
case 'provider-metadata': case 'cache-write-tokens': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end':
case 'tool-error':
// Silent - no user-visible content
break;
Expand Down
14 changes: 11 additions & 3 deletions test/connection-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,19 @@ test('message 里的 "<NNN>" 前缀优先于 statusCode(对齐 CLI 的取值
// ── ② 无内容事件不应产生 Unknown CC event type 警告 ────────────
// 上游每个响应都会发一串不携带内容的事件(text-start / text-end / start /
// start-step / reasoning-start / reasoning-end / provider-metadata /
// tool-input-start|delta|end / tool-error)。Responses 非流式那条路径原先
// 一个静默列表都没有,每个响应刷十来条 warn,真正的错误被淹没。
// tool-input-start|delta|end / tool-error / cache-write-tokens[1.88.2 新增])。
// Responses 非流式那条路径原先一个静默列表都没有,每个响应刷十来条 warn,
// 真正的错误被淹没。

test('标准 NDJSON 序列不产生任何 Unknown CC event type 警告(三协议 × 流式/非流式)', async () => {
const s = await setup();
const s = await setup({ ndjson: [
'{"type":"text-start"}',
'{"type":"cache-write-tokens","cacheWriteTokens":42}',
'{"type":"text-delta","text":"hello"}',
'{"type":"text-end"}',
'{"type":"finish-step","finishReason":"stop","usage":{"inputTokens":9,"outputTokens":3}}',
'{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":9,"outputTokens":3,"cachedInputTokens":0}}',
] });
try {
const msg = { model: 'm', max_tokens: 50, messages: [{ role: 'user', content: 'hi' }] };
await (await s.proxy.post('/v1/chat/completions', { ...CHAT, stream: true }, AUTH)).text();
Expand Down
71 changes: 68 additions & 3 deletions test/endpoints.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// 端点契约:四个路由在正常路径下的行为。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { setup } from './helpers.mjs';

test('POST /v1/chat/completions 流式:返回 OpenAI SSE 且内容正确', async () => {
Expand Down Expand Up @@ -59,11 +63,18 @@ test('POST /v1/responses 流式:返回具名 SSE 事件', async () => {
} finally { await s.close(); }
});

test('GET /v1/models 走 /provider/v1/models', async () => {
test('GET /v1/models 走 /provider/v1/models?view=client', async () => {
const s = await setup({ env: { CC_USE_PROVIDER_MODELS: 'true' }, onRequest: (req, res) => {
if (req.url === '/provider/v1/models') {
const u = new URL(req.url, 'http://mock');
if (u.pathname === '/provider/v1/models' && u.searchParams.get('view') === 'client') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'test/model-1', object: 'model' }] }));
res.end(JSON.stringify({
schemaVersion: 1,
catalogVersion: 'catalog-2026-10-30.1',
refreshAfterSeconds: 60,
roles: {},
data: [{ id: 'test/model-1', name: 'Test Model One', object: 'model' }],
}));
}
}});
try {
Expand All @@ -72,9 +83,63 @@ test('GET /v1/models 走 /provider/v1/models', async () => {
const j = await r.json();
assert.ok(Array.isArray(j.data));
assert.equal(j.data[0].id, 'test/model-1');

// 目录刷新对齐 1.88.2 CLI 的可观测形状:匿名(无 Authorization / 无版本头 / 无伪造 UA),
// 只带 accept;已有 catalogVersion 后才追加 if-none-match。
const hit = s.mock.seen.find(x => x.url.startsWith('/provider/v1/models'));
assert.ok(hit, '应请求 /provider/v1/models');
assert.equal(new URL(hit.url, 'http://mock').searchParams.get('view'), 'client');
assert.equal(hit.headers.accept, 'application/json');
assert.equal(hit.headers.authorization, undefined, '目录刷新是匿名请求,不带 Authorization');
assert.equal(hit.headers['x-command-code-version'], undefined, '不附带版本头');
assert.notEqual(hit.headers['user-agent'], 'cli',
'目录刷新不伪造 CLI UA(裸 fetch 与真机 CLI 一样只带运行时默认值)');
} finally { await s.close(); }
});

test('GET /v1/models 第二次刷新带 if-none-match,304 复用缓存', async () => {
// 把刷新间隔压到 1ms(config.json 覆盖默认值),让第二次请求真的触发一次条件拉取。
const dir = mkdtempSync(join(tmpdir(), 'ccp-models-'));
writeFileSync(join(dir, 'config.json'), JSON.stringify({ modelRefreshIntervalMs: 1 }));
const catalogReqs = [];
const s = await setup({ cwd: dir, env: { CC_USE_PROVIDER_MODELS: 'true' }, onRequest: (req, res) => {
const u = new URL(req.url, 'http://mock');
if (u.pathname !== '/provider/v1/models') return;
catalogReqs.push(req.headers);
if (req.headers['if-none-match'] === '"catalog-2026-10-30.1"') {
res.writeHead(304); // 版本未变:CLI 的 fetchOnce 会沿用缓存
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
schemaVersion: 1,
catalogVersion: 'catalog-2026-10-30.1',
refreshAfterSeconds: 60,
roles: {},
data: [{ id: 'test/model-1', name: 'Test Model One', object: 'model' }],
}));
}});
try {
const first = await (await s.proxy.get('/v1/models', { headers: { Authorization: 'Bearer user_test' } })).json();
assert.equal(first.data[0].id, 'test/model-1');

await sleep(15); // 越过 1ms 刷新窗口
const second = await (await s.proxy.get('/v1/models', { headers: { Authorization: 'Bearer user_test' } })).json();
assert.equal(second.data[0].id, 'test/model-1', '304 后继续服务缓存列表');

assert.equal(catalogReqs.length, 2, '应发生第二次目录请求');
assert.equal(catalogReqs[1]['if-none-match'], '"catalog-2026-10-30.1"',
'第二次请求带引号包裹的 catalogVersion');
assert.equal(catalogReqs[1].authorization, undefined, '条件请求同样匿名');
assert.ok(!s.proxy.logs().includes('Provider models fetch failed'),
'304 不应触发硬编码回退告警');
} finally {
await s.close();
rmSync(dir, { recursive: true, force: true });
}
});

test('GET /health 与 / 返回存活状态', async () => {
const s = await setup();
try {
Expand Down
2 changes: 1 addition & 1 deletion test/envelope.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ test('请求头集合与 CLI 的 buildCommandAuthHeaders 一致(无 x-co-flag
assert.equal(headers['x-taste-learning'], 'false', 'toString() 的字符串,不是布尔');
assert.equal(headers['x-project-slug'], 'c-users-dev-projects-app',
'x-project-slug = slugify(workingDir),盘符保留(@sindresorhus/slugify 的结果)');
assert.ok(headers['x-command-code-version']);
assert.equal(headers['x-command-code-version'], '1.88.2', '报告实际实现的协议版本');
assert.ok(headers.traceparent);
} finally { await s.close(); }
});
Expand Down