From b471cbb94aa79b9f6fe63bdf9cf3f48050bf18ed Mon Sep 17 00:00:00 2001 From: Dimas Febriyanto Date: Tue, 20 Oct 2026 09:34:09 +0700 Subject: [PATCH] chore: reformat proxy.mjs --- proxy.mjs | 4125 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 2691 insertions(+), 1434 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index 8ec2def..d6ba71a 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -2,15 +2,15 @@ * Command Code → OpenAI 兼容代理 * 基于真实 CLI 流量抓包数据构建 */ -import http from 'http'; -import https from 'https'; -import tls from 'tls'; -import { Readable } from 'stream'; -import crypto from 'crypto'; -import { randomUUID } from 'crypto'; -import { readFileSync, existsSync, appendFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import http from "http"; +import https from "https"; +import tls from "tls"; +import { Readable } from "stream"; +import crypto from "crypto"; +import { randomUUID } from "crypto"; +import { readFileSync, existsSync, appendFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; // ── 配置加载 ────────────────────────────────────── const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -18,29 +18,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); function loadConfig() { const defaults = { port: 3000, - host: '0.0.0.0', - apiBase: 'https://api.commandcode.ai', - projectSlug: 'cc-proxy', - logFile: '', - logLevel: 'info', + host: "0.0.0.0", + apiBase: "https://api.commandcode.ai", + projectSlug: "cc-proxy", + logFile: "", + logLevel: "info", useProviderModels: true, - modelRefreshIntervalMs: 5 * 60 * 1000, // 5 minutes + modelRefreshIntervalMs: 5 * 60 * 1000, // 5 minutes zdr: false, - cliMode: 'agent', // 信封 mode。服务端枚举(真机 400 报出来的):agent|learning|custom-agent|custom-agent-create|title-gen|tool-desc|compact|vision - cliSessionMode: 'interactive', // lifecycle metadata 的 mode —— 注意这是另一个枚举:interactive | non-interactive - fingerprintSalt: '', - deviceProjectDir: '', // 伪造的项目目录(留空则用内置的 C:\Users\dev\projects\app) // 改这个值 = 让所有账号换一台设备(见设备指纹注释) + cliMode: "agent", // 信封 mode。服务端枚举(真机 400 报出来的):agent|learning|custom-agent|custom-agent-create|title-gen|tool-desc|compact|vision + cliSessionMode: "interactive", // lifecycle metadata 的 mode —— 注意这是另一个枚举:interactive | non-interactive + fingerprintSalt: "", + deviceProjectDir: "", // 伪造的项目目录(留空则用内置的 C:\Users\dev\projects\app) // 改这个值 = 让所有账号换一台设备(见设备指纹注释) emptySystemPlaceholder: true, // 无 system prompt 时发空格占位,阻止 CC 上游注入 ~7.5K token 默认提示词(issue #17) - upstreamProxy: '', // 上游 HTTP 代理,如 http://127.0.0.1:7890(issue #18) + upstreamProxy: "", // 上游 HTTP 代理,如 http://127.0.0.1:7890(issue #18) }; - const configPath = resolve(__dirname, 'config.json'); + const configPath = resolve(__dirname, "config.json"); if (existsSync(configPath)) { try { - const user = JSON.parse(readFileSync(configPath, 'utf-8')); + const user = JSON.parse(readFileSync(configPath, "utf-8")); Object.assign(defaults, user); } catch (e) { - console.error('[config] Failed to parse config.json:', e.message); + console.error("[config] Failed to parse config.json:", e.message); } } @@ -50,14 +50,22 @@ function loadConfig() { if (process.env.CC_API_BASE) defaults.apiBase = process.env.CC_API_BASE; if (process.env.PROJECT_SLUG) defaults.projectSlug = process.env.PROJECT_SLUG; if (process.env.LOG_FILE) defaults.logFile = process.env.LOG_FILE; - if (process.env.CC_USE_PROVIDER_MODELS) defaults.useProviderModels = process.env.CC_USE_PROVIDER_MODELS !== 'false'; - if (process.env.CMD_ZDR !== undefined) defaults.zdr = process.env.CMD_ZDR === '1'; - if (process.env.CC_FINGERPRINT_SALT !== undefined) defaults.fingerprintSalt = process.env.CC_FINGERPRINT_SALT; - if (process.env.CC_DEVICE_PROJECT_DIR) defaults.deviceProjectDir = process.env.CC_DEVICE_PROJECT_DIR; + if (process.env.CC_USE_PROVIDER_MODELS) + defaults.useProviderModels = process.env.CC_USE_PROVIDER_MODELS !== "false"; + if (process.env.CMD_ZDR !== undefined) + defaults.zdr = process.env.CMD_ZDR === "1"; + if (process.env.CC_FINGERPRINT_SALT !== undefined) + defaults.fingerprintSalt = process.env.CC_FINGERPRINT_SALT; + if (process.env.CC_DEVICE_PROJECT_DIR) + defaults.deviceProjectDir = process.env.CC_DEVICE_PROJECT_DIR; if (process.env.CC_CLI_MODE) defaults.cliMode = process.env.CC_CLI_MODE; - if (process.env.CC_CLI_SESSION_MODE) defaults.cliSessionMode = process.env.CC_CLI_SESSION_MODE; - if (process.env.CC_EMPTY_SYSTEM_PLACEHOLDER) defaults.emptySystemPlaceholder = process.env.CC_EMPTY_SYSTEM_PLACEHOLDER !== 'false'; - if (process.env.CC_UPSTREAM_PROXY) defaults.upstreamProxy = process.env.CC_UPSTREAM_PROXY; + if (process.env.CC_CLI_SESSION_MODE) + defaults.cliSessionMode = process.env.CC_CLI_SESSION_MODE; + if (process.env.CC_EMPTY_SYSTEM_PLACEHOLDER) + defaults.emptySystemPlaceholder = + process.env.CC_EMPTY_SYSTEM_PLACEHOLDER !== "false"; + if (process.env.CC_UPSTREAM_PROXY) + defaults.upstreamProxy = process.env.CC_UPSTREAM_PROXY; return defaults; } @@ -73,28 +81,39 @@ const CFG = loadConfig(); // 而原表 15 项全是物理核数 —— 100% 的指纹都落在这个罕见表述上,是群体分布层面的特征。 // (实现参考 @jinyu2022 的 PR #35,数字逐项复核过。) const FINGERPRINT_CPUS = [ - { model: '12th Gen Intel(R) Core(TM) i7-12650H', cores: 10, threads: 16 }, // 6P+4E - { model: '12th Gen Intel(R) Core(TM) i5-12400F', cores: 6, threads: 12 }, - { model: '12th Gen Intel(R) Core(TM) i9-12900K', cores: 16, threads: 24 }, // 8P+8E - { model: '13th Gen Intel(R) Core(TM) i7-13700K', cores: 16, threads: 24 }, // 8P+8E - { model: '13th Gen Intel(R) Core(TM) i5-13600K', cores: 14, threads: 20 }, // 6P+8E - { model: '13th Gen Intel(R) Core(TM) i9-13900K', cores: 24, threads: 32 }, // 8P+16E - { model: 'Intel(R) Core(TM) Ultra 7 155H', cores: 16, threads: 22 }, // 6P+8E+2LPE(Meteor Lake 有超线程) - { model: 'Intel(R) Core(TM) Ultra 9 285H', cores: 16, threads: 16 }, // 6P+8E+2LPE(Arrow Lake 取消超线程) - { model: 'Intel(R) Core(TM) i9-14900K', cores: 24, threads: 32 }, // 8P+16E - { model: 'Intel(R) Core(TM) i7-14700K', cores: 20, threads: 28 }, // 8P+12E - { model: 'AMD Ryzen 7 7800X3D', cores: 8, threads: 16 }, - { model: 'AMD Ryzen 9 7950X', cores: 16, threads: 32 }, - { model: 'AMD Ryzen 5 7600', cores: 6, threads: 12 }, - { model: 'AMD Ryzen 9 7900X', cores: 12, threads: 24 }, - { model: 'AMD Ryzen 7 5800X3D', cores: 8, threads: 16 }, + { model: "12th Gen Intel(R) Core(TM) i7-12650H", cores: 10, threads: 16 }, // 6P+4E + { model: "12th Gen Intel(R) Core(TM) i5-12400F", cores: 6, threads: 12 }, + { model: "12th Gen Intel(R) Core(TM) i9-12900K", cores: 16, threads: 24 }, // 8P+8E + { model: "13th Gen Intel(R) Core(TM) i7-13700K", cores: 16, threads: 24 }, // 8P+8E + { model: "13th Gen Intel(R) Core(TM) i5-13600K", cores: 14, threads: 20 }, // 6P+8E + { model: "13th Gen Intel(R) Core(TM) i9-13900K", cores: 24, threads: 32 }, // 8P+16E + { model: "Intel(R) Core(TM) Ultra 7 155H", cores: 16, threads: 22 }, // 6P+8E+2LPE(Meteor Lake 有超线程) + { model: "Intel(R) Core(TM) Ultra 9 285H", cores: 16, threads: 16 }, // 6P+8E+2LPE(Arrow Lake 取消超线程) + { model: "Intel(R) Core(TM) i9-14900K", cores: 24, threads: 32 }, // 8P+16E + { model: "Intel(R) Core(TM) i7-14700K", cores: 20, threads: 28 }, // 8P+12E + { model: "AMD Ryzen 7 7800X3D", cores: 8, threads: 16 }, + { model: "AMD Ryzen 9 7950X", cores: 16, threads: 32 }, + { model: "AMD Ryzen 5 7600", cores: 6, threads: 12 }, + { model: "AMD Ryzen 9 7900X", cores: 12, threads: 24 }, + { model: "AMD Ryzen 7 5800X3D", cores: 8, threads: 16 }, ]; const FINGERPRINT_MEMS = [8, 16, 24, 32, 48, 64]; const FINGERPRINT_TZS = [ - 'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'America/Toronto', - 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Moscow', - 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Singapore', 'Asia/Seoul', 'Asia/Hong_Kong', - 'Australia/Sydney', 'Pacific/Auckland', + "America/New_York", + "America/Chicago", + "America/Los_Angeles", + "America/Toronto", + "Europe/London", + "Europe/Berlin", + "Europe/Paris", + "Europe/Moscow", + "Asia/Shanghai", + "Asia/Tokyo", + "Asia/Singapore", + "Asia/Seoul", + "Asia/Hong_Kong", + "Australia/Sydney", + "Pacific/Auckland", ]; const FINGERPRINT_MAC_COUNT_RANGE = [2, 3, 4, 5]; // 随机 2~5 个 MAC @@ -111,25 +130,26 @@ const FINGERPRINT_MAC_COUNT_RANGE = [2, 3, 4, 5]; // 随机 2~5 个 MAC // 原始信号值(MachineGuid / MAC / 主机名 / git 邮箱)只存在于本进程内存, // 出网的永远只有它们的哈希。 // CLI 的根盐(buildMachineFingerprint 常量 sb) -const FP_SALT = 'command-code:device-fingerprint:v1'; +const FP_SALT = "command-code:device-fingerprint:v1"; // 设备档案:指纹 / config.environment / config.workingDir / x-project-slug / lifecycle.os 共用同一份, // 避免出现「指纹说 win32、环境说 linux」这类自相矛盾,也避免把宿主机真实信息(平台、Node 版本、cwd)交给上游。 const DEVICE_PROFILE = { - platform: 'win32', - arch: 'x64', - osRelease: '10.0.22631', + platform: "win32", + arch: "x64", + osRelease: "10.0.22631", isContainer: false, // 伪造的项目目录:与 x-project-slug 同源(真机里 slug = slugify(workingDir)) - projectDir: CFG.deviceProjectDir || 'C:\\Users\\dev\\projects\\app', + projectDir: CFG.deviceProjectDir || "C:\\Users\\dev\\projects\\app", }; -const FP_OS_USERS = ['dev', 'user', 'admin', 'coder', 'engineer', 'work']; -const FP_MAIL_DOMAINS = ['gmail.com', 'outlook.com', 'qq.com', '163.com']; +const FP_OS_USERS = ["dev", "user", "admin", "coder", "engineer", "work"]; +const FP_MAIL_DOMAINS = ["gmail.com", "outlook.com", "qq.com", "163.com"]; // 伪造信号的派生源。加 CC_FINGERPRINT_SALT 可成批换身份 —— 真实账号的 key 动不了,这是逃生口。 // 注意:哈希阶段用的是 CLI 的固定盐(FP_SALT),salt 只影响「伪造出哪台机器」。 function fpDigest(apiKey, field) { - return crypto.createHash('sha256') - .update(`${CFG.fingerprintSalt || ''}\0${apiKey}\0${field}`) + return crypto + .createHash("sha256") + .update(`${CFG.fingerprintSalt || ""}\0${apiKey}\0${field}`) .digest(); } // 从候选池确定性地挑一项:打分取最大。以后往池里加候选只影响「新候选恰好胜出」的那部分 key, @@ -139,15 +159,21 @@ function fpPickIndex(apiKey, field, items, labelOf) { let bestScore = null; for (let i = 0; i < items.length; i++) { const score = fpDigest(apiKey, `${field}\0${labelOf(i)}`); - if (!bestScore || Buffer.compare(score, bestScore) > 0) { bestScore = score; bestIdx = i; } + if (!bestScore || Buffer.compare(score, bestScore) > 0) { + bestScore = score; + bestIdx = i; + } } return bestIdx; } // CLI 的 hashSignal:sha256(FP_SALT + "\0" + value.toLowerCase()),空值返回 undefined(JSON 里被丢掉) function fingerprintHash(value) { - const v = String(value ?? '').trim(); + const v = String(value ?? "").trim(); if (!v) return undefined; - return crypto.createHash('sha256').update(`${FP_SALT}\0${v.toLowerCase()}`).digest('hex'); + return crypto + .createHash("sha256") + .update(`${FP_SALT}\0${v.toLowerCase()}`) + .digest("hex"); } // 与 CLI 的唯一区别是「信号值」:CLI 读真实机器(注册表 / ioreg / machine-id、网卡 MAC、 @@ -155,24 +181,62 @@ function fingerprintHash(value) { // 为什么必须由 apiKey 派生而不是随机:指纹代表「这个账号对应的那台设备」,重启、内存回收、 // 多实例、月额度用尽停用数周后恢复,上游都应看到同一台设备;换指纹本身就是可疑信号。 function generateFingerprint(apiKey) { - const cpuEntry = FINGERPRINT_CPUS[fpPickIndex(apiKey, 'cpu', FINGERPRINT_CPUS, i => `${FINGERPRINT_CPUS[i].model}|${FINGERPRINT_CPUS[i].cores}`)]; - const memGiB = FINGERPRINT_MEMS[fpPickIndex(apiKey, 'mem', FINGERPRINT_MEMS, i => String(FINGERPRINT_MEMS[i]))]; - const tz = FINGERPRINT_TZS[fpPickIndex(apiKey, 'timezone', FINGERPRINT_TZS, i => FINGERPRINT_TZS[i])]; - const macCount = FINGERPRINT_MAC_COUNT_RANGE[fpPickIndex(apiKey, 'macCount', FINGERPRINT_MAC_COUNT_RANGE, i => String(FINGERPRINT_MAC_COUNT_RANGE[i]))]; - const osUser = FP_OS_USERS[fpPickIndex(apiKey, 'osUser', FP_OS_USERS, i => FP_OS_USERS[i])]; - const mailDomain = FP_MAIL_DOMAINS[fpPickIndex(apiKey, 'mailDomain', FP_MAIL_DOMAINS, i => FP_MAIL_DOMAINS[i])]; - const hex = (field, bytes) => fpDigest(apiKey, field).subarray(0, bytes).toString('hex'); + const cpuEntry = + FINGERPRINT_CPUS[ + fpPickIndex( + apiKey, + "cpu", + FINGERPRINT_CPUS, + (i) => `${FINGERPRINT_CPUS[i].model}|${FINGERPRINT_CPUS[i].cores}`, + ) + ]; + const memGiB = + FINGERPRINT_MEMS[ + fpPickIndex(apiKey, "mem", FINGERPRINT_MEMS, (i) => + String(FINGERPRINT_MEMS[i]), + ) + ]; + const tz = + FINGERPRINT_TZS[ + fpPickIndex( + apiKey, + "timezone", + FINGERPRINT_TZS, + (i) => FINGERPRINT_TZS[i], + ) + ]; + const macCount = + FINGERPRINT_MAC_COUNT_RANGE[ + fpPickIndex(apiKey, "macCount", FINGERPRINT_MAC_COUNT_RANGE, (i) => + String(FINGERPRINT_MAC_COUNT_RANGE[i]), + ) + ]; + const osUser = + FP_OS_USERS[ + fpPickIndex(apiKey, "osUser", FP_OS_USERS, (i) => FP_OS_USERS[i]) + ]; + const mailDomain = + FP_MAIL_DOMAINS[ + fpPickIndex( + apiKey, + "mailDomain", + FP_MAIL_DOMAINS, + (i) => FP_MAIL_DOMAINS[i], + ) + ]; + const hex = (field, bytes) => + fpDigest(apiKey, field).subarray(0, bytes).toString("hex"); // Windows MachineGuid 形状:8-4-4-4-12 - const mid = hex('machineId', 16); + const mid = hex("machineId", 16); const machineId = `${mid.slice(0, 8)}-${mid.slice(8, 12)}-${mid.slice(12, 16)}-${mid.slice(16, 20)}-${mid.slice(20, 32)}`; const macs = []; for (let i = 0; i < macCount; i++) { const b = fpDigest(apiKey, `mac${i}`).subarray(0, 6); - macs.push([...b].map(x => x.toString(16).padStart(2, '0')).join(':')); + macs.push([...b].map((x) => x.toString(16).padStart(2, "0")).join(":")); } macs.sort(); // CLI 对 MAC 去重后排序 - const hostname = `DESKTOP-${hex('hostname', 4).toUpperCase()}`; - const gitEmail = `${osUser}.${hex('gitEmail', 3)}@${mailDomain}`; + const hostname = `DESKTOP-${hex("hostname", 4).toUpperCase()}`; + const gitEmail = `${osUser}.${hex("gitEmail", 3)}@${mailDomain}`; const machineIdHash = fingerprintHash(machineId); const macHashes = macs.map(fingerprintHash).filter(Boolean); @@ -182,8 +246,16 @@ function generateFingerprint(apiKey) { // CLI 的 thumbmark:主盐 + "\0machine\0" + join([machineId, macs.join(",")]) // (machineId 非空时不再拼 hostname/cpuModel) - const thumbSeed = [machineId.trim(), macs.join(','), machineId.trim() ? '' : hostname, machineId.trim() ? '' : cpuEntry.model].filter(Boolean); - const thumbmark = crypto.createHash('sha256').update(`${FP_SALT}\0machine\0${thumbSeed.join('|') || 'unknown'}`).digest('hex'); + const thumbSeed = [ + machineId.trim(), + macs.join(","), + machineId.trim() ? "" : hostname, + machineId.trim() ? "" : cpuEntry.model, + ].filter(Boolean); + const thumbmark = crypto + .createHash("sha256") + .update(`${FP_SALT}\0machine\0${thumbSeed.join("|") || "unknown"}`) + .digest("hex"); return { thumbmark, @@ -197,11 +269,11 @@ function generateFingerprint(apiKey) { arch: DEVICE_PROFILE.arch, osRelease: DEVICE_PROFILE.osRelease, cpuModel: cpuEntry.model, - cpuCount: cpuEntry.threads, // 逻辑处理器数,对齐 CLI 的 os.cpus().length + cpuCount: cpuEntry.threads, // 逻辑处理器数,对齐 CLI 的 os.cpus().length memGiB, isContainer: DEVICE_PROFILE.isContainer, timezone: tz, - runtime: 'cli', + runtime: "cli", collectorVersion: 1, }, }; @@ -211,7 +283,7 @@ function generateFingerprint(apiKey) { // 真机发的永远是「形状 + 版本号」自洽的组合;如果版本号跟着 npm 走而形状没变, // 就变成「自称最新版、却说旧方言」—— 这比版本号过期更容易被行为分析挑出来。 // 因此这里报的是协议版本,npm 上更新了只告警、不自动改。 -const CC_PROTOCOL_VERSION = '1.53.1'; +const CC_PROTOCOL_VERSION = "1.84.0"; let CC_VERSION = CC_PROTOCOL_VERSION; const CC_VERSION_REFRESH_MS = 24 * 60 * 60 * 1000; // 24h — 检查一次是否发生漂移 @@ -220,20 +292,25 @@ const CC_VERSION_REFRESH_MS = 24 * 60 * 60 * 1000; // 24h — 检查一次是否 // 绝不会把 x-command-code-version 改成一个我们并未实现的版本。 async function checkProtocolDrift() { try { - const url = 'https://registry.npmjs.org/command-code/latest'; + const url = "https://registry.npmjs.org/command-code/latest"; const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!res.ok) throw new Error(`npm responded with ${res.status}`); const pkg = await res.json(); - const latest = typeof pkg?.version === 'string' ? pkg.version : null; + const latest = typeof pkg?.version === "string" ? pkg.version : null; if (latest && latest !== CC_PROTOCOL_VERSION) { - log('warn', 'CC CLI version drift: protocol may have changed, re-align from the npm package', { - implemented: CC_PROTOCOL_VERSION, latest, - }); + log( + "warn", + "CC CLI version drift: protocol may have changed, re-align from the npm package", + { + implemented: CC_PROTOCOL_VERSION, + latest, + }, + ); } else if (latest) { - log('info', 'CC CLI version in sync', { version: latest }); + log("info", "CC CLI version in sync", { version: latest }); } } catch (e) { - log('warn', 'CC version check failed', { error: e.message }); + log("warn", "CC version check failed", { error: e.message }); } } checkProtocolDrift(); // 启动时立即检查 @@ -253,7 +330,7 @@ setInterval(checkProtocolDrift, CC_VERSION_REFRESH_MS); // 进程内可用 CC_MAX_INFLIGHT,边缘侧用反向代理 limit_conn // (见 README「内存与部署」)。 const MAX_BODY_SIZE = (() => { - const mb = Number.parseInt(process.env.CC_MAX_BODY_MB ?? '', 10); + const mb = Number.parseInt(process.env.CC_MAX_BODY_MB ?? "", 10); return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024; })(); // 上游读空闲超时(issue #19):只计「reader.read() 的等待」,每收到一个 chunk 重置, @@ -262,12 +339,12 @@ const MAX_BODY_SIZE = (() => { // createApiClient 调用点均未传 timeout),合法的长思考停顿可达数百秒, // 遇到推理模型被 30s 误杀 / 触发 429 重试放大时,调大这两个值即可。 const STREAM_IDLE_TIMEOUT_MS = (() => { - const ms = Number.parseInt(process.env.CC_STREAM_IDLE_MS ?? '', 10); - return Number.isFinite(ms) && ms > 0 ? ms : 30000; // 默认 30s — 流式无新数据中断 + const ms = Number.parseInt(process.env.CC_STREAM_IDLE_MS ?? "", 10); + return Number.isFinite(ms) && ms > 0 ? ms : 30000; // 默认 30s — 流式无新数据中断 })(); const NONSTREAM_IDLE_TIMEOUT_MS = (() => { - const ms = Number.parseInt(process.env.CC_NONSTREAM_IDLE_MS ?? '', 10); - return Number.isFinite(ms) && ms > 0 ? ms : 90000; // 默认 90s — 非流式超时更宽容 + const ms = Number.parseInt(process.env.CC_NONSTREAM_IDLE_MS ?? "", 10); + return Number.isFinite(ms) && ms > 0 ? ms : 90000; // 默认 90s — 非流式超时更宽容 })(); // ── 上游闪断透明重试(未吐字前 bounded retry)───────── @@ -278,21 +355,25 @@ const NONSTREAM_IDLE_TIMEOUT_MS = (() => { // 只在「未吐字」时重试:一旦写过头或输出过事件,语义就已提交,重试会造成重复文本。 // 默认 2 = 最多重试 2 次(共 3 次尝试);CC_UPSTREAM_RETRY_MAX=0 可整体关闭。 const UPSTREAM_RETRY_MAX = (() => { - const n = Number.parseInt(process.env.CC_UPSTREAM_RETRY_MAX ?? '', 10); + const n = Number.parseInt(process.env.CC_UPSTREAM_RETRY_MAX ?? "", 10); return Number.isFinite(n) && n >= 0 ? n : 2; })(); const UPSTREAM_RETRY_BASE_MS = (() => { - const ms = Number.parseInt(process.env.CC_UPSTREAM_RETRY_BASE_MS ?? '', 10); - return Number.isFinite(ms) && ms > 0 ? ms : 400; // 退避 = base × 尝试序号 + const ms = Number.parseInt(process.env.CC_UPSTREAM_RETRY_BASE_MS ?? "", 10); + return Number.isFinite(ms) && ms > 0 ? ms : 400; // 退避 = base × 尝试序号 })(); // 区分「传输层闪断」(可安全重试)与「语义错误」(不可重试)。 // STREAM_IDLE_TIMEOUT 是刻意发给下游的「请减少上下文」信号,绝不重试。 function isRetryableUpstreamError(e) { if (!e) return false; - const blob = [e.message, e.code, e.cause?.message, e.cause?.code].filter(Boolean).join(' | '); + const blob = [e.message, e.code, e.cause?.message, e.cause?.code] + .filter(Boolean) + .join(" | "); if (/STREAM_IDLE_TIMEOUT/.test(blob)) return false; - return /terminated|ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|UND_ERR_SOCKET|socket hang up|other side closed|fetch failed/i.test(blob); + return /terminated|ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|UND_ERR_SOCKET|socket hang up|other side closed|fetch failed/i.test( + blob, + ); } // 仅用于日志:rewinds = 实际重试次数,recovered = 重试后成功交付的次数 @@ -313,14 +394,14 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // 注意:body 上限只管住单请求量级,乘数由本项封顶。默认 body 上限 100MB 时, // N × 最坏 550MB —— 要硬性内存上界需同时下调 CC_MAX_BODY_MB。 const MAX_INFLIGHT = (() => { - const n = Number.parseInt(process.env.CC_MAX_INFLIGHT ?? '', 10); - return Number.isFinite(n) && n > 0 ? n : 0; // 默认 0 = 不限 + const n = Number.parseInt(process.env.CC_MAX_INFLIGHT ?? "", 10); + return Number.isFinite(n) && n > 0 ? n : 0; // 默认 0 = 不限 })(); -let inflightCount = 0; // 当前在途请求数(不含 /health) +let inflightCount = 0; // 当前在途请求数(不含 /health) const CLIENT_DRAIN_TIMEOUT_MS = (() => { - const ms = Number.parseInt(process.env.CC_CLIENT_DRAIN_TIMEOUT_MS ?? '', 10); + const ms = Number.parseInt(process.env.CC_CLIENT_DRAIN_TIMEOUT_MS ?? "", 10); return Number.isFinite(ms) && ms > 0 ? ms : 0; })(); @@ -330,10 +411,12 @@ const TIMEOUT_REDUCE_CONTEXT_THRESHOLD = 3; // ── 日志 ───────────────────────────────────────────── function log(level, msg, data) { - const line = `[${new Date().toISOString()}] [${level}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`; + const line = `[${new Date().toISOString()}] [${level}] ${msg}${data ? " " + JSON.stringify(data) : ""}`; console.log(line); if (CFG.logFile) { - try { appendFileSync(CFG.logFile, line + '\n', 'utf-8'); } catch {} + try { + appendFileSync(CFG.logFile, line + "\n", "utf-8"); + } catch {} } } @@ -341,9 +424,11 @@ function log(level, msg, data) { // 之前 CC API error 只记 status,不记 body —— 遇到 400 只能靠猜(问题来源见 hk_sji 排查)。 // 截断到 500 字符,避免异常大的 body 刷爆日志;同时压掉换行,保证一条日志一行。 function summarizeUpstreamError(text, limit = 500) { - if (!text) return ''; - const flat = String(text).replace(/\s+/g, ' ').trim(); - return flat.length > limit ? flat.slice(0, limit) + '…(' + (flat.length - limit) + ' more)' : flat; + if (!text) return ""; + const flat = String(text).replace(/\s+/g, " ").trim(); + return flat.length > limit + ? flat.slice(0, limit) + "…(" + (flat.length - limit) + " more)" + : flat; } /** @@ -355,38 +440,39 @@ function summarizeUpstreamError(text, limit = 500) { function deriveSessionId(apiKey, ccBody) { const params = (ccBody && ccBody.params) || {}; // 逐块喂入,'\0'分隔 - const h = crypto.createHash('sha1'); - h.update(apiKey || '').update('\0'); - h.update(params.model || '').update('\0'); + const h = crypto.createHash("sha1"); + h.update(apiKey || "").update("\0"); + h.update(params.model || "").update("\0"); if (Array.isArray(params.system)) { for (const b of params.system || []) { - if (b && typeof b.text === 'string') h.update(b.text).update('\0'); + if (b && typeof b.text === "string") h.update(b.text).update("\0"); } } for (const msg of params.messages || []) { - if (msg.role !== 'user') break; // assistant / tool 起始后的输入不参与派生 + if (msg.role !== "user") break; // assistant / tool 起始后的输入不参与派生 if (!Array.isArray(msg.content)) continue; for (const p of msg.content) { - if (p.type === 'text' && typeof p.text === 'string') h.update(p.text).update('\0'); + if (p.type === "text" && typeof p.text === "string") + h.update(p.text).update("\0"); } } const bytes = h.digest().subarray(0, 16); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = bytes.toString('hex'); + const hex = bytes.toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } function getSessionId(incomingHeaders, apiKey, promptCacheKey, ccBody) { // 优先从客户端传来的 session 类 header 获取 const candidates = [ - incomingHeaders['x-session-id'], - incomingHeaders['x-claude-code-session-id'], - incomingHeaders['session_id'], + incomingHeaders["x-session-id"], + incomingHeaders["x-claude-code-session-id"], + incomingHeaders["session_id"], promptCacheKey, ]; for (const id of candidates) { - if (id && typeof id === 'string' && id.length >= 8) return id; + if (id && typeof id === "string" && id.length >= 8) return id; } // 客户端未提供会话标识时按请求内容派生 return deriveSessionId(apiKey, ccBody); @@ -396,8 +482,10 @@ function getSessionId(incomingHeaders, apiKey, promptCacheKey, ccBody) { // uuid.safeParse 失败即返回 undefined,该键随之被 JSON.stringify 丢弃。 // 非 UUID 的 session 值一律不发,避免上游看到真 CLI 永远不会产生的 threadId。 function isWireUuid(v) { - return typeof v === 'string' - && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); + return ( + typeof v === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) + ); } // ── 每 Key 独立状态(fingerprint + 初始化节流) ── @@ -416,7 +504,7 @@ function getOrCreateKeyState(apiKey) { lastSeen: 0, }; keyStateStore.set(apiKey, state); - log('info', 'Fingerprint derived for key', { + log("info", "Fingerprint derived for key", { keyPrefix: apiKey.slice(0, 8), thumbmark: state.fingerprint.thumbmark.slice(0, 12), }); @@ -426,18 +514,28 @@ function getOrCreateKeyState(apiKey) { } // 空闲淘汰:纯内存卫生,不影响设备身份(见上) -setInterval(() => { - const now = Date.now(); - let evicted = 0; - for (const [key, state] of keyStateStore) { - if (now - state.lastSeen > KEY_STATE_IDLE_MS) { keyStateStore.delete(key); evicted++; } - } - if (evicted > 0) log('info', 'Key state evicted (idle)', { evicted, remaining: keyStateStore.size }); -}, 60 * 60 * 1000); // 每小时 +setInterval( + () => { + const now = Date.now(); + let evicted = 0; + for (const [key, state] of keyStateStore) { + if (now - state.lastSeen > KEY_STATE_IDLE_MS) { + keyStateStore.delete(key); + evicted++; + } + } + if (evicted > 0) + log("info", "Key state evicted (idle)", { + evicted, + remaining: keyStateStore.size, + }); + }, + 60 * 60 * 1000, +); // 每小时 // ── 初始化预请求(fingerprint + lifecycle,首次 + 每 8h+2h 抖动) ──── -const INIT_REFRESH_MS = 8 * 60 * 60 * 1000; // 8h -const INIT_JITTER_MS = 2 * 60 * 60 * 1000; // 2h 抖动 +const INIT_REFRESH_MS = 8 * 60 * 60 * 1000; // 8h +const INIT_JITTER_MS = 2 * 60 * 60 * 1000; // 2h 抖动 async function ensureInitialized(apiKey, signal) { const state = getOrCreateKeyState(apiKey); @@ -447,96 +545,127 @@ async function ensureInitialized(apiKey, signal) { try { // 并行发两个预请求 const headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", // 官方 CLI 的指纹预请求与生成请求共用同一个 header 常量表(lb = vy = "cli"), // 因此预请求同样是 User-Agent: cli。此前只有 forwardToCC 设了它, // 预请求走 Node 默认的 "node" —— 同一账号的指纹注册与生成请求来自两种 UA, // 是可直接观测的破绽。 - 'User-Agent': 'cli', - 'x-cli-environment': 'production', - 'Authorization': `Bearer ${apiKey}`, - 'x-command-code-version': CC_VERSION, - ...(CFG.zdr ? { 'x-cmd-zdr': '1' } : {}), + "User-Agent": "cli", + "x-cli-environment": "production", + Authorization: `Bearer ${apiKey}`, + "x-command-code-version": CC_VERSION, + ...(CFG.zdr ? { "x-cmd-zdr": "1" } : {}), }; const fingerprint = state.fingerprint || {}; await Promise.all([ upstreamFetch(`${CFG.apiBase}/alpha/fingerprint/record`, { - method: 'POST', headers, signal, + method: "POST", + headers, + signal, body: JSON.stringify(fingerprint), - }).then(r => { - if (!r.ok) log('warn', 'Fingerprint record failed', { status: r.status }); - else log('info', 'Fingerprint recorded'); - }).catch(e => { - if (e.name !== 'AbortError') log('warn', 'Fingerprint record error', { error: e.message }); - }), + }) + .then((r) => { + if (!r.ok) + log("warn", "Fingerprint record failed", { status: r.status }); + else log("info", "Fingerprint recorded"); + }) + .catch((e) => { + if (e.name !== "AbortError") + log("warn", "Fingerprint record error", { error: e.message }); + }), upstreamFetch(`${CFG.apiBase}/alpha/lifecycle-events`, { - method: 'POST', headers, signal, + method: "POST", + headers, + signal, body: JSON.stringify({ - eventType: 'cli_session_exists', + eventType: "cli_session_exists", metadata: { - sessionId: `sess_${crypto.randomBytes(8).toString('hex')}`, + sessionId: `sess_${crypto.randomBytes(8).toString("hex")}`, cliVersion: CC_VERSION, - mode: CFG.cliSessionMode || 'interactive', + mode: CFG.cliSessionMode || "interactive", os: `${fingerprint.components.platform}-${fingerprint.components.arch}`, }, }), - }).then(r => { - if (!r.ok) log('warn', 'Lifecycle event failed', { status: r.status }); - else log('info', 'Lifecycle event sent'); - }).catch(e => { - if (e.name !== 'AbortError') log('warn', 'Lifecycle event error', { error: e.message }); - }), + }) + .then((r) => { + if (!r.ok) + log("warn", "Lifecycle event failed", { status: r.status }); + else log("info", "Lifecycle event sent"); + }) + .catch((e) => { + if (e.name !== "AbortError") + log("warn", "Lifecycle event error", { error: e.message }); + }), ]); // 成功:8h + 2h 随机抖动 const jitter = Math.floor(Math.random() * INIT_JITTER_MS); state.nextInitAt = Date.now() + INIT_REFRESH_MS + jitter; - log('info', 'Fingerprint/lifecycle next refresh', { nextIn: `${(INIT_REFRESH_MS + jitter) / 3600000}h` }); + log("info", "Fingerprint/lifecycle next refresh", { + nextIn: `${(INIT_REFRESH_MS + jitter) / 3600000}h`, + }); } catch (e) { - if (e.name !== 'AbortError') log('warn', 'Fingerprint/lifecycle refresh error, will retry next request', { error: e.message }); + if (e.name !== "AbortError") + log( + "warn", + "Fingerprint/lifecycle refresh error, will retry next request", + { error: e.message }, + ); } } // ── 模型列表 ─────────────────────────────────────── const MODELS = [ // Anthropic - { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }, - { id: 'claude-opus-4-8', name: 'Claude Opus 4.8' }, - { id: 'claude-opus-4-7', name: 'Claude Opus 4.7' }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" }, // OpenAI - { id: 'gpt-5.5', name: 'GPT-5.5' }, - { id: 'gpt-5.4', name: 'GPT-5.4' }, - { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' }, - { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' }, + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, // DeepSeek - { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro' }, - { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash' }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, // Kimi - { id: 'moonshotai/Kimi-K2.6', name: 'Kimi K2.6' }, - { id: 'moonshotai/Kimi-K2.5', name: 'Kimi K2.5' }, + { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" }, + { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, // GLM - { id: 'zai-org/GLM-5.1', name: 'GLM 5.1' }, - { id: 'zai-org/GLM-5', name: 'GLM 5' }, + { id: "zai-org/GLM-5.1", name: "GLM 5.1" }, + { id: "zai-org/GLM-5", name: "GLM 5" }, // MiniMax - { id: 'MiniMaxAI/MiniMax-M3', name: 'MiniMax M3' }, - { id: 'MiniMaxAI/MiniMax-M2.7', name: 'MiniMax M2.7' }, - { id: 'MiniMaxAI/MiniMax-M2.5', name: 'MiniMax M2.5' }, + { id: "MiniMaxAI/MiniMax-M3", name: "MiniMax M3" }, + { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" }, + { id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }, // Qwen - { id: 'Qwen/Qwen3.6-Max-Preview', name: 'Qwen 3.6 Max Preview' }, - { id: 'Qwen/Qwen3.6-Plus', name: 'Qwen 3.6 Plus' }, - { id: 'Qwen/Qwen3.7-Max', name: 'Qwen 3.7 Max' }, + { id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview" }, + { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }, + { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max" }, // Step - { id: 'stepfun/Step-3.7-Flash', name: 'Step 3.7 Flash' }, - { id: 'stepfun/Step-3.5-Flash', name: 'Step 3.5 Flash' }, + { id: "stepfun/Step-3.7-Flash", name: "Step 3.7 Flash" }, + { id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" }, // Xiaomi - { id: 'xiaomi/mimo-v2.5-pro', name: 'MiMo V2.5 Pro' }, - { id: 'xiaomi/mimo-v2.5', name: 'MiMo V2.5' }, + { id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, + { id: "xiaomi/mimo-v2.5", name: "MiMo V2.5" }, // Gemini - { id: 'google/gemini-3.5-flash', name: 'Gemini 3.5 Flash' }, - { id: 'google/gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite' }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "google/gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite" }, + // Free (from /provider/v1/models) + { id: "minimax/minimax-m3.1-flash:free", name: "MiniMax M3.1 Flash" }, + { id: "stealth/glyph-cluster:free", name: "Glyph Cluster" }, + { id: "reflection/beam:free", name: "Beam 1.0" }, + { id: "poolside/laguna-s-2.1-free", name: "Laguna S 2.1" }, + // Meta Muse Spark + { id: "meta/muse-spark-1.1", name: "Muse Spark 1.1" }, + { id: "meta/muse-spark-1.2", name: "Muse Spark 1.2" }, + { id: "meta/muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor" }, + { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3" }, + { id: "meta/muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor" }, + { id: "meta/muse-spark-open", name: "Muse Spark Open" }, ]; // ── 工具函数 ─────────────────────────────────────── @@ -544,16 +673,16 @@ const MODELS = [ // CLI 的 slug 规则:对**完整工作目录**做 slugify(@sindresorhus/slugify),空则 "root",无随机后缀; // 同一个 slug 也是 CLI 本地会话目录名。所以 slug 与 config.workingDir 同源:slug = slugify(workingDir)。 function slugifyProjectPath(p) { - const s = String(p || '') + const s = String(p || "") .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return s || 'root'; + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return s || "root"; } function generateTraceparent() { - const traceId = crypto.randomBytes(16).toString('hex'); - const parentId = crypto.randomBytes(8).toString('hex'); + const traceId = crypto.randomBytes(16).toString("hex"); + const parentId = crypto.randomBytes(8).toString("hex"); return `00-${traceId}-${parentId}-01`; } @@ -565,7 +694,6 @@ function getDateStr() { return new Date().toISOString().slice(0, 10); } - // ── CC 请求体构建 ───────────────────────────────── /** @@ -642,123 +770,169 @@ function getDateStr() { * @returns {CcRequestBody} */ function buildCcRequest(openaiReq) { - const { model, messages, max_tokens, temperature, tools, stream, reasoning_effort, tool_choice, parallel_tool_calls, prompt_cache_key } = openaiReq; + const { + model, + messages, + max_tokens, + temperature, + tools, + stream, + reasoning_effort, + tool_choice, + parallel_tool_calls, + prompt_cache_key, + } = openaiReq; // 提取系统提示:OpenAI 的 system / developer 都映射为系统提示。 // 形态对齐 CLI 的 toWireSystem —— **块数组**,非最后一块补 \n,cache_control 逐块保留。 // (CLI 的 composeSystemPrompt:基础提示词是字符串时发字符串、是 sections 时发块数组; // 真机验证两种形态服务端都接受,见 PROTOCOL-FACTS-1.53.1.md。这里统一用块数组, // 才能把客户端标在 system 上的缓存断点原样送上去。) - const systemMsgs = messages.filter(m => m.role === 'system' || m.role === 'developer'); + const systemMsgs = messages.filter( + (m) => m.role === "system" || m.role === "developer", + ); const systemBlocks = []; for (const m of systemMsgs) { - if (typeof m.content === 'string') { - if (m.content) systemBlocks.push({ type: 'text', text: m.content }); + if (typeof m.content === "string") { + if (m.content) systemBlocks.push({ type: "text", text: m.content }); } else if (Array.isArray(m.content)) { for (const c of m.content) { - const text = c?.text ?? c?.content ?? ''; - if (text === '' && !c?.cache_control) continue; - const block = { type: 'text', text: String(text) }; + const text = c?.text ?? c?.content ?? ""; + if (text === "" && !c?.cache_control) continue; + const block = { type: "text", text: String(text) }; if (c?.cache_control) block.cache_control = c.cache_control; systemBlocks.push(block); } } else if (m.content != null) { - systemBlocks.push({ type: 'text', text: String(m.content) }); + systemBlocks.push({ type: "text", text: String(m.content) }); } } - for (let i = 0; i < systemBlocks.length - 1; i++) systemBlocks[i].text += '\n'; - const chatMessages = messages.filter(m => m.role !== 'system' && m.role !== 'developer'); + for (let i = 0; i < systemBlocks.length - 1; i++) + systemBlocks[i].text += "\n"; + const chatMessages = messages.filter( + (m) => m.role !== "system" && m.role !== "developer", + ); // Build tool_call_id → tool_name reverse lookup // 名字原样透传(理由见 toWireToolName 删除处的注释)。 const toolNameMap = {}; for (const msg of chatMessages) { - if (msg.role === 'assistant' && msg.tool_calls) { + if (msg.role === "assistant" && msg.tool_calls) { for (const tc of msg.tool_calls) { if (tc.id) { - toolNameMap[tc.id] = tc.function?.name || ''; + toolNameMap[tc.id] = tc.function?.name || ""; } } } } // 转换 messages 为 CC 格式 - const ccMessages = chatMessages.map(msg => { - if (msg.role === 'user') { - if (typeof msg.content === 'string') { - return { role: 'user', content: [{ type: 'text', text: msg.content }] }; + const ccMessages = chatMessages.map((msg) => { + if (msg.role === "user") { + if (typeof msg.content === "string") { + return { role: "user", content: [{ type: "text", text: msg.content }] }; } // 多模态:数组 content 原样透传(text + image_url → CC image 格式) if (Array.isArray(msg.content)) { - const parts = msg.content.map(part => { - if (part.type === 'image_url') { - const url = part.image_url?.url || ''; - // CC CLI 真实格式: { type: "image", image: "data:;base64,...", mimeType: "" } - const mediaType = /^data:([^;,]+)/.exec(url)?.[1]; - const imagePart = { type: 'image', image: url }; - if (mediaType) imagePart.mimeType = mediaType; - return imagePart; - } - return part; - }).filter(Boolean); - return { role: 'user', content: parts }; + const parts = msg.content + .map((part) => { + if (part.type === "image_url") { + const url = part.image_url?.url || ""; + // CC CLI 真实格式: { type: "image", image: "data:;base64,...", mimeType: "" } + const mediaType = /^data:([^;,]+)/.exec(url)?.[1]; + const imagePart = { type: "image", image: url }; + if (mediaType) imagePart.mimeType = mediaType; + return imagePart; + } + return part; + }) + .filter(Boolean); + return { role: "user", content: parts }; } - return { role: 'user', content: [{ type: 'text', text: String(msg.content) }] }; + return { + role: "user", + content: [{ type: "text", text: String(msg.content) }], + }; } - if (msg.role === 'assistant') { + if (msg.role === "assistant") { const parts = []; // 思考内容必须回传:CC 在 thinking 模式下校验 reasoning 是否随历史带回, // 丢弃会让上游直接拒绝。次序也必须与 CC CLI 的抓包格式一致 —— // [reasoning, text, tool-call],reasoning 在最前。 + // 客户端(如 OpenCode)可能不回传 reasoning;上游仍要求该字段非空, + // 因此缺失时补一个最小占位,避免 "Missing required parameter: reasoning_content"。 + const hasReasoning = + !!msg.reasoning_content || + (Array.isArray(msg.content) && + msg.content.some((part) => part?.type === "reasoning")); + if (!hasReasoning) { + parts.push({ type: "reasoning", text: " " }); + } if (msg.reasoning_content) { - parts.push({ type: 'reasoning', text: msg.reasoning_content }); + parts.push({ type: "reasoning", text: msg.reasoning_content }); } - if (msg.content && typeof msg.content === 'string') { - if (msg.content) parts.push({ type: 'text', text: msg.content }); + if (msg.content && typeof msg.content === "string") { + if (msg.content) parts.push({ type: "text", text: msg.content }); } else if (msg.content && Array.isArray(msg.content)) { for (const part of msg.content) { if (!part) continue; - if (part.type === 'text') parts.push(part); + if (part.type === "text") parts.push(part); // 客户端直接把 reasoning 放在 content 数组里时同样透传; // 已有 reasoning_content 字段则不重复 - else if (part.type === 'reasoning' && !msg.reasoning_content) parts.push(part); + else if (part.type === "reasoning" && !msg.reasoning_content) + parts.push( + part.text ? part : { ...part, text: " " }, + ); } } if (msg.tool_calls) { for (const tc of msg.tool_calls) { parts.push({ - type: 'tool-call', + type: "tool-call", toolCallId: tc.id, - toolName: tc.function?.name || '', - input: (typeof tc.function?.arguments === 'string' ? tryParseJSON(tc.function.arguments) : (tc.function?.arguments || {})), + toolName: tc.function?.name || "", + input: + typeof tc.function?.arguments === "string" + ? tryParseJSON(tc.function.arguments) + : tc.function?.arguments || {}, }); } } - return { role: 'assistant', content: parts }; + return { role: "assistant", content: parts }; } - if (msg.role === 'tool') { + if (msg.role === "tool") { // toolName 与上面 tool-call 里的一致(同一张 map),不重命名 return { - role: 'tool', - content: [{ - type: 'tool-result', - toolCallId: msg.tool_call_id, - toolName: toolNameMap[msg.tool_call_id] || msg.name || '', - output: { type: 'text', value: toWireToolOutputValue(msg.content) }, - }], + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: msg.tool_call_id, + toolName: toolNameMap[msg.tool_call_id] || msg.name || "", + output: { type: "text", value: toWireToolOutputValue(msg.content) }, + }, + ], }; } // 未知 role 兜底:归一化为 user 并保证 content 为数组,避免 CC 校验拒绝 - return { role: 'user', content: [{ type: 'text', text: String(msg.content ?? '') }] }; + return { + role: "user", + content: [{ type: "text", text: String(msg.content ?? "") }], + }; }); // 缓存断点:system 是块数组,断点可以原样留在 system 上(CLI 的 systemSections[].cache 同义)。 // 客户端已在任意消息块 / system 块上打过断点就保留;否则若给了 OpenAI 系的 prompt_cache_key, // 把断点落在 system 最后一块 —— 缓存按前缀计算,system 正是最前的那段前缀。 - const hasCacheMarker = systemBlocks.some(b => b.cache_control) || ccMessages.some(msg => - Array.isArray(msg.content) && msg.content.some(part => part?.cache_control)); + const hasCacheMarker = + systemBlocks.some((b) => b.cache_control) || + ccMessages.some( + (msg) => + Array.isArray(msg.content) && + msg.content.some((part) => part?.cache_control), + ); if (prompt_cache_key && !hasCacheMarker && systemBlocks.length) { - systemBlocks[systemBlocks.length - 1].cache_control = { type: 'ephemeral' }; + systemBlocks[systemBlocks.length - 1].cache_control = { type: "ephemeral" }; } const body = { @@ -769,22 +943,22 @@ function buildCcRequest(openaiReq) { environment: DEVICE_PROFILE.platform, structure: [], isGitRepo: false, - currentBranch: '', - mainBranch: '', - gitStatus: '', + currentBranch: "", + mainBranch: "", + gitStatus: "", recentCommits: [], }, memory: null, taste: null, - skills: null, // CLI 发 null,不是空串 - permissionMode: 'standard', - mode: CFG.cliMode || 'agent', + skills: null, // CLI 发 null,不是空串 + permissionMode: "standard", + mode: CFG.cliMode || "agent", // threadId 需为合法 UUID,否则整键省略(CLI 的 toWireThreadId)—— 在 forwardToCC 拿到 sessionId 后补 params: { - model: model || 'deepseek/deepseek-v4-flash', + model: model || "deepseek/deepseek-v4-flash", messages: ccMessages, max_tokens: Math.min(max_tokens || 64000, 200000), - stream: true, // CC API 总是 stream + stream: true, // CC API 总是 stream }, }; @@ -798,7 +972,7 @@ function buildCcRequest(openaiReq) { // 真机验证 prompt_tokens 从 7653 降到 85。 // 默认开启;config.json 设 "emptySystemPlaceholder": false 或环境变量 // CC_EMPTY_SYSTEM_PLACEHOLDER=false 可关闭(回到原生的缺省行为)。 - body.params.system = [{ type: 'text', text: ' ' }]; + body.params.system = [{ type: "text", text: " " }]; } if (temperature !== undefined) { body.params.temperature = temperature; @@ -809,22 +983,29 @@ function buildCcRequest(openaiReq) { // CLI 总是下发 tools(没有工具时是空数组)—— 空数组与缺键在 wire 上可观测,这里对齐 // CLI 的 toWireTools:只有 name / description / input_schema,没有 type 字段; // 且**不做**任何名字重写(理由见下面「工具名重写整段删除」的注释) - body.params.tools = (tools || []).map(t => ({ - name: t.function?.name || t.name || '', - description: t.function?.description || t.description || '', - input_schema: t.function?.parameters || t.input_schema || { type: 'object', properties: {} }, - })); + body.params.tools = (tools || []).map((t) => ({ + name: t.function?.name || t.name || "", + description: t.function?.description || t.description || "", + input_schema: t.function?.parameters || + t.input_schema || { type: "object", properties: {} }, + })); if (tool_choice !== undefined) { - if (tool_choice === 'none' || (typeof tool_choice === 'object' && tool_choice?.type === 'none')) { + if ( + tool_choice === "none" || + (typeof tool_choice === "object" && tool_choice?.type === "none") + ) { // none 表示本轮禁用工具:CC 上游 tool_choice 枚举只有 auto / any / tool, // 传 none 会被 CC 以 400 拒绝。语义上清空 tools 且不下发 tool_choice。 body.params.tools = []; - } else if (typeof tool_choice === 'string') { - const map = { 'auto': 'auto', 'required': 'any' }; - body.params.tool_choice = { type: map[tool_choice] || 'auto' }; - } else if (tool_choice.type === 'function') { + } else if (typeof tool_choice === "string") { + const map = { auto: "auto", required: "any" }; + body.params.tool_choice = { type: map[tool_choice] || "auto" }; + } else if (tool_choice.type === "function") { // OpenAI object → Anthropic object - body.params.tool_choice = { type: 'tool', name: tool_choice.function?.name }; + body.params.tool_choice = { + type: "tool", + name: tool_choice.function?.name, + }; } else { body.params.tool_choice = tool_choice; } @@ -864,15 +1045,22 @@ function buildCcRequest(openaiReq) { // CLI 的 toWireToolOutput:只取文本块,用 '\n' 拼接 function toWireToolOutputValue(content) { - if (typeof content === 'string') return content; + if (typeof content === "string") return content; if (Array.isArray(content)) { - return content.filter(c => c && c.type === 'text').map(c => c.text ?? '').join('\n'); + return content + .filter((c) => c && c.type === "text") + .map((c) => c.text ?? "") + .join("\n"); } - return content == null ? '' : String(content); + return content == null ? "" : String(content); } function tryParseJSON(str) { - try { return JSON.parse(str); } catch { return {}; } + try { + return JSON.parse(str); + } catch { + return {}; + } } // ── CC NDJSON → OpenAI SSE 转换 ──────────────────── @@ -890,7 +1078,7 @@ function createSseTranslator(model, completionId, created) { let toolCallIndex = 0; return { - lastCcEvent: '', + lastCcEvent: "", upstreamError: null, inputTokens: 0, outputTokens: 0, @@ -898,61 +1086,80 @@ function createSseTranslator(model, completionId, created) { /** 解析一行 NDJSON,返回 OpenAI chunk 数组 */ parseLine(line) { const trimmed = line.trim(); - if (!trimmed || trimmed === '[DONE]' || trimmed.startsWith(':')) return null; + if (!trimmed || trimmed === "[DONE]" || trimmed.startsWith(":")) + return null; let event; - try { event = JSON.parse(trimmed); } catch { return null; } + try { + event = JSON.parse(trimmed); + } catch { + return null; + } if (!event.type) return null; this.lastCcEvent = event.type; const out = []; switch (event.type) { - case 'text-start': - case 'reasoning-start': - case 'start': - case 'start-step': + case "text-start": + case "reasoning-start": + case "start": + case "start-step": // 忽略,无用户可见内容 break; - case 'text-delta': { - const text = event.text || event.delta || ''; + case "text-delta": { + const text = event.text || event.delta || ""; if (!text) break; - const delta = chunkIndex === 0 ? { role: 'assistant', content: text } : { content: text }; + const delta = + chunkIndex === 0 + ? { role: "assistant", content: text } + : { content: text }; chunkIndex++; sentRole = true; out.push(makeChunk(completionId, created, model, delta, null, null)); break; } - case 'reasoning-delta': { - const text = event.text || ''; + case "reasoning-delta": { + const text = event.text || ""; if (!text) break; - const delta = chunkIndex === 0 - ? { role: 'assistant', reasoning_content: text } - : { reasoning_content: text }; + const delta = + chunkIndex === 0 + ? { role: "assistant", reasoning_content: text } + : { reasoning_content: text }; chunkIndex++; out.push(makeChunk(completionId, created, model, delta, null, null)); break; } - case 'tool-call': { + case "tool-call": { const id = event.toolCallId || `call_${Date.now()}_${toolCallIndex}`; - const name = event.toolName || ''; - const args = typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {}); - const tcEntry = { index: toolCallIndex, id, type: 'function', function: { name, arguments: args } }; - const delta = chunkIndex === 0 - ? { role: 'assistant', content: null, tool_calls: [tcEntry] } - : { tool_calls: [tcEntry] }; + const name = event.toolName || ""; + const args = + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input || {}); + const tcEntry = { + index: toolCallIndex, + id, + type: "function", + function: { name, arguments: args }, + }; + const delta = + chunkIndex === 0 + ? { role: "assistant", content: null, tool_calls: [tcEntry] } + : { tool_calls: [tcEntry] }; chunkIndex++; toolCallIndex++; out.push(makeChunk(completionId, created, model, delta, null, null)); break; } - case 'finish-step': { + case "finish-step": { sawFinish = true; - if (event.finishReason) finishReason = mapFinishReason(event.finishReason); + if (event.finishReason) + finishReason = mapFinishReason(event.finishReason); if (event.usage) { usage = event.usage; this.inputTokens = event.usage.inputTokens ?? 0; @@ -962,22 +1169,30 @@ function createSseTranslator(model, completionId, created) { break; } - case 'finish': { + case "finish": { sawFinish = true; - const fr = toOpenAIFinishReason(finishReason || mapFinishReason(event.finishReason || 'stop')); + const fr = toOpenAIFinishReason( + finishReason || mapFinishReason(event.finishReason || "stop"), + ); const u = event.totalUsage || usage || {}; normalizeUsage(u); this.inputTokens = u.inputTokens ?? 0; this.outputTokens = u.outputTokens ?? 0; this.cachedInputTokens = u.cachedInputTokens ?? 0; - const openaiUsage = u ? { - prompt_tokens: u.inputTokens ?? 0, - completion_tokens: u.outputTokens ?? 0, - total_tokens: (u.inputTokens ?? 0) + (u.outputTokens ?? 0), - prompt_tokens_details: { cached_tokens: u.cachedInputTokens ?? 0 }, - } : undefined; + const openaiUsage = u + ? { + prompt_tokens: u.inputTokens ?? 0, + completion_tokens: u.outputTokens ?? 0, + total_tokens: (u.inputTokens ?? 0) + (u.outputTokens ?? 0), + prompt_tokens_details: { + cached_tokens: u.cachedInputTokens ?? 0, + }, + } + : undefined; if (OPENAI_FINISH_REASONS.has(fr)) { - out.push(makeChunk(completionId, created, model, {}, fr, openaiUsage)); + out.push( + makeChunk(completionId, created, model, {}, fr, openaiUsage), + ); } else { // 走到这里只剩「上游连接失败族」(upstream_error;CC 在 provider 返回空 // 响应时发的裸 'error' 已在 mapFinishReason 里并入它)。原样写进 SSE 会让 @@ -990,12 +1205,12 @@ function createSseTranslator(model, completionId, created) { break; } - case 'error': { - const msg = event.error?.message || event.message || 'Unknown error'; + case "error": { + const msg = event.error?.message || event.message || "Unknown error"; this.upstreamError = mapCcEventError(event); // 先映射再记日志,并把上游自带的状态/可重试性一并打出 —— // 排查容量/限流类问题时,真正需要的就是这两个字段 - log('warn', 'CC stream error', { + log("warn", "CC stream error", { message: msg, upstreamStatus: this.upstreamError.reportedStatus, upstreamRetryable: event.error?.isRetryable, @@ -1008,11 +1223,17 @@ 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 "tool-input-start": + case "tool-input-delta": + case "tool-input-end": + case "tool-error": + case "text-end": // Silent - no user-visible content break; default: - log('warn', 'Unknown CC event type', { type: event.type }); + log("warn", "Unknown CC event type", { type: event.type }); break; } @@ -1027,14 +1248,14 @@ function createSseTranslator(model, completionId, created) { // 到这里只剩上游连接失败族('upstream_error'),措辞与 network/connection-error // 一致。末句保留作防御:不认识的结束原因已由 toOpenAIFinishReason 折成 stop, // 不再走这条路径。 - return unsupportedFinishReason === 'upstream_error' - ? 'provider reported an upstream connection failure' + return unsupportedFinishReason === "upstream_error" + ? "provider reported an upstream connection failure" : `provider reported an unsupported finish reason (${unsupportedFinishReason})`; }, /** 获取 SSE 结束标记 */ getDoneEvent() { - return 'data: [DONE]\n\n'; + return "data: [DONE]\n\n"; }, }; } @@ -1042,7 +1263,7 @@ function createSseTranslator(model, completionId, created) { function makeChunk(id, created, model, delta, finishReason, usage) { const chunk = { id, - object: 'chat.completion.chunk', + object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason || null }], @@ -1056,7 +1277,8 @@ function makeChunk(id, created, model, delta, finishReason, usage) { function normalizeUsage(u) { if (!u) return; const ot = Number(u.outputTokens); - if (!ot) { // 0, null, undefined, NaN → zero input + cached (anti false billing) + if (!ot) { + // 0, null, undefined, NaN → zero input + cached (anti false billing) u.inputTokens = 0; u.cachedInputTokens = 0; } @@ -1072,11 +1294,16 @@ function normalizeUsage(u) { // === inputTokens)。优先采用该字段;缺失时回退到减法,保证老版本上游也能得到正确值。 function anthropicInputTokens(usage, noCacheOverride) { const u = usage || {}; - if (typeof noCacheOverride === 'number' && noCacheOverride >= 0) return noCacheOverride; + if (typeof noCacheOverride === "number" && noCacheOverride >= 0) + return noCacheOverride; const noCache = u.inputTokenDetails && u.inputTokenDetails.noCacheTokens; - if (typeof noCache === 'number' && noCache >= 0) return noCache; - const cacheRead = u.cachedInputTokens || (u.inputTokenDetails && u.inputTokenDetails.cacheReadTokens) || 0; - const cacheWrite = (u.inputTokenDetails && u.inputTokenDetails.cacheWriteTokens) || 0; + if (typeof noCache === "number" && noCache >= 0) return noCache; + const cacheRead = + u.cachedInputTokens || + (u.inputTokenDetails && u.inputTokenDetails.cacheReadTokens) || + 0; + const cacheWrite = + (u.inputTokenDetails && u.inputTokenDetails.cacheWriteTokens) || 0; return Math.max(0, (u.inputTokens || 0) - cacheRead - cacheWrite); } @@ -1097,12 +1324,21 @@ function anthropicInputTokens(usage, noCacheOverride) { // 发 `finish` + `finishReason:"error"`;它不是可透出的业务值,原样返回会变成非法的 // OpenAI finish_reason(严格客户端整条流反序列化失败),也会丢掉可重试语义。 function mapFinishReason(reason) { - const r = String(reason ?? '').trim().toLowerCase(); - if (!r) return 'stop'; - if (r === 'tool-calls' || r === 'tool_calls' || r === 'tool_use') return 'tool_calls'; - if (r === 'length' || r === 'max_tokens' - || r === 'max_output_tokens' || r === 'model_context_window_exceeded') return 'length'; - if (r === 'error' || /^(?:network|connection|upstream)[-_\s]?error$/.test(r)) return 'upstream_error'; + const r = String(reason ?? "") + .trim() + .toLowerCase(); + if (!r) return "stop"; + if (r === "tool-calls" || r === "tool_calls" || r === "tool_use") + return "tool_calls"; + if ( + r === "length" || + r === "max_tokens" || + r === "max_output_tokens" || + r === "model_context_window_exceeded" + ) + return "length"; + if (r === "error" || /^(?:network|connection|upstream)[-_\s]?error$/.test(r)) + return "upstream_error"; return r; } @@ -1117,8 +1353,9 @@ function mapFinishReason(reason) { // 但既然代理认它,就不能让它变成「没完成」,否则会把原本正常的响应误判成 502。 // 真正要拦的是「一个完成信号都没有就断了」。) function incompleteUpstreamDetail(sawFinish, finishReason) { - if (!sawFinish) return 'no finish event'; - if (finishReason === 'upstream_error') return 'provider reported an upstream connection failure'; + if (!sawFinish) return "no finish event"; + if (finishReason === "upstream_error") + return "provider reported an upstream connection failure"; return null; } @@ -1130,7 +1367,7 @@ function incompleteUpstreamError(detail) { body: { error: { message: `Upstream stream ended without a completion finish (${detail}) — response was truncated`, - type: 'upstream_error', + type: "upstream_error", }, retry_after: 10, }, @@ -1140,20 +1377,23 @@ function incompleteUpstreamError(detail) { // ── 错误映射 ─────────────────────────────────────── const CC_STATUS_MAP = { - 400: { status: 400, type: 'invalid_request_error' }, - 401: { status: 401, type: 'authentication_error' }, - 402: { status: 429, type: 'rate_limit_error' }, // payment required → rate limit - 403: { status: 401, type: 'authentication_error' }, - 404: { status: 404, type: 'not_found' }, - 422: { status: 400, type: 'invalid_request_error' }, - 429: { status: 429, type: 'rate_limit_error' }, - 500: { status: 502, type: 'upstream_error' }, - 502: { status: 502, type: 'upstream_error' }, - 503: { status: 503, type: 'temporarily_unavailable' }, + 400: { status: 400, type: "invalid_request_error" }, + 401: { status: 401, type: "authentication_error" }, + 402: { status: 429, type: "rate_limit_error" }, // payment required → rate limit + 403: { status: 401, type: "authentication_error" }, + 404: { status: 404, type: "not_found" }, + 422: { status: 400, type: "invalid_request_error" }, + 429: { status: 429, type: "rate_limit_error" }, + 500: { status: 502, type: "upstream_error" }, + 502: { status: 502, type: "upstream_error" }, + 503: { status: 503, type: "temporarily_unavailable" }, }; function mapCcError(ccStatus, ccBody) { - const mapped = CC_STATUS_MAP[ccStatus] || { status: 502, type: 'upstream_error' }; + const mapped = CC_STATUS_MAP[ccStatus] || { + status: 502, + type: "upstream_error", + }; let message = `CC API error (${ccStatus})`; let code = null; @@ -1175,17 +1415,21 @@ function mapCcError(ccStatus, ccBody) { status: 429, code, body: { - error: { message, type: 'rate_limit_error', ...(code ? { code } : {}) }, + error: { message, type: "rate_limit_error", ...(code ? { code } : {}) }, retry_after: 30, }, }; } - return { status: mapped.status, code, body: { error: { message, type: mapped.type, ...(code ? { code } : {}) } } }; + return { + status: mapped.status, + code, + body: { error: { message, type: mapped.type, ...(code ? { code } : {}) } }, + }; } function mapCcEventError(event) { - const message = event.error?.message || event.message || 'Unknown CC error'; + const message = event.error?.message || event.message || "Unknown CC error"; const code = event.error?.code || event.code || null; // 上游 error 事件除了 message 还可能自带 statusCode / isRetryable —— // CLI 的 readStreamErrorEvent 读的正是这两个字段,取值链是 @@ -1196,9 +1440,14 @@ function mapCcEventError(event) { const statusMatch = message.match(/^<(\d{3})>/); const reportedStatus = statusMatch ? Number(statusMatch[1]) - : (Number.isInteger(event.error?.statusCode) ? event.error.statusCode : null); + : Number.isInteger(event.error?.statusCode) + ? event.error.statusCode + : null; const ccStatus = reportedStatus ?? 502; - const mapped = CC_STATUS_MAP[ccStatus] || { status: 502, type: 'upstream_error' }; + const mapped = CC_STATUS_MAP[ccStatus] || { + status: 502, + type: "upstream_error", + }; // 与 mapCcError 保持一致:终态为 429 时带上 retry_after, // 否则客户端 SDK 拿不到退避提示(402 也映射成 429,一视同仁) @@ -1207,12 +1456,19 @@ function mapCcEventError(event) { status: 429, code, reportedStatus, - body: { error: { message, type: 'rate_limit_error', ...(code ? { code } : {}) }, retry_after: 30 }, + body: { + error: { message, type: "rate_limit_error", ...(code ? { code } : {}) }, + retry_after: 30, + }, }; } - return { status: mapped.status, code, reportedStatus, - body: { error: { message, type: mapped.type, ...(code ? { code } : {}) } } }; + return { + status: mapped.status, + code, + reportedStatus, + body: { error: { message, type: mapped.type, ...(code ? { code } : {}) } }, + }; } // ── HTTP 请求处理 ────────────────────────────────── @@ -1227,10 +1483,14 @@ function readBody(req) { // 让客户端明确收到 413 而不是 Connection reset(issue #7)。 // 但若客户端无视 413 持续上传超过 DRAIN_LIMIT,则强制掐断,不无限吞带宽。 const DRAIN_LIMIT = 32 * 1024 * 1024; - req.on('data', c => { + req.on("data", (c) => { if (settled) { drained += c.length; - if (drained > DRAIN_LIMIT) { try { req.destroy(); } catch {} } + if (drained > DRAIN_LIMIT) { + try { + req.destroy(); + } catch {} + } return; } totalSize += c.length; @@ -1241,11 +1501,14 @@ function readBody(req) { // 只报上限等于让人去猜自己超了多少 —— 客户端要据此决定"拆请求"还是"去申请提额"。 // nginx 开了 proxy_request_buffering 时会带 Content-Length,据此给出真实体积; // 没有该头(chunked)时退回"已收到多少",并标注它是下界。 - const declared = Number.parseInt(req.headers['content-length'] ?? '', 10); + const declared = Number.parseInt( + req.headers["content-length"] ?? "", + 10, + ); const known = Number.isFinite(declared) && declared > 0; const bytes = known ? declared : totalSize; - const sizeNote = ` (body is ${(bytes / 1048576).toFixed(1)}MB${known ? '' : '+'})`; - log('warn', 'Request body rejected (too large)', { + const sizeNote = ` (body is ${(bytes / 1048576).toFixed(1)}MB${known ? "" : "+"})`; + log("warn", "Request body rejected (too large)", { path: req.url, limitMB: mb, bodyMB: +(bytes / 1048576).toFixed(1), @@ -1259,13 +1522,21 @@ function readBody(req) { } chunks.push(c); }); - req.on('end', () => { + req.on("end", () => { if (settled) return; settled = true; - try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } - catch { reject(new Error('Invalid JSON')); } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString())); + } catch { + reject(new Error("Invalid JSON")); + } + }); + req.on("error", (e) => { + if (!settled) { + settled = true; + reject(e); + } }); - req.on('error', e => { if (!settled) { settled = true; reject(e); } }); }); } @@ -1279,19 +1550,28 @@ function waitDrain(res) { return new Promise((resolve) => { let timer = null; const done = () => { - res.off('drain', done); res.off('close', done); res.off('error', done); - if (timer) { clearTimeout(timer); timer = null; } + res.off("drain", done); + res.off("close", done); + res.off("error", done); + if (timer) { + clearTimeout(timer); + timer = null; + } resolve(); }; - res.once('drain', done); res.once('close', done); res.once('error', done); + res.once("drain", done); + res.once("close", done); + res.once("error", done); if (CLIENT_DRAIN_TIMEOUT_MS > 0) { timer = setTimeout(() => { - log('warn', 'Client stalled on backpressure, dropping connection', { - path: res.req?.url || '(unknown)', + log("warn", "Client stalled on backpressure, dropping connection", { + path: res.req?.url || "(unknown)", timeoutMs: CLIENT_DRAIN_TIMEOUT_MS, bufferedBytes: res.writableLength, }); - try { res.destroy(); } catch {} + try { + res.destroy(); + } catch {} done(); }, CLIENT_DRAIN_TIMEOUT_MS); } @@ -1304,19 +1584,29 @@ function waitDrain(res) { // arm() 用 refresh() 把窗口重置为「本轮 read 开始」,与原实现语义一致:超时只计 reader.read() 的等待。 function createIdleWatchdog(timeoutMs) { let rejectFn = null; - const expired = new Promise((_, reject) => { rejectFn = reject; }); + const expired = new Promise((_, reject) => { + rejectFn = reject; + }); expired.catch(() => {}); // 读循环退出后定时器才触发时,避免 unhandledRejection - const timer = setTimeout(() => rejectFn(new Error('STREAM_IDLE_TIMEOUT')), timeoutMs); + const timer = setTimeout( + () => rejectFn(new Error("STREAM_IDLE_TIMEOUT")), + timeoutMs, + ); return { - arm() { timer.refresh(); return expired; }, - dispose() { clearTimeout(timer); }, + arm() { + timer.refresh(); + return expired; + }, + dispose() { + clearTimeout(timer); + }, }; } function sendJSON(res, status, data) { - const headers = { 'Content-Type': 'application/json' }; + const headers = { "Content-Type": "application/json" }; if (data && data.retry_after !== undefined) { - headers['Retry-After'] = String(data.retry_after); + headers["Retry-After"] = String(data.retry_after); } res.writeHead(status, headers); res.end(JSON.stringify(data)); @@ -1324,13 +1614,13 @@ function sendJSON(res, status, data) { function getApiKey(headers) { // Try Authorization: Bearer header (OpenAI SDK style) - const auth = headers['authorization'] || headers['Authorization'] || ''; - if (auth.startsWith('Bearer ')) { + const auth = headers["authorization"] || headers["Authorization"] || ""; + if (auth.startsWith("Bearer ")) { const match = auth.slice(7).match(/user_[a-zA-Z0-9_-]+/); if (match) return match[0]; } // Fall back to x-api-key header (Anthropic SDK style) - const xKey = headers['x-api-key'] || headers['X-Api-Key'] || ''; + const xKey = headers["x-api-key"] || headers["X-Api-Key"] || ""; if (xKey) { const match = xKey.match(/user_[a-zA-Z0-9_-]+/); if (match) return match[0]; @@ -1346,18 +1636,18 @@ function getApiKey(headers) { // 因此不需要 undici / https-proxy-agent,engines >=18 也能用。 // 注意 Node 原生 fetch 不读 HTTPS_PROXY/HTTP_PROXY;官方的环境变量方案需要 // Node >= 22.21 / 24.5 并设 NODE_USE_ENV_PROXY=1(README 有说明)。 -const UPSTREAM_PROXY = CFG.upstreamProxy || ''; +const UPSTREAM_PROXY = CFG.upstreamProxy || ""; const PROXY_CONNECT_TIMEOUT_MS = 15000; // 代理 URL 可能带 user:pass —— 任何日志/错误消息都只允许出现 host:port。 // (README 承诺「隐私保护日志」,把口令打进启动横幅是直接违反。) function redactProxyUrl(raw) { - if (!raw) return '(direct)'; + if (!raw) return "(direct)"; try { const u = new URL(raw); - return `${u.protocol}//${u.hostname}${u.port ? ':' + u.port : ''}`; + return `${u.protocol}//${u.hostname}${u.port ? ":" + u.port : ""}`; } catch { - return '(invalid upstreamProxy)'; + return "(invalid upstreamProxy)"; } } @@ -1367,15 +1657,22 @@ function parseProxyUrl(raw) { u = new URL(raw); } catch { // 不回显原串:里面可能就是口令 - throw new Error('upstreamProxy is not a valid URL (expected http://host:port)'); + throw new Error( + "upstreamProxy is not a valid URL (expected http://host:port)", + ); } - if (u.protocol !== 'http:') { - throw new Error(`upstreamProxy only supports http:// (CONNECT) proxies, got ${u.protocol}//`); + if (u.protocol !== "http:") { + throw new Error( + `upstreamProxy only supports http:// (CONNECT) proxies, got ${u.protocol}//`, + ); } const auth = u.username - ? 'Basic ' + Buffer.from(`${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`).toString('base64') + ? "Basic " + + Buffer.from( + `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`, + ).toString("base64") : null; - return { host: u.hostname, port: Number.parseInt(u.port || '80', 10), auth }; + return { host: u.hostname, port: Number.parseInt(u.port || "80", 10), auth }; } // 启动即校验:写错的代理地址应当立刻拒绝启动,而不是每个请求各 502 一次。 @@ -1383,12 +1680,13 @@ if (UPSTREAM_PROXY) { try { parseProxyUrl(UPSTREAM_PROXY); } catch (e) { - log('error', 'Invalid upstreamProxy, refusing to start', { - error: e.message, value: redactProxyUrl(UPSTREAM_PROXY), + log("error", "Invalid upstreamProxy, refusing to start", { + error: e.message, + value: redactProxyUrl(UPSTREAM_PROXY), }); process.exit(1); } - log('info', 'Upstream requests will go through the configured proxy', { + log("info", "Upstream requests will go through the configured proxy", { proxy: redactProxyUrl(UPSTREAM_PROXY), }); } @@ -1397,8 +1695,9 @@ if (UPSTREAM_PROXY) { function headersToInit(raw) { const out = []; for (const [k, v] of Object.entries(raw)) { - if (Array.isArray(v)) { for (const item of v) out.push([k, String(item)]); } - else if (v !== undefined) out.push([k, String(v)]); + if (Array.isArray(v)) { + for (const item of v) out.push([k, String(item)]); + } else if (v !== undefined) out.push([k, String(v)]); } return out; } @@ -1407,33 +1706,48 @@ function headersToInit(raw) { async function proxyFetch(urlStr, options = {}) { const proxy = parseProxyUrl(UPSTREAM_PROXY); const u = new URL(urlStr); - const isTls = u.protocol === 'https:'; - const port = Number.parseInt(u.port || (isTls ? '443' : '80'), 10); + const isTls = u.protocol === "https:"; + const port = Number.parseInt(u.port || (isTls ? "443" : "80"), 10); const target = `${u.hostname}:${port}`; const { signal, body } = options; - const onAbort = (fn) => { if (signal) signal.addEventListener('abort', fn, { once: true }); }; + const onAbort = (fn) => { + if (signal) signal.addEventListener("abort", fn, { once: true }); + }; // 1. CONNECT 隧道 —— 代理只做裸字节转发,TLS 由本端端到端完成 const rawSocket = await new Promise((resolve, reject) => { const connectReq = http.request({ host: proxy.host, port: proxy.port, - method: 'CONNECT', + method: "CONNECT", path: target, - headers: { Host: target, ...(proxy.auth ? { 'Proxy-Authorization': proxy.auth } : {}) }, + headers: { + Host: target, + ...(proxy.auth ? { "Proxy-Authorization": proxy.auth } : {}), + }, timeout: PROXY_CONNECT_TIMEOUT_MS, }); - connectReq.on('connect', (res, socket) => { + connectReq.on("connect", (res, socket) => { if (res.statusCode !== 200) { socket.destroy(); - reject(new Error(`upstream proxy CONNECT ${target} failed: HTTP ${res.statusCode}`)); + reject( + new Error( + `upstream proxy CONNECT ${target} failed: HTTP ${res.statusCode}`, + ), + ); return; } resolve(socket); }); - connectReq.on('timeout', () => connectReq.destroy(new Error('upstream proxy CONNECT timeout'))); - connectReq.on('error', reject); - onAbort(() => { try { connectReq.destroy(); } catch {} }); + connectReq.on("timeout", () => + connectReq.destroy(new Error("upstream proxy CONNECT timeout")), + ); + connectReq.on("error", reject); + onAbort(() => { + try { + connectReq.destroy(); + } catch {} + }); connectReq.end(); }); @@ -1442,35 +1756,55 @@ async function proxyFetch(urlStr, options = {}) { if (isTls) { socket = tls.connect({ socket: rawSocket, servername: u.hostname }); await new Promise((resolve, reject) => { - socket.once('secureConnect', resolve); - socket.once('error', reject); - onAbort(() => { try { socket.destroy(); } catch {} }); + socket.once("secureConnect", resolve); + socket.once("error", reject); + onAbort(() => { + try { + socket.destroy(); + } catch {} + }); }); } // 3. 复用隧道 socket 发请求 return await new Promise((resolve, reject) => { const mod = isTls ? https : http; - const req = mod.request({ - host: u.hostname, - port, - path: u.pathname + u.search, - method: options.method || 'GET', - headers: options.headers || {}, - createConnection: () => socket, - }, (res) => { - // 204/205/304 按规范不允许带 body,Response 构造器会直接抛 —— 这两个状态必须传 null, - // 同时把连接排空,避免隧道 socket 悬着。 - const nullBodyStatus = res.statusCode === 204 || res.statusCode === 205 || res.statusCode === 304; - if (nullBodyStatus) { try { res.resume(); } catch {} } - resolve(new Response(nullBodyStatus ? null : Readable.toWeb(res), { - status: res.statusCode, - statusText: res.statusMessage, - headers: headersToInit(res.headers), - })); + const req = mod.request( + { + host: u.hostname, + port, + path: u.pathname + u.search, + method: options.method || "GET", + headers: options.headers || {}, + createConnection: () => socket, + }, + (res) => { + // 204/205/304 按规范不允许带 body,Response 构造器会直接抛 —— 这两个状态必须传 null, + // 同时把连接排空,避免隧道 socket 悬着。 + const nullBodyStatus = + res.statusCode === 204 || + res.statusCode === 205 || + res.statusCode === 304; + if (nullBodyStatus) { + try { + res.resume(); + } catch {} + } + resolve( + new Response(nullBodyStatus ? null : Readable.toWeb(res), { + status: res.statusCode, + statusText: res.statusMessage, + headers: headersToInit(res.headers), + }), + ); + }, + ); + req.on("error", reject); + onAbort(() => { + try { + req.destroy(); + } catch {} }); - req.on('error', reject); - onAbort(() => { try { req.destroy(); } catch {} }); if (body !== undefined && body !== null) req.write(body); req.end(); }); @@ -1483,7 +1817,13 @@ function upstreamFetch(urlStr, options) { // ── 流式转发 ──────────────────────────────────────── -async function forwardToCC(body, apiKey, incomingHeaders = {}, signal, promptCacheKey) { +async function forwardToCC( + body, + apiKey, + incomingHeaders = {}, + signal, + promptCacheKey, +) { const url = `${CFG.apiBase}/alpha/generate`; const traceparent = generateTraceparent(); const sessionId = getSessionId(incomingHeaders, apiKey, promptCacheKey, body); @@ -1491,31 +1831,33 @@ async function forwardToCC(body, apiKey, incomingHeaders = {}, signal, promptCac // 同时按 CLI 的键顺序重排:config, memory, taste, skills, permissionMode, threadId, mode, params if (isWireUuid(sessionId)) { const ordered = {}; - for (const k of ['config', 'memory', 'taste', 'skills', 'permissionMode']) ordered[k] = body[k]; + for (const k of ["config", "memory", "taste", "skills", "permissionMode"]) + ordered[k] = body[k]; ordered.threadId = sessionId; - for (const k of ['mode', 'promptCache', 'params']) if (k in body) ordered[k] = body[k]; + for (const k of ["mode", "promptCache", "params"]) + if (k in body) ordered[k] = body[k]; body = ordered; } // 与 CLI 的 buildCommandAuthHeaders 对齐:没有 x-co-flag;User-Agent 固定 "cli" const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'cli', - 'x-command-code-version': CC_VERSION, - 'x-cli-environment': 'production', - 'x-project-slug': slugifyProjectPath(DEVICE_PROFILE.projectDir), - 'x-taste-learning': 'false', - 'x-session-id': sessionId, - 'Authorization': `Bearer ${apiKey}`, - 'traceparent': traceparent, + "Content-Type": "application/json", + "User-Agent": "cli", + "x-command-code-version": CC_VERSION, + "x-cli-environment": "production", + "x-project-slug": slugifyProjectPath(DEVICE_PROFILE.projectDir), + "x-taste-learning": "false", + "x-session-id": sessionId, + Authorization: `Bearer ${apiKey}`, + traceparent: traceparent, }; - if (CFG.zdr || incomingHeaders['x-cmd-zdr'] === '1') { - headers['x-cmd-zdr'] = '1'; + if (CFG.zdr || incomingHeaders["x-cmd-zdr"] === "1") { + headers["x-cmd-zdr"] = "1"; } const response = await upstreamFetch(url, { - method: 'POST', + method: "POST", headers, body: JSON.stringify(body), signal, @@ -1532,21 +1874,31 @@ async function handleChatCompletions(req, res) { openaiReq = await readBody(req); } catch (e) { if (e.statusCode === 413) { - sendJSON(res, 413, { error: { message: e.message, type: 'invalid_request_error' } }); + sendJSON(res, 413, { + error: { message: e.message, type: "invalid_request_error" }, + }); return; } - sendJSON(res, 400, { error: { message: 'Invalid JSON body', type: 'invalid_request_error' } }); + sendJSON(res, 400, { + error: { message: "Invalid JSON body", type: "invalid_request_error" }, + }); return; } const apiKey = getApiKey(req.headers); if (!apiKey) { - sendJSON(res, 401, { error: { message: 'Missing API key. Send in Authorization: Bearer or x-api-key header', type: 'auth_error' } }); + sendJSON(res, 401, { + error: { + message: + "Missing API key. Send in Authorization: Bearer or x-api-key header", + type: "auth_error", + }, + }); return; } const stream = openaiReq.stream === true; - const model = openaiReq.model || 'deepseek/deepseek-v4-flash'; + const model = openaiReq.model || "deepseek/deepseek-v4-flash"; const completionId = `chatcmpl-${randomUUID().slice(0, 12)}`; const created = nowUnix(); @@ -1563,21 +1915,28 @@ async function handleChatCompletions(req, res) { let aborted = false; // 提前初始化,断连回调/超时 catch 安全引用(避免块级作用域 ReferenceError) const startTime = Date.now(); - let bytesReceived = 0; let lastCcEvent = ''; let keepaliveCount = 0; let fullText = ''; + let bytesReceived = 0; + let lastCcEvent = ""; + let keepaliveCount = 0; + let fullText = ""; let reader = null; let translator = null; let attempt = 0; - let upstreamError = null; // 非流式路径解析出的上游语义错误(error 事件) - let delivered = false; // 本次尝试是否真的把正常响应交付给了下游(用于重试后的日志/计数) + let upstreamError = null; // 非流式路径解析出的上游语义错误(error 事件) + let delivered = false; // 本次尝试是否真的把正常响应交付给了下游(用于重试后的日志/计数) // 发起下一次尝试前,把本次尝试的上游连接收干净并记账。 // 只在「下游尚未收到任何字节」时调用 —— 下游没有开始过,重试才是无损的。 const rewindAttempt = async (message, fields) => { - try { reader?.cancel().catch(() => {}); } catch {} - try { abortController.abort(); } catch {} + try { + reader?.cancel().catch(() => {}); + } catch {} + try { + abortController.abort(); + } catch {} upstreamRetryStats.rewinds++; - log('warn', message, { - path: '/v1/chat/completions', + log("warn", message, { + path: "/v1/chat/completions", model, attempt, maxAttempts: UPSTREAM_RETRY_MAX + 1, @@ -1594,480 +1953,689 @@ async function handleChatCompletions(req, res) { // 「对着空气交付」误记成 retry recovered(issue #55)。 // 回调读的 abortController 是每次尝试重绑的 let,闭包取到的永远是当前尝试的控制器, // 所以注册一次即可,重试不需要重复挂载。 - res.on('close', () => { + res.on("close", () => { if (res.writableEnded) return; // Normal completion, not a disconnect aborted = true; - const reason = lastCcEvent?.startsWith('tool-input') ? 'tool-input-silent-timeout' - : lastCcEvent?.includes('delta') ? 'streaming-active-disconnect' - : 'client-hangup'; - abortController.signal.aborted || log('warn', 'Client disconnected', { - path: '/v1/chat/completions', - model, completionId, reason, - streaming: stream, - elapsedMs: Date.now() - startTime, - bytesSent: bytesReceived, - lastCcEvent: lastCcEvent || '(none)', - keepaliveCount, - inputTokens: translator?.inputTokens ?? 0, - outputTokens: translator?.outputTokens ?? 0, - cachedInputTokens: translator?.cachedInputTokens ?? 0, - }); + const reason = lastCcEvent?.startsWith("tool-input") + ? "tool-input-silent-timeout" + : lastCcEvent?.includes("delta") + ? "streaming-active-disconnect" + : "client-hangup"; + abortController.signal.aborted || + log("warn", "Client disconnected", { + path: "/v1/chat/completions", + model, + completionId, + reason, + streaming: stream, + elapsedMs: Date.now() - startTime, + bytesSent: bytesReceived, + lastCcEvent: lastCcEvent || "(none)", + keepaliveCount, + inputTokens: translator?.inputTokens ?? 0, + outputTokens: translator?.outputTokens ?? 0, + cachedInputTokens: translator?.cachedInputTokens ?? 0, + }); if (!abortController.signal.aborted) { // 断连前抢发 usage=0 终止 chunk,避免下游自行估算 token try { - res.write(`data: ${JSON.stringify({ - id: completionId, - object: 'chat.completion.chunk', - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, - })}\n\n`); - res.write('data: [DONE]\n\n'); + res.write( + `data: ${JSON.stringify({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + prompt_tokens_details: { cached_tokens: 0 }, + }, + })}\n\n`, + ); + res.write("data: [DONE]\n\n"); + } catch {} + try { + abortController.abort(); } catch {} - try { abortController.abort(); } catch {} } }); // 上游闪断重试循环:只在「传输层闪断」且「尚未向下游写出任何字节」时 // 才再来一遍;正常路径第一轮即 break。循环体沿用原有缩进、未做重排,只为把 diff 控到最小。 attemptLoop: for (attempt = 1; attempt <= UPSTREAM_RETRY_MAX + 1; attempt++) { - // 退避期间客户端断开了:下游已经走了,再打一次上游只是白烧额度 - if (attempt > 1 && aborted) { - log('info', 'Upstream retry abandoned (client disconnected during backoff)', { - path: '/v1/chat/completions', model, attempt, elapsedMs: Date.now() - startTime, - }); - return; - } - // 每次尝试开始:重置本次请求的状态(上一次可能已被中断 / 半途失败) - abortController = new AbortController(); - bytesReceived = 0; lastCcEvent = ''; keepaliveCount = 0; fullText = ''; - reader = null; translator = null; upstreamError = null; delivered = false; - - try { - // 首次初始化(fingerprint + lifecycle) - await ensureInitialized(apiKey, abortController.signal); - // 转发到 CC API(传入客户端 headers,用于提取 session ID) - const ccResponse = await forwardToCC(ccBody, apiKey, req.headers, abortController.signal, promptCacheKey); - - if (!ccResponse.ok) { - const errorText = await ccResponse.text().catch(() => ''); - const mapped = mapCcError(ccResponse.status, errorText); - log('error', 'CC API error', { status: ccResponse.status, code: mapped.code, body: summarizeUpstreamError(errorText) }); - sendJSON(res, mapped.status, mapped.body); + // 退避期间客户端断开了:下游已经走了,再打一次上游只是白烧额度 + if (attempt > 1 && aborted) { + log( + "info", + "Upstream retry abandoned (client disconnected during backoff)", + { + path: "/v1/chat/completions", + model, + attempt, + elapsedMs: Date.now() - startTime, + }, + ); return; } + // 每次尝试开始:重置本次请求的状态(上一次可能已被中断 / 半途失败) + abortController = new AbortController(); + bytesReceived = 0; + lastCcEvent = ""; + keepaliveCount = 0; + fullText = ""; + reader = null; + translator = null; + upstreamError = null; + delivered = false; - if (stream) { - // ── 流式响应 ── - translator = createSseTranslator(model, completionId, created); - let buffer = ''; - let started = false; // 延迟写 200 header,超时/output=0 时返回 JSON 429/502 让 SDK 自动重试 - const decoder = new TextDecoder(); - reader = ccResponse.body.getReader(); + try { + // 首次初始化(fingerprint + lifecycle) + await ensureInitialized(apiKey, abortController.signal); + // 转发到 CC API(传入客户端 headers,用于提取 session ID) + const ccResponse = await forwardToCC( + ccBody, + apiKey, + req.headers, + abortController.signal, + promptCacheKey, + ); + + if (!ccResponse.ok) { + const errorText = await ccResponse.text().catch(() => ""); + const mapped = mapCcError(ccResponse.status, errorText); + log("error", "CC API error", { + status: ccResponse.status, + code: mapped.code, + body: summarizeUpstreamError(errorText), + }); + sendJSON(res, mapped.status, mapped.body); + return; + } - const idle = createIdleWatchdog(STREAM_IDLE_TIMEOUT_MS); - try { - while (true) { - const result = await Promise.race([reader.read(), idle.arm()]); - const { done, value } = result; - if (done) break; - if (aborted) break; - bytesReceived += value.length; + if (stream) { + // ── 流式响应 ── + translator = createSseTranslator(model, completionId, created); + let buffer = ""; + let started = false; // 延迟写 200 header,超时/output=0 时返回 JSON 429/502 让 SDK 自动重试 + const decoder = new TextDecoder(); + reader = ccResponse.body.getReader(); - const chunkText = decoder.decode(value, { stream: true }); - buffer += chunkText; - // 仅在新到数据含换行时才切分:buffer 中永不残留 '\n',故无换行即无完整行。 - // 避免对增长中的超长单行(大 tool-call / tool_result)反复做全量 split —— O(n²) → O(n)。 - let lines = []; - if (chunkText.indexOf('\n') !== -1) { - lines = buffer.split('\n'); - buffer = lines.pop() || ''; + const idle = createIdleWatchdog(STREAM_IDLE_TIMEOUT_MS); + try { + while (true) { + const result = await Promise.race([reader.read(), idle.arm()]); + const { done, value } = result; + if (done) break; + if (aborted) break; + bytesReceived += value.length; + + const chunkText = decoder.decode(value, { stream: true }); + buffer += chunkText; + // 仅在新到数据含换行时才切分:buffer 中永不残留 '\n',故无换行即无完整行。 + // 避免对增长中的超长单行(大 tool-call / tool_result)反复做全量 split —— O(n²) → O(n)。 + let lines = []; + if (chunkText.indexOf("\n") !== -1) { + lines = buffer.split("\n"); + buffer = lines.pop() || ""; + } + + let hadOutput = false; + for (const line of lines) { + const events = translator.parseLine(line); + if (events) { + if (!started) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + started = true; + } + for (const evt of events) res.write(evt); + await waitDrain(res); + hadOutput = true; + } + if (translator.lastCcEvent) lastCcEvent = translator.lastCcEvent; + } + // silent events 期间发 keepalive,防止客户端超时断开 + if (started && !hadOutput) { + try { + res.write(": keepalive\n\n"); + keepaliveCount++; + } catch {} + await waitDrain(res); + } } - let hadOutput = false; - for (const line of lines) { - const events = translator.parseLine(line); - if (events) { + if (!aborted) { + // 成功完成一次请求,重置连续超时计数 + consecutiveTimeouts = 0; + // 处理剩余 buffer + if (buffer.trim()) { + const events = translator.parseLine(buffer); + if (events) { + if (!started) started = true; + for (const evt of events) res.write(evt); + await waitDrain(res); + } + } + if (translator.upstreamError) { + if (!started) { + sendJSON( + res, + translator.upstreamError.status, + translator.upstreamError.body, + ); + return; + } + try { + res.write( + `data: ${JSON.stringify(translator.upstreamError.body)}\n\n`, + ); + } catch {} + // 上游没有正常走完 finish(无 finish 事件 / provider 报连接失败): + // 不能补一个 finish_reason 就 [DONE] —— 那等于把截断谎报成完整回答。 + // 对齐 CLI:这一族一律按可重试的 502 处理。 + // 必须排在零输出判定之前 —— 上游压根没发 finish 时,「no finish event」才是根因, + // 零输出只是它的表象(此时按 429 报会掩盖真实原因)。 + } else if (translator.incompleteDetail()) { + const detail = translator.incompleteDetail(); + // 对端 FIN(干净收尾、没有 finish 事件)与 RST 是同一类闪断:既然还没向下游吐过 + // 字节,就先内部重试,而不是直接把 502 交给下游去自行重发整个上下文。 + if (!started && !aborted && attempt <= UPSTREAM_RETRY_MAX) { + await rewindAttempt( + "Upstream stream ended incomplete before first byte - retrying", + { reason: detail }, + ); + continue attemptLoop; + } + log("warn", "Upstream stream incomplete", { + path: "/v1/chat/completions", + reason: detail, + }); + const err = incompleteUpstreamError(detail); + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + if (!started) { + sendJSON(res, err.status, err.body); + return; + } + try { + res.write(`data: ${JSON.stringify(err.body)}\n\n`); + } catch {} + // 输出 token 为 0 时记为错误,避免下游异常计费 + } else if (translator.outputTokens === 0) { + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + if (!started) { + sendJSON(res, 429, { + error: { + message: + "Empty response from upstream (zero output tokens)", + type: "rate_limit_error", + }, + retry_after: 10, + }); + return; + } + try { + res.write( + `data: ${JSON.stringify({ error: { message: "Empty response from upstream (zero output tokens)", type: "rate_limit_error" }, retry_after: 10 })}\n\n`, + ); + } catch {} + } else { if (!started) { res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", }); started = true; } - for (const evt of events) res.write(evt); - await waitDrain(res); - hadOutput = true; - } - if (translator.lastCcEvent) lastCcEvent = translator.lastCcEvent; - } - // silent events 期间发 keepalive,防止客户端超时断开 - if (started && !hadOutput) { - try { res.write(': keepalive\n\n'); keepaliveCount++; } catch {} - await waitDrain(res); - } - } - - if (!aborted) { - // 成功完成一次请求,重置连续超时计数 - consecutiveTimeouts = 0; - // 处理剩余 buffer - if (buffer.trim()) { - const events = translator.parseLine(buffer); - if (events) { - if (!started) started = true; - for (const evt of events) res.write(evt); - await waitDrain(res); + res.write(translator.getDoneEvent()); + delivered = true; } } - if (translator.upstreamError) { + } catch (e) { + if (aborted) { + // 客户端已断连,只清理(close handler 已调用 abortController.abort()) + // cancel() 返回 promise:不接住的话,连接已被对端掐断时会抛 UnhandledPromiseRejection + try { + reader.cancel().catch(() => {}); + } catch {} + } else if (e.message === "STREAM_IDLE_TIMEOUT") { + log("warn", "Stream idle timeout", { + path: "/v1/chat/completions", + model, + streaming: true, + timeoutMs: STREAM_IDLE_TIMEOUT_MS, + elapsedMs: Date.now() - startTime, + id: completionId, + bytesReceived, + lastCcEvent: lastCcEvent || "(none)", + inputTokens: translator.inputTokens, + outputTokens: translator.outputTokens, + cachedInputTokens: translator.cachedInputTokens, + }); + try { + reader.cancel().catch(() => {}); + } catch {} + try { + abortController.abort(); + } catch {} // 打断 CC 上游,避免浪费 token + consecutiveTimeouts++; + const timeoutMsg = + consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD + ? "Response timeout - try reducing context length (summarize earlier messages)" + : "Response timeout - request timed out"; if (!started) { - sendJSON(res, translator.upstreamError.status, translator.upstreamError.body); + sendJSON(res, 429, { + error: { + message: timeoutMsg, + type: "rate_limit_error", + input_tokens: 0, + }, + retry_after: 5, + }); return; } - try { res.write(`data: ${JSON.stringify(translator.upstreamError.body)}\n\n`); } catch {} - // 上游没有正常走完 finish(无 finish 事件 / provider 报连接失败): - // 不能补一个 finish_reason 就 [DONE] —— 那等于把截断谎报成完整回答。 - // 对齐 CLI:这一族一律按可重试的 502 处理。 - // 必须排在零输出判定之前 —— 上游压根没发 finish 时,「no finish event」才是根因, - // 零输出只是它的表象(此时按 429 报会掩盖真实原因)。 - } else if (translator.incompleteDetail()) { - const detail = translator.incompleteDetail(); - // 对端 FIN(干净收尾、没有 finish 事件)与 RST 是同一类闪断:既然还没向下游吐过 - // 字节,就先内部重试,而不是直接把 502 交给下游去自行重发整个上下文。 - if (!started && !aborted && attempt <= UPSTREAM_RETRY_MAX) { - await rewindAttempt('Upstream stream ended incomplete before first byte - retrying', { reason: detail }); - continue attemptLoop; + if (!res.writableEnded) { + // 必须 end() 而不是 destroy():res.write 是异步的,紧接着 destroy 会把尚未 + // 刷出的缓冲丢掉并发 RST。反向代理看到上游连接被重置,要么回 502,要么让 + // 客户端看到 connection error —— 这正是"吐字慢 + 间歇性 502"的成因之一。 + // end() 会把错误事件正常送进 SSE 流再发 FIN,客户端 SDK 能按可重试错误处理。 + // 下游若已僵死(不读也不断),由 CLIENT_DRAIN_TIMEOUT_MS 那条路径负责兜底。 + try { + res.end( + `data: ${JSON.stringify({ error: { message: timeoutMsg, type: "rate_limit_error" }, retry_after: 5 })}\n\n`, + ); + } catch {} } - log('warn', 'Upstream stream incomplete', { path: '/v1/chat/completions', reason: detail }); - const err = incompleteUpstreamError(detail); - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - if (!started) { sendJSON(res, err.status, err.body); return; } - try { res.write(`data: ${JSON.stringify(err.body)}\n\n`); } catch {} - // 输出 token 为 0 时记为错误,避免下游异常计费 - } else if (translator.outputTokens === 0) { - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - if (!started) { - sendJSON(res, 429, { error: { message: 'Empty response from upstream (zero output tokens)', type: 'rate_limit_error' }, retry_after: 10 }); + // 已经解析出上游语义错误(429/503 等)时不重试:那是有意传下来的信号,重试会把它吞掉 + } else if ( + !started && + !aborted && + !translator?.upstreamError && + attempt <= UPSTREAM_RETRY_MAX && + isRetryableUpstreamError(e) + ) { + // 传输层闪断且尚未向下游写过任何字节 → 代理内部静默重试(下游全程无感) + await rewindAttempt( + "Upstream stream terminated before first byte - retrying", + { + message: e.message, + cause: e.cause?.code || e.cause?.message || "(none)", + }, + ); + continue attemptLoop; + } else { + // 传输层错误不要覆盖已经解析到的语义错误:把「上游容量不足」说成「代理挂了」是误导 + if (translator?.upstreamError && !started) { + log("warn", "Upstream terminated after a parsed semantic error", { + message: e.message, + }); + try { + abortController.abort(); + } catch {} + sendJSON( + res, + translator.upstreamError.status, + translator.upstreamError.body, + ); return; } - try { res.write(`data: ${JSON.stringify({ error: { message: 'Empty response from upstream (zero output tokens)', type: 'rate_limit_error' }, retry_after: 10 })}\n\n`); } catch {} - } else { + log("error", "Stream error", { message: e.message }); + try { + abortController.abort(); + } catch {} // 打断 CC 上游 if (!started) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', + sendJSON(res, 502, { + error: { + message: `Upstream error: ${e.message}`, + type: "proxy_error", + input_tokens: 0, + }, + retry_after: 10, }); - started = true; + return; + } + if (!res.writableEnded) { + try { + res.write( + `data: ${JSON.stringify({ error: { message: e.message, type: "proxy_error" } })}\n\n`, + ); + } catch {} } - res.write(translator.getDoneEvent()); - delivered = true; - } - } - } catch (e) { - if (aborted) { - // 客户端已断连,只清理(close handler 已调用 abortController.abort()) - // cancel() 返回 promise:不接住的话,连接已被对端掐断时会抛 UnhandledPromiseRejection - try { reader.cancel().catch(() => {}); } catch {} - } else if (e.message === 'STREAM_IDLE_TIMEOUT') { - log('warn', 'Stream idle timeout', { - path: '/v1/chat/completions', - model, - streaming: true, - timeoutMs: STREAM_IDLE_TIMEOUT_MS, - elapsedMs: Date.now() - startTime, - id: completionId, - bytesReceived, - lastCcEvent: lastCcEvent || '(none)', - inputTokens: translator.inputTokens, - outputTokens: translator.outputTokens, - cachedInputTokens: translator.cachedInputTokens, - }); - try { reader.cancel().catch(() => {}); } catch {} - try { abortController.abort(); } catch {} // 打断 CC 上游,避免浪费 token - consecutiveTimeouts++; - const timeoutMsg = consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD - ? 'Response timeout - try reducing context length (summarize earlier messages)' - : 'Response timeout - request timed out'; - if (!started) { - sendJSON(res, 429, { error: { message: timeoutMsg, type: 'rate_limit_error', input_tokens: 0 }, retry_after: 5 }); - return; - } - if (!res.writableEnded) { - // 必须 end() 而不是 destroy():res.write 是异步的,紧接着 destroy 会把尚未 - // 刷出的缓冲丢掉并发 RST。反向代理看到上游连接被重置,要么回 502,要么让 - // 客户端看到 connection error —— 这正是"吐字慢 + 间歇性 502"的成因之一。 - // end() 会把错误事件正常送进 SSE 流再发 FIN,客户端 SDK 能按可重试错误处理。 - // 下游若已僵死(不读也不断),由 CLIENT_DRAIN_TIMEOUT_MS 那条路径负责兜底。 - try { res.end(`data: ${JSON.stringify({ error: { message: timeoutMsg, type: 'rate_limit_error' }, retry_after: 5 })}\n\n`); } catch {} - } - // 已经解析出上游语义错误(429/503 等)时不重试:那是有意传下来的信号,重试会把它吞掉 - } else if (!started && !aborted && !translator?.upstreamError - && attempt <= UPSTREAM_RETRY_MAX && isRetryableUpstreamError(e)) { - // 传输层闪断且尚未向下游写过任何字节 → 代理内部静默重试(下游全程无感) - await rewindAttempt('Upstream stream terminated before first byte - retrying', { - message: e.message, - cause: e.cause?.code || e.cause?.message || '(none)', - }); - continue attemptLoop; - } else { - // 传输层错误不要覆盖已经解析到的语义错误:把「上游容量不足」说成「代理挂了」是误导 - if (translator?.upstreamError && !started) { - log('warn', 'Upstream terminated after a parsed semantic error', { message: e.message }); - try { abortController.abort(); } catch {} - sendJSON(res, translator.upstreamError.status, translator.upstreamError.body); - return; - } - log('error', 'Stream error', { message: e.message }); - try { abortController.abort(); } catch {} // 打断 CC 上游 - if (!started) { - sendJSON(res, 502, { error: { message: `Upstream error: ${e.message}`, type: 'proxy_error', input_tokens: 0 }, retry_after: 10 }); - return; - } - if (!res.writableEnded) { - try { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'proxy_error' } })}\n\n`); } catch {} } + } finally { + idle.dispose(); } - } finally { - idle.dispose(); - } - if (!res.writableEnded) res.end(); - } else { - // ── 非流式响应(缓冲完整 NDJSON)── - let reasoningContent = ''; - let finishReason = 'stop'; - let sawFinish = false; - let usage = null; - let toolCalls = null; - - reader = ccResponse.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; + if (!res.writableEnded) res.end(); + } else { + // ── 非流式响应(缓冲完整 NDJSON)── + let reasoningContent = ""; + let finishReason = "stop"; + let sawFinish = false; + let usage = null; + let toolCalls = null; + + reader = ccResponse.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + + const processLines = () => { + const lines = buf.split("\n"); + buf = lines.pop() || ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === "[DONE]" || trimmed.startsWith(":")) + continue; + try { + const event = JSON.parse(trimmed); + switch (event.type) { + case "text-delta": + lastCcEvent = event.type; + fullText += event.text || ""; + break; + case "reasoning-delta": + lastCcEvent = event.type; + reasoningContent += event.text || ""; + break; + case "tool-call": + lastCcEvent = event.type; + toolCalls = toolCalls || []; + toolCalls.push({ + id: event.toolCallId || "call_" + randomUUID().slice(0, 8), + type: "function", + function: { + name: event.toolName || "", + arguments: + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input || {}), + }, + }); + break; + case "finish-step": + case "finish": + lastCcEvent = event.type; + sawFinish = true; + finishReason = mapFinishReason(event.finishReason || "stop"); + if (event.totalUsage) usage = event.totalUsage; + break; + case "error": + lastCcEvent = event.type; + upstreamError = mapCcEventError(event); + log("warn", "CC stream error (non-stream)", { + message: event.error?.message || event.message, + upstreamStatus: upstreamError.reportedStatus, + upstreamRetryable: event.error?.isRetryable, + code: upstreamError.code, + mappedTo: upstreamError.status, + }); + break; + // 无内容的事件:与流式翻译器的静默列表保持一致。 + // text-start / start / start-step / reasoning-start 原先只在流式路径被识别, + // 非流式路径会掉进 default 打成 'Unknown CC event type' —— 上游每个响应都会发, + // 于是线上刷屏。它们本身不携带内容(内容在 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 "tool-error": + // Silent - no user-visible content + break; + default: + log("warn", "Unknown CC event type", { type: event.type }); + break; + } + } catch {} + } + }; - const processLines = () => { - const lines = buf.split('\n'); - buf = lines.pop() || ''; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed === '[DONE]' || trimmed.startsWith(':')) continue; - try { - const event = JSON.parse(trimmed); - switch (event.type) { - case 'text-delta': lastCcEvent = event.type; fullText += event.text || ''; break; - case 'reasoning-delta': lastCcEvent = event.type; reasoningContent += event.text || ''; break; - case 'tool-call': - lastCcEvent = event.type; - toolCalls = toolCalls || []; - toolCalls.push({ - id: event.toolCallId || ('call_' + randomUUID().slice(0, 8)), - type: 'function', - function: { - name: event.toolName || '', - arguments: typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {}), - }, - }); - break; - case 'finish-step': - case 'finish': - lastCcEvent = event.type; - sawFinish = true; - finishReason = mapFinishReason(event.finishReason || 'stop'); - if (event.totalUsage) usage = event.totalUsage; - break; - case 'error': - lastCcEvent = event.type; - upstreamError = mapCcEventError(event); - log('warn', 'CC stream error (non-stream)', { - message: event.error?.message || event.message, - upstreamStatus: upstreamError.reportedStatus, - upstreamRetryable: event.error?.isRetryable, - code: upstreamError.code, - mappedTo: upstreamError.status, - }); - break; - // 无内容的事件:与流式翻译器的静默列表保持一致。 - // text-start / start / start-step / reasoning-start 原先只在流式路径被识别, - // 非流式路径会掉进 default 打成 'Unknown CC event type' —— 上游每个响应都会发, - // 于是线上刷屏。它们本身不携带内容(内容在 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 'tool-error': - // Silent - no user-visible content - break; - default: - log('warn', 'Unknown CC event type', { type: event.type }); - break; - } - } catch {} + const idle = createIdleWatchdog(NONSTREAM_IDLE_TIMEOUT_MS); + // 读循环抛错(闪断)时也必须释放看门狗:否则每次失败尝试都会留下一个 armed 的定时器, + // 重试期间累积(流式路径的 finally 已覆盖同一件事) + try { + while (true) { + const result = await Promise.race([reader.read(), idle.arm()]); + const { done, value } = result; + if (done) break; + bytesReceived += value.length; + const chunkText = decoder.decode(value, { stream: true }); + buf += chunkText; + // 无换行则不可能产生完整行,跳过全量 split(见 handleChatCompletions 流式段同处说明) + if (chunkText.indexOf("\n") !== -1) processLines(); + } + } finally { + idle.dispose(); } - }; + processLines(); - const idle = createIdleWatchdog(NONSTREAM_IDLE_TIMEOUT_MS); - // 读循环抛错(闪断)时也必须释放看门狗:否则每次失败尝试都会留下一个 armed 的定时器, - // 重试期间累积(流式路径的 finally 已覆盖同一件事) - try { - while (true) { - const result = await Promise.race([reader.read(), idle.arm()]); - const { done, value } = result; - if (done) break; - bytesReceived += value.length; - const chunkText = decoder.decode(value, { stream: true }); - buf += chunkText; - // 无换行则不可能产生完整行,跳过全量 split(见 handleChatCompletions 流式段同处说明) - if (chunkText.indexOf('\n') !== -1) processLines(); + if (upstreamError) { + sendJSON(res, upstreamError.status, upstreamError.body); + return; } - } finally { - idle.dispose(); - } - processLines(); - if (upstreamError) { - sendJSON(res, upstreamError.status, upstreamError.body); - return; - } + // 上游没有正常走完 finish —— 对齐 CLI 按可重试 502 处理,不谎报成功 + const incomplete = incompleteUpstreamDetail(sawFinish, finishReason); + if (incomplete) { + // 尚未向下游写过任何字节(非流式此时 headers 还没发)→ 与传输层闪断同等对待,先重试 + if (!aborted && !upstreamError && attempt <= UPSTREAM_RETRY_MAX) { + await rewindAttempt( + "Upstream stream ended incomplete before first byte - retrying", + { reason: incomplete }, + ); + continue attemptLoop; + } + log("warn", "Upstream stream incomplete", { + path: "/v1/chat/completions", + reason: incomplete, + }); + const err = incompleteUpstreamError(incomplete); + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + sendJSON(res, err.status, err.body); + return; + } - // 上游没有正常走完 finish —— 对齐 CLI 按可重试 502 处理,不谎报成功 - const incomplete = incompleteUpstreamDetail(sawFinish, finishReason); - if (incomplete) { - // 尚未向下游写过任何字节(非流式此时 headers 还没发)→ 与传输层闪断同等对待,先重试 - if (!aborted && !upstreamError && attempt <= UPSTREAM_RETRY_MAX) { - await rewindAttempt('Upstream stream ended incomplete before first byte - retrying', { reason: incomplete }); - continue attemptLoop; + // 输出 token 为 0 时记为错误,避免下游异常计费 + if ((usage?.outputTokens ?? 0) === 0) { + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + sendJSON(res, 429, { + error: { + message: "Empty response from upstream (zero output tokens)", + type: "rate_limit_error", + }, + retry_after: 10, + }); + return; } - log('warn', 'Upstream stream incomplete', { path: '/v1/chat/completions', reason: incomplete }); - const err = incompleteUpstreamError(incomplete); - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - sendJSON(res, err.status, err.body); - return; - } - // 输出 token 为 0 时记为错误,避免下游异常计费 - if ((usage?.outputTokens ?? 0) === 0) { - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - sendJSON(res, 429, { error: { message: 'Empty response from upstream (zero output tokens)', type: 'rate_limit_error' }, retry_after: 10 }); - return; + consecutiveTimeouts = 0; + sendJSON(res, 200, { + id: completionId, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: Object.assign( + { role: "assistant", content: fullText || null }, + toolCalls ? { tool_calls: toolCalls } : {}, + reasoningContent ? { reasoning_content: reasoningContent } : {}, + ), + finish_reason: toOpenAIFinishReason(finishReason), + }, + ], + usage: (() => { + if (!usage) usage = {}; + normalizeUsage(usage); + return { + prompt_tokens: usage.inputTokens ?? 0, + completion_tokens: usage.outputTokens ?? 0, + total_tokens: + (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0), + prompt_tokens_details: { + cached_tokens: usage.cachedInputTokens ?? 0, + }, + }; + })(), + }); + delivered = true; } - - consecutiveTimeouts = 0; - sendJSON(res, 200, { - id: completionId, - object: 'chat.completion', - created, - model, - choices: [{ - index: 0, - message: Object.assign( - { role: 'assistant', content: fullText || null }, - toolCalls ? { tool_calls: toolCalls } : {}, - reasoningContent ? { reasoning_content: reasoningContent } : {}, - ), - finish_reason: toOpenAIFinishReason(finishReason), - }], - usage: (() => { - if (!usage) usage = {}; - normalizeUsage(usage); - return { - prompt_tokens: usage.inputTokens ?? 0, - completion_tokens: usage.outputTokens ?? 0, - total_tokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0), - prompt_tokens_details: { cached_tokens: usage.cachedInputTokens ?? 0 }, - }; - })(), - }); - delivered = true; - } - // 只有本次尝试真的交付了正常响应才算「重试救回来了」; - // 已向下游报错的尝试(502/429)不能记成 recovered - if (attempt > 1 && delivered) { - upstreamRetryStats.recovered++; - log('info', 'Upstream retry recovered', { - path: '/v1/chat/completions', model, attempt, elapsedMs: Date.now() - startTime, - }); - } - break attemptLoop; // 本次尝试已完整处理(成功或已按语义返回错误) - } catch (e) { - if (abortController.signal.aborted) { - log('warn', 'Request cancelled (client disconnected before CC response)', { - path: '/v1/chat/completions', - model, - completionId, - }); - return; // 下游已断连:不再重试(res 已关闭) - } else if (e.message === 'STREAM_IDLE_TIMEOUT') { - log('warn', 'Stream idle timeout', { - path: '/v1/chat/completions', - model, - streaming: false, - timeoutMs: NONSTREAM_IDLE_TIMEOUT_MS, - elapsedMs: Date.now() - startTime, - id: completionId, - bytesReceived, - lastCcEvent: lastCcEvent || '(none)', - partialLen: fullText ? fullText.length : 0, - }); - try { reader?.cancel().catch(() => {}); } catch {} - try { abortController.abort(); } catch {} // 打断 CC 上游 - consecutiveTimeouts++; - const timeoutMsg = consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD - ? 'Response timeout - try reducing context length (summarize earlier messages)' - : 'Response timeout - request timed out'; - res.setHeader('Retry-After', '5'); - sendJSON(res, 429, { error: { message: timeoutMsg, type: 'rate_limit_error', input_tokens: 0 }, retry_after: 5 }); - return; // 超时已按语义回给下游(由下游决定是否重试),本代理不重试 - // 已解析出上游语义错误(429/503 等)时不重试:那是有意传下来的信号,重试会把它吞掉 - } else if (!res.headersSent && !aborted && !upstreamError && !translator?.upstreamError - && attempt <= UPSTREAM_RETRY_MAX && isRetryableUpstreamError(e)) { - // 传输层闪断且尚未向下游写出任何字节 → 代理内部静默重试(下游全程无感) - await rewindAttempt('Upstream error before first byte - retrying', { - message: e.message, - cause: e.cause?.code || e.cause?.message || '(none)', - }); - continue attemptLoop; - } else { - // 传输层错误不要覆盖已经解析到的语义错误:把「上游容量不足」说成「代理挂了」是误导 - const semantic = upstreamError || translator?.upstreamError; - if (semantic && !res.headersSent) { - log('warn', 'Upstream terminated after a parsed semantic error', { message: e.message }); - try { abortController.abort(); } catch {} - sendJSON(res, semantic.status, semantic.body); + // 只有本次尝试真的交付了正常响应才算「重试救回来了」; + // 已向下游报错的尝试(502/429)不能记成 recovered + if (attempt > 1 && delivered) { + upstreamRetryStats.recovered++; + log("info", "Upstream retry recovered", { + path: "/v1/chat/completions", + model, + attempt, + elapsedMs: Date.now() - startTime, + }); + } + break attemptLoop; // 本次尝试已完整处理(成功或已按语义返回错误) + } catch (e) { + if (abortController.signal.aborted) { + log( + "warn", + "Request cancelled (client disconnected before CC response)", + { + path: "/v1/chat/completions", + model, + completionId, + }, + ); + return; // 下游已断连:不再重试(res 已关闭) + } else if (e.message === "STREAM_IDLE_TIMEOUT") { + log("warn", "Stream idle timeout", { + path: "/v1/chat/completions", + model, + streaming: false, + timeoutMs: NONSTREAM_IDLE_TIMEOUT_MS, + elapsedMs: Date.now() - startTime, + id: completionId, + bytesReceived, + lastCcEvent: lastCcEvent || "(none)", + partialLen: fullText ? fullText.length : 0, + }); + try { + reader?.cancel().catch(() => {}); + } catch {} + try { + abortController.abort(); + } catch {} // 打断 CC 上游 + consecutiveTimeouts++; + const timeoutMsg = + consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD + ? "Response timeout - try reducing context length (summarize earlier messages)" + : "Response timeout - request timed out"; + res.setHeader("Retry-After", "5"); + sendJSON(res, 429, { + error: { + message: timeoutMsg, + type: "rate_limit_error", + input_tokens: 0, + }, + retry_after: 5, + }); + return; // 超时已按语义回给下游(由下游决定是否重试),本代理不重试 + // 已解析出上游语义错误(429/503 等)时不重试:那是有意传下来的信号,重试会把它吞掉 + } else if ( + !res.headersSent && + !aborted && + !upstreamError && + !translator?.upstreamError && + attempt <= UPSTREAM_RETRY_MAX && + isRetryableUpstreamError(e) + ) { + // 传输层闪断且尚未向下游写出任何字节 → 代理内部静默重试(下游全程无感) + await rewindAttempt("Upstream error before first byte - retrying", { + message: e.message, + cause: e.cause?.code || e.cause?.message || "(none)", + }); + continue attemptLoop; + } else { + // 传输层错误不要覆盖已经解析到的语义错误:把「上游容量不足」说成「代理挂了」是误导 + const semantic = upstreamError || translator?.upstreamError; + if (semantic && !res.headersSent) { + log("warn", "Upstream terminated after a parsed semantic error", { + message: e.message, + }); + try { + abortController.abort(); + } catch {} + sendJSON(res, semantic.status, semantic.body); + return; + } + log("error", "Upstream error", { message: e.message }); + try { + abortController.abort(); + } catch {} // 打断 CC 上游 + sendJSON(res, 502, { + error: { + message: `Upstream error: ${e.message}`, + type: "proxy_error", + input_tokens: 0, + }, + retry_after: 10, + }); return; } - log('error', 'Upstream error', { message: e.message }); - try { abortController.abort(); } catch {} // 打断 CC 上游 - sendJSON(res, 502, { error: { message: `Upstream error: ${e.message}`, type: 'proxy_error', input_tokens: 0 }, retry_after: 10 }); - return; } - } - } // ← end of attemptLoop + } // ← end of attemptLoop } // ── Anthropic /v1/messages 协议转换 ───────────────── function mapAnthropicStopReason(finishReason) { switch (finishReason) { - case 'tool_calls': return 'tool_use'; - case 'length': return 'max_tokens'; - case 'stop': return 'end_turn'; + case "tool_calls": + return "tool_use"; + case "length": + return "max_tokens"; + case "stop": + return "end_turn"; // Anthropic 的原生枚举,必须原样透出:它表示「这一轮被暂停,后面还有内容」。 // 折成 end_turn 会让下游把半截回答当成写完了(CLI 是靠自动续写把它吸收掉的, // 代理不自动续写,就必须如实上报,不能吞掉)。 - case 'pause_turn': return 'pause_turn'; - case 'refusal': return 'refusal'; - default: return 'end_turn'; + case "pause_turn": + return "pause_turn"; + case "refusal": + return "refusal"; + default: + return "end_turn"; } } // OpenAI 的 finish_reason 只有 stop | length | tool_calls | content_filter | function_call。 // pause_turn 没有对应值:折成 'stop' 是谎报完成(正是要修的问题), // 折成 'length' 至少如实表达了「输出不完整」,下游的截断处理会做对的事。 -const OPENAI_FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_filter', 'function_call']); +const OPENAI_FINISH_REASONS = new Set([ + "stop", + "length", + "tool_calls", + "content_filter", + "function_call", +]); // 内部规范化取值 → 可透出的 OpenAI finish_reason。必须**全函数**:任何非标准值 // 原样写进 SSE / JSON body,严格客户端(GrokZen 的 FinishReason 是无兜底的枚举) @@ -2085,11 +2653,14 @@ const OPENAI_FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_ // 写 response was truncated,既把「完成了但原因不认识」说成「没完成」,又白丢 // 一轮回答(provider 若稳定返回 other 会重试到上限后整轮失败)。 function toOpenAIFinishReason(finishReason) { - if (finishReason === 'pause_turn') return 'length'; - if (finishReason === 'upstream_error') return finishReason; + if (finishReason === "pause_turn") return "length"; + if (finishReason === "upstream_error") return finishReason; if (OPENAI_FINISH_REASONS.has(finishReason)) return finishReason; - log('warn', 'Unrecognized upstream finish reason', { reason: String(finishReason), mappedTo: 'stop' }); - return 'stop'; + log("warn", "Unrecognized upstream finish reason", { + reason: String(finishReason), + mappedTo: "stop", + }); + return "stop"; } // Generate a Claude-format fake signature for thinking blocks. @@ -2100,40 +2671,69 @@ function toOpenAIFinishReason(finishReason) { // The payload is derived from the thinking text so each block's signature // differs (closer to spec, avoids identical-signature quirks). function fakeThinkingSignature(thinkingText) { - const seed = crypto.createHash('sha256').update(thinkingText || 'dsh-proxy-thinking').digest().subarray(0, 64); + const seed = crypto + .createHash("sha256") + .update(thinkingText || "dsh-proxy-thinking") + .digest() + .subarray(0, 64); const raw = Buffer.concat([Buffer.from([0x12, seed.length]), seed]); - return raw.toString('base64'); + return raw.toString("base64"); } -function buildAnthropicResponse(model, fullText, toolCalls, finishReason, usage, thinkingText) { +function buildAnthropicResponse( + model, + fullText, + toolCalls, + finishReason, + usage, + thinkingText, +) { const content = []; - if (thinkingText) content.push({ type: 'thinking', thinking: thinkingText, signature: fakeThinkingSignature(thinkingText) }); - if (fullText) content.push({ type: 'text', text: fullText }); + if (thinkingText) + content.push({ + type: "thinking", + thinking: thinkingText, + signature: fakeThinkingSignature(thinkingText), + }); + if (fullText) content.push({ type: "text", text: fullText }); if (toolCalls) { for (const tc of toolCalls) { let input = {}; - try { input = JSON.parse(tc.function.arguments); } catch { input = {}; } - content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input }); + try { + input = JSON.parse(tc.function.arguments); + } catch { + input = {}; + } + content.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input, + }); } } return { id: `msg_${randomUUID().slice(0, 12)}`, - type: 'message', - role: 'assistant', + type: "message", + role: "assistant", model, content, - stop_reason: mapAnthropicStopReason(finishReason || 'stop'), + stop_reason: mapAnthropicStopReason(finishReason || "stop"), stop_sequence: null, usage: (() => { normalizeUsage(usage || {}); // CC 未回报 usage 时按内容长度估算输出 token,避免客户端展示/记账为 0 - const estOut = Math.max(1, - Math.ceil(((fullText || '').length + (thinkingText || '').length) / 4) + (toolCalls ? toolCalls.length * 20 : 0)); + const estOut = Math.max( + 1, + Math.ceil(((fullText || "").length + (thinkingText || "").length) / 4) + + (toolCalls ? toolCalls.length * 20 : 0), + ); return { // input_tokens 只计非缓存部分(Anthropic 语义),与 cache_* 相加才等于总输入 input_tokens: anthropicInputTokens(usage), output_tokens: usage?.outputTokens || estOut, - cache_creation_input_tokens: usage?.inputTokenDetails?.cacheWriteTokens ?? 0, + cache_creation_input_tokens: + usage?.inputTokenDetails?.cacheWriteTokens ?? 0, cache_read_input_tokens: usage?.cachedInputTokens ?? 0, }; })(), @@ -2142,21 +2742,21 @@ function buildAnthropicResponse(model, fullText, toolCalls, finishReason, usage, function convertAnthropicToOpenAI(anthropicReq) { // 1. Extract system prompt (top-level, not in messages array) - let systemPrompt = ''; + let systemPrompt = ""; let systemBlocks = null; if (anthropicReq.system) { - if (typeof anthropicReq.system === 'string') { + if (typeof anthropicReq.system === "string") { systemPrompt = anthropicReq.system; } else if (Array.isArray(anthropicReq.system)) { // 保留 cache_control:buildCcRequest 需要块数组才能把断点下发(CLI 的 params.system 就是块数组) systemBlocks = anthropicReq.system - .filter(b => b && b.type === 'text') - .map(b => { - const blk = { type: 'text', text: b.text ?? '' }; + .filter((b) => b && b.type === "text") + .map((b) => { + const blk = { type: "text", text: b.text ?? "" }; if (b.cache_control) blk.cache_control = b.cache_control; return blk; }); - systemPrompt = systemBlocks.map(b => b.text).join('\n'); + systemPrompt = systemBlocks.map((b) => b.text).join("\n"); } } @@ -2165,33 +2765,42 @@ function convertAnthropicToOpenAI(anthropicReq) { const openaiMessages = []; if (systemPrompt) { - openaiMessages.push({ role: 'system', content: systemBlocks && systemBlocks.length ? systemBlocks : systemPrompt }); + openaiMessages.push({ + role: "system", + content: + systemBlocks && systemBlocks.length ? systemBlocks : systemPrompt, + }); } const messages = anthropicReq.messages || []; for (const msg of messages) { - if (msg.role === 'assistant') { - let textContent = ''; + if (msg.role === "assistant") { + let textContent = ""; // Anthropic 的 thinking block 承载思考内容,需转成 reasoning_content // 交给 buildCcRequest 回传,否则 CC 会因缺少 reasoning 而拒绝 - let thinkingContent = ''; + let thinkingContent = ""; const textParts = []; let textHasCache = false; const toolCalls = []; - const blocks = Array.isArray(msg.content) ? msg.content : [{ type: 'text', text: msg.content || '' }]; + const blocks = Array.isArray(msg.content) + ? msg.content + : [{ type: "text", text: msg.content || "" }]; for (const block of blocks) { - if (block.type === 'text') { - textContent += block.text || ''; - const part = { type: 'text', text: block.text || '' }; - if (block.cache_control) { part.cache_control = block.cache_control; textHasCache = true; } + if (block.type === "text") { + textContent += block.text || ""; + const part = { type: "text", text: block.text || "" }; + if (block.cache_control) { + part.cache_control = block.cache_control; + textHasCache = true; + } textParts.push(part); - } else if (block.type === 'thinking') { - thinkingContent += block.thinking || ''; - } else if (block.type === 'tool_use') { + } else if (block.type === "thinking") { + thinkingContent += block.thinking || ""; + } else if (block.type === "tool_use") { toolNameFromId[block.id] = block.name; toolCalls.push({ id: block.id, - type: 'function', + type: "function", function: { name: block.name, arguments: JSON.stringify(block.input || {}), @@ -2199,33 +2808,43 @@ function convertAnthropicToOpenAI(anthropicReq) { }); } } - const assistantMsg = { role: 'assistant', content: (textParts.length > 1 || textHasCache) ? textParts : (textContent || null) }; + const assistantMsg = { + role: "assistant", + content: + textParts.length > 1 || textHasCache + ? textParts + : textContent || null, + }; if (thinkingContent) assistantMsg.reasoning_content = thinkingContent; if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls; openaiMessages.push(assistantMsg); - } else if (msg.role === 'user') { - let textContent = ''; + } else if (msg.role === "user") { + let textContent = ""; // parts 保持原始顺序(text / image_url),与 CLI 的 toWireMessages 一致 const parts = []; let textHasCache = false; const toolResults = []; - if (typeof msg.content === 'string') { + if (typeof msg.content === "string") { textContent = msg.content; } else if (Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === 'text') { - textContent += block.text || ''; - const part = { type: 'text', text: block.text || '' }; - if (block.cache_control) { part.cache_control = block.cache_control; textHasCache = true; } + if (block.type === "text") { + textContent += block.text || ""; + const part = { type: "text", text: block.text || "" }; + if (block.cache_control) { + part.cache_control = block.cache_control; + textHasCache = true; + } parts.push(part); - } else if (block.type === 'image') { + } else if (block.type === "image") { // Anthropic 图片块:{ type:'image', source:{ type:'base64', media_type, data } } 或 source.url const s = block.source || {}; - const url = s.type === 'base64' && s.data - ? `data:${s.media_type || 'image/png'};base64,${s.data}` - : (s.url || ''); - if (url) parts.push({ type: 'image_url', image_url: { url } }); - } else if (block.type === 'tool_result') { + const url = + s.type === "base64" && s.data + ? `data:${s.media_type || "image/png"};base64,${s.data}` + : s.url || ""; + if (url) parts.push({ type: "image_url", image_url: { url } }); + } else if (block.type === "tool_result") { toolResults.push(block); } } @@ -2235,28 +2854,42 @@ function convertAnthropicToOpenAI(anthropicReq) { // tool_calls,同一条 user 消息里的文本要排在 tool 结果之后 } for (const tr of toolResults) { - const toolContent = typeof tr.content === 'string' ? tr.content - : Array.isArray(tr.content) ? tr.content.map(c => c.text || '').join('\n') - : String(tr.content || ''); + const toolContent = + typeof tr.content === "string" + ? tr.content + : Array.isArray(tr.content) + ? tr.content.map((c) => c.text || "").join("\n") + : String(tr.content || ""); // OpenAI 语义里 tool 消息的 name 是可选的;会话恢复等场景下 tool_use_id 可能 // 找不到对应 assistant tool_use(历史被客户端裁剪),此时不硬塞空 name, // 避免 CC 上游报 "Tool result is missing"(issue #15) - const toolMsg = { role: 'tool', tool_call_id: tr.tool_use_id, content: toolContent }; - if (toolNameFromId[tr.tool_use_id]) toolMsg.name = toolNameFromId[tr.tool_use_id]; + const toolMsg = { + role: "tool", + tool_call_id: tr.tool_use_id, + content: toolContent, + }; + if (toolNameFromId[tr.tool_use_id]) + toolMsg.name = toolNameFromId[tr.tool_use_id]; openaiMessages.push(toolMsg); } if (parts.length || textContent) { // 单块纯文本仍用字符串(线格不变);多块 / 带断点 / 含图片时用块数组(CLI 的形态)。 // 注意:content 为字符串时 parts 为空,必须用 textContent 判空(否则整条消息会丢) - const singleText = parts.length <= 1 && (parts.length === 0 || parts[0].type === 'text') && !textHasCache; - openaiMessages.push({ role: 'user', content: singleText ? textContent : parts }); + const singleText = + parts.length <= 1 && + (parts.length === 0 || parts[0].type === "text") && + !textHasCache; + openaiMessages.push({ + role: "user", + content: singleText ? textContent : parts, + }); } } } // 3. Build OpenAI request const openaiReq = { - model: anthropicReq.model || 'deepseek/deepseek-v4-flash', + model: anthropicReq.model || "deepseek/deepseek-v4-flash", messages: openaiMessages, max_tokens: anthropicReq.max_tokens || 64000, stream: anthropicReq.stream === true, @@ -2264,12 +2897,12 @@ function convertAnthropicToOpenAI(anthropicReq) { // 4. Map tools if (anthropicReq.tools && anthropicReq.tools.length > 0) { - openaiReq.tools = anthropicReq.tools.map(t => ({ - type: 'function', + openaiReq.tools = anthropicReq.tools.map((t) => ({ + type: "function", function: { name: t.name, - description: t.description || '', - parameters: t.input_schema || { type: 'object', properties: {} }, + description: t.description || "", + parameters: t.input_schema || { type: "object", properties: {} }, }, })); } @@ -2277,35 +2910,37 @@ function convertAnthropicToOpenAI(anthropicReq) { // 5. Map tool_choice if (anthropicReq.tool_choice) { const tc = anthropicReq.tool_choice; - if (tc.type === 'auto' || tc.type === undefined) { - openaiReq.tool_choice = 'auto'; - } else if (tc.type === 'any') { - openaiReq.tool_choice = 'required'; - } else if (tc.type === 'tool') { - openaiReq.tool_choice = { type: 'function', function: { name: tc.name } }; - } else if (tc.type === 'none') { - openaiReq.tool_choice = 'none'; + if (tc.type === "auto" || tc.type === undefined) { + openaiReq.tool_choice = "auto"; + } else if (tc.type === "any") { + openaiReq.tool_choice = "required"; + } else if (tc.type === "tool") { + openaiReq.tool_choice = { type: "function", function: { name: tc.name } }; + } else if (tc.type === "none") { + openaiReq.tool_choice = "none"; } } // 6. Optional params - if (anthropicReq.temperature !== undefined) openaiReq.temperature = anthropicReq.temperature; + if (anthropicReq.temperature !== undefined) + openaiReq.temperature = anthropicReq.temperature; if (anthropicReq.top_p !== undefined) openaiReq.top_p = anthropicReq.top_p; if (anthropicReq.stop_sequences) openaiReq.stop = anthropicReq.stop_sequences; - if (anthropicReq.metadata?.user_id) openaiReq.user = anthropicReq.metadata.user_id; + if (anthropicReq.metadata?.user_id) + openaiReq.user = anthropicReq.metadata.user_id; // 7. Anthropic thinking → reasoning_effort(LiteLLM 标准映射) if (anthropicReq.thinking) { const t = anthropicReq.thinking; - if (t.type === 'disabled' || t.type === 'none') { + if (t.type === "disabled" || t.type === "none") { // 不发送 reasoning_effort - } else if (t.type === 'adaptive') { - openaiReq.reasoning_effort = t.effort ?? 'medium'; + } else if (t.type === "adaptive") { + openaiReq.reasoning_effort = t.effort ?? "medium"; } else if (t.budget_tokens !== undefined) { - if (t.budget_tokens >= 10000) openaiReq.reasoning_effort = 'high'; - else if (t.budget_tokens >= 5000) openaiReq.reasoning_effort = 'medium'; - else if (t.budget_tokens >= 2000) openaiReq.reasoning_effort = 'low'; - else openaiReq.reasoning_effort = 'low'; // <2000 → low + if (t.budget_tokens >= 10000) openaiReq.reasoning_effort = "high"; + else if (t.budget_tokens >= 5000) openaiReq.reasoning_effort = "medium"; + else if (t.budget_tokens >= 2000) openaiReq.reasoning_effort = "low"; + else openaiReq.reasoning_effort = "low"; // <2000 → low } } @@ -2325,7 +2960,7 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { let outputTokens = 0; let cachedInputTokens = 0; let cacheWriteTokens = 0; - let noCacheTokens = -1; // -1 = 上游未提供该字段,改用减法兜底 + let noCacheTokens = -1; // -1 = 上游未提供该字段,改用减法兜底 let stopReason = null; // 归一化后的 finishReason(mapAnthropicStopReason 之前的值),用于判定「是否正常结束」 let finishNorm = null; @@ -2333,7 +2968,7 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { // 'finish-step' 不在 CLI 的事件集里,故这里同样只认 'finish'。 let sawFinish = false; let hasError = false; - let currentThinkingText = ''; // accumulated thinking text for the open block + let currentThinkingText = ""; // accumulated thinking text for the open block // Close the current block (text or thinking) if one is active. // For thinking blocks, emit a signature_delta (Anthropic standard) before stop. @@ -2341,16 +2976,19 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { if (blockStarted) { const idx = currentBlockIndex; const type = currentBlockType; - let out = ''; - if (type === 'thinking') { - out += `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: idx, delta: { type: 'signature_delta', signature: fakeThinkingSignature(currentThinkingText) } })}\n\n`; - currentThinkingText = ''; + let out = ""; + if (type === "thinking") { + out += `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: idx, delta: { type: "signature_delta", signature: fakeThinkingSignature(currentThinkingText) } })}\n\n`; + currentThinkingText = ""; } blockStarted = false; currentBlockType = null; - return out + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: idx })}\n\n`; + return ( + out + + `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: idx })}\n\n` + ); } - return ''; + return ""; } const closeTextBlock = closeBlock; @@ -2361,37 +2999,40 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { currentBlockIndex = nextBlockIndex++; currentBlockType = type; blockStarted = true; - return close + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: currentBlockIndex, content_block: contentBlock })}\n\n`; + return ( + close + + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: currentBlockIndex, content_block: contentBlock })}\n\n` + ); } - return ''; + return ""; } // Open a new text block (closing any previous block first) function startTextBlock() { - return startBlock('text', { type: 'text', text: '' }); + return startBlock("text", { type: "text", text: "" }); } // Open a new thinking block (closing any previous block first) function startThinkingBlock() { - return startBlock('thinking', { type: 'thinking', thinking: '' }); + return startBlock("thinking", { type: "thinking", thinking: "" }); } // Emit message_start (always the first event) yield `event: message_start\ndata: ${JSON.stringify({ - type: 'message_start', + type: "message_start", message: { id: messageId, - type: 'message', - role: 'assistant', + type: "message", + role: "assistant", content: [], model, usage: { input_tokens: 0, output_tokens: 0 }, - } + }, })}\n\n`; const reader = response.body.getReader(); const decoder = new TextDecoder(); - let buffer = ''; + let buffer = ""; const idle = createIdleWatchdog(STREAM_IDLE_TIMEOUT_MS); try { @@ -2404,68 +3045,80 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { buffer += chunkText; // 同 handleChatCompletions:无换行即无完整行,跳过全量 split let lines = []; - if (chunkText.indexOf('\n') !== -1) { - lines = buffer.split('\n'); - buffer = lines.pop() || ''; + if (chunkText.indexOf("\n") !== -1) { + lines = buffer.split("\n"); + buffer = lines.pop() || ""; } let hadOutput = false; for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || trimmed === '[DONE]') continue; + if (!trimmed || trimmed === "[DONE]") continue; let event; - try { event = JSON.parse(trimmed); } catch { continue; } + try { + event = JSON.parse(trimmed); + } catch { + continue; + } if (!event.type) continue; ctx.lastCcEvent = event.type; switch (event.type) { - case 'start': case 'start-step': case 'text-start': case 'reasoning-start': + case "start": + case "start-step": + case "text-start": + case "reasoning-start": // Signal events, no user-visible data break; - case 'reasoning-delta': { + case "reasoning-delta": { // CC reasoning → Anthropic thinking block (Claude Code shows this as thinking) - const text = event.text || ''; + const text = event.text || ""; if (!text) break; const startBlock = startThinkingBlock(); currentThinkingText += text; - yield startBlock + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: currentBlockIndex, delta: { type: 'thinking_delta', thinking: text } })}\n\n`; + yield startBlock + + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: currentBlockIndex, delta: { type: "thinking_delta", thinking: text } })}\n\n`; hadOutput = true; break; } - case 'text-delta': { - const text = event.text || ''; + case "text-delta": { + const text = event.text || ""; const startBlock = startTextBlock(); - yield startBlock + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: currentBlockIndex, delta: { type: 'text_delta', text } })}\n\n`; + yield startBlock + + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: currentBlockIndex, delta: { type: "text_delta", text } })}\n\n`; outputTokens += 1; hadOutput = true; break; } - case 'tool-call': { + case "tool-call": { // Close any pending text block const closeBlock = closeTextBlock(); if (closeBlock) yield closeBlock; const id = event.toolCallId || `toolu_${randomUUID().slice(0, 12)}`; - const name = event.toolName || ''; - const input = typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {}); + const name = event.toolName || ""; + const input = + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input || {}); const tcIndex = nextBlockIndex++; - yield `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: tcIndex, content_block: { type: 'tool_use', id, name, input: {} } })}\n\n`; - yield `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: tcIndex, delta: { type: 'input_json_delta', partial_json: input } })}\n\n`; - yield `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: tcIndex })}\n\n`; + yield `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: tcIndex, content_block: { type: "tool_use", id, name, input: {} } })}\n\n`; + yield `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: tcIndex, delta: { type: "input_json_delta", partial_json: input } })}\n\n`; + yield `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: tcIndex })}\n\n`; outputTokens += 20; break; } - case 'finish-step': - case 'finish': { + case "finish-step": + case "finish": { // 上游的 finishReason 是 'tool-calls'(连字符),必须先过 mapFinishReason 规范化成 // 'tool_calls',否则会掉进 mapAnthropicStopReason 的 default 变成 end_turn。 // 真机实测踩到过:工具调用成功但 stop_reason 报 end_turn。 - sawFinish = true; // finish-step 与 finish 都算完成信号 + sawFinish = true; // finish-step 与 finish 都算完成信号 if (event.finishReason) { finishNorm = mapFinishReason(event.finishReason); stopReason = mapAnthropicStopReason(finishNorm); @@ -2476,8 +3129,9 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { inputTokens = u.inputTokens ?? inputTokens; outputTokens = u.outputTokens ?? outputTokens; cachedInputTokens = u.cachedInputTokens ?? cachedInputTokens; - cacheWriteTokens = u.inputTokenDetails?.cacheWriteTokens ?? cacheWriteTokens; - if (typeof u.inputTokenDetails?.noCacheTokens === 'number') { + cacheWriteTokens = + u.inputTokenDetails?.cacheWriteTokens ?? cacheWriteTokens; + if (typeof u.inputTokenDetails?.noCacheTokens === "number") { noCacheTokens = u.inputTokenDetails.noCacheTokens; } ctx.inputTokens = inputTokens; @@ -2489,19 +3143,25 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { break; } - case 'error': { + case "error": { hasError = true; const upstreamError = mapCcEventError(event); ctx.upstreamError = upstreamError; - yield `event: error\ndata: ${JSON.stringify({ type: 'error', error: upstreamError.body.error })}\n\n`; + yield `event: error\ndata: ${JSON.stringify({ type: "error", error: upstreamError.body.error })}\n\n`; 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 "tool-input-start": + case "tool-input-delta": + case "tool-input-end": + case "tool-error": + case "text-end": // Silent - no user-visible content break; default: - log('warn', 'Unknown CC event type', { type: event.type }); + log("warn", "Unknown CC event type", { type: event.type }); break; } } @@ -2525,42 +3185,51 @@ async function* createAnthropicSseTranslator(response, model, messageId, ctx) { // 对齐 CLI:这一族一律按可重试错误处理。 const incomplete = incompleteUpstreamDetail(sawFinish, finishNorm); if (incomplete) { - log('warn', 'Upstream stream incomplete', { path: '/v1/messages', reason: incomplete }); - yield `event: error\ndata: ${JSON.stringify({ type: 'error', error: incompleteUpstreamError(incomplete).body.error })}\n\n`; - // 输出 token 为 0 时记为错误,避免下游异常计费 + log("warn", "Upstream stream incomplete", { + path: "/v1/messages", + reason: incomplete, + }); + yield `event: error\ndata: ${JSON.stringify({ type: "error", error: incompleteUpstreamError(incomplete).body.error })}\n\n`; + // 输出 token 为 0 时记为错误,避免下游异常计费 } else if (outputTokens === 0) { - yield `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'Empty response from upstream (zero output tokens)' }, retry_after: 10 })}\n\n`; + yield `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "Empty response from upstream (zero output tokens)" }, retry_after: 10 })}\n\n`; } else { yield `event: message_delta\ndata: ${JSON.stringify({ - type: 'message_delta', - delta: { stop_reason: stopReason || 'end_turn' }, + type: "message_delta", + delta: { stop_reason: stopReason || "end_turn" }, usage: { output_tokens: outputTokens, cache_read_input_tokens: cachedInputTokens, cache_creation_input_tokens: cacheWriteTokens || 0, // 只计非缓存部分;否则下游把 input 与 cache_read 相加会得到约两倍(issue #25) - input_tokens: noCacheTokens >= 0 - ? noCacheTokens - : Math.max(0, inputTokens - cachedInputTokens - (cacheWriteTokens || 0)), + input_tokens: + noCacheTokens >= 0 + ? noCacheTokens + : Math.max( + 0, + inputTokens - cachedInputTokens - (cacheWriteTokens || 0), + ), }, })}\n\n`; - yield `event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`; + yield `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`; } } } finally { // 确保流中断时通知上游 idle.dispose(); - try { reader.cancel().catch(() => {}); } catch {} + try { + reader.cancel().catch(() => {}); + } catch {} } } function sendAnthropicError(res, status, type, message, retryAfter) { - const body = { type: 'error', error: { type, message } }; - const headers = { 'Content-Type': 'application/json' }; + const body = { type: "error", error: { type, message } }; + const headers = { "Content-Type": "application/json" }; if (retryAfter !== undefined) { body.retry_after = retryAfter; - headers['Retry-After'] = String(retryAfter); + headers["Retry-After"] = String(retryAfter); } res.writeHead(status, headers); res.end(JSON.stringify(body)); @@ -2572,21 +3241,28 @@ async function handleMessages(req, res) { anthropicReq = await readBody(req); } catch (e) { if (e.statusCode === 413) { - sendAnthropicError(res, 413, 'invalid_request_error', e.message); + sendAnthropicError(res, 413, "invalid_request_error", e.message); return; } - sendAnthropicError(res, 400, 'invalid_request_error', 'Invalid JSON body'); + sendAnthropicError(res, 400, "invalid_request_error", "Invalid JSON body"); return; } const apiKey = getApiKey(req.headers); if (!apiKey) { - sendJSON(res, 401, { type: 'error', error: { type: 'authentication_error', message: 'Missing API key. Send in Authorization: Bearer or x-api-key header' } }); + sendJSON(res, 401, { + type: "error", + error: { + type: "authentication_error", + message: + "Missing API key. Send in Authorization: Bearer or x-api-key header", + }, + }); return; } const stream = anthropicReq.stream === true; - const model = anthropicReq.model || 'claude-sonnet-4-6'; + const model = anthropicReq.model || "claude-sonnet-4-6"; // Convert Anthropic → OpenAI → CC let openaiReq = convertAnthropicToOpenAI(anthropicReq); @@ -2600,41 +3276,67 @@ async function handleMessages(req, res) { let aborted = false; // 提前初始化,断连回调/超时 catch 安全引用(避免块级作用域 ReferenceError) const startTime = Date.now(); - let messageId = ''; + let messageId = ""; let reader = null; - let bytesReceived = 0; let lastCcEvent = ''; let fullText = ''; + let bytesReceived = 0; + let lastCcEvent = ""; + let fullText = ""; try { // 首次初始化(fingerprint + lifecycle) await ensureInitialized(apiKey, abortController.signal); - const ccResponse = await forwardToCC(ccBody, apiKey, req.headers, abortController.signal); + const ccResponse = await forwardToCC( + ccBody, + apiKey, + req.headers, + abortController.signal, + ); if (!ccResponse.ok) { - const errorText = await ccResponse.text().catch(() => ''); + const errorText = await ccResponse.text().catch(() => ""); const mapped = mapCcError(ccResponse.status, errorText); - log('error', 'CC API error (Anthropic)', { status: ccResponse.status, code: mapped.code, body: summarizeUpstreamError(errorText) }); - sendAnthropicError(res, mapped.status, mapped.body.error.type, mapped.body.error.message); + log("error", "CC API error (Anthropic)", { + status: ccResponse.status, + code: mapped.code, + body: summarizeUpstreamError(errorText), + }); + sendAnthropicError( + res, + mapped.status, + mapped.body.error.type, + mapped.body.error.message, + ); return; } // 下游断连检测:打断 CC 上游 + 记录日志 - res.on('close', () => { + res.on("close", () => { if (res.writableEnded) return; // Normal completion, not a disconnect aborted = true; if (!abortController.signal.aborted) { // 断连前抢发 usage=0 终止事件,避免下游自行估算 token try { - res.write(`event: message_delta\ndata: ${JSON.stringify({ - type: 'message_delta', - delta: { stop_reason: 'end_turn' }, - usage: { output_tokens: 0, input_tokens: 0, cache_read_input_tokens: 0 }, - })}\n\n`); - res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`); + res.write( + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + output_tokens: 0, + input_tokens: 0, + cache_read_input_tokens: 0, + }, + })}\n\n`, + ); + res.write( + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ); + } catch {} + try { + abortController.abort(); } catch {} - try { abortController.abort(); } catch {} } - log('warn', 'Client disconnected', { - path: '/v1/messages', + log("warn", "Client disconnected", { + path: "/v1/messages", model, messageId, streaming: stream, @@ -2651,17 +3353,21 @@ async function handleMessages(req, res) { let started = false; const buf = []; const SSE_HEADERS = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", }; const flushBuf = async () => { if (!started) { res.writeHead(200, SSE_HEADERS); started = true; } - for (const ev of buf) { try { res.write(ev); } catch {} } + for (const ev of buf) { + try { + res.write(ev); + } catch {} + } buf.length = 0; await waitDrain(res); }; @@ -2673,23 +3379,46 @@ async function handleMessages(req, res) { const heartbeat = setInterval(() => { // 不向已积压的下游继续塞数据:定时器回调是同步的,无法 await waitDrain, // 因此用 writableNeedDrain 直接跳过本轮心跳(背压场景下少发一个 ping 无副作用) - if (started && !aborted && !res.writableEnded && !res.writableNeedDrain && Date.now() - lastSentAt > 15000) { - try { res.write('event: ping\ndata: {"type":"ping"}\n\n'); lastSentAt = Date.now(); } catch {} + if ( + started && + !aborted && + !res.writableEnded && + !res.writableNeedDrain && + Date.now() - lastSentAt > 15000 + ) { + try { + res.write('event: ping\ndata: {"type":"ping"}\n\n'); + lastSentAt = Date.now(); + } catch {} } }, 5000); let ctx; try { - messageId = 'msg_' + randomUUID().slice(0, 12); - ctx = { bytesReceived: 0, lastCcEvent: '', inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, upstreamError: null }; - const generator = createAnthropicSseTranslator(ccResponse, model, messageId, ctx); + messageId = "msg_" + randomUUID().slice(0, 12); + ctx = { + bytesReceived: 0, + lastCcEvent: "", + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + upstreamError: null, + }; + const generator = createAnthropicSseTranslator( + ccResponse, + model, + messageId, + ctx, + ); for await (const event of generator) { if (aborted) break; - if (!started && !event.startsWith('event: message_start')) { + if (!started && !event.startsWith("event: message_start")) { await flushBuf(); } if (started) { - try { res.write(event); } catch {} + try { + res.write(event); + } catch {} lastSentAt = Date.now(); await waitDrain(res); } else { @@ -2710,9 +3439,17 @@ async function handleMessages(req, res) { } // started 时 error 事件已在循环中经 SSE 下发,按规范 error 事件即终结 } else if (ctx.outputTokens === 0) { - try { abortController.abort(); } catch {} + try { + abortController.abort(); + } catch {} if (!started) { - sendAnthropicError(res, 429, 'rate_limit_error', 'Empty response from upstream (zero output tokens)', 10); + sendAnthropicError( + res, + 429, + "rate_limit_error", + "Empty response from upstream (zero output tokens)", + 10, + ); return; } await flushBuf(); @@ -2723,47 +3460,65 @@ async function handleMessages(req, res) { } catch (e) { if (aborted) { // 客户端已断连,只清理(close handler 已调用 abortController.abort()) - } else if (e.message === 'STREAM_IDLE_TIMEOUT') { - log('warn', 'Stream idle timeout', { - path: '/v1/messages', + } else if (e.message === "STREAM_IDLE_TIMEOUT") { + log("warn", "Stream idle timeout", { + path: "/v1/messages", model, streaming: true, timeoutMs: STREAM_IDLE_TIMEOUT_MS, elapsedMs: Date.now() - startTime, id: messageId, bytesReceived: ctx.bytesReceived, - lastCcEvent: ctx.lastCcEvent || '(none)', + lastCcEvent: ctx.lastCcEvent || "(none)", inputTokens: ctx.inputTokens, outputTokens: ctx.outputTokens, cachedInputTokens: ctx.cachedInputTokens, }); - try { abortController.abort(); } catch {} // 打断 CC 上游 + try { + abortController.abort(); + } catch {} // 打断 CC 上游 if (!started) { consecutiveTimeouts++; - const timeoutMsg = consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD - ? 'Response timeout - try reducing context length (summarize earlier messages)' - : 'Response timeout - request timed out'; - sendAnthropicError(res, 429, 'rate_limit_error', timeoutMsg); + const timeoutMsg = + consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD + ? "Response timeout - try reducing context length (summarize earlier messages)" + : "Response timeout - request timed out"; + sendAnthropicError(res, 429, "rate_limit_error", timeoutMsg); return; } if (!res.writableEnded) { consecutiveTimeouts++; - const timeoutMsg = consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD - ? 'Response timeout - try reducing context length (summarize earlier messages)' - : 'Response timeout - request timed out'; + const timeoutMsg = + consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD + ? "Response timeout - try reducing context length (summarize earlier messages)" + : "Response timeout - request timed out"; // end() 而不是 destroy():理由见 handleChatCompletions 流式超时分支 - try { res.end(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: timeoutMsg }, retry_after: 5 })}\n\n`); } catch {} + try { + res.end( + `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: timeoutMsg }, retry_after: 5 })}\n\n`, + ); + } catch {} } } else { - log('error', 'Anthropic stream error', { message: e.message }); - try { abortController.abort(); } catch {} // 打断 CC 上游 + log("error", "Anthropic stream error", { message: e.message }); + try { + abortController.abort(); + } catch {} // 打断 CC 上游 if (!started) { - sendAnthropicError(res, 502, 'proxy_error', `Upstream error: ${e.message}`, 10); + sendAnthropicError( + res, + 502, + "proxy_error", + `Upstream error: ${e.message}`, + 10, + ); return; } if (!res.writableEnded) { try { - res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'internal_error', message: e.message } })}\n\n`); + res.write( + `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "internal_error", message: e.message } })}\n\n`, + ); } catch {} } } @@ -2774,51 +3529,61 @@ async function handleMessages(req, res) { if (!res.writableEnded) res.end(); } else { // ── 非流式 Anthropic JSON ── - const messageId = 'msg_' + randomUUID().slice(0, 12); - let finishReason = 'stop'; + const messageId = "msg_" + randomUUID().slice(0, 12); + let finishReason = "stop"; let sawFinish = false; let usage = null; let toolCalls = null; - let thinkingText = ''; // CC reasoning → Anthropic thinking block + let thinkingText = ""; // CC reasoning → Anthropic thinking block let upstreamError = null; reader = ccResponse.body.getReader(); const decoder = new TextDecoder(); - let buf = ''; + let buf = ""; const processLines = () => { - const lines = buf.split('\n'); - buf = lines.pop() || ''; + const lines = buf.split("\n"); + buf = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || trimmed === '[DONE]') continue; + if (!trimmed || trimmed === "[DONE]") continue; try { const event = JSON.parse(trimmed); switch (event.type) { - case 'text-delta': lastCcEvent = event.type; fullText += event.text || ''; break; - case 'reasoning-delta': lastCcEvent = event.type; thinkingText += event.text || ''; break; - case 'tool-call': + case "text-delta": + lastCcEvent = event.type; + fullText += event.text || ""; + break; + case "reasoning-delta": + lastCcEvent = event.type; + thinkingText += event.text || ""; + break; + case "tool-call": lastCcEvent = event.type; (toolCalls = toolCalls || []).push({ - id: event.toolCallId || ('call_' + randomUUID().slice(0, 8)), - type: 'function', + id: event.toolCallId || "call_" + randomUUID().slice(0, 8), + type: "function", function: { - name: event.toolName || '', - arguments: typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {}), + name: event.toolName || "", + arguments: + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input || {}), }, }); break; - case 'finish-step': - case 'finish': + case "finish-step": + case "finish": lastCcEvent = event.type; sawFinish = true; - finishReason = mapFinishReason(event.finishReason || 'stop'); - if (event.totalUsage || event.usage) usage = event.totalUsage || event.usage; + finishReason = mapFinishReason(event.finishReason || "stop"); + if (event.totalUsage || event.usage) + usage = event.totalUsage || event.usage; break; - case 'error': + case "error": lastCcEvent = event.type; upstreamError = mapCcEventError(event); - log('warn', 'CC error (Anthropic non-stream)', { + log("warn", "CC error (Anthropic non-stream)", { message: event.error?.message || event.message, upstreamStatus: upstreamError.reportedStatus, upstreamRetryable: event.error?.isRetryable, @@ -2830,14 +3595,21 @@ async function handleMessages(req, res) { // text-start / start / start-step / reasoning-start 原先只在流式路径被识别, // 非流式路径会掉进 default 打成 'Unknown CC event type' —— 上游每个响应都会发, // 于是线上刷屏。它们本身不携带内容(内容在 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 'tool-error': + 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 "tool-error": // Silent - no user-visible content break; default: - log('warn', 'Unknown CC event type', { type: event.type }); + log("warn", "Unknown CC event type", { type: event.type }); break; } } catch {} @@ -2853,13 +3625,18 @@ async function handleMessages(req, res) { const chunkText = decoder.decode(value, { stream: true }); buf += chunkText; // 无换行则不可能产生完整行,跳过全量 split - if (chunkText.indexOf('\n') !== -1) processLines(); + if (chunkText.indexOf("\n") !== -1) processLines(); } idle.dispose(); processLines(); if (upstreamError) { - sendAnthropicError(res, upstreamError.status, upstreamError.body.error.type, upstreamError.body.error.message); + sendAnthropicError( + res, + upstreamError.status, + upstreamError.body.error.type, + upstreamError.body.error.message, + ); return; } @@ -2867,10 +3644,21 @@ async function handleMessages(req, res) { { const incomplete = incompleteUpstreamDetail(sawFinish, finishReason); if (incomplete) { - log('warn', 'Upstream stream incomplete', { path: '/v1/messages', reason: incomplete }); + log("warn", "Upstream stream incomplete", { + path: "/v1/messages", + reason: incomplete, + }); const err = incompleteUpstreamError(incomplete); - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - sendAnthropicError(res, err.status, err.body.error.type, err.body.error.message, err.retry_after); + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + sendAnthropicError( + res, + err.status, + err.body.error.type, + err.body.error.message, + err.retry_after, + ); return; } } @@ -2878,45 +3666,81 @@ async function handleMessages(req, res) { // 零输出判定改为按实际内容:上游偶发不回 totalUsage 时,旧逻辑(usage?.outputTokens ?? 0 === 0) // 会把有完整文本的响应误杀成 429 if (!fullText && !thinkingText && !toolCalls) { - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - sendAnthropicError(res, 429, 'rate_limit_error', 'Empty response from upstream (zero output tokens)', 10); + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + sendAnthropicError( + res, + 429, + "rate_limit_error", + "Empty response from upstream (zero output tokens)", + 10, + ); return; } consecutiveTimeouts = 0; - sendJSON(res, 200, buildAnthropicResponse(model, fullText, toolCalls, finishReason, usage, thinkingText)); + sendJSON( + res, + 200, + buildAnthropicResponse( + model, + fullText, + toolCalls, + finishReason, + usage, + thinkingText, + ), + ); } } catch (e) { if (abortController.signal.aborted) { - log('warn', 'Request cancelled (client disconnected before CC response)', { - path: '/v1/messages', - model, - messageId, - }); - } else if (e.message === 'STREAM_IDLE_TIMEOUT') { - log('warn', 'Stream idle timeout', { - path: '/v1/messages', + log( + "warn", + "Request cancelled (client disconnected before CC response)", + { + path: "/v1/messages", + model, + messageId, + }, + ); + } else if (e.message === "STREAM_IDLE_TIMEOUT") { + log("warn", "Stream idle timeout", { + path: "/v1/messages", model, streaming: false, timeoutMs: NONSTREAM_IDLE_TIMEOUT_MS, elapsedMs: Date.now() - startTime, id: messageId, bytesReceived, - lastCcEvent: lastCcEvent || '(none)', + lastCcEvent: lastCcEvent || "(none)", partialLen: fullText ? fullText.length : 0, }); - try { reader?.cancel().catch(() => {}); } catch {} - try { abortController.abort(); } catch {} // 打断 CC 上游 + try { + reader?.cancel().catch(() => {}); + } catch {} + try { + abortController.abort(); + } catch {} // 打断 CC 上游 consecutiveTimeouts++; - const timeoutMsg = consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD - ? 'Response timeout - try reducing context length (summarize earlier messages)' - : 'Response timeout - request timed out'; - res.setHeader('Retry-After', '5'); - sendAnthropicError(res, 429, 'rate_limit_error', timeoutMsg); + const timeoutMsg = + consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD + ? "Response timeout - try reducing context length (summarize earlier messages)" + : "Response timeout - request timed out"; + res.setHeader("Retry-After", "5"); + sendAnthropicError(res, 429, "rate_limit_error", timeoutMsg); } else { - log('error', 'Upstream error', { message: e.message }); - try { abortController.abort(); } catch {} // 打断 CC 上游 - sendAnthropicError(res, 502, 'proxy_error', `Upstream error: ${e.message}`, 10); + log("error", "Upstream error", { message: e.message }); + try { + abortController.abort(); + } catch {} // 打断 CC 上游 + sendAnthropicError( + res, + 502, + "proxy_error", + `Upstream error: ${e.message}`, + 10, + ); } } } @@ -2928,18 +3752,19 @@ let modelsLastFetch = 0; async function fetchModels(apiKey) { const now = Date.now(); - if (dynamicModels && (now - modelsLastFetch) < CFG.modelRefreshIntervalMs) { + if (dynamicModels && now - modelsLastFetch < CFG.modelRefreshIntervalMs) { return dynamicModels; } try { - if (!apiKey || !CFG.useProviderModels) throw new Error('Provider models disabled'); + 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, + Authorization: `Bearer ${apiKey}`, + "x-cli-environment": "production", + "x-command-code-version": CC_VERSION, }, signal: AbortSignal.timeout(10000), }); @@ -2947,18 +3772,24 @@ async function fetchModels(apiKey) { if (response.ok) { const data = await response.json(); if (Array.isArray(data.data)) { - dynamicModels = data.data.map(m => ({ + dynamicModels = data.data.map((m) => ({ id: m.id, 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, + }); return dynamicModels; } } - log('warn', 'Provider models fetch failed, using hardcoded list', { status: response.status }); + log("warn", "Provider models fetch failed, using hardcoded list", { + status: response.status, + }); } catch (e) { - log('warn', 'Provider models fetch error, using hardcoded list', { error: e.message }); + log("warn", "Provider models fetch error, using hardcoded list", { + error: e.message, + }); } // Fallback to hardcoded MODELS @@ -2972,24 +3803,34 @@ async function fetchModels(apiKey) { // 收到直接 400,避免静默降级成错误答案。 function responsesTextOf(content) { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - return content.map(p => (p && typeof p === 'object' ? (p.text || '') : '')).join(''); + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((p) => (p && typeof p === "object" ? p.text || "" : "")) + .join(""); } /** `input_image` 部件(含省略 type 只带 image_url 的写法)。 */ function isResponsesImagePart(part) { - return !!part && typeof part === 'object' && (part.type === 'input_image' || part.image_url !== undefined); + return ( + !!part && + typeof part === "object" && + (part.type === "input_image" || part.image_url !== undefined) + ); } /** Responses 的图 → Chat 的 `image_url` 部件(对象形的 image_url,CC 侧读 `.url`)。 */ function responsesImagePartsOf(parts) { if (!Array.isArray(parts)) return []; - return parts.filter(isResponsesImagePart).map(part => { - const raw = part.image_url !== undefined ? part.image_url : part.input_image; - const url = typeof raw === 'string' ? raw : (raw && raw.url) || ''; - return url ? { type: 'image_url', image_url: { url } } : null; - }).filter(Boolean); + return parts + .filter(isResponsesImagePart) + .map((part) => { + const raw = + part.image_url !== undefined ? part.image_url : part.input_image; + const url = typeof raw === "string" ? raw : (raw && raw.url) || ""; + return url ? { type: "image_url", image_url: { url } } : null; + }) + .filter(Boolean); } // data URL 图片:一段文本里超过这个长度的 data URL 就当成"图",提出来单独发;小图留在文本里 @@ -3003,10 +3844,10 @@ const DATA_URL_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi; function extractInlineImages(text) { const images = []; const stripped = String(text).replace(DATA_URL_RE, (url) => { - if (url.length < INLINE_IMAGE_MIN) return url; // 小图留文本,别把工具输出切碎 - if (url.length > MAX_TOOL_IMAGE_URL) return '[image omitted: too large]'; + if (url.length < INLINE_IMAGE_MIN) return url; // 小图留文本,别把工具输出切碎 + if (url.length > MAX_TOOL_IMAGE_URL) return "[image omitted: too large]"; images.push(url); - return '[image]'; + return "[image]"; }); return { text: stripped, images }; } @@ -3015,7 +3856,7 @@ function extractInlineImages(text) { // Codex Desktop 的截图工具就是 [{type:'input_image', image_url:'data:image/png;base64,...'}]。 // 返回 { text, images },images 为 data URL 字符串数组。 function splitToolOutput(output) { - if (output === undefined || output === null) return { text: '', images: [] }; + if (output === undefined || output === null) return { text: "", images: [] }; const texts = []; const images = []; const pushText = (raw) => { @@ -3023,29 +3864,32 @@ function splitToolOutput(output) { if (r.text) texts.push(r.text); images.push(...r.images); }; - if (typeof output === 'string') { + if (typeof output === "string") { pushText(output); } else if (Array.isArray(output)) { for (const part of output) { if (!part) continue; - if (part.type === 'input_image' || part.type === 'image_url') { - const url = typeof part.image_url === 'string' ? part.image_url : (part.image_url && part.image_url.url) || ''; - if (url.startsWith('data:')) { + if (part.type === "input_image" || part.type === "image_url") { + const url = + typeof part.image_url === "string" + ? part.image_url + : (part.image_url && part.image_url.url) || ""; + if (url.startsWith("data:")) { if (url.length <= MAX_TOOL_IMAGE_URL) images.push(url); - else texts.push('[image omitted: too large]'); + else texts.push("[image omitted: too large]"); } else if (url) { - texts.push(`[image: ${url}]`); // 外链图上游不认,只能留个说明 + texts.push(`[image: ${url}]`); // 外链图上游不认,只能留个说明 } - } else if (typeof part.text === 'string') { + } else if (typeof part.text === "string") { pushText(part.text); } else { - pushText(JSON.stringify(part)); // 未知块保持原样,与旧行为一致 + pushText(JSON.stringify(part)); // 未知块保持原样,与旧行为一致 } } } else { pushText(JSON.stringify(output)); } - return { text: texts.filter(Boolean).join('\n'), images }; + return { text: texts.filter(Boolean).join("\n"), images }; } // 单条请求内"工具截图"的总字节预算。工具截图会随每一轮请求全量重传,是内存与首字延迟的 // 头号杀手:真机实测一个 Codex 会话里 11 张截图 ≈5.5MB base64,配合 README 记录的内存放大 @@ -3053,7 +3897,7 @@ function splitToolOutput(output) { // 策略:从**最新**往回保留,预算内照发,超预算的老图替换成占位说明 —— 让模型知道有图被丢, // 而不是以为历史里本来就没图。CC_MAX_TOOL_IMAGE_MB=0 关闭该行为。 const MAX_TOOL_IMAGE_BYTES = (() => { - const mb = Number.parseFloat(process.env.CC_MAX_TOOL_IMAGE_MB ?? '6'); + const mb = Number.parseFloat(process.env.CC_MAX_TOOL_IMAGE_MB ?? "6"); return Number.isFinite(mb) && mb > 0 ? Math.round(mb * 1024 * 1024) : 0; })(); @@ -3062,40 +3906,57 @@ function trimToolImages(messages) { const refs = []; for (const m of messages) { if (!Array.isArray(m.content)) continue; - for (const part of m.content) if (part && part._toolImage) refs.push({ m, part }); + for (const part of m.content) + if (part && part._toolImage) refs.push({ m, part }); } if (!refs.length) return; const keep = new Set(); let used = 0; - for (let i = refs.length - 1; i >= 0; i--) { // 从最新往回挑,至少保一张 - const len = (refs[i].part.image_url && refs[i].part.image_url.url || '').length; - if (keep.size === 0 || used + len <= MAX_TOOL_IMAGE_BYTES) { keep.add(i); used += len; } + for (let i = refs.length - 1; i >= 0; i--) { + // 从最新往回挑,至少保一张 + const len = ((refs[i].part.image_url && refs[i].part.image_url.url) || "") + .length; + if (keep.size === 0 || used + len <= MAX_TOOL_IMAGE_BYTES) { + keep.add(i); + used += len; + } } - if (keep.size === refs.length) return; // 没超预算,原样不动 + if (keep.size === refs.length) return; // 没超预算,原样不动 let droppedBytes = 0; for (let i = 0; i < refs.length; i++) { if (keep.has(i)) continue; const idx = refs[i].m.content.indexOf(refs[i].part); if (idx === -1) continue; - droppedBytes += (refs[i].part.image_url && refs[i].part.image_url.url || '').length; - refs[i].m.content[idx] = { type: 'text', text: '[older tool screenshot omitted: image budget exceeded]' }; + droppedBytes += ( + (refs[i].part.image_url && refs[i].part.image_url.url) || + "" + ).length; + refs[i].m.content[idx] = { + type: "text", + text: "[older tool screenshot omitted: image budget exceeded]", + }; } - log('warn', 'Tool images trimmed to budget', { - total: refs.length, kept: keep.size, dropped: refs.length - keep.size, - keptBytes: used, droppedBytes, budgetBytes: MAX_TOOL_IMAGE_BYTES, + log("warn", "Tool images trimmed to budget", { + total: refs.length, + kept: keep.size, + dropped: refs.length - keep.size, + keptBytes: used, + droppedBytes, + budgetBytes: MAX_TOOL_IMAGE_BYTES, }); } - function responsesReasoningOf(item) { - if (!item) return ''; - if (Array.isArray(item.summary) && item.summary.length) return item.summary.map(p => (p && p.text) || '').join(''); - if (Array.isArray(item.content) && item.content.length) return item.content.map(p => (p && p.text) || '').join(''); - return typeof item.text === 'string' ? item.text : ''; + if (!item) return ""; + if (Array.isArray(item.summary) && item.summary.length) + return item.summary.map((p) => (p && p.text) || "").join(""); + if (Array.isArray(item.content) && item.content.length) + return item.content.map((p) => (p && p.text) || "").join(""); + return typeof item.text === "string" ? item.text : ""; } function newResponsesId(prefix) { - return prefix + randomUUID().replace(/-/g, '').slice(0, 24); + return prefix + randomUUID().replace(/-/g, "").slice(0, 24); } function convertResponsesToChat(respReq) { @@ -3103,7 +3964,7 @@ function convertResponsesToChat(respReq) { if (respReq.instructions !== undefined && respReq.instructions !== null) { const sys = responsesTextOf(respReq.instructions); - if (sys) messages.push({ role: 'system', content: sys }); + if (sys) messages.push({ role: "system", content: sys }); } // Responses 把 reasoning / message / function_call 拆成并列 item, @@ -3117,33 +3978,37 @@ function convertResponsesToChat(respReq) { const flushToolImages = () => { if (!pendingToolImages.length) return; messages.push({ - role: 'user', + role: "user", content: [ - { type: 'text', text: '[images returned by tool calls]' }, + { type: "text", text: "[images returned by tool calls]" }, ...pendingToolImages, ], }); pendingToolImages = []; }; - const ensurePending = () => (pending = pending || { role: 'assistant', content: null, tool_calls: [] }); + const ensurePending = () => + (pending = pending || { role: "assistant", content: null, tool_calls: [] }); const flushPending = () => { if (!pending) return; if (!pending.tool_calls.length) delete pending.tool_calls; if (!pending.reasoning_content) delete pending.reasoning_content; - if (pending.content === null && !pending.tool_calls) { pending = null; return; } + if (pending.content === null && !pending.tool_calls) { + pending = null; + return; + } messages.push(pending); pending = null; }; const input = respReq.input; - if (typeof input === 'string') { - messages.push({ role: 'user', content: input }); + if (typeof input === "string") { + messages.push({ role: "user", content: input }); } else if (Array.isArray(input)) { for (const item of input) { - if (!item || typeof item !== 'object') continue; - const _itemType = item.type ?? (item.role ? 'message' : undefined); + if (!item || typeof item !== "object") continue; + const _itemType = item.type ?? (item.role ? "message" : undefined); // 非 function_call_output 的 item 打断了 tool_result 序列,先把累积的图片 flush - if (_itemType !== 'function_call_output') flushToolImages(); + if (_itemType !== "function_call_output") flushToolImages(); // OpenAI 规范里 input 数组的联合类型第一个成员是 EasyInputMessage,它的 // required 只有 role 与 content —— type 是可选的(官方文档与 SDK 示例普遍写作 // { role: 'user', content: 'hi' })。item.type 为 undefined 但有 role 时按 @@ -3151,43 +4016,46 @@ function convertResponsesToChat(respReq) { // "input is required" 的误导性报错;混合形态时更糟 —— 校验能过,用户在 // HTTP 200 下静默丢消息。这里只在 type 缺失时兜底,带 type 的 item 判定不变。 switch (_itemType) { - case 'reasoning': { + case "reasoning": { const t = responsesReasoningOf(item); if (t) ensurePending().reasoning_content = t; break; } - case 'message': { + case "message": { const text = responsesTextOf(item.content); const images = responsesImagePartsOf(item.content); - if (item.role === 'assistant') { + if (item.role === "assistant") { // assistant 消息上的图没有落点:Chat 的 assistant 内容只有文本槽 if (text) ensurePending().content = text; - } else if (item.role === 'system' || item.role === 'developer') { + } else if (item.role === "system" || item.role === "developer") { flushPending(); - messages.push({ role: 'system', content: text }); + messages.push({ role: "system", content: text }); } else if (images.length) { // 用户槽的图必须原样保留:CC 只收 user 角色上的图,丢掉时上游照样回 200, // 客户端侧看不见任何异常(只表现为模型"没看到图")。 flushPending(); messages.push({ - role: 'user', - content: [...(text ? [{ type: 'text', text }] : []), ...images], + role: "user", + content: [...(text ? [{ type: "text", text }] : []), ...images], }); } else { flushPending(); - messages.push({ role: 'user', content: text }); + messages.push({ role: "user", content: text }); } break; } - case 'function_call': { + case "function_call": { ensurePending().tool_calls.push({ - id: item.call_id || item.id || ('call_' + randomUUID().slice(0, 8)), - type: 'function', - function: { name: item.name || '', arguments: item.arguments || '{}' }, + id: item.call_id || item.id || "call_" + randomUUID().slice(0, 8), + type: "function", + function: { + name: item.name || "", + arguments: item.arguments || "{}", + }, }); break; } - case 'function_call_output': { + case "function_call_output": { flushPending(); // 工具结果里可能带图(Codex Desktop 截图工具即 output=[{type:'input_image', image_url:'data:image/png;base64,...'}])。 // 绝不能 JSON.stringify 成文本送上游:base64 会按文本分词,真机实测单张 2.76MB 截图 ≈ 1.92M token, @@ -3195,21 +4063,35 @@ function convertResponsesToChat(respReq) { // 官方 CLI 的排布是:tool-result 只放文本,图片提出来放进紧跟其后的一条 user 消息 // (见 command-code@1.66.0 dist/cli.mjs 的 convertUserMessage / tool_result 分支)。 const { text, images } = splitToolOutput(item.output); - messages.push({ role: 'tool', tool_call_id: item.call_id || '', content: text }); + messages.push({ + role: "tool", + tool_call_id: item.call_id || "", + content: text, + }); if (images.length) { - log('info', 'Deferred tool-output images (batched after tool results)', { - call_id: item.call_id, count: images.length, bytes: images.reduce((a, u) => a + u.length, 0), - }); + log( + "info", + "Deferred tool-output images (batched after tool results)", + { + call_id: item.call_id, + count: images.length, + bytes: images.reduce((a, u) => a + u.length, 0), + }, + ); // 不立即 push:并行 tool_call 的图片统一累积,由 flushToolImages() 放到所有 // tool_result 之后。content 走 image_url 形态,交给 buildCcRequest 的 user 图片分支转换。 pendingToolImages.push( - ...images.map(url => ({ type: 'image_url', image_url: { url }, _toolImage: true })), + ...images.map((url) => ({ + type: "image_url", + image_url: { url }, + _toolImage: true, + })), ); } break; } default: { - log('warn', 'Unknown Responses input item type', { type: item.type }); + log("warn", "Unknown Responses input item type", { type: item.type }); break; } } @@ -3223,30 +4105,42 @@ function convertResponsesToChat(respReq) { let tools; if (Array.isArray(respReq.tools) && respReq.tools.length) { - tools = respReq.tools.filter(t => t && (t.type === 'function' || t.name)).map(t => ({ - type: 'function', - function: { - name: t.name || '', - description: t.description || '', - parameters: t.parameters || { type: 'object', properties: {} }, - }, - })); + tools = respReq.tools + .filter((t) => t && (t.type === "function" || t.name)) + .map((t) => ({ + type: "function", + function: { + name: t.name || "", + description: t.description || "", + parameters: t.parameters || { type: "object", properties: {} }, + }, + })); if (!tools.length) tools = undefined; } let toolChoice; const tc = respReq.tool_choice; - if (typeof tc === 'string') toolChoice = tc; - else if (tc && typeof tc === 'object' && tc.name) toolChoice = { type: 'function', function: { name: tc.name } }; - - const out = { model: respReq.model, messages, stream: respReq.stream === true }; + if (typeof tc === "string") toolChoice = tc; + else if (tc && typeof tc === "object" && tc.name) + toolChoice = { type: "function", function: { name: tc.name } }; + + const out = { + model: respReq.model, + messages, + stream: respReq.stream === true, + }; if (tools) out.tools = tools; if (toolChoice) out.tool_choice = toolChoice; - if (respReq.max_output_tokens !== undefined) out.max_tokens = respReq.max_output_tokens; + if (respReq.max_output_tokens !== undefined) + out.max_tokens = respReq.max_output_tokens; if (respReq.temperature !== undefined) out.temperature = respReq.temperature; if (respReq.top_p !== undefined) out.top_p = respReq.top_p; - if (respReq.parallel_tool_calls !== undefined) out.parallel_tool_calls = respReq.parallel_tool_calls; - const eff = respReq.reasoning && typeof respReq.reasoning === 'object' ? respReq.reasoning.effort : undefined; + if (respReq.parallel_tool_calls !== undefined) + out.parallel_tool_calls = respReq.parallel_tool_calls; + const eff = + respReq.reasoning && typeof respReq.reasoning === "object" + ? respReq.reasoning.effort + : undefined; if (eff) out.reasoning_effort = eff; return out; } @@ -3265,7 +4159,8 @@ function buildResponsesUsage(usage, fallbackOutputTokens) { // 规范里 cached_tokens 与 cache_write_tokens 都是 required input_tokens_details: { cached_tokens: u.cachedInputTokens || 0, - cache_write_tokens: (u.inputTokenDetails && u.inputTokenDetails.cacheWriteTokens) || 0, + cache_write_tokens: + (u.inputTokenDetails && u.inputTokenDetails.cacheWriteTokens) || 0, }, output_tokens: outTok, output_tokens_details: { reasoning_tokens: 0 }, @@ -3276,55 +4171,78 @@ function buildResponsesUsage(usage, fallbackOutputTokens) { function buildResponsesOutput(fullText, thinkingText, toolCalls) { const output = []; if (thinkingText) { - output.push({ type: 'reasoning', id: newResponsesId('rs_'), summary: [{ type: 'summary_text', text: thinkingText }] }); + output.push({ + type: "reasoning", + id: newResponsesId("rs_"), + summary: [{ type: "summary_text", text: thinkingText }], + }); } if (fullText) { output.push({ - type: 'message', id: newResponsesId('msg_'), status: 'completed', role: 'assistant', - content: [{ type: 'output_text', text: fullText, annotations: [] }], + type: "message", + id: newResponsesId("msg_"), + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: fullText, annotations: [] }], }); } - for (const tc of (toolCalls || [])) { - const rawArgs = tc.function ? tc.function.arguments : '{}'; + for (const tc of toolCalls || []) { + const rawArgs = tc.function ? tc.function.arguments : "{}"; output.push({ - type: 'function_call', id: newResponsesId('fc_'), call_id: tc.id, - name: tc.function ? (tc.function.name || '') : '', - arguments: typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs || {}), - status: 'completed', + type: "function_call", + id: newResponsesId("fc_"), + call_id: tc.id, + name: tc.function ? tc.function.name || "" : "", + arguments: + typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs || {}), + status: "completed", }); } return output; } -function buildResponsesObject(responseId, model, created, fullText, thinkingText, toolCalls, usage, opts) { +function buildResponsesObject( + responseId, + model, + created, + fullText, + thinkingText, + toolCalls, + usage, + opts, +) { const o = opts || {}; - const truncated = o.finishReason === 'length'; - const paused = o.finishReason === 'pause_turn'; + const truncated = o.finishReason === "length"; + const paused = o.finishReason === "pause_turn"; return { id: responseId, - object: 'response', + object: "response", created_at: created, - status: (truncated || paused) ? 'incomplete' : 'completed', + status: truncated || paused ? "incomplete" : "completed", completed_at: nowUnix(), error: null, - incomplete_details: truncated ? { reason: 'max_output_tokens' } - : paused ? { reason: 'pause_turn' } : null, + incomplete_details: truncated + ? { reason: "max_output_tokens" } + : paused + ? { reason: "pause_turn" } + : null, input: o.input || [], instructions: o.instructions === undefined ? null : o.instructions, - max_output_tokens: o.max_output_tokens === undefined ? null : o.max_output_tokens, + max_output_tokens: + o.max_output_tokens === undefined ? null : o.max_output_tokens, model, output: buildResponsesOutput(fullText, thinkingText, toolCalls), - output_text: fullText || '', + output_text: fullText || "", parallel_tool_calls: true, previous_response_id: null, reasoning: o.reasoning || null, store: false, temperature: o.temperature === undefined ? 1 : o.temperature, - text: { format: { type: 'text' } }, - tool_choice: o.tool_choice || 'auto', + text: { format: { type: "text" } }, + tool_choice: o.tool_choice || "auto", tools: o.tools || [], top_p: o.top_p === undefined ? 1 : o.top_p, - truncation: 'disabled', + truncation: "disabled", usage: buildResponsesUsage(usage, 0), user: null, metadata: {}, @@ -3340,28 +4258,44 @@ function sendResponsesError(res, status, type, message, retryAfter) { // CC NDJSON → Responses 具名 SSE 事件(每个事件都必需的 sequence_number 递增发送) function createResponsesSseTranslator(model, responseId, created) { let seq = 0; - const sse = (type, data) => 'event: ' + type + '\ndata: ' + JSON.stringify(Object.assign({ type, sequence_number: seq++ }, data)) + '\n\n'; + const sse = (type, data) => + "event: " + + type + + "\ndata: " + + JSON.stringify(Object.assign({ type, sequence_number: seq++ }, data)) + + "\n\n"; let createdSent = false; let current = null; let outputIndex = 0; const doneItems = []; let usage = null; - let textAcc = ''; + let textAcc = ""; let finishReason = null; // 是否见过完成信号(见 incompleteUpstreamDetail 的口径说明) let sawFinish = false; const baseResponse = (status, output) => ({ - id: responseId, object: 'response', created_at: created, status, - output: output || [], output_text: '', model, error: null, incomplete_details: null, - parallel_tool_calls: true, previous_response_id: null, store: false, tools: [], metadata: {}, + id: responseId, + object: "response", + created_at: created, + status, + output: output || [], + output_text: "", + model, + error: null, + incomplete_details: null, + parallel_tool_calls: true, + previous_response_id: null, + store: false, + tools: [], + metadata: {}, }); function startResponse() { createdSent = true; return [ - sse('response.created', { response: baseResponse('in_progress') }), - sse('response.in_progress', { response: baseResponse('in_progress') }), + sse("response.created", { response: baseResponse("in_progress") }), + sse("response.in_progress", { response: baseResponse("in_progress") }), ]; } @@ -3370,27 +4304,58 @@ function createResponsesSseTranslator(model, responseId, created) { const out = []; const item = current.item; const idx = current.index; - if (current.kind === 'message') { - out.push(sse('response.output_text.done', { item_id: item.id, output_index: idx, content_index: 0, text: current.textBuf, logprobs: [] })); - out.push(sse('response.content_part.done', { - item_id: item.id, output_index: idx, content_index: 0, - part: { type: 'output_text', text: current.textBuf, annotations: [] }, - })); - item.content = [{ type: 'output_text', text: current.textBuf, annotations: [] }]; - item.status = 'completed'; - } else if (current.kind === 'function_call') { - out.push(sse('response.function_call_arguments.done', { item_id: item.id, output_index: idx, arguments: item.arguments })); - item.status = 'completed'; - } else if (current.kind === 'reasoning') { - out.push(sse('response.reasoning_summary_text.done', { item_id: item.id, output_index: idx, summary_index: 0, text: current.textBuf })); - out.push(sse('response.reasoning_summary_part.done', { - item_id: item.id, output_index: idx, summary_index: 0, - part: { type: 'summary_text', text: current.textBuf }, - })); - item.summary = [{ type: 'summary_text', text: current.textBuf }]; - item.status = 'completed'; + if (current.kind === "message") { + out.push( + sse("response.output_text.done", { + item_id: item.id, + output_index: idx, + content_index: 0, + text: current.textBuf, + logprobs: [], + }), + ); + out.push( + sse("response.content_part.done", { + item_id: item.id, + output_index: idx, + content_index: 0, + part: { type: "output_text", text: current.textBuf, annotations: [] }, + }), + ); + item.content = [ + { type: "output_text", text: current.textBuf, annotations: [] }, + ]; + item.status = "completed"; + } else if (current.kind === "function_call") { + out.push( + sse("response.function_call_arguments.done", { + item_id: item.id, + output_index: idx, + arguments: item.arguments, + }), + ); + item.status = "completed"; + } else if (current.kind === "reasoning") { + out.push( + sse("response.reasoning_summary_text.done", { + item_id: item.id, + output_index: idx, + summary_index: 0, + text: current.textBuf, + }), + ); + out.push( + sse("response.reasoning_summary_part.done", { + item_id: item.id, + output_index: idx, + summary_index: 0, + part: { type: "summary_text", text: current.textBuf }, + }), + ); + item.summary = [{ type: "summary_text", text: current.textBuf }]; + item.status = "completed"; } - out.push(sse('response.output_item.done', { output_index: idx, item })); + out.push(sse("response.output_item.done", { output_index: idx, item })); doneItems.push(item); current = null; return out; @@ -3398,24 +4363,34 @@ function createResponsesSseTranslator(model, responseId, created) { function openItem(kind, item) { const out = closeItem(); - current = { kind, index: outputIndex++, item, textBuf: '' }; - out.push(sse('response.output_item.added', { output_index: current.index, item })); - if (kind === 'message') { - out.push(sse('response.content_part.added', { - item_id: item.id, output_index: current.index, content_index: 0, - part: { type: 'output_text', text: '', annotations: [] }, - })); - } else if (kind === 'reasoning') { - out.push(sse('response.reasoning_summary_part.added', { - item_id: item.id, output_index: current.index, summary_index: 0, - part: { type: 'summary_text', text: '' }, - })); + current = { kind, index: outputIndex++, item, textBuf: "" }; + out.push( + sse("response.output_item.added", { output_index: current.index, item }), + ); + if (kind === "message") { + out.push( + sse("response.content_part.added", { + item_id: item.id, + output_index: current.index, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }), + ); + } else if (kind === "reasoning") { + out.push( + sse("response.reasoning_summary_part.added", { + item_id: item.id, + output_index: current.index, + summary_index: 0, + part: { type: "summary_text", text: "" }, + }), + ); } return out; } return { - lastCcEvent: '', + lastCcEvent: "", upstreamError: null, inputTokens: 0, outputTokens: 0, @@ -3423,70 +4398,132 @@ function createResponsesSseTranslator(model, responseId, created) { // 提前发 created/in_progress(不带内容):上游是 reasoning 模型,大 prompt 首字可能要十几秒, // 这期间一个字节都不出网就会被中间层(实测 EdgeOne 源站 ~15s)或客户端首字节超时掐掉 start: startResponse, - get started() { return createdSent; }, + get started() { + return createdSent; + }, // 是否真的产出过 output item(开过 item 或收尾过 item 都算)。 // #54 之后 created/in_progress 在收到 200 时就先行发出,started 恒为 true, // 零输出防护不能再拿它当判据 —— 只能看「有没有实际内容」。 - get hasOutput() { return outputIndex > 0 || doneItems.length > 0; }, - get stopReason() { return finishReason; }, + get hasOutput() { + return outputIndex > 0 || doneItems.length > 0; + }, + get stopReason() { + return finishReason; + }, parseLine(line) { const trimmed = line.trim(); - if (!trimmed || trimmed === '[DONE]' || trimmed.startsWith(':')) return null; + if (!trimmed || trimmed === "[DONE]" || trimmed.startsWith(":")) + return null; let event; - try { event = JSON.parse(trimmed); } catch { return null; } + try { + event = JSON.parse(trimmed); + } catch { + return null; + } if (!event.type) return null; this.lastCcEvent = event.type; const out = []; switch (event.type) { - case 'text-start': case 'reasoning-start': case 'start': case 'start-step': + case "text-start": + case "reasoning-start": + case "start": + case "start-step": break; - case 'text-delta': { - const text = event.text || event.delta || ''; + case "text-delta": { + const text = event.text || event.delta || ""; if (!text) break; if (!createdSent) out.push.apply(out, startResponse()); - if (!current || current.kind !== 'message') { - out.push.apply(out, openItem('message', { type: 'message', id: newResponsesId('msg_'), status: 'in_progress', role: 'assistant', content: [] })); + if (!current || current.kind !== "message") { + out.push.apply( + out, + openItem("message", { + type: "message", + id: newResponsesId("msg_"), + status: "in_progress", + role: "assistant", + content: [], + }), + ); } current.textBuf += text; textAcc += text; - out.push(sse('response.output_text.delta', { item_id: current.item.id, output_index: current.index, content_index: 0, delta: text, logprobs: [] })); + out.push( + sse("response.output_text.delta", { + item_id: current.item.id, + output_index: current.index, + content_index: 0, + delta: text, + logprobs: [], + }), + ); break; } - case 'reasoning-delta': { - const text = event.text || ''; + case "reasoning-delta": { + const text = event.text || ""; if (!text) break; if (!createdSent) out.push.apply(out, startResponse()); - if (!current || current.kind !== 'reasoning') { - out.push.apply(out, openItem('reasoning', { type: 'reasoning', id: newResponsesId('rs_'), summary: [], status: 'in_progress' })); + if (!current || current.kind !== "reasoning") { + out.push.apply( + out, + openItem("reasoning", { + type: "reasoning", + id: newResponsesId("rs_"), + summary: [], + status: "in_progress", + }), + ); } current.textBuf += text; - out.push(sse('response.reasoning_summary_text.delta', { - item_id: current.item.id, output_index: current.index, summary_index: 0, delta: text, - })); + out.push( + sse("response.reasoning_summary_text.delta", { + item_id: current.item.id, + output_index: current.index, + summary_index: 0, + delta: text, + }), + ); break; } - case 'tool-call': { + case "tool-call": { if (!createdSent) out.push.apply(out, startResponse()); - const callId = event.toolCallId || newResponsesId('call_'); - const args = typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {}); - out.push.apply(out, openItem('function_call', { - type: 'function_call', id: newResponsesId('fc_'), call_id: callId, - name: event.toolName || '', arguments: '', status: 'in_progress', - })); + const callId = event.toolCallId || newResponsesId("call_"); + const args = + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input || {}); + out.push.apply( + out, + openItem("function_call", { + type: "function_call", + id: newResponsesId("fc_"), + call_id: callId, + name: event.toolName || "", + arguments: "", + status: "in_progress", + }), + ); current.item.arguments = args; - out.push(sse('response.function_call_arguments.delta', { item_id: current.item.id, output_index: current.index, delta: args })); + out.push( + sse("response.function_call_arguments.delta", { + item_id: current.item.id, + output_index: current.index, + delta: args, + }), + ); break; } - case 'finish': { + case "finish": { sawFinish = true; // 必须归一化:截断类不止 'length'(还有 max_output_tokens / // model_context_window_exceeded),原来直接比对原始值会漏判成 completed。 - finishReason = event.finishReason ? mapFinishReason(event.finishReason) : null; + finishReason = event.finishReason + ? mapFinishReason(event.finishReason) + : null; const u = event.totalUsage || event.usage || null; if (u) { normalizeUsage(u); @@ -3498,14 +4535,14 @@ function createResponsesSseTranslator(model, responseId, created) { break; } - case 'error': { + case "error": { this.upstreamError = mapCcEventError(event); // 上游在 HTTP 200 之后于**流内**报错时,这里以前只赋值不打日志:客户端收到 400, // 而 journalctl 里一片安静(本次排障就是靠 nginx 的 body_bytes_sent=0 反推的)。 // 对齐 /v1/chat/completions 路径的 CC stream error。 - log('warn', 'CC stream error', { - path: '/v1/responses', - message: event.error?.message || event.message || 'Unknown error', + log("warn", "CC stream error", { + path: "/v1/responses", + message: event.error?.message || event.message || "Unknown error", upstreamStatus: this.upstreamError.reportedStatus, code: this.upstreamError.code, mappedTo: this.upstreamError.status, @@ -3513,7 +4550,8 @@ function createResponsesSseTranslator(model, responseId, created) { break; } - default: break; + default: + break; } return out.length ? out : null; }, @@ -3524,38 +4562,69 @@ function createResponsesSseTranslator(model, responseId, created) { // 对齐 CLI:按可重试的 upstream_error 处理。 const incomplete = incompleteUpstreamDetail(sawFinish, finishReason); if (incomplete) { - log('warn', 'Upstream stream incomplete', { path: '/v1/responses', reason: incomplete }); - out.push(sse('response.failed', { - response: Object.assign(baseResponse('failed'), { - error: { code: 'upstream_error', message: incompleteUpstreamError(incomplete).body.error.message }, + log("warn", "Upstream stream incomplete", { + path: "/v1/responses", + reason: incomplete, + }); + out.push( + sse("response.failed", { + response: Object.assign(baseResponse("failed"), { + error: { + code: "upstream_error", + message: incompleteUpstreamError(incomplete).body.error.message, + }, + }), }), - })); + ); return out; } // 'length' 表示被截断(max_output_tokens / model_context_window_exceeded 都归一到这里); // 'pause_turn' 同样是「后面还有内容没发完」,规范要求 status=incomplete。 - const truncated = finishReason === 'length'; - const paused = finishReason === 'pause_turn'; - out.push(sse(truncated || paused ? 'response.incomplete' : 'response.completed', { - response: Object.assign(baseResponse(truncated || paused ? 'incomplete' : 'completed', doneItems.slice()), { - output_text: textAcc, - incomplete_details: truncated ? { reason: 'max_output_tokens' } - : paused ? { reason: 'pause_turn' } : null, - usage: buildResponsesUsage(usage, this.outputTokens), - }), - })); + const truncated = finishReason === "length"; + const paused = finishReason === "pause_turn"; + out.push( + sse( + truncated || paused ? "response.incomplete" : "response.completed", + { + response: Object.assign( + baseResponse( + truncated || paused ? "incomplete" : "completed", + doneItems.slice(), + ), + { + output_text: textAcc, + incomplete_details: truncated + ? { reason: "max_output_tokens" } + : paused + ? { reason: "pause_turn" } + : null, + usage: buildResponsesUsage(usage, this.outputTokens), + }, + ), + }, + ), + ); return out; }, fail(message) { if (!createdSent) return []; - return [sse('response.failed', { - response: Object.assign(baseResponse('failed'), { - error: { code: 'upstream_error', message: message || 'Upstream error' }, + return [ + sse("response.failed", { + response: Object.assign(baseResponse("failed"), { + error: { + code: "upstream_error", + message: message || "Upstream error", + }, + }), }), - })]; + ]; }, errorEvent(message) { - return sse('error', { code: null, message: message || 'Upstream error', param: null }); + return sse("error", { + code: null, + message: message || "Upstream error", + param: null, + }); }, }; } @@ -3565,41 +4634,57 @@ async function handleResponses(req, res) { try { respReq = await readBody(req); } catch (e) { - if (e.statusCode === 413) { sendResponsesError(res, 413, 'invalid_request_error', e.message); return; } - sendResponsesError(res, 400, 'invalid_request_error', 'Invalid JSON body'); + if (e.statusCode === 413) { + sendResponsesError(res, 413, "invalid_request_error", e.message); + return; + } + sendResponsesError(res, 400, "invalid_request_error", "Invalid JSON body"); return; } if (respReq.previous_response_id) { - sendResponsesError(res, 400, 'invalid_request_error', - 'previous_response_id is not supported (this proxy is stateless); send the full input each turn'); + sendResponsesError( + res, + 400, + "invalid_request_error", + "previous_response_id is not supported (this proxy is stateless); send the full input each turn", + ); return; } const apiKey = getApiKey(req.headers); if (!apiKey) { - sendResponsesError(res, 401, 'authentication_error', - 'Missing API key. Send in Authorization: Bearer or x-api-key header'); + sendResponsesError( + res, + 401, + "authentication_error", + "Missing API key. Send in Authorization: Bearer or x-api-key header", + ); return; } let chatReq = convertResponsesToChat(respReq); if (!chatReq.messages.length) { - sendResponsesError(res, 400, 'invalid_request_error', 'input is required'); + sendResponsesError(res, 400, "invalid_request_error", "input is required"); return; } const stream = chatReq.stream === true; - const model = chatReq.model || 'deepseek/deepseek-v4-flash'; - const responseId = newResponsesId('resp_'); + const model = chatReq.model || "deepseek/deepseek-v4-flash"; + const responseId = newResponsesId("resp_"); const created = nowUnix(); const echoOpts = { - instructions: respReq.instructions === undefined ? null : respReq.instructions, - max_output_tokens: respReq.max_output_tokens === undefined ? null : respReq.max_output_tokens, + instructions: + respReq.instructions === undefined ? null : respReq.instructions, + max_output_tokens: + respReq.max_output_tokens === undefined + ? null + : respReq.max_output_tokens, temperature: respReq.temperature, top_p: respReq.top_p, reasoning: respReq.reasoning || null, - tool_choice: typeof respReq.tool_choice === 'string' ? respReq.tool_choice : 'auto', + tool_choice: + typeof respReq.tool_choice === "string" ? respReq.tool_choice : "auto", tools: respReq.tools || [], }; const ccBody = buildCcRequest(chatReq); @@ -3612,47 +4697,75 @@ async function handleResponses(req, res) { let aborted = false; const startTime = Date.now(); let bytesReceived = 0; - let lastCcEvent = ''; + let lastCcEvent = ""; let reader = null; let translator = null; - res.on('close', () => { + res.on("close", () => { if (res.writableEnded) return; aborted = true; - log('warn', 'Client disconnected', { - path: '/v1/responses', model, responseId, elapsedMs: Date.now() - startTime, - bytesSent: bytesReceived, lastCcEvent: lastCcEvent || '(none)', + log("warn", "Client disconnected", { + path: "/v1/responses", + model, + responseId, + elapsedMs: Date.now() - startTime, + bytesSent: bytesReceived, + lastCcEvent: lastCcEvent || "(none)", }); - if (!abortController.signal.aborted) { try { abortController.abort(); } catch (e2) {} } + if (!abortController.signal.aborted) { + try { + abortController.abort(); + } catch (e2) {} + } }); try { await ensureInitialized(apiKey, abortController.signal); - const ccResponse = await forwardToCC(ccBody, apiKey, req.headers, abortController.signal, promptCacheKey); + const ccResponse = await forwardToCC( + ccBody, + apiKey, + req.headers, + abortController.signal, + promptCacheKey, + ); if (!ccResponse.ok) { - const errorText = await ccResponse.text().catch(() => ''); + const errorText = await ccResponse.text().catch(() => ""); const mapped = mapCcError(ccResponse.status, errorText); - log('error', 'CC API error', { status: ccResponse.status, path: '/v1/responses', code: mapped.code, body: summarizeUpstreamError(errorText) }); - sendResponsesError(res, mapped.status, mapped.body.error.type, mapped.body.error.message, mapped.body.retry_after); + log("error", "CC API error", { + status: ccResponse.status, + path: "/v1/responses", + code: mapped.code, + body: summarizeUpstreamError(errorText), + }); + sendResponsesError( + res, + mapped.status, + mapped.body.error.type, + mapped.body.error.message, + mapped.body.retry_after, + ); return; } if (stream) { translator = createResponsesSseTranslator(model, responseId, created); - let buffer = ''; + let buffer = ""; let started = false; const decoder = new TextDecoder(); reader = ccResponse.body.getReader(); const idle = createIdleWatchdog(STREAM_IDLE_TIMEOUT_MS); const SSE_HEADERS = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", }; const writeEvents = async (evts) => { - if (!started) { res.writeHead(200, SSE_HEADERS); started = true; } + if (!started) { + res.writeHead(200, SSE_HEADERS); + started = true; + } for (const e2 of evts) res.write(e2); await waitDrain(res); }; @@ -3671,8 +4784,11 @@ async function handleResponses(req, res) { // 会把连接掐掉 —— 现象是客户端 ~16s 断连、代理侧 Client disconnected、nginx 只记到很少字节。 const heartbeat = setInterval(() => { // 回调是同步的,无法 await waitDrain,所以用 writableNeedDrain 直接跳过(背压时少一条注释无副作用) - if (aborted || !started || res.writableEnded || res.writableNeedDrain) return; - try { res.write(': keepalive\n\n'); } catch (e2) {} + if (aborted || !started || res.writableEnded || res.writableNeedDrain) + return; + try { + res.write(": keepalive\n\n"); + } catch (e2) {} }, 5000); try { @@ -3687,9 +4803,9 @@ async function handleResponses(req, res) { const chunkText = decoder.decode(value, { stream: true }); buffer += chunkText; let lines = []; - if (chunkText.indexOf('\n') !== -1) { - lines = buffer.split('\n'); - buffer = lines.pop() || ''; + if (chunkText.indexOf("\n") !== -1) { + lines = buffer.split("\n"); + buffer = lines.pop() || ""; } for (const line of lines) { @@ -3706,62 +4822,106 @@ async function handleResponses(req, res) { } if (translator.upstreamError) { if (!started) { - sendResponsesError(res, translator.upstreamError.status, - translator.upstreamError.body.error.type, translator.upstreamError.body.error.message, - translator.upstreamError.body.retry_after); + sendResponsesError( + res, + translator.upstreamError.status, + translator.upstreamError.body.error.type, + translator.upstreamError.body.error.message, + translator.upstreamError.body.retry_after, + ); return; } - const failed = translator.fail(translator.upstreamError.body.error.message); + const failed = translator.fail( + translator.upstreamError.body.error.message, + ); if (failed.length) await writeEvents(failed); } else if (translator.outputTokens === 0 && !translator.hasOutput) { - try { if (!abortController.signal.aborted) abortController.abort(); } catch (e2) {} + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch (e2) {} if (!started) { - sendResponsesError(res, 429, 'rate_limit_error', - 'Empty response from upstream (zero output tokens)', 10); + sendResponsesError( + res, + 429, + "rate_limit_error", + "Empty response from upstream (zero output tokens)", + 10, + ); return; } // created 已随 200 先行发出,响应头按 200 提交后状态码改不回 429 —— // 这里再调 sendResponsesError 会抛 ERR_HTTP_HEADERS_SENT(issue #56)。 // 按本文件既有失败口径走 response.failed:空响应绝不能经 finish() 包装成 // response.completed 谎报成功(与 #38/#39 修掉的静默截断同类)。 - const failed = translator.fail('Empty response from upstream (zero output tokens)'); + const failed = translator.fail( + "Empty response from upstream (zero output tokens)", + ); if (failed.length) await writeEvents(failed); // 就地收尾:下面的 return 会跳过流式分支尾部的 res.end(), // 漏掉这条客户端会挂在永不结束的 SSE 上。 if (!res.writableEnded) res.end(); return; } else { - if (!started) { res.writeHead(200, SSE_HEADERS); started = true; } + if (!started) { + res.writeHead(200, SSE_HEADERS); + started = true; + } for (const e2 of translator.finish()) res.write(e2); } consecutiveTimeouts = 0; } } catch (e) { if (aborted) { - try { reader.cancel().catch(() => {}); } catch (e2) {} - } else if (e.message === 'STREAM_IDLE_TIMEOUT') { - log('warn', 'Stream idle timeout', { - path: '/v1/responses', model, streaming: true, timeoutMs: STREAM_IDLE_TIMEOUT_MS, - elapsedMs: Date.now() - startTime, bytesReceived, lastCcEvent: lastCcEvent || '(none)', + try { + reader.cancel().catch(() => {}); + } catch (e2) {} + } else if (e.message === "STREAM_IDLE_TIMEOUT") { + log("warn", "Stream idle timeout", { + path: "/v1/responses", + model, + streaming: true, + timeoutMs: STREAM_IDLE_TIMEOUT_MS, + elapsedMs: Date.now() - startTime, + bytesReceived, + lastCcEvent: lastCcEvent || "(none)", }); consecutiveTimeouts++; - const timeoutMsg = consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD - ? 'Response timeout - try reducing context length (summarize earlier messages)' - : 'Response timeout - request timed out'; - if (!started) { sendResponsesError(res, 429, 'rate_limit_error', timeoutMsg, 5); return; } + const timeoutMsg = + consecutiveTimeouts >= TIMEOUT_REDUCE_CONTEXT_THRESHOLD + ? "Response timeout - try reducing context length (summarize earlier messages)" + : "Response timeout - request timed out"; + if (!started) { + sendResponsesError(res, 429, "rate_limit_error", timeoutMsg, 5); + return; + } if (!res.writableEnded) { // end() 而不是 destroy():理由见 handleChatCompletions 流式超时分支 - try { res.end(translator.errorEvent(timeoutMsg)); } catch (e2) {} + try { + res.end(translator.errorEvent(timeoutMsg)); + } catch (e2) {} } } else { - log('error', 'Stream error', { message: e.message, path: '/v1/responses' }); - try { abortController.abort(); } catch (e2) {} + log("error", "Stream error", { + message: e.message, + path: "/v1/responses", + }); + try { + abortController.abort(); + } catch (e2) {} if (!started) { - sendResponsesError(res, 502, 'proxy_error', 'Upstream error: ' + e.message, 10); + sendResponsesError( + res, + 502, + "proxy_error", + "Upstream error: " + e.message, + 10, + ); return; } if (!res.writableEnded) { - try { res.write(translator.errorEvent(e.message)); } catch (e2) {} + try { + res.write(translator.errorEvent(e.message)); + } catch (e2) {} } } } finally { @@ -3772,51 +4932,66 @@ async function handleResponses(req, res) { if (!res.writableEnded) res.end(); } else { // ── 非流式:缓冲完整 NDJSON 后一次性构造 Responses 对象 ── - let fullText = ''; - let thinkingText = ''; + let fullText = ""; + let thinkingText = ""; let usage = null; - let finishReason = 'stop'; + let finishReason = "stop"; let sawFinish = false; let upstreamError = null; const toolCalls = []; reader = ccResponse.body.getReader(); const decoder = new TextDecoder(); - let buf = ''; + let buf = ""; const processLines = () => { - const lines = buf.split('\n'); - buf = lines.pop() || ''; + const lines = buf.split("\n"); + buf = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || trimmed === '[DONE]' || trimmed.startsWith(':')) continue; + if (!trimmed || trimmed === "[DONE]" || trimmed.startsWith(":")) + continue; let event; - try { event = JSON.parse(trimmed); } catch (e2) { continue; } + try { + event = JSON.parse(trimmed); + } catch (e2) { + continue; + } switch (event.type) { - case 'text-delta': lastCcEvent = event.type; fullText += event.text || ''; break; - case 'reasoning-delta': lastCcEvent = event.type; thinkingText += event.text || ''; break; - case 'tool-call': { + case "text-delta": + lastCcEvent = event.type; + fullText += event.text || ""; + break; + case "reasoning-delta": + lastCcEvent = event.type; + thinkingText += event.text || ""; + break; + case "tool-call": { lastCcEvent = event.type; toolCalls.push({ - id: event.toolCallId || ('call_' + randomUUID().slice(0, 8)), - type: 'function', + id: event.toolCallId || "call_" + randomUUID().slice(0, 8), + type: "function", function: { - name: event.toolName || '', - arguments: typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {}), + name: event.toolName || "", + arguments: + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input || {}), }, }); break; } - case 'finish-step': - case 'finish': + case "finish-step": + case "finish": lastCcEvent = event.type; sawFinish = true; - finishReason = mapFinishReason(event.finishReason || 'stop'); - if (event.totalUsage || event.usage) usage = event.totalUsage || event.usage; + finishReason = mapFinishReason(event.finishReason || "stop"); + if (event.totalUsage || event.usage) + usage = event.totalUsage || event.usage; break; - case 'error': + case "error": lastCcEvent = event.type; upstreamError = mapCcEventError(event); - log('warn', 'CC stream error (non-stream)', { + log("warn", "CC stream error (non-stream)", { message: event.error ? event.error.message : event.message, upstreamStatus: upstreamError.reportedStatus, upstreamRetryable: event.error?.isRetryable, @@ -3829,14 +5004,21 @@ async function handleResponses(req, res) { //(text-start / text-end / start / start-step / reasoning-start / reasoning-end / // provider-metadata / tool-input-* / tool-error)全部掉进 default 打成 // '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 'tool-error': + 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 "tool-error": // Silent - no user-visible content break; default: - log('warn', 'Unknown CC event type', { type: event.type }); + log("warn", "Unknown CC event type", { type: event.type }); break; } } @@ -3851,14 +5033,19 @@ async function handleResponses(req, res) { bytesReceived += value.length; const chunkText = decoder.decode(value, { stream: true }); buf += chunkText; - if (chunkText.indexOf('\n') !== -1) processLines(); + if (chunkText.indexOf("\n") !== -1) processLines(); } idle.dispose(); processLines(); if (upstreamError) { - sendResponsesError(res, upstreamError.status, upstreamError.body.error.type, - upstreamError.body.error.message, upstreamError.body.retry_after); + sendResponsesError( + res, + upstreamError.status, + upstreamError.body.error.type, + upstreamError.body.error.message, + upstreamError.body.retry_after, + ); return; } @@ -3866,34 +5053,74 @@ async function handleResponses(req, res) { { const incomplete = incompleteUpstreamDetail(sawFinish, finishReason); if (incomplete) { - log('warn', 'Upstream stream incomplete', { path: '/v1/responses', reason: incomplete }); + log("warn", "Upstream stream incomplete", { + path: "/v1/responses", + reason: incomplete, + }); const err = incompleteUpstreamError(incomplete); - try { if (!abortController.signal.aborted) abortController.abort(); } catch {} - sendResponsesError(res, err.status, err.body.error.type, err.body.error.message, err.retry_after); + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch {} + sendResponsesError( + res, + err.status, + err.body.error.type, + err.body.error.message, + err.retry_after, + ); return; } } if (!fullText && !thinkingText && !toolCalls.length) { - try { if (!abortController.signal.aborted) abortController.abort(); } catch (e2) {} - sendResponsesError(res, 429, 'rate_limit_error', - 'Empty response from upstream (zero output tokens)', 10); + try { + if (!abortController.signal.aborted) abortController.abort(); + } catch (e2) {} + sendResponsesError( + res, + 429, + "rate_limit_error", + "Empty response from upstream (zero output tokens)", + 10, + ); return; } consecutiveTimeouts = 0; echoOpts.finishReason = finishReason; - sendJSON(res, 200, buildResponsesObject( - responseId, model, created, fullText, thinkingText, toolCalls, usage, echoOpts)); + sendJSON( + res, + 200, + buildResponsesObject( + responseId, + model, + created, + fullText, + thinkingText, + toolCalls, + usage, + echoOpts, + ), + ); } } catch (e) { - if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return; - log('error', 'Responses handler error', { message: e.message }); + if (e.name === "AbortError" || e.code === "ABORT_ERR") return; + log("error", "Responses handler error", { message: e.message }); if (!res.headersSent) { - sendResponsesError(res, 502, 'proxy_error', 'Upstream error: ' + e.message, 10); + sendResponsesError( + res, + 502, + "proxy_error", + "Upstream error: " + e.message, + 10, + ); } else if (!res.writableEnded) { - try { res.write(translator ? translator.errorEvent(e.message) : ''); } catch (e2) {} - try { res.end(); } catch (e2) {} + try { + res.write(translator ? translator.errorEvent(e.message) : ""); + } catch (e2) {} + try { + res.end(); + } catch (e2) {} } } } @@ -3903,46 +5130,51 @@ async function handleModels(req, res) { const models = await fetchModels(apiKey); const now = nowUnix(); sendJSON(res, 200, { - object: 'list', - data: models.map(m => ({ + object: "list", + data: models.map((m) => ({ id: m.id, - object: 'model', + object: "model", created: now, - owned_by: 'command-code', + owned_by: "command-code", })), }); } function handleHealth(req, res) { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('OK'); + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("OK"); } // ── 服务器 ────────────────────────────────────────── const server = http.createServer(async (req, res) => { // CORS - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', '*'); - if (req.method === 'OPTIONS') { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } - const host = req.headers.host || 'localhost'; + const host = req.headers.host || "localhost"; const url = new URL(req.url, `http://${host}`); // 在途上限准入。/health 与 / 例外:探活与编排器不该因业务繁忙而收 503。 - const isLiveness = url.pathname === '/health' || url.pathname === '/'; + const isLiveness = url.pathname === "/health" || url.pathname === "/"; if (!isLiveness && MAX_INFLIGHT > 0) { if (inflightCount >= MAX_INFLIGHT) { - log('warn', 'In-flight limit reached, rejecting request', { - maxInflight: MAX_INFLIGHT, inflight: inflightCount, path: url.pathname, + log("warn", "In-flight limit reached, rejecting request", { + maxInflight: MAX_INFLIGHT, + inflight: inflightCount, + path: url.pathname, }); sendJSON(res, 503, { - error: { message: `Too many concurrent requests (limit ${MAX_INFLIGHT}), retry shortly`, type: 'server_busy' }, + error: { + message: `Too many concurrent requests (limit ${MAX_INFLIGHT}), retry shortly`, + type: "server_busy", + }, retry_after: 5, }); return; @@ -3956,39 +5188,46 @@ const server = http.createServer(async (req, res) => { released = true; if (inflightCount > 0) inflightCount--; }; - res.once('finish', release); - res.once('close', release); + res.once("finish", release); + res.once("close", release); } try { - if (url.pathname === '/v1/chat/completions' && req.method === 'POST') { + if (url.pathname === "/v1/chat/completions" && req.method === "POST") { await handleChatCompletions(req, res); - } else if (url.pathname === '/v1/messages' && req.method === 'POST') { + } else if (url.pathname === "/v1/messages" && req.method === "POST") { await handleMessages(req, res); - } else if (url.pathname === '/v1/responses' && req.method === 'POST') { + } else if (url.pathname === "/v1/responses" && req.method === "POST") { await handleResponses(req, res); - } else if (url.pathname === '/v1/models' && req.method === 'GET') { + } else if (url.pathname === "/v1/models" && req.method === "GET") { await handleModels(req, res); - } else if (url.pathname === '/health' || url.pathname === '/') { + } else if (url.pathname === "/health" || url.pathname === "/") { handleHealth(req, res); } else { - sendJSON(res, 404, { error: { message: 'Not found', type: 'not_found' } }); + sendJSON(res, 404, { + error: { message: "Not found", type: "not_found" }, + }); } } catch (e) { - sendJSON(res, 500, { error: { message: e.message, type: 'internal_error' } }); + sendJSON(res, 500, { + error: { message: e.message, type: "internal_error" }, + }); } }); // 全局兜底:abort 触发的异步 rejection 不会让进程崩溃 -process.on('unhandledRejection', (reason) => { - if (reason?.name === 'AbortError' || reason?.code === 'ABORT_ERR') { +process.on("unhandledRejection", (reason) => { + if (reason?.name === "AbortError" || reason?.code === "ABORT_ERR") { // 客户端断连触发的 abort — 预期行为,静默处理 - log('info', 'Aborted request cleaned up'); + log("info", "Aborted request cleaned up"); } else if (isRetryableUpstreamError(reason)) { // 上游传输层闪断造成的异步 rejection(如 socket terminated),已被重试机制或上层吸收 - log('info', 'Upstream socket terminated asynchronously (handled)'); + log("info", "Upstream socket terminated asynchronously (handled)"); } else { - log('error', 'Unhandled rejection', { message: reason?.message || String(reason), stack: reason?.stack?.split('\n')[0] }); + log("error", "Unhandled rejection", { + message: reason?.message || String(reason), + stack: reason?.stack?.split("\n")[0], + }); } }); @@ -4005,45 +5244,63 @@ process.on('unhandledRejection', (reason) => { // 反向代理后部署的建议一致(keepAliveTimeout > 前端 idle timeout)。 // 反代侧仍建议设 keepalive_timeout 60s 以内。 const KEEPALIVE_TIMEOUT_MS = (() => { - const ms = Number.parseInt(process.env.CC_KEEPALIVE_TIMEOUT_MS ?? '', 10); + const ms = Number.parseInt(process.env.CC_KEEPALIVE_TIMEOUT_MS ?? "", 10); return Number.isFinite(ms) && ms > 0 ? ms : 65000; })(); server.keepAliveTimeout = KEEPALIVE_TIMEOUT_MS; -server.headersTimeout = KEEPALIVE_TIMEOUT_MS + 1000; // Node 要求 headersTimeout > keepAliveTimeout +server.headersTimeout = KEEPALIVE_TIMEOUT_MS + 1000; // Node 要求 headersTimeout > keepAliveTimeout server.listen(CFG.port, CFG.host, () => { - log('info', 'CC Proxy started', { + log("info", "CC Proxy started", { url: `http://${CFG.host}:${CFG.port}`, api: CFG.apiBase, models: MODELS.length, - session: 'content-derived x-session-id', - zdr: CFG.zdr ? 'enabled (x-cmd-zdr: 1 on generation/init requests)' : 'off (CMD_ZDR=1 or per-request x-cmd-zdr: 1 to enable)', - emptySystemPlaceholder: CFG.emptySystemPlaceholder ? 'on (space placeholder for requests without system prompt, issue #17)' : 'off', - logFile: CFG.logFile || '(console only)', - clientDrainTimeout: CLIENT_DRAIN_TIMEOUT_MS > 0 ? `${CLIENT_DRAIN_TIMEOUT_MS}ms` : 'disabled', + session: "content-derived x-session-id", + zdr: CFG.zdr + ? "enabled (x-cmd-zdr: 1 on generation/init requests)" + : "off (CMD_ZDR=1 or per-request x-cmd-zdr: 1 to enable)", + emptySystemPlaceholder: CFG.emptySystemPlaceholder + ? "on (space placeholder for requests without system prompt, issue #17)" + : "off", + logFile: CFG.logFile || "(console only)", + clientDrainTimeout: + CLIENT_DRAIN_TIMEOUT_MS > 0 ? `${CLIENT_DRAIN_TIMEOUT_MS}ms` : "disabled", keepAliveTimeout: `${KEEPALIVE_TIMEOUT_MS}ms (反代侧 keepalive_timeout 必须小于它)`, idleTimeouts: `stream ${STREAM_IDLE_TIMEOUT_MS}ms / nonstream ${NONSTREAM_IDLE_TIMEOUT_MS}ms`, - maxInflight: MAX_INFLIGHT > 0 ? `${MAX_INFLIGHT} (global, /health exempt)` : 'unlimited (CC_MAX_INFLIGHT=0)', + maxInflight: + MAX_INFLIGHT > 0 + ? `${MAX_INFLIGHT} (global, /health exempt)` + : "unlimited (CC_MAX_INFLIGHT=0)", upstreamProxy: redactProxyUrl(UPSTREAM_PROXY), - upstreamRetry: UPSTREAM_RETRY_MAX > 0 - ? `${UPSTREAM_RETRY_MAX} retries, base ${UPSTREAM_RETRY_BASE_MS}ms (only before first byte)` - : 'disabled (CC_UPSTREAM_RETRY_MAX=0)', - fingerprint: `derived from API key (salt: ${CFG.fingerprintSalt ? 'set' : 'unset'})`, + upstreamRetry: + UPSTREAM_RETRY_MAX > 0 + ? `${UPSTREAM_RETRY_MAX} retries, base ${UPSTREAM_RETRY_BASE_MS}ms (only before first byte)` + : "disabled (CC_UPSTREAM_RETRY_MAX=0)", + fingerprint: `derived from API key (salt: ${CFG.fingerprintSalt ? "set" : "unset"})`, }); if (CLIENT_DRAIN_TIMEOUT_MS > 0) { - log('info', 'Client drain timeout enabled', { timeoutMs: CLIENT_DRAIN_TIMEOUT_MS }); + log("info", "Client drain timeout enabled", { + timeoutMs: CLIENT_DRAIN_TIMEOUT_MS, + }); } // 内存提示:body 上限隐含的最坏内存 = 上限 × 实测放大系数(见 MAX_BODY_SIZE 注释 / issue #20) const bodyCapMB = Math.round(MAX_BODY_SIZE / 1048576); const worstCaseMB = Math.round(bodyCapMB * 5.5); if (worstCaseMB >= 500) { - log('warn', 'Request body limit implies high per-request worst-case memory', { - maxBodyMB: bodyCapMB, - worstCaseRSSPerRequestMB: worstCaseMB, - hint: 'lower CC_MAX_BODY_MB, set CC_MAX_INFLIGHT, and/or cap in-flight requests at the reverse proxy (see README)', - }); + log( + "warn", + "Request body limit implies high per-request worst-case memory", + { + maxBodyMB: bodyCapMB, + worstCaseRSSPerRequestMB: worstCaseMB, + hint: "lower CC_MAX_BODY_MB, set CC_MAX_INFLIGHT, and/or cap in-flight requests at the reverse proxy (see README)", + }, + ); } if (!CFG.apiKey) { - log('info', 'No API key in config. API key must be sent in Authorization: Bearer header per request.'); + log( + "info", + "No API key in config. API key must be sent in Authorization: Bearer header per request.", + ); } });