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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ CNB_REPO_SLUG=your-org/ai-proxy
# Per-stream idle watchdog (ms)
#PROXY_IDLE_TIMEOUT_MS=300000

# ---- thinking (optional, off by default) ----
# Forward the client's thinking toggles (reasoning_effort / reasoning /
# enable_thinking / thinking, and Anthropic thinking:{type:enabled}) to the
# upstream thinking variant instead of stripping them.
#
# Why this is off by default: the thinking variant bills its reasoning phase
# against max_tokens. With a small budget the reasoning consumes everything and
# the answer is never generated (finish_reason=length, empty content).
#
# Turn it on only if your clients send a large max_tokens (>= 32k is a safe
# starting point; 60k was verified). Requests that carry no thinking toggle are
# unaffected either way.
#CNB2API_ALLOW_THINKING=1

# ---- self-registration to the fixed-domain relay (optional) ----
# Full URL of the relay endpoint, e.g. https://ai.example.com/ops/register
#REGISTER_URL=
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,25 @@ npm run quota # or: npx cnb2api-quota
| `PROXY_PORT` | `9001` | Listen port. |
| `PROXY_UPSTREAM_TIMEOUT_MS` | `15000` | Upstream connect / first-byte timeout (ms). |
| `PROXY_IDLE_TIMEOUT_MS` | `300000` | Per-stream idle watchdog (ms). |
| `CNB2API_ALLOW_THINKING` | off | Set to `1` to forward client thinking toggles (`reasoning_effort` / `reasoning` / `enable_thinking` / `thinking`, and Anthropic `thinking:{type:"enabled"}`) to the upstream thinking variant instead of stripping them. Only use it if your clients send a large `max_tokens` — see the thinking note below. |
| `REGISTER_URL` | — (optional) | Relay `/ops/register` endpoint for self-registration. |
| `REG_TOKEN` | — (optional) | Shared secret for self-registration. |

### Thinking is stripped by default

The upstream thinking variant bills its reasoning phase against `max_tokens`. With a
small budget the reasoning consumes the whole allowance, the stream ends with
`finish_reason: length` and **no answer is ever generated** (measured: a hard prompt
at `max_tokens` 2048 and 8192 produced 4.6k / 20.3k characters of reasoning and zero
content). To keep clients from hitting that, thinking toggles are removed from every
outbound request.

That default is safe but absolute: it also locks out callers that *do* provide a large
budget, for whom thinking works correctly (verified at `max_tokens: 60000`, where
reasoning and answer are emitted in sequence). Set `CNB2API_ALLOW_THINKING=1` to opt in
and take responsibility for the budget. Requests that carry no thinking toggle behave
identically either way.

---

## FAQ
Expand Down
12 changes: 12 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,21 @@ npm run quota # 或: npx cnb2api-quota
| `PROXY_PORT` | `9001` | 服务监听端口。 |
| `PROXY_UPSTREAM_TIMEOUT_MS` | `15000` | 上游连接首字节超时 (ms)。 |
| `PROXY_IDLE_TIMEOUT_MS` | `300000` | 流式事件空闲超时看门狗 (ms)。 |
| `CNB2API_ALLOW_THINKING` | 关闭 | 设为 `1` 时,把客户端的思考开关(`reasoning_effort` / `reasoning` / `enable_thinking` / `thinking`,以及 Anthropic 的 `thinking:{type:"enabled"}`)透传到上游 thinking 变体,而不是出站剥除。仅在客户端会给出足够大 `max_tokens` 时开启——详见下方说明。 |
| `REGISTER_URL` | — (可选) | 中继自注册 `/ops/register` 地址。 |
| `REG_TOKEN` | — (可选) | 中继自注册鉴权密钥。 |

### 默认剥除思考开关

上游 thinking 变体会把推理阶段计入 `max_tokens`。预算偏小时推理吃满全部额度,流以
`finish_reason: length` 结束且**答案永不产出**(实测:硬 prompt 在 `max_tokens` 2048 与
8192 下分别产出 4.6k / 20.3k 字符推理、正文为零)。为避免客户端踩到该退化形态,出站请求
统一剥除思考开关。

该默认值安全但绝对:它同时把**给了足够预算**的调用方一并挡在门外,而这类调用方本来是能
正常思考的(`max_tokens: 60000` 实测推理与正文依次产出)。设 `CNB2API_ALLOW_THINKING=1`
即可显式开启,并由调用方自行保证预算。未携带思考开关的请求在两种设置下行为完全一致。

---

## 常见问题 (FAQ)
Expand Down
13 changes: 12 additions & 1 deletion src/anthropic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function neutralize(text) {
return t;
}

export function fromAnthropicRequest(body) {
export function fromAnthropicRequest(body, { allowThinking = false } = {}) {
if (!body || typeof body !== 'object') throw new ProtocolError('request body must be a JSON object');
if (!Array.isArray(body.messages) || body.messages.length === 0) {
throw new ProtocolError('messages: must be a non-empty array');
Expand Down Expand Up @@ -108,6 +108,17 @@ export function fromAnthropicRequest(body) {
const tc = convertToolChoice(body.tool_choice);
if (tc) out.tool_choice = tc;
}

// 思考开关(默认丢弃,见下):Anthropic 用 thinking:{type,budget_tokens} 表达,
// 上游 OpenAI 端点认 thinking:{type:enabled} + reasoning_effort。仅当部署显式开启
// CNB2API_ALLOW_THINKING=1 时映射透传;默认丢弃,理由与 proxy.mjs 出站剥除同一
// 教训——thinking 变体把推理计入 max_tokens,预算不足时 content 永不产出。
// budget_tokens 不映射:上游没有对应字段,强度用 reasoning_effort 表达。
if (allowThinking && body.thinking && typeof body.thinking === 'object'
&& body.thinking.type === 'enabled') {
out.thinking = { type: 'enabled' };
if (typeof body.thinking.effort === 'string') out.reasoning_effort = body.thinking.effort;
}
return out;
}

Expand Down
5 changes: 5 additions & 0 deletions src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export const config = {
idleTimeoutMs: Number(process.env.PROXY_IDLE_TIMEOUT_MS || 300_000), // per-stream idle cap (reset each chunk)
authFailWindowMs: 60_000,
authFailMax: 10,
// 放行客户端的思考开关(reasoning_effort / reasoning / enable_thinking /
// thinking)到上游 thinking 变体,而不是在出站时剥除。默认关闭:剥除是安全
// 默认值,理由见 src/proxy.mjs 的剥除说明。开启者必须自行保证 max_tokens
// 足够大——thinking 变体把推理阶段也计入该预算。
allowThinking: process.env.CNB2API_ALLOW_THINKING === '1',
upstreamUrl: '',
};

Expand Down
9 changes: 9 additions & 0 deletions src/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ import { observe as observeModel } from './models.mjs';
// content 全空(答案永不生成,真机实测 2048/8192 token 必现)。上游无正确开关可配,
// 出站统一剥除、锁定非 thinking 变体(与 /v1/messages 请求侧丢弃 thinking 块同一策略)。
// 表驱动:上游若引入新的思考开关命名,往这张表加一项即可。
//
// 逃生口:剥除是"安全默认值",不是"唯一正确策略"。它把客户端永久锁死在非 thinking
// 变体上,思考能力整体消失——2026-10-08 同步本提交后,某下游的 CNB 侧 reasoning 从
// 每天数百万字符直接归零,且试遍参数拼写(reasoningEffort / enableThinking /
// chat_template_kwargs / extra_body / thinking_budget)都无法再触达 thinking 变体。
// 会翻车的组合其实只是"思考开关 + 过小的 max_tokens";max_tokens 给够时推理与正文会
// 依次产出(2026-09-18/09-20 真实流量在 max_tokens=60000 下完全正常)。
// 因此:默认仍剥除,工作区显式设 CNB2API_ALLOW_THINKING=1 时放行,由客户端自负预算。
const REASONING_TRIGGERS = ['reasoning_effort', 'reasoning', 'enable_thinking', 'thinking'];

function stripReasoningTriggers(payload) {
if (config.allowThinking) return payload;
if (!REASONING_TRIGGERS.some((k) => k in payload)) return payload;
const out = { ...payload };
for (const k of REASONING_TRIGGERS) delete out[k];
Expand Down
2 changes: 1 addition & 1 deletion src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ const server = http.createServer(async (req, res) => {

let openaiPayload;
try {
openaiPayload = fromAnthropicRequest(anthropicBody);
openaiPayload = fromAnthropicRequest(anthropicBody, { allowThinking: config.allowThinking });
} catch (e) {
if (e instanceof ProtocolError) {
return send(res, e.status, { type: 'error', error: { type: 'invalid_request_error', message: e.message } });
Expand Down
33 changes: 33 additions & 0 deletions test/anthropic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,39 @@ test('请求转换:thinking 请求参数不泄漏出站(真机教训:触
assert.equal(out.messages.length, 1);
});

test('请求转换:显式开启 CNB2API_ALLOW_THINKING 后 thinking 映射透传(逃生口)', () => {
// 默认丢弃是安全默认值,但会把客户端永久锁死在非 thinking 变体上。部署显式
// opt-in 后按上游 OpenAI 端点认识的形状映射:thinking:{type:enabled} +
// reasoning_effort。budget_tokens 不映射(上游无对应字段)。
const out = fromAnthropicRequest({
model: 'claude-sonnet-4-5',
max_tokens: 60000,
thinking: { type: 'enabled', budget_tokens: 2048, effort: 'high' },
messages: [{ role: 'user', content: 'hi' }],
}, { allowThinking: true });
assert.deepEqual(out.thinking, { type: 'enabled' });
assert.equal(out.reasoning_effort, 'high');
// 其余字段照旧
assert.equal(out.max_tokens, 60000);
assert.equal(out.messages.length, 1);

// thinking:disabled 不该被映射成 enabled —— 客户端明确要关就必须关
const disabled = fromAnthropicRequest({
max_tokens: 60000,
thinking: { type: 'disabled' },
messages: [{ role: 'user', content: 'hi' }],
}, { allowThinking: true });
assert.equal(disabled.thinking, undefined);

// 没带 thinking 的请求不受开启影响(无开关可透传)
const plain = fromAnthropicRequest({
max_tokens: 60000,
messages: [{ role: 'user', content: 'hi' }],
}, { allowThinking: true });
assert.equal(plain.thinking, undefined);
assert.equal(plain.reasoning_effort, undefined);
});

// ---------------------------------------------------------------------------
// 非流式响应转换
// ---------------------------------------------------------------------------
Expand Down
42 changes: 42 additions & 0 deletions test/proxy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,45 @@ test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN
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');
});

// ---------------------------------------------------------------------------
// 逃生口:CNB2API_ALLOW_THINKING=1 时放行思考开关
// 独立起一个进程(配置在启动时读取),用独立端口避免与主实例冲突
// ---------------------------------------------------------------------------
test('CNB2API_ALLOW_THINKING=1 → 思考开关透传到上游(逃生口)', async () => {
const OPTIN_PORT = PROXY_PORT + 2;
const optIn = spawn(process.execPath, ['src/server.mjs'], {
cwd: new URL('..', import.meta.url).pathname,
env: {
...process.env, PROXY_PORT: String(OPTIN_PORT), PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo',
CNB_TOKEN: 'test-token', UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}`,
PROXY_MODELS_STATE: join(tmpdir(), `cnb2api-optin-${process.pid}.json`),
CNB2API_ALLOW_THINKING: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
try {
await waitPort(OPTIN_PORT);
const r = await fetch(`http://127.0.0.1:${OPTIN_PORT}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}` },
body: JSON.stringify({
model: 'mock-model',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
max_tokens: 60000,
reasoning_effort: 'high',
thinking: { type: 'enabled' },
}),
});
assert.equal(r.status, 200);
await r.text();
assert.equal(upstreamState.lastBody.reasoning_effort, 'high', 'opt-in must forward reasoning_effort');
assert.deepEqual(upstreamState.lastBody.thinking, { type: 'enabled' }, 'opt-in must forward thinking');
// 非思考字段仍原样透传
assert.equal(upstreamState.lastBody.model, 'mock-model');
assert.equal(upstreamState.lastBody.max_tokens, 60000);
} finally {
optIn.kill('SIGTERM');
}
});