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
28 changes: 21 additions & 7 deletions src/anthropic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,32 @@ export function fromAnthropicRequest(body, { allowThinking = false } = {}) {
if (tc) out.tool_choice = tc;
}

// 思考开关:默认丢弃(触发上游 thinking 变体会因推理计入 max_tokens 而耗尽预算,
// 见 proxy.mjs 的剥除注释)。显式开启时把 Anthropic 顶层 thinking 翻成上游能识别的
// OpenAI 侧形态;type:"disabled" 与缺省都不加字段 = 走非 thinking 变体。
// budget_tokens 无上游对应字段,不映射 —— 上游按 max_tokens 统一控制推理+正文预算。
// 思考开关:默认丢弃(触发上游思考变体会因推理计入 max_tokens 而耗尽预算,
// 见 proxy.mjs 的剥除注释)。显式开启时把 Anthropic 顶层 thinking 翻成上游能识别的形态;
// type:"disabled" 与缺省都不加字段 = 走非思考变体。
//
// ⚠️ 真机实测(2026-10-16,对 CNB 上游逐个拼写验证):上游**只认 `reasoning_effort`**。
// `thinking:{type:"enabled"}`、`enable_thinking:true`、`reasoning:{effort}`、`thinking:true`
// 全部无效 —— reasoning_content 恒为 0,与不带任何思考参数的表现完全一致。
// 所以这里必须映射成 reasoning_effort;映射成 thinking 会让 /v1/messages 永远拿不到思考。
// Anthropic 的 budget_tokens 无上游对应字段,按档位折算成 effort。
if (allowThinking && body.thinking && body.thinking.type === 'enabled') {
out.thinking = { type: 'enabled' };
// 客户端若显式给了 effort(Anthropic 协议外字段,但转发链常见)一并透传
if (typeof body.reasoning_effort === 'string') out.reasoning_effort = body.reasoning_effort;
out.reasoning_effort = typeof body.reasoning_effort === 'string'
? body.reasoning_effort // 客户端显式给了 effort(协议外字段,但转发链常见)→ 原样透传
: budgetToEffort(body.thinking.budget_tokens);
}
return out;
}

// Anthropic thinking.budget_tokens → 上游 reasoning_effort 档位。
// 上游只分 low/medium/high(实测三档都能触发思考,档位差异表现在 reasoning 长度上)。
function budgetToEffort(budget) {
if (typeof budget !== 'number' || !Number.isFinite(budget)) return 'high'; // 没给预算就按高档
if (budget <= 4096) return 'low';
if (budget <= 16384) return 'medium';
return 'high';
}

function normalizeSystem(system) {
if (system === undefined || system === null) return '';
if (typeof system === 'string') return system;
Expand Down
17 changes: 12 additions & 5 deletions src/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import { aggregate } from './sse.mjs';
import { record as recordUsage } from './usage.mjs';
import { observe as observeModel } from './models.mjs';

// 出站请求剥除思考开关:带 reasoning_effort 等思考参数时上游路由到 thinking 变体,
// 而该变体把推理阶段也计入 max_tokens——推理吃满预算后 finish_reason=length、
// content 全空(答案永不生成,真机实测 2048/8192 token 必现)。上游无正确开关可配,
// 出站统一剥除、锁定非 thinking 变体(与 /v1/messages 请求侧丢弃 thinking 块同一策略)。
// 表驱动:上游若引入新的思考开关命名,往这张表加一项即可。
// 出站请求剥除思考开关:带上思考参数命中上游思考变体后,推理阶段也计入 max_tokens——
// 推理吃满预算后 finish_reason=length、content 全空(答案永不生成,真机实测 2048/8192 必现、
// 给到 32000 才正常)。所以默认出站统一剥除、锁定非思考变体(与 /v1/messages 请求侧丢弃
// thinking 块同一策略);显式开启思考由 CNB2API_ALLOW_THINKING 控制。
//
// ⚠️ 真机实测(2026-10-16,逐个拼写打到真实上游):
// 只有 `reasoning_effort` 能触发思考变体;`reasoning:{effort}`、`enable_thinking`、
// `thinking:{type:"enabled"}`、`thinking:true` 全部无效(reasoning_content 恒为 0,
// 与完全不带思考参数的表现一致)。因此:
// - 对外(OpenAI 路径)→ 客户端给 reasoning_effort 即可;
// - 对 Anthropic 路径 → anthropic.mjs 把 thinking 映射成 reasoning_effort(不能映射成 thinking)。
// 剥除列表仍保留全部 4 个拼写:多余剥除无害,且能防上游将来引入新的有效拼写。
const REASONING_TRIGGERS = ['reasoning_effort', 'reasoning', 'enable_thinking', 'thinking'];

function stripReasoningTriggers(payload) {
Expand Down
99 changes: 90 additions & 9 deletions test/anthropic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -233,28 +233,44 @@ test('请求转换:thinking 请求参数不泄漏出站(真机教训:触
assert.equal(out.messages.length, 1);
});

test('请求转换:allowThinking 开启时 thinking 映射为上游形态(默认关闭不受影响)', () => {
test('请求转换:allowThinking 开启时 thinking 映射为 reasoning_effort(默认关闭不受影响)', () => {
const body = {
model: 'claude-sonnet-4-5',
max_tokens: 64000,
thinking: { type: 'enabled', budget_tokens: 2048 },
messages: [{ role: 'user', content: 'hi' }],
};
// enabled → 透出上游能识别的开关;budget_tokens 无上游对应字段,不映射
// ⚠️ 真机实测:上游只认 reasoning_effort;thinking:{type:"enabled"} 打到真实上游
// reasoning_content 恒为 0(与不带参数一致)。所以必须映射成 reasoning_effort。
const on = fromAnthropicRequest(body, { allowThinking: true });
assert.deepEqual(on.thinking, { type: 'enabled' });
assert.equal(on.budget_tokens, undefined);
assert.equal(on.reasoning_effort, 'low', 'budget 2048 → low');
assert.equal(on.thinking, undefined, 'must NOT emit thinking: upstream ignores it');
assert.equal(on.enable_thinking, undefined);
assert.equal(on.max_tokens, 64000);
// type:"disabled" → 不加字段 = 走非 thinking 变体(不是加一个 disabled 开关)
assert.equal(on.budget_tokens, undefined);

// budget_tokens 档位折算
const at = (b) => fromAnthropicRequest({ ...body, thinking: { type: 'enabled', budget_tokens: b } }, { allowThinking: true }).reasoning_effort;
assert.equal(at(1024), 'low');
assert.equal(at(4096), 'low');
assert.equal(at(4097), 'medium');
assert.equal(at(16384), 'medium');
assert.equal(at(16385), 'high');
assert.equal(at(31999), 'high');
// 缺省/非法 budget → high(宁可高不要低,低了推理写不完会吃穿 max_tokens)
assert.equal(at(undefined), 'high');
assert.equal(at(NaN), 'high');

// type:"disabled" → 不加字段 = 走非思考变体(不是加一个 disabled 开关)
const off = fromAnthropicRequest({ ...body, thinking: { type: 'disabled' } }, { allowThinking: true });
assert.equal(off.reasoning_effort, undefined);
assert.equal(off.thinking, undefined);
assert.equal(off.enable_thinking, undefined);
// 不带 thinking 的普通请求两种模式下完全一致
const plain = { model: 'claude-sonnet-4-5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] };
assert.deepEqual(fromAnthropicRequest(plain, { allowThinking: true }), fromAnthropicRequest(plain));
// 显式开启 + 客户端给了 effort(协议外字段)→ 一并透传
const effort = fromAnthropicRequest({ ...body, reasoning_effort: 'high' }, { allowThinking: true });
assert.equal(effort.reasoning_effort, 'high');
// 客户端显式给了 effort(协议外字段)→ 优先用它,忽略 budget_tokens
const effort = fromAnthropicRequest({ ...body, reasoning_effort: 'medium' }, { allowThinking: true });
assert.equal(effort.reasoning_effort, 'medium');
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -413,11 +429,14 @@ const MOCK_PORT = 19121;
const PROXY_PORT = 19122;
const KEY = 'test-key-12345';

let lastUpstreamBody = null; // 捕获出站请求体(思考开关映射类断言用)

const mockServer = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const parsed = JSON.parse(body);
lastUpstreamBody = parsed;
// 断言:打到上游的一定是 OpenAI 格式(翻译正确性)
assert.ok(Array.isArray(parsed.messages));
assert.equal(parsed.stream, true);
Expand Down Expand Up @@ -564,3 +583,65 @@ test('E2E 请求校验:messages 缺失 → 400 Anthropic error envelope', asyn
assert.equal(body.type, 'error');
assert.equal(body.error.type, 'invalid_request_error');
});

// ---------------------------------------------------------------------------
// E2E:思考 opt-in —— /v1/messages 的 thinking 必须落到上游的 reasoning_effort
//
// 真机实测(2026-10-16):上游只认 reasoning_effort;thinking:{type:"enabled"} 打到真实
// 上游 reasoning_content 恒为 0。这条 E2E 钉住"映射成 reasoning_effort"这个事实,
// 防止未来重构把它改回 thinking(那会让 /v1/messages 静默失去思考)。
// ---------------------------------------------------------------------------
const THINK_PORT = PROXY_PORT + 1;

async function withThinkProxy(fn) {
const p = spawn(process.execPath, ['src/server.mjs'], {
env: {
...process.env, PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', CNB_TOKEN: 'test-token',
UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}/upstream`, PROXY_PORT: String(THINK_PORT),
CNB2API_ALLOW_THINKING: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
try {
const t0 = Date.now();
for (;;) {
try { await fetch(`http://127.0.0.1:${THINK_PORT}/health`); break; } catch {}
if (Date.now() - t0 > 5000) throw new Error('think proxy not up');
await new Promise((r) => setTimeout(r, 100));
}
return await fn();
} finally {
p.kill('SIGTERM');
}
}

test('E2E 思考 opt-in:thinking:{enabled,budget_tokens} → 上游收到 reasoning_effort(不是 thinking)', async () => {
await withThinkProxy(async () => {
const res = await fetch(`http://127.0.0.1:${THINK_PORT}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({
model: 'claude-x', max_tokens: 64000,
thinking: { type: 'enabled', budget_tokens: 31999 },
messages: [{ role: 'user', content: 'hi' }],
}),
});
assert.equal(res.status, 200);
await res.json();
assert.equal(lastUpstreamBody.reasoning_effort, 'high', 'budget 31999 → high');
assert.equal(lastUpstreamBody.thinking, undefined, 'must not forward thinking: upstream ignores it');
assert.equal(lastUpstreamBody.max_tokens, 64000);
});
});

test('E2E 思考 opt-in:未开开关时 thinking 完全不外泄(默认行为不变)', async () => {
const res = await postMessages({
model: 'claude-x', max_tokens: 64000,
thinking: { type: 'enabled', budget_tokens: 31999 },
messages: [{ role: 'user', content: 'hi' }],
}, { 'x-api-key': KEY });
assert.equal(res.status, 200);
await res.json();
assert.equal(lastUpstreamBody.reasoning_effort, undefined);
assert.equal(lastUpstreamBody.thinking, undefined);
});