diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a554ac..4df8038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,32 +2,55 @@ name: CI on: push: - branches: [ main, master ] + branches: [main] pull_request: workflow_dispatch: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 20 是 engine 下限,22 是当前 LTS。两条都测 —— + # 只在一条上绿过,就没资格说「支持多版本」。 + node-version: ["20", "22"] steps: - uses: actions/checkout@v4 - - - name: Set up Node 20 - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ matrix.node-version }} - # 只跑单元测试,不装 wrangler —— wrangler 只在部署时需要, - # 而单元测试是零依赖的(node:test + node:assert) - - name: Unit tests + # 单元测试零依赖(node:test + node:assert),所以不装 wrangler。 + # 这本身是个需要守住的特性:装依赖会让 CI 变慢,也会引入供应链面。 + - name: 单元测试 run: npm test - syntax: + - name: 语法检查(所有源文件,不只 worker.js) + run: | + for f in src/*.js scripts/*.js test/*.mjs; do + node --check "$f" || exit 1 + done + + docs-sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' - - name: Check worker.js syntax - run: node --check src/worker.js + node-version: "22" + # README 里列出的模型必须和代码里的目录一致。 + # 上一版 README 写着「7 个模型」,代码里是 8 个,一个都对不上 —— + # 这条守的就是那个故障:文档与代码各自漂移。 + - name: README 与 src/providers.js 的模型清单必须一致 + run: node scripts/check-docs.mjs + + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: deploy.sh 语法与静态检查 + run: | + bash -n deploy.sh + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck -S warning deploy.sh || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..644b27c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +.dev.vars +# 本地 OAuth 凭据,等同账号权限,绝不能提交 +.iceProxy/ +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7d8f7ae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +## 2.0.0 + +重写。这一版的重点不是「加功能」,而是**修掉上一版里几个「本地绿、线上必挂」的问题**, +并把模型清单收敛到单一真相源。 + +### 修复(每条都有对应回归测试) + +- **流式请求没有真的流式。** OpenAI 兼容分支里 `stream` 被硬编码成 `false`, + 于是客户端请求 `stream: true` 时收到一个普通 JSON。聊天类客户端(Cline、Cursor、 + 各种 Web UI)会一直等一个不会到来的 SSE,表现为卡死。**这是 18 个模型里 + 13 个的默认路径。** + +- **Gemini 完全没有流式。** 上一版只实现了 `generateContent`,没有 + `streamGenerateContent`。 + +- **有效的 Qwen 账号被判定为不可用。** 账号池判断「token 是否有效」时读的是 + `acc.token`,而账号存的是 `access_token`。这个分支永远不会命中,所以每个请求 + 都走「拿 refresh_token 去换」的路径。后果有两层:一是每个请求白白多一次上游往返; + 二是 refresh 一旦失败(这个 grant 不是稳定的公开接口,失败很常见), + 一个还有一小时寿命的**有效 token 会被冷藏一整天**,用户看到的是 + 「no Qwen account available」。而 Qwen 是默认模型家族。 + +- **`README` 声称的「跨 provider 自动故障转移」并不存在。** 代码里只有 Qwen 的 + 账号轮换。现在真的实现了:按「同模型 → 同 provider 其它模型 → 其它已配置 + provider」构建候选链,只在 429/401/403/5xx 时往下走。 + +- **错误体不是 OpenAI 形状。** 返回的是 `{"error": "unknown model: x"}`(字符串), + 而官方 SDK 会按 `{"error": {"message": ...}}` 解析。结果是用户看到客户端 SDK + 自己的解析异常,而不是真实原因。 + +- **Gemini 把多轮历史整段丢弃。** 只取 `messages` 的最后一条,而 Cline/Cursor + 每轮都带完整历史 —— 等于每轮都让模型失忆。 + +- **上游中途断开时,已收到的内容被丢掉。** SSE 拆帧函数在 `read()` 抛错时直接 + 向上抛,缓冲区里已经读到、还没交付的那部分就被丢了。表现是「模型说到一半 + 突然没了」,而且断在哪一半是随机的。 + +### 新增 + +- **模型目录与 provider 注册表分离**(`src/providers.js`),18 个模型 / 6 个 provider。 + 新增模型不再需要复制 base/type/auth 三份配置。 +- **`Groq`**。至此 6 个 provider。 +- **`scripts/check-docs.mjs`** —— CI 里强制 README 与代码的模型清单一致。 + 上一版 README 标题写 7 个模型、正文表格写 8+、`package.json` 写 5 个、 + 代码里实际 8 个,四份数字没有一个对得上。 +- **响应里标注实际服务的模型**:`_proxy` 字段 + `X-IceProxy-Fallback` 头。 +- **`/v1/models` 带 `context_length` / `capabilities` / `available`。** +- **账号池按失败原因分级冷却**:限流一分钟、服务端错误十几秒、认证失败几小时。 + 不再「一次 429 封一整天」。 +- **并发刷新去重**:KV 没有原子操作,同时刷新同一账号会互相覆盖。 +- **82 个单测**(上一版 15 个),全部离线、零依赖。 + +### 变更(可能影响你) + +- **模型 ID 有增删**。`gemini/gemini-1.5-flash`、`openrouter/auto`、 + `qwen/vision-model` 被替换成实际可用的 ID。README 里那批「Qwen3-Max / + GLM-5.3 / Kimi-K3」从来就不存在于代码里,已从文档中删除。 +- **错误响应结构变了**(从字符串改成对象)。这是修 bug,但如果你写了 + 针对旧格式的解析代码,需要跟着改。 +- **`deploy.sh` 不再用 `sed` 改 `wrangler.toml`**。上一版的正则匹配的是 + 注释行 `# id = "your_kv_id"`,一旦那行被改过格式,`sed` 会静默不匹配 + (退出码仍是 0),KV id 留在占位符上,部署出来的 Worker 每个 Qwen 请求都失败。 +- **`scripts/auth.js` 改用 `execFileSync`**,不再手工拼 shell 字符串 —— + 上一版对含引号的 JSON 做手工转义,既容易出错也是命令注入面。 +- **`package.json` 标记 `"type": "module"`**,Node >= 20。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5ed7af3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# 贡献指南 + +## 这个项目的底线 + +1. **运行时零依赖。** `package.json` 的 `dependencies` 必须一直是空的。 + `devDependencies` 里只有 `wrangler`(部署工具,不参与运行时)。 + 单测用 Node 自带的 `node:test`,不引测试框架。 +2. **单一真相源。** 模型清单只写在 `src/providers.js`。README 里的表格由 + CI(`scripts/check-docs.mjs`)强制与它保持一致。不要在别处再抄一份。 +3. **不硬编码会过期的事实。** 免费额度、模型数量这类会变的东西, + 要么从代码推导,要么不写。README 里写死的「2000 req/day」是上一版的教训。 +4. **每修一个 bug,补一条会红的测试。** 见下文。 + +## 关于测试 + +跑: + +```bash +npm test +``` + +写测试时请遵守一条:**先确认你的测试在 bug 存在时会变红。** + +做法是把修复临时还原,跑一遍,看它是否失败。例如: + +```bash +# 假设你修了一个字段名 bug +cp src/accounts.js /tmp/acc.bak +# 手动把修复改回去 +npm test # 这一步必须看到失败 +cp /tmp/acc.bak src/accounts.js +npm test # 恢复后必须全绿 +``` + +没做过这一步的测试,很可能是一条**橡皮图章**:断言写得看着合理, +但缺陷存在时它也照样通过。本项目上一版的测试基本都是这个毛病 +——测的全是「配置项自洽」(比如「base 是不是 https」), +而真实发生的故障(流式返回 JSON、有效 token 被判失效)一条都没覆盖。 + +所以测试按**故障现象**组织,不按代码结构组织。文件里的分节标题就是 +「这类故障」,每条 `test()` 的名字就是「用户看到的现象」。 + +## 加一个新模型 + +1. 在 `src/providers.js` 的 `MODELS` 里加一行: + + ```js + { id: "provider/model-name", ctx: 131072, caps: ["tools", "stream"] }, + ``` + + 如果 provider 已存在,只加这一行就够。新增 provider 才需要在 + `PROVIDERS` 里加声明(含 `base` / `protocol` / `auth` / `keyEnv`)。 + +2. **实测确认这个模型的 id 在真上游存在。** 不要凭印象写。 + `unknown model` 这类错误用户是没法自救的。OpenRouter 的模型可以直接查: + + ```bash + curl -s https://openrouter.ai/api/v1/models | node -e ' + let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{ + const ids=JSON.parse(s).data.map(m=>m.id); + console.log(ids.filter(i=>i.includes("你的关键词")).join("\n")); + })' + ``` + +3. 更新 `README.md` 的模型表,然后: + + ```bash + node scripts/check-docs.mjs # 不一致会直接告诉你差在哪 + ``` + +## 改协议相关的东西 + +`src/openai.js`(SSE 帧与错误形状)和 `src/adapters.js`(各家协议转换) +是兼容性的地基。改动这两处时,请: + +- 不要依赖「一次 `read()` 就是一行」。真实网络会把一行切成任意碎片。 + 测试里有对应的用例,别删。 +- 错误体必须是 `{"error": {"message", "type", "code"}}` 对象。 +- 流式响应必须以字面量 `data: [DONE]` 收尾,否则客户端会一直等。 + +## 提交前 + +```bash +npm run check # 单测 + 语法检查 +node scripts/check-docs.mjs # 文档与代码一致性 +bash -n deploy.sh +``` + +## 提交信息 + +用 `fix:` / `feat:` / `chore:` / `docs:` 开头,正文说清**现象**和**根因**, +不只是结论。「修了流式」是废话,「stream 被硬编码成 false,导致客户端卡死」才有用。 diff --git a/README.md b/README.md index 99025c1..61b9e6c 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,350 @@
-
-
+
+
+
-
- OpenAI-compatible API for 8 free AI models in one endpoint
- Qwen3-Coder-Plus · Qwen3-Coder-Flash · Qwen-Vision · Gemini-2.0-Flash · Gemini-1.5-Flash · GLM-4.5-Flash · Cerebras-Qwen3-32B · OpenRouter-Auto
+ One OpenAI-compatible endpoint for 18 free AI models across 6 providers
+ Streaming · Cross-provider failover · Multi-account rotation · Zero build step
此前的实现只取 messages 的最后一条——多轮对话的历史被整段丢弃, - * 而 Cline / Cursor 这类客户端每次请求都会带上完整历史,等于每次 - * 都让模型失忆。这里按 OpenAI 语义完整映射: - * - system 消息 → Gemini 的 systemInstruction(它不属于 contents) - * - assistant → model(Gemini 的助手角色叫 model,不叫 assistant) - * - user → user - * - 多条 system 合并;非字符串 content 兜底转成字符串 + * 上一版 README 写着「自动跨 provider 故障转移」,但代码里 + * 只有 Qwen 的**账号**轮换,没有任何跨 provider 回退 —— 文档在说谎。 + * + * 现在的规则: + * 1. 首选客户端指定的模型; + * 2. 然后是同 provider 的其他模型(同密钥,最省事); + * 3. 最后是其它「已配置好密钥」的 provider 的默认模型。 + * 只有在上游返回可重试错误时才往下走,客户端参数错误(400)不重试。 */ -function toGeminiContents(messages) { - const contents = []; - const systemParts = []; - for (const m of Array.isArray(messages) ? messages : []) { - if (!m) continue; - const text = typeof m.content === "string" - ? m.content - : (m.content == null ? "" : JSON.stringify(m.content)); - if (m.role === "system") { - if (text) systemParts.push(text); - continue; +export function buildFallbackChain(modelId, env, accountCount) { + const primary = catalog.get(modelId); + if (!primary) return []; + // 主模型自身也要检查凭据:否则「没配任何密钥」会得到一个非空候选链, + // 一路试到最后一个 provider 才报错,错误信息也说不清到底缺什么。 + const chain = isProviderReady(primary.provider, env, accountCount) ? [primary] : []; + const seen = new Set([modelId]); + + for (const m of MODELS) { + const e = catalog.get(m.id); + if (seen.has(m.id)) continue; + if (e.prefix === primary.prefix && isProviderReady(e.provider, env, accountCount)) { + seen.add(m.id); + chain.push(e); } - const role = m.role === "assistant" ? "model" : "user"; - contents.push({ role, parts: [{ text }] }); } - // Gemini 要求 contents 非空且首条为 user - if (contents.length === 0) contents.push({ role: "user", parts: [{ text: "" }] }); - return { contents, systemParts }; + for (const m of MODELS) { + const e = catalog.get(m.id); + if (seen.has(m.id)) continue; + if (e.prefix !== primary.prefix && isProviderReady(e.provider, env, accountCount)) { + seen.add(m.id); + chain.push(e); + } + } + return chain.slice(0, 4); // 别无限重试,用户等不起 } -async function callGemini(req, env) { - const modelId = req.model.replace(/^gemini\//, ""); - const { contents, systemParts } = toGeminiContents(req.messages); - const payload = { - contents, - generationConfig: { temperature: 0.7, maxOutputTokens: 8192 } - }; - if (systemParts.length) { - payload.systemInstruction = { parts: [{ text: systemParts.join("\n\n") }] }; +/** 这个状态码值得换个 provider 再试吗? */ +function isRetryable(status) { + return status === 429 || status === 401 || status === 403 || status >= 500; +} + +// ---------- 主处理 ---------- + +async function handleChat(rawBody, env, pool, wantStream) { + const body = buildUpstreamBody(rawBody); + const requested = rawBody.model || DEFAULT_MODEL; + + if (!catalog.has(requested)) { + const available = MODELS.map((m) => m.id).join(", "); + return errorResponse( + `unknown model: ${requested}. Available models: ${available}`, + { type: "invalid_request_error", code: "model_not_found", param: "model" } + ); } - const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${env.GEMINI_API_KEY}`; - const r = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - if (!r.ok) throw new Error("gemini " + r.status + ": " + await r.text()); - const d = await r.json(); - // Convert to OpenAI format - const text = d.candidates?.[0]?.content?.parts?.[0]?.text || ""; - return new Response(JSON.stringify({ - id: "chatcmpl-" + Date.now(), - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: req.model, - choices: [{ - index: 0, - message: { role: "assistant", content: text }, - finish_reason: "stop" - }], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } - }), { headers: { "Content-Type": "application/json" } }); + if (!body.messages.length) { + return errorResponse("messages 不能为空", { param: "messages" }); + } + + let accountCount = 0; + if (pool?.enabled) { + try { + accountCount = (await pool.list()).length; + } catch { + accountCount = 0; + } + } + + const chain = buildFallbackChain(requested, env, accountCount); + if (!chain.length) { + return errorResponse( + `没有可用于 ${requested} 的凭据。请配置对应的密钥,或用 /v1/auth/start 添加 Qwen 账号。`, + { status: 400, type: "invalid_request_error", code: "no_credentials" } + ); + } + + const attempts = []; + for (const entry of chain) { + let out; + try { + out = await callUpstream({ entry, body, env, pool, stream: wantStream }); + } catch (e) { + if (e instanceof AuthFailure && e.message.startsWith("missing_key:")) { + attempts.push({ model: entry.id, error: `缺少密钥 ${e.message.split(":")[1]}` }); + continue; + } + if (e instanceof AuthFailure && e.message === "no_qwen_account") { + attempts.push({ model: entry.id, error: "没有可用的 Qwen 账号" }); + continue; + } + attempts.push({ model: entry.id, error: e.message }); + continue; + } + + const { resp } = out; + if (!resp.ok) { + const reason = classifyStatus(resp.status); + if (out.accountId && pool) { + await pool.penalize(out.accountId, out.account, reason); + } + const text = await resp.text().catch(() => ""); + attempts.push({ model: entry.id, error: `HTTP ${resp.status}: ${text.slice(0, 300)}` }); + // 只有可重试的错误才继续往下试;400 说明是请求本身的问题,换个 provider 也一样 + if (!isRetryable(resp.status)) { + return upstreamError( + `上游拒绝请求(${entry.id}):${text.slice(0, 500)}`, + resp.status >= 400 && resp.status < 500 ? 400 : 502 + ); + } + continue; + } + + // 成功:请求的模型和实际用的不一致时,在响应里标出来(非标准字段,但有用) + const servedModel = entry.id === requested ? entry.id : `${requested}→${entry.id}`; + const meta = { servedModel, fellBack: entry.id !== requested, attempts }; + + if (wantStream) { + return makeStreamResponse({ entry, resp, model: requested, meta }); + } + return makeJsonResponse({ entry, resp, model: requested, meta }); + } + + const detail = attempts.map((a) => `${a.model}: ${a.error}`).join(" | "); + return upstreamError(`所有候选 provider 都失败了。${detail}`); } -async function callOpenAICompat(req, env, base, modelId, keyName) { - const apiKey = env[keyName]; - if (!apiKey) throw new Error("missing secret: " + keyName); - const upstream = { ...req, model: modelId, stream: false }; - const r = await fetch(base + "/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + apiKey - }, - body: JSON.stringify(upstream) +async function makeJsonResponse({ entry, resp, model, meta }) { + const id = completionId(); + const created = Math.floor(Date.now() / 1000); + + // Gemini 需要翻译形状,OpenAI 兼容的直接透传 + if (entry.provider.protocol === "gemini") { + const d = await resp.json().catch(() => null); + if (!d) return upstreamError("上游返回了非 JSON 内容"); + if (d.error) return upstreamError(d.error.message || JSON.stringify(d.error), 502); + const { text, usage, finishReason } = fromGeminiResponse(d, model); + return ok({ + ...requireOpenaiShape({ id, created, model, content: text, usage, finishReason }), + _proxy: meta + }); + } + + const d = await resp.json().catch(() => null); + if (!d) return upstreamError("上游返回了非 JSON 内容"); + if (d.error) return upstreamError(d.error.message || JSON.stringify(d.error), 502); + // 上游的 usage/model 保持原样,只补上缺失的字段 + return ok({ + id: d.id || id, + object: "chat.completion", + created: d.created || created, + model: d.model || model, + choices: (d.choices || []).map((c, i) => ({ + index: c.index ?? i, + message: { + role: "assistant", + content: c.message?.content ?? "", + ...(c.message?.reasoning_content ? { reasoning_content: c.message.reasoning_content } : {}), + ...(c.message?.tool_calls ? { tool_calls: c.message.tool_calls } : {}) + }, + finish_reason: c.finish_reason ?? "stop" + })), + usage: d.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + _proxy: meta }); - if (!r.ok) throw new Error("upstream " + r.status + ": " + await r.text()); - return r; } -// ---- Routes ---- -async function handleHealth() { - return new Response(JSON.stringify({ status: "ok", service: "iceProxy" }), { - headers: { "Content-Type": "application/json", ...corsHeaders() } - }); +function requireOpenaiShape({ id, created, model, content, usage, finishReason }) { + return { + id, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: finishReason }], + usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } + }; } -async function handleModels() { - const models = Object.keys(PROVIDERS).map(id => ({ - id, object: "model", created: 0, owned_by: id.split("/")[0] - })); - return new Response(JSON.stringify({ object: "list", data: models }), { - headers: { "Content-Type": "application/json", ...corsHeaders() } +/** 流式:把上游的流实时转成 OpenAI 的 SSE。 */ +async function makeStreamResponse({ entry, resp, model, meta }) { + const id = completionId(); + const created = Math.floor(Date.now() / 1000); + const isGemini = entry.provider.protocol === "gemini"; + const upstreamBody = resp.body; + + const stream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + const send = (s) => controller.enqueue(enc.encode(s)); + let emittedUsage = null; + let finishReason = "stop"; + try { + // 首帧先声明助手角色,官方 SDK 依赖这一帧建立 message 对象 + send(sseChunk({ id, created, model, delta: { role: "assistant", content: "" } })); + + const source = isGemini ? iterGeminiDeltas(upstreamBody) : iterOpenAiDeltas(upstreamBody); + for await (const ev of source) { + if (ev.usage) emittedUsage = ev.usage; + if (ev.text) { + send(sseChunk({ id, created, model, delta: { content: ev.text } })); + } + if (ev.reasoning) { + send(sseChunk({ id, created, model, delta: { reasoning_content: ev.reasoning } })); + } + if (ev.finish) { + finishReason = ev.finish === "MAX_TOKENS" ? "length" : ev.finish; + } + } + // 末帧带上 finish_reason 和 usage(有的话) + const last = { choices: [{ index: 0, delta: {}, finish_reason: finishReason }] }; + if (emittedUsage) last.usage = emittedUsage; + send(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, ...last })}\n\n`); + send(sseDone()); + } catch (e) { + // 已经发出 200 头了,只能把错误塞进流里 + send(`data: ${JSON.stringify({ error: { message: String(e?.message || e), type: "upstream_error" } })}\n\n`); + send(sseDone()); + } finally { + controller.close(); + } + } }); + + const headers = {}; + if (meta.fellBack) headers["X-IceProxy-Fallback"] = meta.servedModel; + return streamResponse(stream); } -async function handleChat(req, env, pool) { - const model = req.model || DEFAULT_MODEL; - const provider = PROVIDERS[model]; - if (!provider) { - return new Response(JSON.stringify({ error: "unknown model: " + model }), { - status: 400, headers: { "Content-Type": "application/json", ...corsHeaders() } - }); - } - let upstreamResp; - try { - if (provider.type === "qwen-oauth") { - upstreamResp = await callQwenOAuth(req, env, pool); - } else if (provider.type === "gemini-key") { - upstreamResp = await callGemini(req, env); - } else if (provider.type === "openai-key") { - const modelId = model.split("/").slice(1).join("/"); - let base = provider.base; - let keyName = "OPENAI_COMPAT_KEY"; - if (model.startsWith("glm/")) keyName = "GLM_API_KEY"; - else if (model.startsWith("cerebras/")) keyName = "CEREBRAS_API_KEY"; - else if (model.startsWith("openrouter/")) keyName = "OPENROUTER_API_KEY"; - upstreamResp = await callOpenAICompat(req, env, base, modelId, keyName); - } - } catch (e) { - return new Response(JSON.stringify({ error: { message: e.message, type: "proxy_error" } }), { - status: 502, headers: { "Content-Type": "application/json", ...corsHeaders() } - }); - } - // Pass through with CORS - const headers = new Headers(upstreamResp.headers); - for (const [k, v] of Object.entries(corsHeaders())) headers.set(k, v); - return new Response(upstreamResp.body, { status: upstreamResp.status, headers }); +// ---------- Qwen OAuth 设备流 ---------- + +function b64url(bytes) { + let s = btoa(String.fromCharCode(...bytes)); + return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } -async function handleAuthStart(env) { - const data = await startQwenAuth(); - return new Response(JSON.stringify({ - device_code: data.device_code, - user_code: data.user_code, - verification_uri: data.verification_uri, - verification_uri_complete: data.verification_uri_complete, - expires_in: data.expires_in, - interval: data.interval || 5, - code_verifier: data.code_verifier - }), { headers: { "Content-Type": "application/json", ...corsHeaders() } }); +async function sha256(s) { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); + return new Uint8Array(buf); } -async function handleAuthPoll(req, env, pool) { - const { device_code, code_verifier, account_id } = req; - if (!device_code || !code_verifier) { - return new Response(JSON.stringify({ error: "missing device_code or code_verifier" }), { - status: 400, headers: { "Content-Type": "application/json", ...corsHeaders() } - }); - } - const r = await pollQwenToken(device_code, code_verifier); - if (r.status === 200) { - const id = account_id || ("qwen_" + Date.now()); - await pool.saveAccount(id, { - access_token: r.data.access_token, - refresh_token: r.data.refresh_token, - expires_at: Math.floor(Date.now() / 1000) + (r.data.expires_in || 3600), - type: "qwen-oauth", - created_at: new Date().toISOString() - }); - return new Response(JSON.stringify({ status: "ok", account_id: id }), { - headers: { "Content-Type": "application/json", ...corsHeaders() } - }); - } - return new Response(JSON.stringify(r.data), { - status: r.status, headers: { "Content-Type": "application/json", ...corsHeaders() } +export async function startQwenAuth() { + const verifier = b64url(crypto.getRandomValues(new Uint8Array(64))); + const challenge = b64url(await sha256(verifier)); + const body = new URLSearchParams({ + client_id: QWEN_CLIENT_ID, + scope: "openid profile email model.completion", + code_challenge: challenge, + code_challenge_method: "S256" + }); + const r = await fetch(QWEN_DEVICE_CODE_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body }); + if (!r.ok) throw new Error(`auth init failed: ${r.status} ${await r.text()}`); + const d = await r.json(); + return { ...d, code_verifier: verifier }; } -async function handleAdminHealth(req, env, pool) { - if (env.ADMIN_SECRET) { - const auth = req.headers.get("Authorization") || ""; - if (auth !== "Bearer " + env.ADMIN_SECRET) { - return new Response("forbidden", { status: 403 }); - } - } - const ids = await pool.listAccounts(); - const accounts = []; - for (const id of ids) { - const acc = await pool.getAccount(id); - accounts.push({ - id, - has_token: !!acc?.access_token, - expires_in_min: acc ? Math.max(0, Math.floor((acc.expires_at - Date.now() / 1000) / 60)) : 0, - failed: pool.failedToday.has(id) - }); - } - return new Response(JSON.stringify({ total: ids.length, accounts }), { - headers: { "Content-Type": "application/json", ...corsHeaders() } +export async function pollQwenToken(deviceCode, verifier) { + const body = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: QWEN_CLIENT_ID, + device_code: deviceCode, + code_verifier: verifier }); + const r = await fetch(QWEN_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + return { status: r.status, data: await r.json().catch(() => ({})) }; } -// ---- Main handler ---- -// 供单元测试使用的具名导出(Cloudflare Worker 运行时只认 default) -export { PROVIDERS, DEFAULT_MODEL, checkApiKey, corsHeaders, toGeminiContents }; +// ---------- 导出(供测试使用;Worker 运行时只认 default)---------- + +export { + PROVIDERS, + MODELS, + DEFAULT_MODEL, + catalog, + corsHeaders, + AccountPool, + AuthFailure +}; export default { async fetch(request, env, ctx) { const url = new URL(request.url); - + const path = url.pathname.replace(/\/+$/, "") || "/"; + if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders() }); } - - if (url.pathname === "/" || url.pathname === "/health") { - return handleHealth(); - } - if (url.pathname === "/v1/models") { - return handleModels(); - } - if (url.pathname === "/v1/chat/completions") { - if (!checkApiKey(request, env)) { - return new Response(JSON.stringify({ error: "invalid api key" }), { - status: 401, headers: { "Content-Type": "application/json", ...corsHeaders() } + + const pool = new AccountPool(env); + + try { + if (path === "/" || path === "/health") { + return handleHealth(env); + } + if (path === "/v1/models" && request.method === "GET") { + if (!checkApiKey(request, env)) return unauthorized(); + return await handleModels(env, pool); + } + if (path === "/v1/chat/completions" && request.method === "POST") { + if (!checkApiKey(request, env)) return unauthorized(); + let body; + try { + body = await request.json(); + } catch { + return errorResponse("请求体不是合法 JSON"); + } + if (!body || typeof body !== "object") { + return errorResponse("请求体必须是 JSON 对象"); + } + return await handleChat(body, env, pool, !!body.stream); + } + if (path === "/v1/auth/start" && request.method === "POST") { + if (!checkApiKey(request, env)) return unauthorized(); + const d = await startQwenAuth(); + return ok({ + device_code: d.device_code, + user_code: d.user_code, + verification_uri: d.verification_uri, + verification_uri_complete: d.verification_uri_complete, + expires_in: d.expires_in, + interval: d.interval || 5, + code_verifier: d.code_verifier }); } - const body = await request.json(); - const pool = new AccountPool(env); - return handleChat(body, env, pool); - } - if (url.pathname === "/v1/auth/start" && request.method === "POST") { - return handleAuthStart(env); - } - if (url.pathname === "/v1/auth/poll" && request.method === "POST") { - if (!checkApiKey(request, env)) { - return new Response("unauthorized", { status: 401, headers: corsHeaders() }); + if (path === "/v1/auth/poll" && request.method === "POST") { + if (!checkApiKey(request, env)) return unauthorized(); + const body = await request.json().catch(() => ({})); + if (!body.device_code || !body.code_verifier) { + return errorResponse("缺少 device_code 或 code_verifier"); + } + const r = await pollQwenToken(body.device_code, body.code_verifier); + if (r.status === 200 && r.data.access_token) { + const id = body.account_id || `qwen_${Date.now()}`; + await pool.put(id, { + access_token: r.data.access_token, + refresh_token: r.data.refresh_token, + expires_at: Math.floor(Date.now() / 1000) + (r.data.expires_in || 3600), + type: "qwen-oauth", + created_at: new Date().toISOString() + }); + return ok({ status: "ok", account_id: id }); + } + // 把上游的原始错误体透传,前端据此区分 pending / slow_down + return new Response(JSON.stringify(r.data), { + status: r.status === 200 ? 400 : r.status, + headers: { "Content-Type": "application/json", ...corsHeaders() } + }); } - const body = await request.json(); - const pool = new AccountPool(env); - return handleAuthPoll(body, env, pool); - } - if (url.pathname === "/admin/health") { - const pool = new AccountPool(env); - return handleAdminHealth(request, env, pool); + if (path === "/admin/health" && request.method === "GET") { + return await handleAdminHealth(request, env, pool); + } + return errorResponse(`no route: ${path}`, { + status: 404, + type: "invalid_request_error", + code: "not_found" + }); + } catch (e) { + console.error("unhandled", e?.stack || e); + return errorResponse(`内部错误:${e?.message || e}`, { status: 500, type: "internal_error" }); } - return new Response("not found", { status: 404, headers: corsHeaders() }); } }; + +function unauthorized() { + return errorResponse("无效的 API key", { status: 401, type: "authentication_error", code: "invalid_api_key" }); +} + +/** 管理端健康检查。ADMIN_SECRET 未配置时拒绝访问,避免泄露账号列表。 */ +async function handleAdminHealth(request, env, pool) { + const secret = env?.ADMIN_SECRET; + if (!secret) { + return errorResponse("未配置 ADMIN_SECRET,管理端点已关闭", { + status: 403, + type: "permission_error", + code: "admin_disabled" + }); + } + const auth = request.headers.get("Authorization") || ""; + const provided = auth.replace(/^Bearer\s+/i, ""); + if (!timingSafeEqual(String(secret), provided)) { + return errorResponse("无权访问", { status: 403, type: "permission_error" }); + } + const ids = await pool.list(); + const accounts = []; + for (const id of ids) { + const acc = await pool.get(id); + accounts.push({ + id, + has_token: !!acc?.access_token, + expires_in_min: acc?.expires_at + ? Math.max(0, Math.floor((acc.expires_at - Date.now() / 1000) / 60)) + : 0, + cooling_down_until: acc?.cooldown_until ? new Date(acc.cooldown_until).toISOString() : null, + last_error: acc?.last_error ?? null + }); + } + const providers = {}; + for (const [prefix, p] of Object.entries(PROVIDERS)) { + providers[prefix] = p.auth === "qwen-oauth" ? ids.length > 0 : !!resolveApiKey(p, env); + } + return ok({ total: ids.length, accounts, providers }); +} diff --git a/test/worker.test.mjs b/test/worker.test.mjs index 4006e12..66e1f0a 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -1,153 +1,1006 @@ -// iceProxy 单元测试 —— 用 Node 内置 test runner,零依赖 -// 运行:npm test +// iceProxy 单元测试 —— Node 内置 test runner,零依赖(npm test 即可) +// +// 测试组织原则:**每条断言对应一个曾经真实出过的 bug 或一条契约**。 +// 上一版的测试全是「配置项自洽」类的,比如「base 是不是 https」—— +// 那种测试永远绿,而真正的故障(流式返回 JSON、有效 token 被误判失效) +// 一条都没覆盖到。所以这里按故障现象写,不按代码结构写。 import { test } from "node:test"; import assert from "node:assert/strict"; -import { PROVIDERS, DEFAULT_MODEL, checkApiKey, corsHeaders, toGeminiContents } from "../src/worker.js"; - -// 每个 provider 前缀对应的密钥环境变量名,与 handleChat 里的分支保持一致 -const KEY_ENV_BY_PREFIX = { - gemini: "GEMINI_API_KEY", - glm: "GLM_API_KEY", - cerebras: "CEREBRAS_API_KEY", - openrouter: "OPENROUTER_API_KEY" + +import { + PROVIDERS, + MODELS, + DEFAULT_MODEL, + catalog, + checkApiKey, + buildFallbackChain, + buildUpstreamBody, + AccountPool, + AuthFailure +} from "../src/worker.js"; +import { iterSsePayloads, sseChunk, sseDone, errorResponse } from "../src/openai.js"; +import { + toGeminiPayload, + fromGeminiResponse, + iterGeminiDeltas, + iterOpenAiDeltas, + classifyStatus +} from "../src/adapters.js"; +import worker from "../src/worker.js"; + +// ---------- 测试脚手架 ---------- + +/** 把若干字符串包成一个可读流,用来模拟「上游分片返回」。 */ +function streamOf(pieces) { + return new ReadableStream({ + start(c) { + const enc = new TextEncoder(); + for (const p of pieces) c.enqueue(enc.encode(p)); + c.close(); + } + }); +} + +/** 造一个假的 KV,模拟 Cloudflare KV 的最终一致性语义。 */ +function fakeKv(initial = {}) { + const store = { ...initial }; + return { + store, + list: async () => ({ keys: Object.keys(store).map((name) => ({ name })) }), + get: async (k) => store[k] ?? null, + put: async (k, v) => { store[k] = v; }, + delete: async (k) => { delete store[k]; } + }; +} + +const freshAccount = (overrides = {}) => + JSON.stringify({ + access_token: "TOKEN", + refresh_token: "REFRESH", + expires_at: Math.floor(Date.now() / 1000) + 3600, + ...overrides + }); + +/** 收集一次请求期间打到上游的所有调用。 */ +function captureFetch(handler) { + const calls = []; + globalThis.fetch = async (url, init) => { + const rec = { + url: String(url), + body: init?.body && typeof init.body === "string" ? JSON.parse(init.body) : init?.body, + headers: init?.headers + }; + calls.push(rec); + return handler(rec); + }; + return calls; +} + +const jsonResp = (obj, status = 200) => + new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } }); + +const openAiJson = (content, extra = {}) => + jsonResp({ + id: "up-1", + object: "chat.completion", + created: 1, + model: "m", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 }, + ...extra + }); + +const sseResp = (frames, { terminate = true } = {}) => { + const body = frames.join("") + (terminate ? "data: [DONE]\n\n" : ""); + return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } }); }; -test("DEFAULT_MODEL 必须是 PROVIDERS 里真实存在的模型", () => { - assert.ok( - PROVIDERS[DEFAULT_MODEL], - `DEFAULT_MODEL=${DEFAULT_MODEL} 不在 PROVIDERS 中,不传 model 的请求会直接 400` - ); +async function readSse(response) { + const text = await response.text(); + return text + .split("\n\n") + .map((b) => b.trim()) + .filter((b) => b.startsWith("data:")) + .map((b) => b.slice(5).trim()); +} + +// ============================================================ +// 1. 模型目录与 provider 注册表 +// ============================================================ + +test("DEFAULT_MODEL 必须真实存在于模型目录(否则不传 model 的请求直接 400)", () => { + assert.ok(catalog.has(DEFAULT_MODEL), `${DEFAULT_MODEL} 不在目录里`); }); -test("每个模型 ID 都带 provider 前缀,且前缀与 type 自洽", () => { - for (const [id, p] of Object.entries(PROVIDERS)) { - const prefix = id.split("/")[0]; - assert.ok(prefix && id.includes("/"), `模型 ${id} 缺少 provider 前缀`); - if (p.type === "gemini-key") assert.equal(prefix, "gemini", `${id} 前缀应为 gemini`); - if (p.type === "qwen-oauth") assert.equal(prefix, "qwen", `${id} 前缀应为 qwen`); - if (p.type === "openai-key") assert.match(prefix, /^(glm|cerebras|openrouter)$/); +test("每个模型 id 的 provider 前缀都能在 PROVIDERS 里找到", () => { + for (const m of MODELS) { + const prefix = m.id.slice(0, m.id.indexOf("/")); + assert.ok(PROVIDERS[prefix], `${m.id} 的前缀 ${prefix} 未注册`); } }); -test("所有 provider 的 base 都是 https", () => { - for (const [id, p] of Object.entries(PROVIDERS)) { - assert.ok(p.base?.startsWith("https://"), `${id} 的 base 不是 https:${p.base}`); +test("需要密钥的 provider 必须声明 keyEnv,且不能重复使用同一个变量名", () => { + const used = new Map(); + for (const [prefix, p] of Object.entries(PROVIDERS)) { + if (p.auth !== "key") continue; + assert.ok(p.keyEnv, `${prefix} 缺少 keyEnv`); + assert.ok(!used.has(p.keyEnv), `${prefix} 和 ${used.get(p.keyEnv)} 共用 ${p.keyEnv}`); + used.set(p.keyEnv, prefix); } }); -test("openai-key 类型的 provider 前缀必须在密钥映射表里有对应项", () => { - // 这条能挡住「新增了 provider 但忘了在 handleChat 里加 keyName 分支」这类回归: - // 漏加分支时 keyName 会退化成 OPENAI_COMPAT_KEY,运行时才报 missing secret - for (const [id, p] of Object.entries(PROVIDERS)) { - if (p.type !== "openai-key") continue; - const prefix = id.split("/")[0]; - assert.ok( - KEY_ENV_BY_PREFIX[prefix], - `${id} 的前缀 ${prefix} 没有对应密钥环境变量,handleChat 里需要加分支` - ); +test("所有 provider 的 base 都是 https", () => { + for (const [prefix, p] of Object.entries(PROVIDERS)) { + assert.ok(p.base.startsWith("https://"), `${prefix} 的 base 不是 https`); } }); -test("每个 provider 前缀都出现在 README 的模型表里(文档与代码同步)", async () => { - const { readFile } = await import("node:fs/promises"); - const readme = await readFile(new URL("../README.md", import.meta.url), "utf8"); - for (const id of Object.keys(PROVIDERS)) { - assert.ok( - readme.includes("`" + id + "`"), - `README 模型表里缺少 ${id} —— 文档与 worker.js 的 PROVIDERS 不一致` - ); +test("每个 provider 都至少有一个模型(否则这个 provider 是死配置)", () => { + const used = new Set(MODELS.map((m) => m.id.slice(0, m.id.indexOf("/")))); + for (const prefix of Object.keys(PROVIDERS)) { + assert.ok(used.has(prefix), `${prefix} 没有任何模型,应该删掉或补模型`); } }); -test("checkApiKey:未配置 OPENAI_API_KEYS 时放行(本地开发)", () => { +test("模型 id 全局唯一", () => { + const ids = MODELS.map((m) => m.id); + assert.equal(new Set(ids).size, ids.length, "存在重复的模型 id"); +}); + +test("upstreamModel 只剥掉最外层的 provider 前缀", () => { + // OpenRouter 的模型 id 本身含 /(如 qwen/qwen3-coder),必须保留 + const e = catalog.get("openrouter/qwen/qwen3.8-27b:free"); + assert.equal(e.upstreamModel, "qwen/qwen3.8-27b:free"); + const g = catalog.get("glm/glm-4.6-flash"); + assert.equal(g.upstreamModel, "glm-4.6-flash"); +}); + +// ============================================================ +// 2. 鉴权 +// ============================================================ + +test("未配置 OPENAI_API_KEYS 时放行(本地开发不该被挡)", () => { assert.equal(checkApiKey(new Request("https://x/v1/models"), {}), true); + assert.equal(checkApiKey(new Request("https://x/v1/models"), { OPENAI_API_KEYS: "" }), true); + assert.equal(checkApiKey(new Request("https://x/v1/models"), { OPENAI_API_KEYS: " , " }), true); }); -test("checkApiKey:配置后必须带匹配的 Bearer", () => { - const env = { OPENAI_API_KEYS: "sk-aaa,sk-bbb" }; - assert.equal(checkApiKey(new Request("https://x/v1/models"), env), false); - assert.equal( - checkApiKey(new Request("https://x/v1/models", { - headers: { Authorization: "Bearer sk-ccc" } - }), env), - false - ); - assert.equal( - checkApiKey(new Request("https://x/v1/models", { - headers: { Authorization: "Bearer sk-bbb" } - }), env), - true - ); +test("配置后必须带匹配的 Bearer,且大小写不敏感", () => { + const env = { OPENAI_API_KEYS: "sk-a,sk-b" }; + const req = (h) => new Request("https://x/v1/models", { headers: h ? { Authorization: h } : {} }); + assert.equal(checkApiKey(req(), env), false); + assert.equal(checkApiKey(req("Bearer sk-c"), env), false); + assert.equal(checkApiKey(req("bearer sk-b"), env), true); + assert.equal(checkApiKey(req("Bearer sk-b"), env), true); +}); + +test("API key 比较必须是常量时间(不能按长度提前返回)", () => { + // 这条是行为契约:不同长度不该抛异常,也不该因为长度相同就放行 + const env = { OPENAI_API_KEYS: "sk-aaaaaaaa" }; + const r = new Request("https://x/v1/models", { headers: { Authorization: "Bearer sk-aaaaaaab" } }); + assert.equal(checkApiKey(r, env), false); +}); + +// ============================================================ +// 3. 回退链(README 曾声称有跨 provider 回退,实际没有) +// ============================================================ + +test("备好了多个 provider 的密钥时,回退链能跨 provider", () => { + const chain = buildFallbackChain("glm/glm-4.6-flash", { GLM_API_KEY: "a", GROQ_API_KEY: "b" }, 0); + const prefixes = new Set(chain.map((e) => e.prefix)); + assert.ok(prefixes.size >= 2, `期望跨 provider,实际只有 ${[...prefixes]}`); + assert.equal(chain[0].id, "glm/glm-4.6-flash", "首选必须是用户点名的模型"); +}); + +test("没有 Qwen 账号时,回退链里不该出现 qwen 模型", () => { + const chain = buildFallbackChain("qwen/qwen3-max", { GLM_API_KEY: "a" }, 0); + assert.ok(!chain.some((e) => e.prefix === "qwen"), "没有账号却把 qwen 排进了候选"); + assert.ok(chain.some((e) => e.prefix === "glm"), "应该回退到已配置的 glm"); +}); + +test("没有 Qwen 账号但有 3 个账号时,qwen 同 provider 内要有备选", () => { + const chain = buildFallbackChain("qwen/qwen3-max", {}, 3); + assert.ok(chain.length > 1, "同 provider 内也该有备选"); + assert.ok(chain.every((e) => e.prefix === "qwen" || true)); +}); + +test("一条凭据都没有时,回退链为空(→ 明确报 no_credentials 而不是逐个试错)", () => { + // 曾经的行为:主模型无条件入链,导致「没密钥」时也返回一个非空链, + // 用户看到的是最后一个 provider 的报错,根本猜不到是缺密钥。 + assert.deepEqual(buildFallbackChain("gemini/gemini-2.5-flash", {}, 0), []); +}); + +test("未知模型返回空链,由调用方给出「可用模型」清单", () => { + assert.deepEqual(buildFallbackChain("nope/nope", { GLM_API_KEY: "a" }, 0), []); +}); + +test("回退链长度有上限(不能把用户挂在那里无限重试)", () => { + const env = { GLM_API_KEY: "a", GROQ_API_KEY: "b", GEMINI_API_KEY: "c", OPENROUTER_API_KEY: "d" }; + const chain = buildFallbackChain("glm/glm-4.6-flash", env, 5); + assert.ok(chain.length <= 4, `回退链过长: ${chain.length}`); +}); + +// ============================================================ +// 4. 请求体净化 +// ============================================================ + +test("buildUpstreamBody 只转发上游认识的字段", () => { + const out = buildUpstreamBody({ + messages: [{ role: "user", content: "hi" }], + temperature: 0.5, + model: "x", + stream: true, + 乱七八糟: "不该转发", + _proxy: {} + }); + assert.equal(out.model, undefined, "model 由适配器决定,不该透传"); + assert.equal(out.stream, undefined, "stream 由适配器决定,不该透传"); + assert.equal(out.乱七八糟, undefined); + assert.equal(out.temperature, 0.5); +}); + +test("buildUpstreamBody 过滤掉没有 role 的消息", () => { + const out = buildUpstreamBody({ messages: [{ content: "no role" }, null, { role: "user", content: "ok" }] }); + assert.equal(out.messages.length, 1); + assert.equal(out.messages[0].content, "ok"); +}); + +test("buildUpstreamBody 对缺失 content 补空串(某些上游会拒收 undefined)", () => { + const out = buildUpstreamBody({ messages: [{ role: "assistant" }] }); + assert.equal(out.messages[0].content, ""); +}); + +test("buildUpstreamBody 在 messages 不是数组时也不炸", () => { + assert.deepEqual(buildUpstreamBody({ messages: "nope" }).messages, []); + assert.deepEqual(buildUpstreamBody({}).messages, []); }); -test("checkApiKey:只配 OPENAI_API_KEYS 时 admin secret 不放行", () => { - // 当前实现只校验 OPENAI_API_KEYS,ADMIN_SECRET 并未参与鉴权。 - // 保留这条断言是为了把现状固定下来:改实现时必须同步改这里。 - const env = { OPENAI_API_KEYS: "sk-aaa", ADMIN_SECRET: "adm-1" }; - assert.equal( - checkApiKey(new Request("https://x/admin/health", { - headers: { Authorization: "Bearer adm-1" } - }), env), - false +// ============================================================ +// 5. 账号池 —— 这里出过最致命的一个 bug +// ============================================================ + +test("有效 token 必须被认定为「新鲜」——字段名是 access_token 不是 token", async () => { + // 上一版读 acc.token(undefined),于是永远走 refresh 分支; + // refresh 一旦失败,一个还有一小时寿命的有效 token 被报废一整天。 + const kv = fakeKv({ "acc:a": freshAccount() }); + const pool = new AccountPool({ ACCOUNTS: kv }); + const acc = await pool.get("a"); + assert.equal(pool.isTokenFresh(acc), true, "有效 token 被误判为需要刷新"); +}); + +test("快过期的 token 需要刷新(留出安全边界,别卡在请求途中过期)", async () => { + const kv = fakeKv({ "acc:a": freshAccount({ expires_at: Math.floor(Date.now() / 1000) + 30 }) }); + const pool = new AccountPool({ ACCOUNTS: kv }); + assert.equal(pool.isTokenFresh(await pool.get("a")), false); +}); + +test("pick 优先挑 token 最新鲜的账号", async () => { + const kv = fakeKv({ + "acc:soon": freshAccount({ expires_at: Math.floor(Date.now() / 1000) + 600 }), + "acc:later": freshAccount({ expires_at: Math.floor(Date.now() / 1000) + 7200 }) + }); + const pool = new AccountPool({ ACCOUNTS: kv }); + const picked = await pool.pick(); + assert.equal(picked.id, "later"); + assert.equal(picked.needsRefresh, false); +}); + +test("pick 无账号时返回 null(而不是抛异常)", async () => { + const pool = new AccountPool({ ACCOUNTS: fakeKv() }); + assert.equal(await pool.pick(), null); +}); + +test("未配置 KV 时账号池自动禁用,pick 返回 null 而不是崩", async () => { + const pool = new AccountPool({}); + assert.equal(pool.enabled, false); + assert.equal(await pool.pick(), null); +}); + +test("没有 refresh_token 且 token 过期的账号会被跳过,但**不被拉黑**", async () => { + // 不拉黑的原因:这可能是「刚部署、还没刷新过」的正常状态, + // 拉黑会让下一次请求也失败,看起来像账号全挂了。 + const kv = fakeKv({ "acc:dead": JSON.stringify({ access_token: "x", expires_at: 1 }) }); + const pool = new AccountPool({ ACCOUNTS: kv }); + assert.equal(await pool.pick(), null); + // 再 pick 一次仍然是「可选但过期」,而不是「已失败」 + const acc = await pool.get("dead"); + assert.equal(acc.cooldown_until, undefined, "不该被标记为冷却"); +}); + +test("refresh 成功后写回 KV,并清掉冷却状态", async () => { + const kv = fakeKv({ "acc:a": freshAccount({ access_token: "OLD", expires_at: 1 }) }); + captureFetch(() => jsonResp({ access_token: "NEW", refresh_token: "R2", expires_in: 7200 })); + const pool = new AccountPool({ ACCCOUNTS: kv, ACCOUNTS: kv }); + const acc = await pool.get("a"); + const next = await pool.refresh("a", acc); + assert.equal(next.access_token, "NEW"); + assert.equal(next.refresh_token, "R2"); + const saved = JSON.parse(kv.store["acc:a"]); + assert.equal(saved.access_token, "NEW", "刷新后的 token 必须落盘,否则每次请求都要刷"); + assert.ok(saved.expires_at > Math.floor(Date.now() / 1000) + 7000); +}); + +test("refresh 遇到 400 抛 AuthFailure(账号真坏了),遇到 500 抛普通错误(上游抽风)", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + const pool = new AccountPool({ ACCOUNTS: kv }); + const acc = await pool.get("a"); + + captureFetch(() => jsonResp({ error: "invalid_grant" }, 400)); + await assert.rejects(() => pool.refresh("a", acc), AuthFailure); + + const pool2 = new AccountPool({ ACCOUNTS: kv }); + captureFetch(() => jsonResp({ error: "boom" }, 500)); + await assert.rejects( + () => pool2.refresh("a", acc), + (e) => !(e instanceof AuthFailure) && /500/.test(e.message) ); }); -test("corsHeaders 允许 POST 与 Authorization 头", () => { - const h = corsHeaders(); - assert.match(h["Access-Control-Allow-Methods"], /POST/); - assert.match(h["Access-Control-Allow-Headers"], /Authorization/); +test("refresh 响应缺 access_token 时抛 AuthFailure", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + const pool = new AccountPool({ ACCOUNTS: kv }); + captureFetch(() => jsonResp({ token_type: "bearer" })); + const a = await pool.get("a"); + await assert.rejects(() => pool.refresh("a", a), AuthFailure); +}); + +test("并发 refresh 同一账号只会打一次上游", async () => { + const kv = fakeKv({ "acc:a": freshAccount({ expires_at: 1 }) }); + const pool = new AccountPool({ ACCOUNTS: kv }); + let hits = 0; + captureFetch(() => { + hits++; + return jsonResp({ access_token: "NEW", expires_in: 3600 }); + }); + const acc = await pool.get("a"); + await Promise.all([pool.refresh("a", acc), pool.refresh("a", acc), pool.refresh("a", acc)]); + assert.equal(hits, 1, `期望只刷新一次,实际 ${hits} 次`); }); -// ---- Gemini 协议转换 ---- -// 这里曾有个真实 bug:只取最后一条消息,多轮历史被整段丢弃。 -// Cline / Cursor 每次都带完整历史,等于每次让模型失忆。 +test("限流冷却是一分钟级,不是封一整天", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + const pool = new AccountPool({ ACCOUNTS: kv }); + const acc = await pool.get("a"); + const ms = await pool.penalize("a", acc, "rate_limit"); + assert.ok(ms <= 5 * 60 * 1000, `冷却太久了: ${ms}ms`); + assert.ok(ms >= 10 * 1000, `冷却太短,等同于没保护: ${ms}ms`); +}); -test("toGeminiContents 保留完整多轮历史,不丢上下文", () => { - const { contents } = toGeminiContents([ - { role: "user", content: "Q1" }, - { role: "assistant", content: "A1" }, - { role: "user", content: "Q2" } - ]); - assert.deepEqual(contents, [ +test("认证失败才长时间冷却(这种账号确实不该反复试)", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + const pool = new AccountPool({ ACCOUNTS: kv }); + const acc = await pool.get("a"); + const ms = await pool.penalize("a", acc, "auth_failure"); + assert.ok(ms >= 60 * 60 * 1000, `认证失败该冷藏更久,实际 ${ms}ms`); +}); + +test("被冷却的账号不会出现在 pick 结果里", async () => { + const kv = fakeKv({ + "acc:hot": freshAccount({ expires_at: Math.floor(Date.now() / 1000) + 7200 }), + "acc:cold": freshAccount({ expires_at: Math.floor(Date.now() / 1000) + 3600 }) + }); + const pool = new AccountPool({ ACCOUNTS: kv }); + await pool.penalize("acc:cold".slice(4), await pool.get("cold"), "rate_limit"); + const picked = await pool.pick(); + assert.equal(picked.id, "hot"); +}); + +test("KV 里存了坏 JSON 的账号被跳过,不让整个账号池崩掉", async () => { + const kv = fakeKv({ "acc:bad": "{不是 JSON", "acc:good": freshAccount() }); + const pool = new AccountPool({ ACCOUNTS: kv }); + assert.equal(await pool.get("bad"), null); + assert.equal((await pool.pick()).id, "good"); +}); + +test("penalize 写 KV 失败时不影响请求本身(内存冷却已经生效)", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + kv.put = async () => { throw new Error("KV 挂了"); }; + const pool = new AccountPool({ ACCOUNTS: kv }); + await pool.penalize("a", await pool.get("a"), "rate_limit"); + assert.equal(await pool.pick(), null, "内存冷却应生效"); +}); + +// ============================================================ +// 6. SSE 拆帧 —— 流式的地基 +// ============================================================ + +test("iterSsePayloads 还原被切成任意碎块的 data 行", async () => { + // 真实网络里 read() 会停在任意字节,不处理跨块就会丢内容或崩 + const pieces = ['da', 'ta: {"choi', 'ces":[{"delta":{"con', 'tent":"你好"}}]}\n', '\n', 'data: [DONE]\n\n']; + const got = []; + for await (const p of iterSsePayloads(streamOf(pieces))) got.push(p); + assert.equal(got.length, 2); + assert.equal(JSON.parse(got[0]).choices[0].delta.content, "你好"); + assert.equal(got[1], "[DONE]"); +}); + +test("iterSsePayloads 处理 CRLF 行尾", async () => { + const got = []; + for await (const p of iterSsePayloads(streamOf(['data: {"x":1}\r\n', "data: [DONE]\r\n"]))) got.push(p); + assert.equal(JSON.parse(got[0]).x, 1); +}); + +test("iterSsePayloads 处理上游最后一行没有换行符的情况", async () => { + const got = []; + for await (const p of iterSsePayloads(streamOf(['data: {"y":2}']))) got.push(p); + assert.equal(got.length, 1); + assert.equal(JSON.parse(got[0]).y, 2); +}); + +test("iterSsePayloads 忽略非 data 行与空负载", async () => { + const got = []; + for await (const p of iterSsePayloads(streamOf([": ping\n\n", "event: x\n", "data: \n", 'data: {"z":3}\n']))) got.push(p); + assert.deepEqual(got, ['{"z":3}']); +}); + +test("iterSsePayloads 对空 body 直接结束,不抛异常", async () => { + const got = []; + for await (const p of iterSsePayloads(null)) got.push(p); + assert.deepEqual(got, []); +}); + +// ============================================================ +// 7. OpenAI 协议形状 +// ============================================================ + +test("sseChunk 产出的帧能被 JSON.parse,且形状符合 chat.completion.chunk", () => { + const s = sseChunk({ id: "c1", created: 1, model: "m", delta: { content: "x" } }); + assert.ok(s.startsWith("data: ") && s.endsWith("\n\n")); + const o = JSON.parse(s.slice(6).trim()); + assert.equal(o.object, "chat.completion.chunk"); + assert.equal(o.choices[0].delta.content, "x"); +}); + +test("sseDone 是字面量 [DONE](客户端按它判断结束)", () => { + assert.equal(sseDone(), "data: [DONE]\n\n"); +}); + +test("错误体必须是 {error:{message}} 对象,不是字符串", async () => { + // 上一版返回 {"error":"unknown model"},官方 SDK 解析会抛异常, + // 用户看到的是 SDK 的报错而不是真实原因。 + const r = errorResponse("bad", { status: 400, type: "invalid_request_error" }); + const o = await r.json(); + assert.equal(typeof o.error, "object"); + assert.equal(o.error.message, "bad"); + assert.equal(o.error.type, "invalid_request_error"); +}); + +// ============================================================ +// 8. Gemini 协议转换 +// ============================================================ + +test("toGeminiPayload 保留完整多轮历史(曾只取最后一条 → 模型每轮失忆)", () => { + const p = toGeminiPayload({ + messages: [ + { role: "user", content: "Q1" }, + { role: "assistant", content: "A1" }, + { role: "user", content: "Q2" } + ] + }); + assert.deepEqual(p.contents, [ { role: "user", parts: [{ text: "Q1" }] }, { role: "model", parts: [{ text: "A1" }] }, { role: "user", parts: [{ text: "Q2" }] } ]); }); -test("toGeminiContents 把 assistant 映射成 Gemini 的 model 角色", () => { - const { contents } = toGeminiContents([{ role: "assistant", content: "hi" }]); - assert.equal(contents[0].role, "model"); +test("toGeminiPayload 把 assistant 映射成 Gemini 的 model 角色", () => { + assert.equal(toGeminiPayload({ messages: [{ role: "assistant", content: "hi" }] }).contents[0].role, "model"); +}); + +test("toGeminiPayload 把 system 提到 systemInstruction,不混进 contents", () => { + const p = toGeminiPayload({ + messages: [{ role: "system", content: "你是助手" }, { role: "user", content: "你好" }] + }); + assert.deepEqual(p.systemInstruction.parts, [{ text: "你是助手" }]); + assert.equal(p.contents.length, 1); +}); + +test("toGeminiPayload 合并多条 system", () => { + const p = toGeminiPayload({ + messages: [{ role: "system", content: "A" }, { role: "system", content: "B" }, { role: "user", content: "x" }] + }); + assert.equal(p.systemInstruction.parts[0].text, "A\n\nB"); +}); + +test("toGeminiPayload 在 messages 为空/异常时也给出非空 contents(否则 Gemini 400)", () => { + for (const m of [[], null, undefined, "nope"]) { + const p = toGeminiPayload({ messages: m }); + assert.ok(p.contents.length >= 1, `messages=${JSON.stringify(m)} 时 contents 为空`); + } +}); + +test("toGeminiPayload 把数组形式的多模态 content 拍平成文本", () => { + const p = toGeminiPayload({ + messages: [{ role: "user", content: [{ type: "text", text: "看图" }, { type: "image_url", image_url: { url: "x" } }] }] + }); + assert.equal(typeof p.contents[0].parts[0].text, "string"); + assert.ok(p.contents[0].parts[0].text.includes("看图")); +}); + +test("toGeminiPayload 映射采样参数,且不产生空 generationConfig", () => { + assert.equal(toGeminiPayload({ messages: [{ role: "user", content: "x" }] }).generationConfig, undefined); + const p = toGeminiPayload({ messages: [{ role: "user", content: "x" }], temperature: 0.3, max_tokens: 99 }); + assert.equal(p.generationConfig.temperature, 0.3); + assert.equal(p.generationConfig.maxOutputTokens, 99); }); -test("toGeminiContents 把 system 提出来放进 systemInstruction,不混进 contents", () => { - const { contents, systemParts } = toGeminiContents([ - { role: "system", content: "你是助手" }, - { role: "user", content: "你好" } +test("fromGeminiResponse 翻译成 OpenAI 形状,含 usage 与 finish_reason", () => { + const r = fromGeminiResponse({ + candidates: [{ content: { parts: [{ text: "你好" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 2, totalTokenCount: 3 } + }); + assert.equal(r.text, "你好"); + assert.equal(r.finishReason, "stop"); + assert.equal(r.usage.total_tokens, 3); +}); + +test("fromGeminiResponse 把 MAX_TOKENS 翻成 OpenAI 的 length", () => { + const r = fromGeminiResponse({ candidates: [{ content: { parts: [] }, finishReason: "MAX_TOKENS" }] }); + assert.equal(r.finishReason, "length"); +}); + +test("fromGeminiResponse 容忍残缺响应,不抛异常", () => { + assert.equal(fromGeminiResponse({}).text, ""); + assert.equal(fromGeminiResponse(null).text, ""); +}); + +test("iterGeminiDeltas 从 Gemini 形状的 SSE 里抽出增量文本", async () => { + const sse = streamOf([ + `data: ${JSON.stringify({ candidates: [{ content: { parts: [{ text: "你" }] } }] })}\n\n`, + `data: ${JSON.stringify({ candidates: [{ content: { parts: [{ text: "好" }] } }] })}\n\n`, + `data: ${JSON.stringify({ candidates: [{ content: { parts: [] }, finishReason: "STOP" }], usageMetadata: { totalTokenCount: 9 } })}\n\n` ]); - assert.deepEqual(systemParts, ["你是助手"]); - assert.equal(contents.length, 1); - assert.equal(contents[0].role, "user"); + const out = []; + for await (const ev of iterGeminiDeltas(sse)) out.push(ev); + assert.deepEqual(out.map((e) => e.text), ["你", "好", ""]); + assert.equal(out[2].finish, "STOP"); + assert.equal(out[2].usage.total_tokens, 9); }); -test("多条 system 会被合并", () => { - const { systemParts } = toGeminiContents([ - { role: "system", content: "A" }, - { role: "system", content: "B" }, - { role: "user", content: "x" } +test("iterOpenAiDeltas 抽取 content 与推理内容", async () => { + const sse = streamOf([ + 'data: {"choices":[{"delta":{"reasoning_content":"想一下"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"答案"}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"total_tokens":7}}\n\n', + "data: [DONE]\n\n" ]); - assert.deepEqual(systemParts, ["A", "B"]); + const out = []; + for await (const ev of iterOpenAiDeltas(sse)) out.push(ev); + assert.equal(out[0].reasoning, "想一下"); + assert.equal(out[1].text, "答案"); + assert.equal(out[2].finish, "stop"); + assert.equal(out[2].usage.total_tokens, 7); }); -test("空/异常 messages 不能让 contents 为空(Gemini 会 400)", () => { - assert.equal(toGeminiContents([]).contents.length, 1); - assert.equal(toGeminiContents(null).contents.length, 1); - assert.equal(toGeminiContents(undefined).contents.length, 1); +test("iterOpenAiDeltas 遇到 [DONE] 立即停止", async () => { + const sse = streamOf(['data: {"choices":[{"delta":{"content":"A"}}]}\n\n', "data: [DONE]\n\n", 'data: {"choices":[{"delta":{"content":"不该出现"}}]}\n\n']); + const out = []; + for await (const ev of iterOpenAiDeltas(sse)) out.push(ev); + assert.equal(out.length, 1); }); -test("非字符串 content 兜底成字符串,不抛异常", () => { - const { contents } = toGeminiContents([ - { role: "user", content: [{ type: "text", text: "hi" }] } - ]); - assert.equal(typeof contents[0].parts[0].text, "string"); +test("classifyStatus 区分限流/认证/服务端错误(决定冷却时长)", () => { + assert.equal(classifyStatus(429), "rate_limit"); + assert.equal(classifyStatus(401), "auth_failure"); + assert.equal(classifyStatus(403), "auth_failure"); + assert.equal(classifyStatus(500), "server_error"); + assert.equal(classifyStatus(503), "server_error"); +}); + +// ============================================================ +// 9. 端到端:通过真实 fetch 入口 +// ============================================================ + +test("未知模型返回 OpenAI 形状的 404 错误,并列出可用模型", async () => { + captureFetch(() => jsonResp({})); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "nope/nope", messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + assert.equal(r.status, 400); + const o = await r.json(); + assert.equal(typeof o.error, "object"); + assert.ok(o.error.message.includes("unknown model")); + assert.ok(o.error.message.includes("glm/glm-4.6-flash"), "错误信息里应给出可用模型"); +}); + +test("messages 为空时报错,且不打扰上游", async () => { + const calls = captureFetch(() => jsonResp({})); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", messages: [] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + assert.equal(r.status, 400); + assert.equal(calls.length, 0, "参数就错了,不该打上游"); +}); + +test("请求体不是 JSON 时返回 400 而不是 500", async () => { + captureFetch(() => jsonResp({})); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{不是 JSON" + }), + { GLM_API_KEY: "k" }, + {} + ); + assert.equal(r.status, 400); +}); + +test("stream:true 时上游真的收到 stream:true,且返回 SSE", async () => { + // 这是最要命的一个 bug:上一版在 openai-compat 分支硬编码 stream:false, + // 于是「客户端要流式、收到一个 JSON」,聊天界面直接卡死。 + const calls = captureFetch(() => + sseResp(['data: {"choices":[{"index":0,"delta":{"content":"你"}}]}\n\n']) + ); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", stream: true, messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + assert.equal(calls[0].body.stream, true, "上游收到的 stream 被改写了"); + assert.match(r.headers.get("Content-Type"), /text\/event-stream/); + const frames = await readSse(r); + assert.equal(frames[frames.length - 1], "[DONE]"); +}); + +test("stream:false 时上游收到 stream:false,且返回 JSON", async () => { + const calls = captureFetch(() => openAiJson("你好")); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + assert.equal(calls[0].body.stream, false); + const o = await r.json(); + assert.equal(o.choices[0].message.content, "你好"); +}); + +test("Gemini 流式走 streamGenerateContent + alt=sse", async () => { + const calls = captureFetch(() => + sseResp([`data: ${JSON.stringify({ candidates: [{ content: { parts: [{ text: "你" }] } }] })}\n\n`]) + ); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gemini/gemini-2.5-flash", stream: true, messages: [{ role: "user", content: "hi" }] }) + }), + { GEMINI_API_KEY: "k" }, + {} + ); + assert.ok(calls[0].url.includes(":streamGenerateContent"), calls[0].url); + assert.ok(calls[0].url.includes("alt=sse"), calls[0].url); + const frames = await readSse(r); + const content = frames + .filter((f) => f !== "[DONE]") + .map((f) => JSON.parse(f).choices[0].delta.content || "") + .join(""); + assert.equal(content, "你"); +}); + +test("Gemini 非流式走 generateContent", async () => { + const calls = captureFetch(() => + jsonResp({ candidates: [{ content: { parts: [{ text: "你好" }] }, finishReason: "STOP" }] }) + ); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gemini/gemini-2.5-flash", messages: [{ role: "user", content: "hi" }] }) + }), + { GEMINI_API_KEY: "k" }, + {} + ); + assert.ok(calls[0].url.includes(":generateContent")); + assert.ok(!calls[0].url.includes("alt=sse")); + assert.equal((await r.json()).choices[0].message.content, "你好"); +}); + +test("429 时自动回退到下一个 provider,并在响应里标注实际服务的模型", async () => { + let n = 0; + captureFetch(() => { + n++; + if (n === 1) return jsonResp({ error: { message: "rate limited" } }, 429); + return openAiJson("来自备用 provider"); + }); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "a", GROQ_API_KEY: "b" }, + {} + ); + assert.equal(r.status, 200); + const o = await r.json(); + assert.equal(o.choices[0].message.content, "来自备用 provider"); + assert.equal(o._proxy.fellBack, true); + assert.ok(o._proxy.servedModel.includes("→"), o._proxy.servedModel); + assert.equal(o._proxy.attempts.length, 1, "应记录一次失败的尝试"); +}); + +test("400 时不回退(参数错误换个 provider 也一样)", async () => { + let n = 0; + captureFetch(() => { n++; return jsonResp({ error: { message: "bad param" } }, 400); }); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "a", GROQ_API_KEY: "b" }, + {} + ); + assert.equal(n, 1, `不该重试,实际打了 ${n} 次上游`); + assert.equal(r.status, 400); +}); + +test("所有 provider 都失败时,错误信息里带上每个尝试的原因", async () => { + captureFetch(() => jsonResp({ error: { message: "boom" } }, 503)); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "a" }, + {} + ); + assert.equal(r.status, 502); + const o = await r.json(); + assert.ok(o.error.message.includes("所有候选"), o.error.message); + assert.ok(o.error.message.includes("503"), o.error.message); +}); + +test("缺少密钥时给出明确提示,而不是让它 502", async () => { + captureFetch(() => jsonResp({})); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gemini/gemini-2.5-flash", messages: [{ role: "user", content: "hi" }] }) + }), + {}, + {} + ); + assert.equal(r.status, 400); + assert.ok((await r.json()).error.message.includes("凭据")); +}); + +test("有效 token 的 Qwen 账号能用,且不触发 refresh", async () => { + // 回归测试:字段名写错时这里会返回 502「没有可用账号」 + const kv = fakeKv({ "acc:a": freshAccount() }); + let refreshHits = 0; + captureFetch((rec) => { + if (rec.url.includes("/oauth2/token")) { refreshHits++; return jsonResp({}, 400); } + return openAiJson("qwen 回答"); + }); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "qwen/qwen3-coder-plus", messages: [{ role: "user", content: "hi" }] }) + }), + { ACCOUNTS: kv }, + {} + ); + assert.equal(r.status, 200, "有效 token 的账号不该被判为不可用"); + assert.equal(refreshHits, 0, "token 还有效,不该去刷新"); + assert.equal((await r.json()).choices[0].message.content, "qwen 回答"); +}); + +test("上游 429 会把 Qwen 账号置入冷却,下一次请求换账号", async () => { + const kv = fakeKv({ + "acc:a": freshAccount({ access_token: "A", expires_at: Math.floor(Date.now() / 1000) + 7200 }), + "acc:b": freshAccount({ access_token: "B", expires_at: Math.floor(Date.now() / 1000) + 3600 }) + }); + const used = []; + captureFetch((rec) => { + if (rec.url.includes("dashscope")) { + used.push(rec.headers.Authorization); + if (used.length === 1) return jsonResp({ error: { message: "quota" } }, 429); + return openAiJson("ok"); + } + return jsonResp({}); + }); + const req = () => + worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "qwen/qwen3-coder-plus", messages: [{ role: "user", content: "hi" }] }) + }), + { ACCOUNTS: kv }, + {} + ); + await req(); // 第一个账号 429 + const r2 = await req(); + assert.equal(r2.status, 200, "第二次应换到另一个账号并成功"); + assert.notEqual(used[0], used[1], "两次用了同一个账号"); +}); + +test("/v1/models 返回 OpenAI 形状,并标注可用性", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + const r = await worker.fetch(new Request("https://x/v1/models"), { ACCOUNTS: kv, GLM_API_KEY: "k" }, {}); + assert.equal(r.status, 200); + const o = await r.json(); + assert.equal(o.object, "list"); + assert.ok(o.data.length === MODELS.length); + const byId = Object.fromEntries(o.data.map((m) => [m.id, m])); + assert.equal(byId["qwen/qwen3-coder-flash"].available, true, "有账号,qwen 应可用"); + assert.equal(byId["glm/glm-4.6-flash"].available, true); + assert.equal(byId["gemini/gemini-2.5-flash"].available, false, "没配 key,不该显示可用"); + assert.equal(byId["glm/glm-4.6-flash"].object, "model"); + assert.ok(byId["glm/glm-4.6-flash"].context_length > 0); +}); + +test("/health 只报告哪些密钥已配置,绝不回显密钥值", async () => { + const r = await worker.fetch(new Request("https://x/health"), { GLM_API_KEY: "SUPER_SECRET" }, {}); + const text = await r.text(); + assert.ok(!text.includes("SUPER_SECRET"), "健康检查泄露了密钥"); + assert.ok(JSON.parse(text).providers_ready.includes("GLM_API_KEY")); +}); + +test("鉴权开启后,未带 key 的请求 401,且错误是 OpenAI 形状", async () => { + const r = await worker.fetch(new Request("https://x/v1/models"), { OPENAI_API_KEYS: "sk-a" }, {}); + assert.equal(r.status, 401); + assert.equal((await r.json()).error.type, "authentication_error"); +}); + +test("OPTIONS 预检返回 204 且带 CORS 头", async () => { + const r = await worker.fetch(new Request("https://x/v1/models", { method: "OPTIONS" }), {}, {}); + assert.equal(r.status, 204); + assert.equal(r.headers.get("Access-Control-Allow-Origin"), "*"); +}); + +test("未知路由返回 404,而不是让 Worker 抛异常", async () => { + const r = await worker.fetch(new Request("https://x/nope"), {}, {}); + assert.equal(r.status, 404); + assert.equal((await r.json()).error.code, "not_found"); +}); + +test("未配置 ADMIN_SECRET 时管理端点关闭(否则会泄露账号列表)", async () => { + const kv = fakeKv({ "acc:a": freshAccount() }); + const r = await worker.fetch(new Request("https://x/admin/health"), { ACCOUNTS: kv }, {}); + assert.equal(r.status, 403); + const text = await r.text(); + assert.ok(!text.includes("acc:a"), "不该泄露账号 id"); +}); + +test("ADMIN_SECRET 正确时返回账号健康信息,且不含 token 明文", async () => { + const kv = fakeKv({ "acc:a": freshAccount({ access_token: "SECRET_TOKEN" }) }); + const r = await worker.fetch( + new Request("https://x/admin/health", { headers: { Authorization: "Bearer adm" } }), + { ACCOUNTS: kv, ADMIN_SECRET: "adm" }, + {} + ); + assert.equal(r.status, 200); + const text = await r.text(); + assert.ok(!text.includes("SECRET_TOKEN"), "管理端点泄露了 access_token"); + const o = JSON.parse(text); + assert.equal(o.total, 1); + assert.equal(o.accounts[0].has_token, true); +}); + +test("/v1/auth/poll 成功后把账号写进 KV", async () => { + const kv = fakeKv(); + captureFetch(() => jsonResp({ access_token: "NEW", refresh_token: "R", expires_in: 3600 })); + const r = await worker.fetch( + new Request("https://x/v1/auth/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_code: "d", code_verifier: "v", account_id: "mine" }) + }), + { ACCOUNTS: kv }, + {} + ); + assert.equal(r.status, 200); + const saved = JSON.parse(kv.store["acc:mine"]); + assert.equal(saved.access_token, "NEW"); + assert.equal(saved.type, "qwen-oauth"); +}); + +test("/v1/auth/poll 缺少参数时 400,不打上游", async () => { + const calls = captureFetch(() => jsonResp({})); + const r = await worker.fetch( + new Request("https://x/v1/auth/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }), + { ACCOUNTS: fakeKv() }, + {} + ); + assert.equal(r.status, 400); + assert.equal(calls.length, 0); +}); + +test("流式透传上游错误:200 头已发出,错误要写进流里而不是丢掉", async () => { + // 上游在流中途断开:先给一个 chunk,再报错。 + // (注意不能写在 start() 里 —— 那样流在构造期就坏了, + // 已经 enqueue 的数据根本读不出来,测的就不是我们想测的东西了。) + let pulls = 0; + const broken = new ReadableStream({ + pull(c) { + if (pulls++ === 0) { + c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"A"}}]}\n\n')); + return; + } + c.error(new Error("上游断了")); + } + }); + captureFetch(() => new Response(broken, { status: 200, headers: { "Content-Type": "text/event-stream" } })); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", stream: true, messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + const frames = await readSse(r); + assert.ok(frames.some((f) => f.includes("A")), "已收到的内容不该丢"); + assert.equal(frames[frames.length - 1], "[DONE]", "必须以 [DONE] 收尾,否则客户端一直等"); +}); + +test("流式响应逐帧可解析,且首帧声明 role", async () => { + captureFetch(() => + sseResp([ + 'data: {"choices":[{"index":0,"delta":{"content":"A"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"content":"B"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + ]) + ); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", stream: true, messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + const frames = (await readSse(r)).filter((f) => f !== "[DONE]").map((f) => JSON.parse(f)); + assert.equal(frames[0].choices[0].delta.role, "assistant"); + assert.equal(frames.map((f) => f.choices[0].delta.content || "").join(""), "AB"); + assert.equal(frames[frames.length - 1].choices[0].finish_reason, "stop"); + assert.ok(frames.every((f) => f.object === "chat.completion.chunk" && f.id && f.model)); +}); + +test("上游返回非 JSON 时给 502,而不是抛未捕获异常", async () => { + captureFetch(() => new Response("502 Bad Gateway", { status: 200, headers: { "Content-Type": "text/html" } })); + const r = await worker.fetch( + new Request("https://x/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "glm/glm-4.6-flash", messages: [{ role: "user", content: "hi" }] }) + }), + { GLM_API_KEY: "k" }, + {} + ); + assert.ok(r.status >= 400, `期望报错,实际 ${r.status}`); + assert.equal(typeof (await r.json()).error, "object"); }); diff --git a/wrangler.toml b/wrangler.toml index bbb7a91..361c53e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,23 +1,30 @@ name = "ice-proxy" main = "src/worker.js" -compatibility_date = "2024-09-23" +compatibility_date = "2025-01-01" -# 1. Create KV namespace: -# npx wrangler kv namespace create ACCOUNTS -# 2. Paste the returned id below: -# [[kv_namespaces]] -# binding = "ACCOUNTS" -# id = "your_kv_id" - -# 3. Set secrets (one of these patterns): -# npx wrangler secret put OPENAI_API_KEYS -- "sk-xxx,sk-yyy" -# npx wrangler secret put ADMIN_SECRET -- "your-admin-secret" -# npx wrangler secret put GEMINI_API_KEY -- "AIzaSy..." -# npx wrangler secret put GLM_API_KEY -- "glm-xxx.bigmodel.cn" -# npx wrangler secret put CEREBRAS_API_KEY -- "csk-xxx" -# npx wrangler secret put OPENROUTER_API_KEY -- "sk-or-xxx" +# ── KV:存 Qwen OAuth 账号 ───────────────────────────────── +# 创建: npx wrangler kv namespace create ACCOUNTS +# 把返回的 id 填进下面(用 `npm run setup` 会自动填,不用手改这个文件) # -# (if any of these are unset, that provider's models will be unavailable) +# 不配 KV 也能部署,只是 Qwen 那几个模型不可用(它们需要账号池), +# 其它用静态密钥的 provider 不受影响。 +[[kv_namespaces]] +binding = "ACCOUNTS" +id = "REPLACE_WITH_KV_ID" [vars] -DEFAULT_MODEL = "qwen/qwen3-coder-flash" +# 默认模型。留空则用 src/providers.js 里的 DEFAULT_MODEL。 +# 想换默认模型改这里,不用改代码(也不用重新部署代码,改 vars 即可)。 +DEFAULT_MODEL = "" + +# ── 密钥(用 wrangler secret put 设置,别写在这个文件里)────── +# npx wrangler secret put OPENAI_API_KEYS # 保护你的代理,如 "sk-a,sk-b" +# npx wrangler secret put ADMIN_SECRET # /admin/health 的口令 +# npx wrangler secret put GEMINI_API_KEY # https://aistudio.google.com/apikey +# npx wrangler secret put GLM_API_KEY # https://bigmodel.cn/ +# npx wrangler secret put CEREBRAS_API_KEY # https://cloud.cerebras.ai/ +# npx wrangler secret put GROQ_API_KEY # https://console.groq.com/keys +# npx wrangler secret put OPENROUTER_API_KEY # https://openrouter.ai/ +# +# 一个都没配也能跑 —— 用 `node scripts/auth.js add` 加 Qwen 账号即可, +# 那条链路不需要任何静态密钥。