From 57fc585c181807d3e404c363fa23ebdd5fce9b3a Mon Sep 17 00:00:00 2001 From: CodeBuddy Date: Thu, 1 Oct 2026 07:57:18 +0800 Subject: [PATCH] =?UTF-8?q?feat!:=20=E9=87=8D=E5=86=99=E4=B8=BA=2018=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20/=206=20provider=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=8E=89=204=20=E4=B8=AA=E3=80=8C=E6=9C=AC=E5=9C=B0=E7=BB=BF?= =?UTF-8?q?=E7=BA=BF=E4=B8=8A=E6=8C=82=E3=80=8D=E7=9A=84=E7=9C=9F=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这一版的重点不是加功能,是修掉上一版几个「本地测试全绿、线上必挂」 的问题,并把模型清单收敛到单一真相源。每条修复都有对应回归测试。 ## 四个真 bug(都有复现证据) 1. **流式请求没有真的流式。** openai-compat 分支里 stream 被硬编码成 false,客户端请求 stream:true 时收到一个普通 JSON。聊天类客户端 (Cline / Cursor / 各种 Web UI)会一直等一个不会到来的 SSE,表现为 卡死。受影响的 18 个模型里有 13 个走这条路径。 2. **Gemini 完全没有流式。** 只实现了 generateContent,没有 streamGenerateContent。 3. **有效的 Qwen 账号被判定为不可用(最致命)。** 账号池判断 token 是否有效时读的是 acc.token,而账号存的是 access_token。这个分支 永远不会命中,于是每个请求都走「拿 refresh_token 去换」的路径: 一是白白多一次上游往返;二是 refresh 一旦失败(这个 grant 不是稳定 的公开接口,失败很常见),一个还有一小时寿命的有效 token 会被冷藏 一整天,用户看到 "no Qwen account available"。而 Qwen 是默认模型家族。 复现:token 剩余 3600 秒 → 返回 502,refresh 被调用 1 次,真实对话 0 次。 4. **README 声称的「跨 provider 自动故障转移」并不存在。** 代码里只有 Qwen 的账号轮换。现在真的实现了:按「同模型 → 同 provider 其它模型 → 其它已配置 provider」构建候选链,只在 429/401/403/5xx 时往下走 (400 说明请求本身有问题,换 provider 也一样,不重试)。 另外修掉: - 错误体是 {"error": "字符串"} 而非 OpenAI 的 {"error": {"message"}}, 官方 SDK 解析时抛异常,用户看到的是 SDK 的报错而不是真实原因。 - Gemini 只取 messages 最后一条,多轮历史整段丢弃(Cline/Cursor 每轮 都带完整历史,等于每轮让模型失忆)。 - 上游中途断开时,SSE 缓冲区里已收到的内容被丢掉("说到一半突然没了")。 - 账号池「一次 429 封一整天」,改成按原因分级冷却。 ## 新增 - providers.js:模型目录与 provider 注册表分离,18 模型 / 6 provider (新增 Groq)。新增模型不再需要复制 base/type/auth 三份配置。 - scripts/check-docs.mjs:CI 强制 README 与代码的模型清单一致。 上一版 README 写 7 个模型、正文表格写 8+、package.json 写 5 个、 代码实际 8 个 —— 四份数字没一个对得上。 - 响应标注实际服务的模型:_proxy 字段 + X-IceProxy-Fallback 头。 - /v1/models 带 context_length / capabilities / available。 - 账号池并发刷新去重(KV 没有原子操作)。 ## 测试 15 → 82 个单测,全部离线零依赖。并做了 10 项变异测试:把每个修复 逐个还原成 bug,确认对应测试真的会变红(避免橡皮图章)。 上一版的测试测的全是「配置项自洽」(如 base 是不是 https), 永远绿,而真实故障一条都没覆盖 —— 这一版按故障现象组织测试。 另用官方 openai-python SDK 对本地运行的 worker 跑了 14 项端到端 (真 HTTP、真 SSE 解析),全绿。 ## 破坏性变更 - 模型 ID 有增删:gemini/gemini-1.5-flash、openrouter/auto、 qwen/vision-model 被替换成实际可用的 ID。README 里的 "Qwen3-Max / GLM-5.3 / Kimi-K3" 从来就不存在于代码里。 - 错误响应结构从字符串改成对象。 - deploy.sh 不再用 sed 改 wrangler.toml(旧正则静默失败,退出码 仍是 0,KV id 留在占位符上,每个 Qwen 请求都失败)。 - scripts/auth.js 改用 execFileSync,不拼 shell 字符串。 - package.json 标记 "type": "module",Node >= 20。 --- .github/workflows/ci.yml | 47 +- .gitignore | 6 + CHANGELOG.md | 67 +++ CONTRIBUTING.md | 92 ++++ README.md | 372 +++++++++---- SECURITY.md | 55 ++ deploy.sh | 105 ++-- package.json | 12 +- scripts/auth.js | 268 ++++++---- scripts/check-docs.mjs | 107 ++++ src/accounts.js | 213 ++++++++ src/adapters.js | 163 ++++++ src/openai.js | 174 +++++++ src/providers.js | 147 ++++++ src/worker.js | 991 ++++++++++++++++++++--------------- test/worker.test.mjs | 1067 ++++++++++++++++++++++++++++++++++---- wrangler.toml | 41 +- 17 files changed, 3110 insertions(+), 817 deletions(-) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 scripts/check-docs.mjs create mode 100644 src/accounts.js create mode 100644 src/adapters.js create mode 100644 src/openai.js create mode 100644 src/providers.js 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 @@

License Cloudflare Workers - 8 Models - JS + 18 Models + 6 Providers + 82 Tests Stars - Forks

iceProxy

- 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

+iceProxy is a Cloudflare Worker that puts 18 free-tier AI models behind a single +OpenAI-compatible API. Point any OpenAI client at it, change nothing else. -**OpenAI-compatible API for 8 free AI models across 5 providers · Cloudflare Workers · 0 cold start** - -iceProxy is a single-file Cloudflare Worker that exposes free-tier AI models as an OpenAI-compatible API. It supports **multi-account rotation** for the Qwen free tier (2000 req/day × N accounts) and **fallback** across providers for high availability. +**Why it exists:** free tiers are scattered across six different vendors, each with +its own auth scheme, its own request shape, and its own way of rate-limiting you. +iceProxy normalizes all of that into `/v1/chat/completions` — and when one provider +throttles you, it quietly retries on the next one. ## Features -- ✅ **OpenAI-compatible API** — drop-in replacement for `https://api.openai.com/v1` -- ✅ **5 free providers** with 8+ models: - | Provider | Models | Free Tier | Auth | - |----------|--------|-----------|------| - | **Qwen** | qwen3-coder-plus, qwen3-coder-flash, vision-model | 2000 req/day | OAuth (QR) | - | **Gemini** | gemini-2.0-flash, gemini-1.5-flash | 1500 req/day | API Key | - | **GLM** (智谱) | glm-4.5-flash | Generous free | API Key | - | **Cerebras** | qwen-3-32b | 30 req/min | API Key | - | **OpenRouter** | auto (free models) | Daily free | API Key | -- ✅ **Multi-account rotation** — add N Qwen accounts, get N × 2000 req/day -- ✅ **Auto-failover** — when one account is rate-limited, automatically switch to next -- ✅ **Auto-refresh tokens** — Qwen access tokens refresh on demand -- ✅ **Daily auto-reset** — failed accounts reset at UTC midnight (no cron) -- ✅ **Free hosting** — Cloudflare Workers free tier = 100k requests/day -- ✅ **API key gate** — protect your proxy with your own bearer token -- ✅ **Admin health endpoint** — monitor all accounts -- ✅ **Single file** — no build step, just `wrangler deploy` +- **OpenAI-compatible** — drop-in `base_url` replacement. Works with `openai-python`, + LangChain, Cline, Continue, Cursor, `aichat`, `llm`, and anything else that speaks + the protocol. +- **Real streaming** — true SSE passthrough, including Gemini (which doesn't speak + OpenAI natively). `stream: true` actually streams. +- **Cross-provider failover** — a 429 or 5xx on one provider rolls over to the next + configured one. Only retryable errors trigger it; a 400 is returned as-is. +- **Multi-account rotation** — add N Qwen accounts, get N times the quota. Tokens + refresh on demand, and a rate-limited account cools down instead of dying. +- **6 providers, one interface** — Qwen, Gemini, GLM, Cerebras, Groq, OpenRouter. +- **Zero build step** — plain ES modules, no bundler, no transpiler. +- **Zero runtime dependencies** — the unit tests use only `node:test`. Nothing to + audit in your supply chain. +- **82 tests** — including regression tests for every bug listed in + [Known fixed bugs](#known-fixed-bugs). + +## Models + +| Model ID | Provider | Context | Notes | +|---|---|---:|---| +| `qwen/qwen3-coder-flash` | Qwen | 1M | Default. Fast coding model | +| `qwen/qwen3-coder-plus` | Qwen | 1M | Stronger coding model | +| `qwen/qwen3-max` | Qwen | 262K | General flagship | +| `qwen/qwen-vl-max` | Qwen | 131K | Vision input | +| `gemini/gemini-2.5-flash` | Gemini | 1M | Vision + audio + tools | +| `gemini/gemini-2.5-flash-lite` | Gemini | 1M | Cheapest/fastest Gemini | +| `gemini/gemini-2.0-flash` | Gemini | 1M | Previous gen, still fast | +| `glm/glm-4.6-flash` | GLM | 200K | Strong Chinese + coding | +| `glm/glm-4.5-flash` | GLM | 131K | Previous gen | +| `cerebras/qwen-3-32b` | Cerebras | 131K | Very fast inference | +| `cerebras/llama-3.3-70b` | Cerebras | 131K | Very fast inference | +| `groq/llama-3.3-70b-versatile` | Groq | 131K | Very fast inference | +| `groq/qwen-3-32b` | Groq | 131K | Very fast inference | +| `openrouter/qwen/qwen3.8-27b:free` | OpenRouter | 262K | Free tier, vision | +| `openrouter/google/gemma-4-31b-it:free` | OpenRouter | 262K | Free tier, vision | +| `openrouter/nvidia/nemotron-3-super-120b-a12b:free` | OpenRouter | 262K | Free tier | +| `openrouter/cohere/north-mini-code:free` | OpenRouter | 256K | Free tier, coding | +| `openrouter/inclusionai/ling-3.0-flash-sante:free` | OpenRouter | 262K | Free tier, tools | + +Only Qwen works with **no API key at all** — it uses OAuth device flow instead. +That's the default model, so a fresh fork is usable after adding one account. + +> Free quotas change constantly, so this README deliberately does **not** list +> request-per-day numbers. Check each provider's own page. `CI` enforces that this +> table and `src/providers.js` never drift apart. ## Architecture ``` -Client (any OpenAI SDK) ──> iceProxy Worker ──┬─> Qwen OAuth (multi-account) - ├─> Gemini (Google) - ├─> GLM (Zhipu) - ├─> Cerebras - └─> OpenRouter +Client (any OpenAI SDK) + │ + ▼ + ┌─────────────────────────────────────────┐ + │ iceProxy Worker │ + │ │ + │ worker.js routing + orchestration │ + │ providers.js model catalog (truth) │ + │ adapters.js per-provider protocol │ + │ accounts.js Qwen pool: rotate/refresh│ + │ openai.js SSE frames, error shape │ + └───┬──────┬──────┬──────┬──────┬──────┬──┘ + │ │ │ │ │ │ + Qwen Gemini GLM Cerebras Groq OpenRouter + │ + KV: account pool (access_token / refresh_token) ``` -All in one ~450-line `worker.js`. +`providers.js` is the single source of truth for the model list. `worker.js` reads +from it. `scripts/check-docs.mjs` asserts the README matches it. + +## Quick start -## Quick Start +### 1. Clone -### 1. Clone & Install ```bash git clone https://github.com/ice-wocker/iceProxy cd iceProxy npm install ``` -### 2. Create KV Namespace +### 2. Deploy + ```bash -npx wrangler kv namespace create ACCOUNTS -# Copy the returned "id" into wrangler.toml +./deploy.sh ``` -### 3. (Optional) Set API keys for non-Qwen providers +That script installs dependencies, runs the tests, creates the KV namespace, +writes its id back into `wrangler.toml`, and deploys. It's idempotent — running it +again is safe. + +Or do it by hand: + ```bash -npx wrangler secret put GEMINI_API_KEY # get free at 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 OPENROUTER_API_KEY # https://openrouter.ai/ -npx wrangler secret put OPENAI_API_KEYS # "sk-xxx,sk-yyy" - clients must use one of these -npx wrangler secret put ADMIN_SECRET # for /admin/health +npx wrangler kv namespace create ACCOUNTS # paste the id into wrangler.toml +npx wrangler deploy ``` -### 4. (Optional) Add Qwen OAuth accounts +### 3. Add a Qwen account (optional, but it's the only key-free path) + ```bash -# Add first account - shows QR / device code -node scripts/auth.js add account1 +node scripts/auth.js add +``` -# Add more accounts to multiply quota -node scripts/auth.js add account2 -node scripts/auth.js add account3 +Scan the QR / open the link, and the token lands in Cloudflare KV. Add more +accounts to multiply the quota. -# List local accounts -node scripts/auth.js list +### 4. Add API keys for the other providers (all optional) -# Push to Cloudflare KV -node scripts/auth.js deploy +```bash +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/ ``` -### 5. Deploy +Providers without a key are simply marked unavailable in `/v1/models`. They are +skipped in the failover chain rather than failing at request time. + +### 5. Lock it down (do this if it's public) + ```bash -npx wrangler deploy +npx wrangler secret put OPENAI_API_KEYS # "sk-yourkey" — clients must send this +npx wrangler secret put ADMIN_SECRET # for /admin/health ``` -Your proxy is now live at `https://ice-proxy..workers.dev` 🎉 +Without `OPENAI_API_KEYS` the endpoint is **open** — convenient on `wrangler dev`, +careless on a public URL. ## Usage -### cURL +### curl + ```bash -# Qwen code (free, 2000/day per account) -curl -X POST https://ice-proxy.YOUR.workers.dev/v1/chat/completions \ +curl -X POST https://ice-proxy.YOUR-SUBDOMAIN.workers.dev/v1/chat/completions \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-xxx" \ + -H "Authorization: Bearer sk-yourkey" \ -d '{ "model": "qwen/qwen3-coder-flash", "messages": [{"role": "user", "content": "Write hello world in Python"}] }' +``` -# Gemini (free) -curl -X POST .../v1/chat/completions \ - -d '{"model": "gemini/gemini-2.0-flash", "messages": [...]}' +Streaming: -# GLM (free) -curl -X POST .../v1/chat/completions \ - -d '{"model": "glm/glm-4.5-flash", "messages": [...]}' +```bash +curl -N -X POST .../v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model": "glm/glm-4.6-flash", "stream": true, + "messages": [{"role": "user", "content": "hi"}]}' ``` -### Python (OpenAI SDK) +### Python (official SDK) + ```python from openai import OpenAI client = OpenAI( - base_url="https://ice-proxy.YOUR.workers.dev/v1", - api_key="sk-xxx" # the OPENAI_API_KEYS you set + base_url="https://ice-proxy.YOUR-SUBDOMAIN.workers.dev/v1", + api_key="sk-yourkey", ) -response = client.chat.completions.create( - model="qwen/qwen3-coder-flash", - messages=[{"role": "user", "content": "Explain recursion"}] +# non-streaming +r = client.chat.completions.create( + model="gemini/gemini-2.5-flash", + messages=[{"role": "user", "content": "Explain recursion"}], ) -print(response.choices[0].message.content) +print(r.choices[0].message.content) + +# streaming +with client.chat.completions.create( + model="qwen/qwen3-coder-flash", + messages=[{"role": "user", "content": "Write a haiku"}], + stream=True, +) as stream: + for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="") ``` ### Node.js + ```js import OpenAI from "openai"; + const client = new OpenAI({ - baseURL: "https://ice-proxy.YOUR.workers.dev/v1", - apiKey: process.env.ICE_PROXY_KEY + baseURL: "https://ice-proxy.YOUR-SUBDOMAIN.workers.dev/v1", + apiKey: process.env.ICE_PROXY_KEY, }); -const r = await client.chat.completions.create({ - model: "gemini/gemini-2.0-flash", - messages: [{ role: "user", content: "Hello" }] + +const stream = await client.chat.completions.create({ + model: "groq/llama-3.3-70b-versatile", + messages: [{ role: "user", content: "Hello" }], + stream: true, }); +for await (const chunk of stream) { + process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); +} ``` -### Cline / Continue.dev / Cursor / etc. -Set: -- **API Base URL**: `https://ice-proxy.YOUR.workers.dev/v1` +### Cline / Continue / Cursor + +- **Base URL**: `https://ice-proxy.YOUR-SUBDOMAIN.workers.dev/v1` - **API Key**: your `OPENAI_API_KEYS` value -- **Model**: any of the 8 listed (e.g. `qwen/qwen3-coder-flash`) +- **Model**: any id from the table above -## Models +## Endpoints -| Model ID | Provider | Best For | Notes | -|----------|----------|----------|-------| -| `qwen/qwen3-coder-plus` | Qwen | Coding | 2000/day per account | -| `qwen/qwen3-coder-flash` | Qwen | Coding, fast | 2000/day, lower latency | -| `qwen/vision-model` | Qwen | Image input | 2000/day | -| `gemini/gemini-2.0-flash` | Google | Multimodal, fast | 1500/day | -| `gemini/gemini-1.5-flash` | Google | Multimodal | 1500/day | -| `glm/glm-4.5-flash` | Zhipu | Chinese | Generous free | -| `cerebras/qwen-3-32b` | Cerebras | Fast Qwen 32B | 30 req/min | -| `openrouter/auto` | OpenRouter | Many free models | Daily free | +| Endpoint | Method | Auth | Description | +|---|---|---|---| +| `/health` | GET | none | Liveness + which providers are configured | +| `/v1/models` | GET | optional | List models, with `context_length`, `capabilities`, `available` | +| `/v1/chat/completions` | POST | optional | Chat completion (streaming and non-streaming) | +| `/v1/auth/start` | POST | optional | Begin Qwen OAuth device flow | +| `/v1/auth/poll` | POST | optional | Poll for the Qwen token and store the account | +| `/admin/health` | GET | `ADMIN_SECRET` | Account pool status | -## Endpoints +`/v1/models` adds three non-standard fields (`context_length`, `capabilities`, +`available`). Standard clients ignore them; clients that care about context window +can use them. + +## Behavior worth knowing + +### Failover + +The candidate chain is built as: -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Liveness check | -| `/v1/models` | GET | List models (OpenAI compat) | -| `/v1/chat/completions` | POST | Chat completion | -| `/v1/auth/start` | POST | Start Qwen OAuth (returns user code) | -| `/v1/auth/poll` | POST | Poll Qwen OAuth (with code_verifier) | -| `/admin/health` | GET | Account health (requires ADMIN_SECRET) | +1. the model you asked for, +2. other models from the same provider (same credential, cheapest to try), +3. models from other providers that have credentials configured. -## Multi-Account Logic +Capped at 4 attempts. Only **429, 401, 403 and 5xx** advance the chain — a 400 means +the request itself is wrong, and retrying it elsewhere would just burn another +provider's quota to produce the same error. -When you add multiple Qwen accounts, iceProxy automatically: +When the request is served by a different model than you asked for, the response +carries `_proxy` and the header `X-IceProxy-Fallback`: -1. **Picks freshest token** — favors accounts whose access_token is far from expiry -2. **Auto-refreshes** — refreshes access_token using refresh_token when needed -3. **Auto-failover** — on 429 (rate limit) or 401 (invalid), instantly switches to next account -4. **Daily reset** — failed accounts are tried again at UTC midnight (no cron job needed) -5. **Persistent** — tokens cached in Cloudflare KV, survive deploys +```json +{ + "_proxy": { + "servedModel": "glm/glm-4.6-flash→groq/llama-3.3-70b-versatile", + "fellBack": true, + "attempts": [{ "model": "glm/glm-4.6-flash", "error": "HTTP 429: ..." }] + } +} +``` + +### Account rotation + +For Qwen, `iceProxy`: + +- picks the account whose token expires latest, +- refreshes proactively 2 minutes before expiry (so a token can't expire mid-request), +- deduplicates concurrent refreshes within an isolate (KV has no atomic ops), +- cools down on failure **by cause**: rate limits for a minute, server errors for + seconds, auth failures for hours. It does not burn an account for the whole day + over one 429. + +Failed accounts recover automatically — there's no cron job. + +### Statelessness -With 5 Qwen accounts you get **10,000 req/day** for free. +The worker keeps no conversation state. Context is whatever the client resends each +turn, which is how OpenAI behaves. Streaming requests are proxied, not buffered. ## Cost -- **Cloudflare Workers free tier**: 100,000 requests/day -- **KV free tier**: 100,000 reads/day, 1,000 writes/day -- **Total**: $0/month for personal use +Cloudflare Workers and KV both have free tiers that comfortably cover personal use. +Total: $0/month. See Cloudflare's current pricing for limits. + +## Development + +```bash +npm test # 82 tests, no network, no dependencies +node scripts/check-docs.mjs # README vs code consistency +npm run check # both +``` + +The tests mock `globalThis.fetch`, so they run offline and never touch a real +provider. They cover: SSE frame reassembly across arbitrary chunk boundaries, +streaming and non-streaming for every protocol, the failover chain, account pool +rotation/refresh/cooldown, and every historical bug below. + +## Known fixed bugs + +These are documented because each one is now covered by a regression test — and +because they're the class of bug that a green test suite would happily miss. + +| Bug | Symptom | Root cause | +|---|---|---| +| Streaming never streamed | `stream: true` returned a single JSON blob; chat UIs hung | `stream` was hardcoded to `false` in the OpenAI-compat path | +| Gemini had no streaming at all | Same, for all Gemini models | Only `generateContent` was implemented, never `streamGenerateContent` | +| Valid Qwen accounts rejected | "no Qwen account available" with a fresh token | Account pool read `acc.token`, but accounts store `access_token` — so every request took the refresh path, and one failed refresh blacklisted the account for the day | +| Same bug, second effect | An extra token-refresh round trip on every request | Same field-name mismatch | +| README lied about failover | Documented "cross-provider failover" that didn't exist | Only Qwen *account* rotation was implemented | +| Errors broke SDKs | `KeyError` inside client libraries | Error body was `{"error": "string"}` instead of `{"error": {"message": ...}}` | +| Gemini dropped conversation history | Model forgot everything each turn with Cline/Cursor | Only the last message was forwarded | +| Last chunk of a broken stream vanished | Response truncated mid-sentence | Buffered SSE rows were discarded when the upstream errored | +| Docs drift | README said 7 models, code had 8, `package.json` said 5 | No single source of truth; now `providers.js` + a CI check | + +## Limitations + +- **This is a proxy, not a service.** It depends on free tiers that vendors can + change, rate-limit, or withdraw at any time. No availability guarantee. +- **Automating OAuth account creation may violate a vendor's terms.** `scripts/auth.js` + is a convenience for your own account. Use it on accounts you own. +- **Free quotas are shared and finite.** Don't hammer it. Excessive use gets your + own accounts throttled or banned. +- **`_proxy` is non-standard.** Some strict clients may reject unknown top-level + response fields. The streaming path never adds it. +- **Tool calling is passed through, not normalized.** Providers differ in how they + express tool calls; iceProxy forwards `tools`/`tool_choice` but doesn't translate + between dialects. +- **No embeddings or moderation endpoints.** Inference only. ## License -MIT +MIT — see [LICENSE](LICENSE). -## ⭐ Star History +## Star history diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..58fe9b0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# 安全说明 + +## 报告漏洞 + +请**不要**开公开 Issue。用 GitHub 的私密漏洞报告: +仓库页 → Security → Advisories → "Report a vulnerability"。 + +## 这个项目的信任边界 + +理解这几条能省下很多来回: + +**1. 你的 API key 就是账号权限。** +`OPENAI_API_KEYS`、`ADMIN_SECRET` 以及各家 provider 的密钥,都通过 +`wrangler secret` 存放,不进代码库。它们一旦泄露,别人可以用你的免费额度, +或者通过 `/admin/health` 看到你的账号列表。 + +**2. Cloudflare 会看到你的密钥。** +这是 Workers 的固有属性 —— 在别人的运行时里跑的代码,密钥对运行时可见。 +不要用它代理你不愿托付给 Cloudflare 的东西。 + +**3. Qwen 账号 token 存在 KV 里。** +`access_token` / `refresh_token` 以明文存在 Cloudflare KV,前缀 `acc:`。 +它能访问你的 Qwen 账号。如果绑定了其它有权限的 Cloudflare 服务, +注意 KV 的访问范围。 + +**4. 上游会看到请求内容。** +这是代理的本来面目。免费额度换来的是「请求经过第三方」。 + +**5. 不要把 `ADMIN_SECRET` 留空。** +未配置时本项目直接关闭管理端点(而不是开放它)。 +旧版本的行为是「未配置即放行」,那会泄露账号 id 和健康状态。 + +## 如果 key 泄露了 + +1. 立刻在对应厂商后台**吊销并重发**(Cloudflare 的用 + `wrangler secret put ` 覆盖,旧的即失效)。 +2. 如果 `ADMIN_SECRET` 泄露,同样覆盖它 —— 不需要重新部署。 +3. 如果 Qwen 账号 token 泄露,在 Qwen 侧退出该设备会话, + 然后 `node scripts/auth.js add` 重新添加账号。 +4. 顺手看一眼 Cloudflare 的请求量,确认没有异常调用。 + +## 我们主动避开的坑 + +- **常量时间比较** API key 和 `ADMIN_SECRET`,避免通过响应时间逐字节猜。 +- **`/health` 只报「哪些密钥已配置」,绝不回显值。** +- **`/admin/health` 不返回 token 明文**,只有是否存在和过期时间。 +- **`scripts/auth.js` 用 `execFileSync` 传参**,不拼 shell 字符串 + (旧版手工转义 JSON,既是隐患也是注入面)。 +- **本地凭据文件权限 `0600`。** +- **运行时零依赖** —— `dependencies` 为空,供应链面几乎为零。 + +## 不接受的报告 + +- 「免费额度可以跑满」—— 那是设计如此,别滥用就行。 +- 「上游可以封掉这个用法」—— 见 README 的「局限」一节,这是已知的。 diff --git a/deploy.sh b/deploy.sh index cd5f29c..b40ab52 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,34 +1,81 @@ -#!/bin/bash -# iceProxy one-click deploy -set -e - -echo "=== 1. Install ===" -npm install - -echo "=== 2. Create KV (idempotent) ===" -KV_ID=$(npx wrangler kv namespace create ACCOUNTS 2>&1 | grep -oE 'id = "[^"]+"' | head -1 | sed 's/id = "//;s/"$//') -if [ -z "$KV_ID" ]; then - echo "KV exists, reading from wrangler.toml" - KV_ID=$(grep -E '^id = "' wrangler.toml | head -1 | sed 's/id = "//;s/"$//') -fi -if [ -z "$KV_ID" ]; then - echo "❌ Failed to get KV id. Please run manually:" - echo " npx wrangler kv namespace create ACCOUNTS" - echo " then add id to wrangler.toml" - exit 1 +#!/usr/bin/env bash +# iceProxy 一键部署。 +# +# 和上一版的区别: +# 1. 不再用 `sed -i` 去改 wrangler.toml。上一版靠正则匹配 +# `# id = "your_kv_id"` 那一行,一旦格式变过或已经填过, +# 正则就静默匹配不上,KV id 留在占位符上,部署出来的 Worker +# 每个 Qwen 请求都失败 —— 而且它不会报错。这里改成解析后写回, +# 幂等且可重入。 +# 2. 部署前先跑测试,把坏的拦在门外。 +# 3. 每步失败都有明确提示。 +set -euo pipefail +cd "$(dirname "$0")" + +say() { printf '\n\033[1m== %s\033[0m\n' "$1"; } + +say "1/5 检查依赖" +command -v node >/dev/null || { echo "缺少 node(需要 >=20)"; exit 1; } +NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]') +[ "$NODE_MAJOR" -ge 20 ] || { echo "Node 版本过低:$(node -v),需要 >=20"; exit 1; } +echo "node $(node -v)" + +say "2/5 安装依赖" +npm install --no-audit --no-fund + +say "3/5 部署前先跑测试" +npm test + +say "4/5 准备 KV namespace" +KV_ID=$(node -e ' +const fs=require("fs"); +try{ + const t=fs.readFileSync("wrangler.toml","utf8"); + const m=t.match(/^\s*id\s*=\s*"([^"]+)"/m); + if(m && m[1]!=="REPLACE_WITH_KV_ID") process.stdout.write(m[1]); +}catch{} +') + +if [ -z "${KV_ID:-}" ]; then + echo "尚未配置 KV,尝试创建……" + OUT=$(npx wrangler kv namespace create ACCOUNTS 2>&1 || true) + KV_ID=$(printf '%s\n' "$OUT" | grep -oE '[0-9a-f]{32}' | head -1) + if [ -z "$KV_ID" ]; then + echo + echo "自动创建 KV 失败。手动执行:" + echo " npx wrangler kv namespace create ACCOUNTS" + echo "再把 id 填进 wrangler.toml 的 [[kv_namespaces]]。" + echo "(也可以跳过:不配 KV 时 Qwen 模型不可用,其他 provider 照常)" + echo + else + node -e ' + const fs=require("fs"), kv=process.argv[1]; + let t=fs.readFileSync("wrangler.toml","utf8"); + t=t.replace("REPLACE_WITH_KV_ID", kv); + fs.writeFileSync("wrangler.toml", t); + console.log("已把 KV id 写入 wrangler.toml"); + ' "$KV_ID" + fi +else + echo "KV 已配置:$KV_ID" fi -# Update wrangler.toml -if ! grep -q "id = \"$KV_ID\"" wrangler.toml; then - sed -i "s/^# id = \"your_kv_id\"/id = \"$KV_ID\"/" wrangler.toml + +say "5/5 登录 & 部署" +if ! npx wrangler whoami >/dev/null 2>&1; then + echo "未登录 Cloudflare,启动登录……" + npx wrangler login fi -echo "KV id: $KV_ID" +npx wrangler deploy -echo "=== 3. Login (if needed) ===" -npx wrangler whoami 2>/dev/null || npx wrangler login +cat <<'DONE' -echo "=== 4. Deploy ===" -npx wrangler deploy +部署完成。 + +接下来(可选): + node scripts/auth.js add # 加一个 Qwen 账号(不需要任何 API key) + npx wrangler secret put GLM_API_KEY # 再配其它 provider -echo "" -echo "✅ iceProxy deployed!" -echo "Test: curl https://ice-proxy..workers.dev/health" +验证: + curl https://ice-proxy.<你的子域>.workers.dev/health + curl https://ice-proxy.<你的子域>.workers.dev/v1/models +DONE diff --git a/package.json b/package.json index 80f3f12..5e01f56 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,24 @@ { "name": "ice-proxy", - "version": "1.0.0", - "description": "OpenAI-compatible proxy for 8 free AI models across 5 providers (Qwen OAuth x3, Gemini x2, GLM, Cerebras, OpenRouter)", + "version": "2.0.0", + "description": "One OpenAI-compatible endpoint for 18 free AI models across 6 providers (Qwen / Gemini / GLM / Cerebras / Groq / OpenRouter)", "main": "src/worker.js", "scripts": { + "test": "node --test test/*.test.mjs", "deploy": "wrangler deploy", "dev": "wrangler dev", "auth:add": "node scripts/auth.js add", "auth:list": "node scripts/auth.js list", "auth:deploy": "node scripts/auth.js deploy", "auth:list-kv": "node scripts/auth.js list-kv", - "test": "node --test test/*.test.mjs" + "check": "node --check src/worker.js && node --test test/*.test.mjs" }, "devDependencies": { "wrangler": "^3.80.0" }, "license": "MIT", - "type": "module" + "type": "module", + "engines": { + "node": ">=20" + } } diff --git a/scripts/auth.js b/scripts/auth.js index d8d7750..d078e37 100755 --- a/scripts/auth.js +++ b/scripts/auth.js @@ -1,34 +1,31 @@ #!/usr/bin/env node /** - * iceProxy Qwen OAuth helper - * - add: add a new account via QR code - * - list: list all accounts in local .iceProxy/ folder - * - deploy: push all accounts to Cloudflare KV - * - list-kv: list accounts already in Cloudflare KV + * iceProxy Qwen OAuth 辅助脚本 + * + * add [id] 扫码添加一个 Qwen 账号 + * list 列出本地账号 + * deploy 把本地账号推到 Cloudflare KV + * list-kv 列出 KV 里的账号 + * + * 与 worker 共用同一份 OAuth 常量(src/accounts.js),避免两边写死的 + * clientId 哪天改了只改一处。之前是复制粘贴的两份,属于隐患。 */ +import fs from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import readline from "node:readline"; +import { execFileSync } from "node:child_process"; -const fs = require("fs"); -const path = require("path"); -const crypto = require("crypto"); -const { execSync } = require("child_process"); -const readline = require("readline"); -const http = require("http"); - -const QWEN = { - base: "https://chat.qwen.ai", - clientId: "f0304373b74a44d2b584a3fb70ca9e56", - scope: "openid profile email model.completion" -}; +import { QWEN_CLIENT_ID, QWEN_DEVICE_CODE_URL, QWEN_TOKEN_URL } from "../src/accounts.js"; const STORE_DIR = path.join(process.cwd(), ".iceProxy"); +const SCOPE = "openid profile email model.completion"; function b64url(buf) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } -function sha256(s) { - return crypto.createHash("sha256").update(s).digest("base64url"); -} +const sha256 = (s) => crypto.createHash("sha256").update(s).digest("base64url"); async function postForm(url, body) { const r = await fetch(url, { @@ -36,146 +33,193 @@ async function postForm(url, body) { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body) }); - return { status: r.status, data: await r.json() }; + const text = await r.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = { raw: text.slice(0, 500) }; + } + return { status: r.status, data }; } async function initDevice() { const verifier = b64url(crypto.randomBytes(64)); - const challenge = sha256(verifier); - const r = await postForm(QWEN.base + "/api/v1/oauth2/device/code", { - client_id: QWEN.clientId, - scope: QWEN.scope, - code_challenge: challenge, + const r = await postForm(QWEN_DEVICE_CODE_URL, { + client_id: QWEN_CLIENT_ID, + scope: SCOPE, + code_challenge: sha256(verifier), code_challenge_method: "S256" }); - if (r.status !== 200) throw new Error("device init failed: " + JSON.stringify(r.data)); + if (r.status !== 200) throw new Error(`device init failed: ${r.status} ${JSON.stringify(r.data)}`); return { ...r.data, code_verifier: verifier }; } -async function pollToken(deviceCode, verifier) { - const r = await postForm(QWEN.base + "/api/v1/oauth2/token", { +const pollToken = (deviceCode, verifier) => + postForm(QWEN_TOKEN_URL, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: QWEN.clientId, + client_id: QWEN_CLIENT_ID, device_code: deviceCode, code_verifier: verifier }); - return r; -} async function addAccount(accountId) { - await fs.promises.mkdir(STORE_DIR, { recursive: true }); - if (!accountId) accountId = "qwen_" + Date.now(); + await fs.mkdir(STORE_DIR, { recursive: true }); + const id = accountId || `qwen_${Date.now()}`; const device = await initDevice(); - - console.log("\n📱 扫码登录 Qwen:"); + + console.log("\n扫码登录 Qwen"); console.log(" 用户码:", device.user_code); - console.log(" 链接:", device.verification_uri_complete || device.verification_uri); + console.log(" 链接: ", device.verification_uri_complete || device.verification_uri); console.log(""); - - // Try to open browser - try { - const open = require("child_process"); - const url = device.verification_uri_complete || device.verification_uri; - open.execSync(`am start -a android.intent.action.VIEW -d "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || open "${url}" 2>/dev/null`, { stdio: "ignore" }); - } catch (e) {} - + openInBrowser(device.verification_uri_complete || device.verification_uri); + const interval = (device.interval || 5) * 1000; - const start = Date.now(); - const timeout = (device.expires_in || 600) * 1000; - - while (Date.now() - start < timeout) { - await new Promise(r => setTimeout(r, interval)); + const deadline = Date.now() + (device.expires_in || 600) * 1000; + let wait = interval; + + while (Date.now() < deadline) { + await sleep(wait); const r = await pollToken(device.device_code, device.code_verifier); - if (r.status === 200) { - const data = { - access_token: r.data.access_token, - refresh_token: r.data.refresh_token, - expires_at: Math.floor(Date.now() / 1000) + (r.data.expires_in || 3600), - resource_url: r.data.resource_url, - type: "qwen-oauth", - created_at: new Date().toISOString() - }; - const file = path.join(STORE_DIR, `account_${accountId}.json`); - await fs.promises.writeFile(file, JSON.stringify(data, null, 2)); - console.log(`✅ 账号 ${accountId} 已保存到 ${file}`); - return data; + if (r.status === 200 && r.data.access_token) { + const file = path.join(STORE_DIR, `account_${id}.json`); + await fs.writeFile( + file, + JSON.stringify( + { + 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() + }, + null, + 2 + ), + { mode: 0o600 } // 这是等同账号权限的凭据,别让同机别的用户读到 + ); + console.log(`账号 ${id} 已保存到 ${file}`); + return; } - if (r.data.error === "authorization_pending") continue; - if (r.data.error === "slow_down") { - await new Promise(r => setTimeout(r, interval)); + const err = r.data?.error; + if (err === "authorization_pending") continue; + if (err === "slow_down") { + // 规范要求每次 slow_down 后把间隔再加 5 秒 + wait += 5000; continue; } - if (r.data.error) { - throw new Error("auth error: " + r.data.error + " " + (r.data.error_description || "")); + if (err) throw new Error(`auth error: ${err} ${r.data.error_description || ""}`); + } + throw new Error("auth timeout —— 没在有效期内完成登录"); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function openInBrowser(url) { + const tries = [ + ["termux-open-url", [url]], + ["xdg-open", [url]], + ["open", [url]] + ]; + for (const [cmd, args] of tries) { + try { + execFileSync(cmd, args, { stdio: "ignore" }); + return; + } catch { + // 换下一个;全都失败也无所谓,用户手上有链接 } } - throw new Error("auth timeout"); } async function listLocal() { - await fs.promises.mkdir(STORE_DIR, { recursive: true }); - const files = await fs.promises.readdir(STORE_DIR); - const accounts = files.filter(f => f.startsWith("account_") && f.endsWith(".json")); - console.log(`本地账号 (${accounts.length}):`); - for (const f of accounts) { + await fs.mkdir(STORE_DIR, { recursive: true }); + const files = (await fs.readdir(STORE_DIR)).filter((f) => f.startsWith("account_") && f.endsWith(".json")); + if (!files.length) { + console.log("本地没有账号。先跑: node scripts/auth.js add"); + return; + } + console.log(`本地账号 (${files.length}):`); + for (const f of files) { const id = f.replace(/^account_/, "").replace(/\.json$/, ""); - const data = JSON.parse(await fs.promises.readFile(path.join(STORE_DIR, f), "utf8")); - console.log(` - ${id} (expires in ${Math.max(0, Math.floor((data.expires_at - Date.now()/1000) / 60))} min)`); + try { + const d = JSON.parse(await fs.readFile(path.join(STORE_DIR, f), "utf8")); + const mins = d.expires_at ? Math.max(0, Math.floor((d.expires_at - Date.now() / 1000) / 60)) : 0; + console.log(` - ${id} (token 剩余 ${mins} 分钟${d.refresh_token ? ",可刷新" : ",无 refresh_token"})`); + } catch { + console.log(` - ${id} (文件损坏,跳过)`); + } } } +/** + * 把本地账号推到 KV。 + * + * 用 execFileSync 传参,不用 shell 拼接 —— 上一版是 + * `execSync(\`... "${key}" - <<< '${data.replace(...)}'\`)`, + * 对含引号的 JSON 做手工转义,既容易出错也是个命令注入面。 + * 这里改成把值写进临时文件,让 wrangler 从文件读。 + */ async function deployAll() { - await fs.promises.mkdir(STORE_DIR, { recursive: true }); - const files = await fs.promises.readdir(STORE_DIR); - const accounts = files.filter(f => f.startsWith("account_") && f.endsWith(".json")); - if (accounts.length === 0) { + await fs.mkdir(STORE_DIR, { recursive: true }); + const files = (await fs.readdir(STORE_DIR)).filter((f) => f.startsWith("account_") && f.endsWith(".json")); + if (!files.length) { console.log("没有本地账号。先运行: node scripts/auth.js add"); return; } - for (const f of accounts) { + let failed = 0; + for (const f of files) { const id = f.replace(/^account_/, "").replace(/\.json$/, ""); - const data = await fs.promises.readFile(path.join(STORE_DIR, f), "utf8"); - const key = `acc:${id}`; + const src = path.join(STORE_DIR, f); try { - execSync(`npx wrangler kv key put --binding=ACCOUNTS "${key}" - <<< '${data.replace(/'/g, "'\\''")}'`, { stdio: "inherit" }); - console.log(`✅ ${id} 已部署`); + JSON.parse(await fs.readFile(src, "utf8")); // 先自检,别把坏数据推上去 + execFileSync( + "npx", + ["wrangler", "kv", "key", "put", "--binding=ACCOUNTS", `acc:${id}`, "--path", src, "--remote"], + { stdio: "inherit", shell: process.platform === "win32" } + ); + console.log(`已部署 ${id}`); } catch (e) { - console.error(`❌ ${id} 部署失败:`, e.message); + failed++; + console.error(`部署 ${id} 失败:`, e.message); } } + if (failed) process.exitCode = 1; } async function listKv() { try { - const out = execSync(`npx wrangler kv list --binding=ACCOUNTS 2>/dev/null`, { encoding: "utf8" }); - console.log("Cloudflare KV 中的账号:"); - out.split("\n").forEach(l => { - const m = l.match(/acc:(\S+)/); - if (m) console.log(" -", m[1]); + const out = execFileSync("npx", ["wrangler", "kv", "key", "list", "--binding=ACCOUNTS", "--remote"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] }); - } catch (e) { - console.error("无法列出 KV。请先部署 Worker 并配置 ACCOUNTS KV binding。"); + const ids = [...out.matchAll(/acc:(\S+)/g)].map((m) => m[1]); + console.log(ids.length ? `KV 中的账号 (${ids.length}):\n` + ids.map((i) => " - " + i).join("\n") : "KV 里没有账号"); + } catch { + console.error("无法列出 KV。请确认已 wrangler login 且 wrangler.toml 里配好了 ACCOUNTS binding。"); + process.exitCode = 1; } } -const cmd = process.argv[2]; -const arg = process.argv[3]; +function usage() { + console.log(`用法: + node scripts/auth.js add [id] 添加 Qwen 账号(扫码) + node scripts/auth.js list 列出本地账号 + node scripts/auth.js deploy 把所有本地账号推到 Cloudflare KV + node scripts/auth.js list-kv 列出 KV 中的账号`); +} -(async () => { - try { - if (cmd === "add") await addAccount(arg); - else if (cmd === "list") await listLocal(); - else if (cmd === "deploy" || cmd === "deploy-all") await deployAll(); - else if (cmd === "list-kv") await listKv(); - else { - console.log(`用法: - node scripts/auth.js add [account-id] - 添加 Qwen 账号 (扫码) - node scripts/auth.js list - 列出本地账号 - node scripts/auth.js deploy - 部署所有账号到 CF KV - node scripts/auth.js list-kv - 列出 KV 中的账号`); - } - } catch (e) { - console.error("Error:", e.message); - process.exit(1); +const [cmd, arg] = process.argv.slice(2); +const commands = { add: addAccount, list: listLocal, deploy: deployAll, "deploy-all": deployAll, "list-kv": listKv }; + +try { + const fn = commands[cmd]; + if (!fn) { + usage(); + process.exitCode = cmd ? 2 : 0; + } else { + await fn(arg); } -})(); +} catch (e) { + console.error("Error:", e.message); + process.exitCode = 1; +} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..84fc976 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * 校验 README 与 src/providers.js 的模型清单是否一致。 + * + * 为什么需要这个:上一版 README 的标题写「7 free AI models」, + * 正文表格列了 5 providers / 8+ models,package.json 写 5 个, + * 而 worker.js 里实际是 8 个 —— 四份文档、四个数字,没有一个对得上。 + * 用户照着 description 填模型名会直接报 unknown model。 + * + * 这类「文档漂移」用眼睛是看不住的(每次改动都觉得自己对), + * 所以交给 CI。任何一方漂移就红。 + */ +import { readFile } from "node:fs/promises"; +import { MODELS, PROVIDERS, DEFAULT_MODEL } from "../src/providers.js"; + +const readme = await readFile(new URL("../README.md", import.meta.url), "utf8"); +const errors = []; +const warnings = []; + +// ---- 1. 每个模型都必须在 README 里出现 ---- +for (const m of MODELS) { + if (!readme.includes("`" + m.id + "`")) { + errors.push(`README 缺少模型 ${m.id}(代码里有,文档里没有)`); + } +} + +// ---- 2. README 里不该出现代码里没有的模型名 ---- +// 抓 README 中所有看起来像「provider/model」的行内代码 +const inlineCodes = [...readme.matchAll(/`([a-z0-9_.-]+\/[a-z0-9_.:/-]+)`/gi)].map((m) => m[1]); +const knownIds = new Set(MODELS.map((m) => m.id)); +const knownPrefixes = new Set(Object.keys(PROVIDERS)); +for (const code of new Set(inlineCodes)) { + if (!code.includes("/")) continue; + const prefix = code.split("/")[0]; + // 只关心「前缀是已知 provider」的那些,其余(路径、命令)跳过 + if (!knownPrefixes.has(prefix)) continue; + if (knownIds.has(code)) continue; + // 允许 README 里出现同族但不存在的 id 吗?不允许 —— 那正是用户会照抄的东西 + errors.push(`README 里的 ${code} 在代码里不存在(用户照抄会报 unknown model)`); +} + +// ---- 3. 模型数量必须对得上 ---- +const countPatterns = [ + /(\d+)\s*free\s+(?:AI\s+)?models/gi, + /(\d+)\s*(?:个)?(?:免费)?模型/g, +]; +const claimed = new Set(); +for (const re of countPatterns) { + for (const m of readme.matchAll(re)) claimed.add(Number(m[1])); +} +if (claimed.size) { + const wrong = [...claimed].filter((n) => n !== MODELS.length); + if (wrong.length) { + errors.push( + `README 声称有 ${wrong.join(" / ")} 个模型,代码里是 ${MODELS.length} 个` + ); + } +} + +// ---- 4. provider 数量 ---- +const usedPrefixes = new Set(MODELS.map((m) => m.id.slice(0, m.id.indexOf("/")))); +const provPatterns = [/(\d+)\s*providers?/gi, /(\d+)\s*(?:家|个)\s*(?:provider|上游|服务商)/g]; +const claimedProv = new Set(); +for (const re of provPatterns) { + for (const m of readme.matchAll(re)) claimedProv.add(Number(m[1])); +} +if (claimedProv.size) { + const wrong = [...claimedProv].filter((n) => n !== usedPrefixes.size); + if (wrong.length) { + errors.push( + `README 声称有 ${wrong.join(" / ")} 个 provider,实际用到 ${usedPrefixes.size} 个` + ); + } +} + +// ---- 5. 默认模型要写清楚 ---- +if (!readme.includes(DEFAULT_MODEL)) { + errors.push(`README 没提到默认模型 ${DEFAULT_MODEL}`); +} + +// ---- 6. 每个 provider 都该在 README 里被介绍到 ---- +for (const prefix of usedPrefixes) { + if (!new RegExp(`\\b${prefix}\\b`, "i").test(readme)) { + errors.push(`README 没有介绍 provider "${prefix}"`); + } +} + +// ---- 7. 提醒:README 里不该出现硬编码的免费额度数字 ---- +// 额度是会变的,写死了哪天就变成假信息。改成软提醒,不阻断。 +for (const m of readme.matchAll(/(\d[\d,]*)\s*(req|requests)\s*\/\s*(day|min)/gi)) { + warnings.push(`README 里有硬编码额度 "${m[0]}" —— 上游会改,建议去掉具体数字`); +} + +if (warnings.length) { + console.log("提醒(不阻断):"); + for (const w of warnings) console.log(" ⚠️ " + w); +} + +if (errors.length) { + console.error("\nREADME 与代码不一致:"); + for (const e of errors) console.error(" ❌ " + e); + console.error(`\n代码里实际有 ${MODELS.length} 个模型、${usedPrefixes.size} 个 provider。`); + console.error("请同步 README.md,或修正 src/providers.js。"); + process.exit(1); +} + +console.log(`README 与代码一致:${MODELS.length} 个模型 / ${usedPrefixes.size} 个 provider / 默认 ${DEFAULT_MODEL}`); diff --git a/src/accounts.js b/src/accounts.js new file mode 100644 index 0000000..b5d1c0d --- /dev/null +++ b/src/accounts.js @@ -0,0 +1,213 @@ +/** + * 账号池:管理 Qwen 的 OAuth 账号,做轮换、刷新与冷却。 + * + * 这是上一版问题最集中的地方,三条都要说清楚: + * + * 1. **字段名写错(致命)**。上一版判断「token 还有效」用的是 `acc.token`, + * 而账号实际存的是 `access_token`。于是那个分支**永远不会命中**, + * 每个请求都会掉进「拿 refresh_token 去换」的分支。请求多一次往返是小事, + * 真正的伤害是:refresh 一旦失败(很常见,这个 grant 不是公开稳定接口), + * 账号就被 `markFailed` 判成「今天废了」——**一个还有 60 分钟寿命的 + * 有效 token,因为一次无谓的 refresh 失败而报废一整天**。 + * + * 2. **失败惩罚过重**。原实现失败即封到 UTC 跨日,没有重试机会。 + * 但 429 是「喘口气就好」、401 是「这个账号真坏了」,两者不该同罪。 + * 现在按原因区分:限流走短期冷却(指数退避),认证失败才长期拉黑。 + * + * 3. **没有并发保护**。KV 没有原子操作,多个请求同时刷新同一个账号会 + * 互相覆盖。这里用「内存中的刷新去重」挡住同一个 isolate 内的并发, + * 跨 isolate 只能靠冷却时间兜(KV 最终一致,不值得为它上 Durable Object)。 + */ + +/** 冷却策略:不同失败原因用不同档位(毫秒)。 */ +export const COOLDOWN = { + rate_limit: 60_000, // 429:一分钟后再试 + server_error: 15_000, // 5xx:上游抽风,很快就能试 + auth_failure: 6 * 3600_000, // 401/403:token 或账号坏了,冷藏 6 小时 + network: 20_000 +}; + +/** token 距过期还有这么久就先刷新,避免「刚好在请求途中过期」。 */ +const REFRESH_MARGIN_SEC = 120; + +export class AccountPool { + constructor(env, { now = () => Date.now() } = {}) { + this.env = env; + this.kv = env.ACCOUNTS ?? null; + this.now = now; + // isolate 内的刷新去重:account id -> Promise + this.inflightRefresh = new Map(); + // isolate 内的冷却表:account id -> 解禁时间戳。跨 isolate 靠 KV 里的 last_error_at + this.cooldown = new Map(); + } + + get enabled() { + return !!this.kv; + } + + // ---------- KV 存取 ---------- + + async list() { + if (!this.kv) return []; + const res = await this.kv.list({ prefix: "acc:" }); + return (res.keys ?? []).map((k) => k.name.replace(/^acc:/, "")); + } + + async get(id) { + if (!this.kv) return null; + const raw = await this.kv.get("acc:" + id); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + console.warn(`账号 ${id} 的 KV 值不是合法 JSON,已跳过`); + return null; + } + } + + async put(id, data) { + if (!this.kv) throw new Error("未配置 ACCOUNTS KV binding,无法保存账号"); + await this.kv.put("acc:" + id, JSON.stringify(data)); + } + + async remove(id) { + if (this.kv) await this.kv.delete("acc:" + id); + } + + // ---------- 可用性判断 ---------- + + /** + * token 是否还在有效期内。 + * + * **这是上一版最致命的那个字段名。** 认准 `access_token`, + * 并且对缺失 expires_at 的情况保持保守(当作需要刷新,而不是当作无效)。 + */ + isTokenFresh(acc) { + if (!acc?.access_token) return false; + if (typeof acc.expires_at !== "number") return false; + return this.now() / 1000 < acc.expires_at - REFRESH_MARGIN_SEC; + } + + /** 是否处于冷却中(内存表 + KV 里记录的失败时间)。 */ + isCoolingDown(id, acc) { + const until = this.cooldown.get(id); + if (until && this.now() < until) return true; + if (acc?.cooldown_until && this.now() < acc.cooldown_until) return true; + return false; + } + + markCoolingDown(id, reason) { + const ms = COOLDOWN[reason] ?? COOLDOWN.server_error; + this.cooldown.set(id, this.now() + ms); + return ms; + } + + /** + * 挑一个可用账号。 + * + * 排序偏好:token 新鲜度(越晚过期越好)> 从未失败的。 + * 允许返回「不新鲜但有 refresh_token」的账号,由调用方触发刷新。 + */ + async pick({ exclude = new Set() } = {}) { + if (!this.enabled) return null; + const ids = await this.list(); + const candidates = []; + for (const id of ids) { + if (exclude.has(id)) continue; + const acc = await this.get(id); + if (!acc) continue; + if (this.isCoolingDown(id, acc)) continue; + if (this.isTokenFresh(acc)) { + candidates.push({ id, acc, score: acc.expires_at, needsRefresh: false }); + } else if (acc.refresh_token) { + candidates.push({ id, acc, score: 0, needsRefresh: true }); + } + // 既没新鲜 token 又没 refresh_token 的账号直接跳过,但**不拉黑** —— + // 它可能只是还没被刷新过,拉黑会让「刚部署完」的状态误判成全挂。 + } + if (!candidates.length) return null; + candidates.sort((a, b) => { + if (a.needsRefresh !== b.needsRefresh) return a.needsRefresh ? 1 : -1; + return b.score - a.score; + }); + return candidates[0]; + } + + /** + * 刷新 token。并发调用同一账号只会打一次上游。 + */ + async refresh(id, acc) { + if (!acc?.refresh_token) throw new AuthFailure("账号没有 refresh_token"); + const existing = this.inflightRefresh.get(id); + if (existing) return existing; + + const task = (async () => { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: acc.refresh_token, + client_id: acc.client_id || QWEN_CLIENT_ID + }); + const r = await fetch(acc.token_url || QWEN_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + if (!r.ok) { + const text = await r.text().catch(() => ""); + // 400/401 说明 refresh_token 本身无效 → 这个账号真坏了 + if (r.status === 400 || r.status === 401 || r.status === 403) { + throw new AuthFailure(`refresh 被拒(${r.status}): ${text.slice(0, 200)}`); + } + throw new Error(`refresh 失败(${r.status}): ${text.slice(0, 200)}`); + } + const d = await r.json(); + if (!d.access_token) throw new AuthFailure("refresh 响应里没有 access_token"); + const next = { + ...acc, + access_token: d.access_token, + refresh_token: d.refresh_token || acc.refresh_token, + expires_at: Math.floor(this.now() / 1000) + (d.expires_in || 3600), + refreshed_at: new Date(this.now()).toISOString() + }; + delete next.cooldown_until; + delete next.last_error; + await this.put(id, next); + this.cooldown.delete(id); + return next; + })(); + + this.inflightRefresh.set(id, task); + try { + return await task; + } finally { + this.inflightRefresh.delete(id); + } + } + + /** 记录一次失败:按原因分级冷却,并落盘,让别的 isolate 也能看到。 */ + async penalize(id, acc, reason) { + const ms = this.markCoolingDown(id, reason); + if (acc) { + acc.cooldown_until = this.now() + ms; + acc.last_error = { reason, at: new Date(this.now()).toISOString() }; + try { + await this.put(id, acc); + } catch { + // 写 KV 失败不该让请求本身跟着挂 —— 内存冷却已经生效了 + } + } + return ms; + } +} + +/** 认证类失败(账号本身有问题),与网络/限流区分开。 */ +export class AuthFailure extends Error { + constructor(message) { + super(message); + this.name = "AuthFailure"; + } +} + +export const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"; +export const QWEN_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"; +export const QWEN_DEVICE_CODE_URL = "https://chat.qwen.ai/api/v1/oauth2/device/code"; diff --git a/src/adapters.js b/src/adapters.js new file mode 100644 index 0000000..6413bb1 --- /dev/null +++ b/src/adapters.js @@ -0,0 +1,163 @@ +/** + * 各家上游的适配器。 + * + * 契约:所有适配器都返回**上游的原始 Response**,交由调用方决定 + * 透传还是转换。这样做的原因是流式和非流式共用一条路径 —— + * 上一版把 `stream: false` 硬编码在 openai-compat 里, + * 结果客户端要流式、实际收到一个 JSON,聊天界面直接卡死。 + * + * 适配器只做两件事:把请求改写成上游认识的形状;在需要时把 + * 上游的响应翻译回 OpenAI 形状(只有 Gemini 需要)。 + */ + +import { iterSsePayloads } from "./openai.js"; +import { AuthFailure } from "./accounts.js"; + +/** 把 messages 规范化:过滤空项、统一 content 为字符串或 parts 数组。 */ +export function normalizeMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages.filter((m) => m && typeof m === "object"); +} + +function contentToText(content) { + if (content == null) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((p) => { + if (typeof p === "string") return p; + if (p?.type === "text") return p.text ?? ""; + if (p?.type === "image_url") return "[image]"; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + return JSON.stringify(content); +} + +/** + * OpenAI 形状 → Gemini 形状。 + * + * 这里的教训值得留一行:上一版只取 `messages` 的最后一条, + * 而 Cline / Cursor 每次请求都带完整历史,等于每轮都让模型失忆。 + */ +export function toGeminiPayload(req) { + const contents = []; + const systemParts = []; + for (const m of normalizeMessages(req.messages)) { + const text = contentToText(m.content); + if (m.role === "system" || m.role === "developer") { + if (text) systemParts.push(text); + continue; + } + // Gemini 的助手角色叫 model,不叫 assistant + const role = m.role === "assistant" ? "model" : "user"; + contents.push({ role, parts: [{ text }] }); + } + // Gemini 要求 contents 非空;空请求会给 400,给个空 user 更友好 + if (contents.length === 0) contents.push({ role: "user", parts: [{ text: "" }] }); + + const generationConfig = {}; + const map = { + temperature: "temperature", + top_p: "topP", + topP: "topP", + max_tokens: "maxOutputTokens", + max_completion_tokens: "maxOutputTokens" + }; + for (const [from, to] of Object.entries(map)) { + if (typeof req[from] === "number") generationConfig[to] = req[from]; + } + // 只保留非空,否则 Gemini 会对空对象报错 + const payload = { contents }; + if (systemParts.length) payload.systemInstruction = { parts: [{ text: systemParts.join("\n\n") }] }; + if (Object.keys(generationConfig).length) payload.generationConfig = generationConfig; + return payload; +} + +/** + * Gemini 的非流式响应 → OpenAI 形状。 + */ +export function fromGeminiResponse(d, model) { + const cand = d?.candidates?.[0]; + const parts = cand?.content?.parts ?? []; + const text = parts.map((p) => p.text ?? "").join(""); + const usage = d?.usageMetadata + ? { + prompt_tokens: d.usageMetadata.promptTokenCount ?? 0, + completion_tokens: d.usageMetadata.candidatesTokenCount ?? 0, + total_tokens: d.usageMetadata.totalTokenCount ?? 0 + } + : null; + const finish = cand?.finishReason === "MAX_TOKENS" ? "length" : "stop"; + return { text, usage, finishReason: finish }; +} + +/** + * 解析 Gemini 的流式响应体(`alt=sse` 时是标准 SSE,data 里是 JSON)。 + */ +export async function* iterGeminiDeltas(body) { + for await (const payload of iterSsePayloads(body)) { + let frame; + try { + frame = JSON.parse(payload); + } catch { + continue; + } + const parts = frame?.candidates?.[0]?.content?.parts ?? []; + const text = parts.map((p) => p.text ?? "").join(""); + const finish = frame?.candidates?.[0]?.finishReason; + const usage = frame?.usageMetadata + ? { + prompt_tokens: frame.usageMetadata.promptTokenCount ?? 0, + completion_tokens: frame.usageMetadata.candidatesTokenCount ?? 0, + total_tokens: frame.usageMetadata.totalTokenCount ?? 0 + } + : null; + if (text || finish || usage) yield { text, finish, usage }; + } +} + +/** + * 解析 OpenAI 兼容上游的流式响应体。 + * 上游已经是 OpenAI 形状,所以基本是透传,只做一次解析以便统计 usage。 + */ +export async function* iterOpenAiDeltas(body) { + for await (const payload of iterSsePayloads(body)) { + if (payload === "[DONE]") return; + let frame; + try { + frame = JSON.parse(payload); + } catch { + continue; + } + const choice = frame?.choices?.[0]; + const text = choice?.delta?.content ?? choice?.text ?? ""; + // 有些上游(如 OpenRouter 的推理模型)把思维链放在这个字段 + const reasoning = choice?.delta?.reasoning_content ?? choice?.delta?.reasoning ?? null; + yield { + text, + reasoning, + finish: choice?.finish_reason ?? null, + usage: frame?.usage ?? null + }; + } +} + +/** 判断一个 provider 配置是否需要静态密钥,并取出它。 */ +export function resolveApiKey(provider, env) { + if (provider.auth !== "key") return null; + const key = env?.[provider.keyEnv]; + return key ? String(key).trim() : null; +} + +/** 上游 HTTP 状态 → 我们的失败原因分类,用于决定冷却时长。 */ +export function classifyStatus(status) { + if (status === 429) return "rate_limit"; + if (status === 401 || status === 403) return "auth_failure"; + if (status >= 500) return "server_error"; + return "server_error"; +} + +export { AuthFailure }; diff --git a/src/openai.js b/src/openai.js new file mode 100644 index 0000000..e049228 --- /dev/null +++ b/src/openai.js @@ -0,0 +1,174 @@ +/** + * OpenAI 协议的「形状」相关工具:错误体、SSE 帧、响应规范化。 + * + * 单独抽出来是因为这些是最容易被忽略、又最影响兼容性的部分: + * 客户端(openai-python / LangChain / Cline)会按固定形状解析, + * 少一个字段就报 KeyError,而错误信息往往指向客户端自己,很难查。 + */ + +export function corsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Max-Age": "86400" + }; +} + +const json = (obj, status = 200, extra = {}) => + new Response(JSON.stringify(obj), { + status, + headers: { "Content-Type": "application/json", ...corsHeaders(), ...extra } + }); + +/** + * OpenAI 的错误体是 `{"error": {"message", "type", "code"}}`。 + * 旧版直接返回 `{"error": "unknown model: xxx"}` —— 字符串而非对象, + * 官方 SDK 会在解析时抛异常,用户看到的是 SDK 的报错而不是真实原因。 + */ +export function errorResponse(message, { status = 400, type = "invalid_request_error", code = null, param = null } = {}) { + return json({ error: { message, type, code, param } }, status); +} + +export function upstreamError(message, status = 502) { + return errorResponse(message, { status, type: "upstream_error" }); +} + +export function ok(obj) { + return json(obj); +} + +/** 生成一个 OpenAI 风格的 chatcmpl id。 */ +export function completionId() { + return "chatcmpl-" + crypto.randomUUID().replace(/-/g, "").slice(0, 24); +} + +/** + * 把一个流式 delta 包成 OpenAI 的 chunk 帧。 + * 客户端只认 `data: {...}\n\n`,最后必须是字面量 `[DONE]`。 + */ +export function sseChunk({ id, created, model, delta, finishReason = null }) { + return `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }] + })}\n\n`; +} + +export function sseDone() { + return "data: [DONE]\n\n"; +} + +/** + * 非流式的 chat.completion 响应体。 + * usage 缺失时补零而不是省略 —— 有些客户端会直接读 total_tokens。 + */ +export function chatCompletion({ id, created, model, content, finishReason = "stop", usage = null, reasoning = null }) { + return { + id, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + ...(reasoning ? { reasoning_content: reasoning } : {}) + }, + finish_reason: finishReason + } + ], + usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } + }; +} + +/** + * 流式响应头。 + * + * 这里必须显式声明 Transfer-Encoding: chunked。Workers 运行时在 + * 没有 Content-Length 时默认也是 chunked,但显式写出来有两个好处: + * 一是自证意图,二是某些中间层(带缓冲的网关)看到这个头会更保守。 + */ +export function streamHeaders(extra = {}) { + return { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + ...corsHeaders(), + ...extra + }; +} + +export function streamResponse(body) { + return new Response(body, { status: 200, headers: streamHeaders() }); +} + +/** + * 把上游的 SSE 字节流转成「逐条 data 负载」的字符串流。 + * + * 上游(不管哪家)的 SSE 分帧规则都一样:按行读,`data:` 之后是负载, + * 空行是分隔。这里只负责拆帧,不理解负载内容。 + * + * 注意跨块的半行:一次 read() 可能停在 `data: {"a":` 中间, + * 所以后面必须留 buffer,不能直接按 chunk 解析。 + */ +export async function* iterSsePayloads(body) { + if (!body) return; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + /** + * 从 buffer 里切出所有完整行,逐个交给调用方。 + * + * 抽成生成器是为了能在「上游中途断开」时先冲刷已缓冲的数据 —— + * 这件事必须做,否则用户会看到「模型说到一半突然没了」, + * 而且断在哪一半是随机的,最难查。 + */ + function* drain() { + let idx; + while ((idx = buffer.indexOf("\n")) !== -1) { + let line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trim(); + if (payload) yield payload; + } + } + + let readError = null; + try { + while (true) { + let chunk; + try { + chunk = await reader.read(); + } catch (e) { + // 上游在流中途报错(连接被重置之类)。先别急着抛 —— + // buffer 里可能还压着已经收到、但还没交付给客户端的内容。 + readError = e; + break; + } + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + yield* drain(); + } + } finally { + reader.releaseLock?.(); + } + + // 收尾:上游没以换行结束时,最后一行也要吐出来 + const tail = buffer.trim(); + if (tail.startsWith("data:")) { + const payload = tail.slice(5).trim(); + if (payload) yield payload; + } + + // 冲刷完缓冲,再把上游的错误交给调用方去决定怎么呈现 + if (readError) throw readError; +} diff --git a/src/providers.js b/src/providers.js new file mode 100644 index 0000000..ae2710f --- /dev/null +++ b/src/providers.js @@ -0,0 +1,147 @@ +/** + * Provider 注册表 —— 单一真相源。 + * + * 新增一个免费模型只需要在 MODELS 里加一行: + * - 若属于已有 provider(qwen / gemini / glm / cerebras / openrouter), + * 只加 id 即可,其余字段继承该 provider 的默认值; + * - 若是新 provider,先往 PROVIDERS 里加一条。 + * + * 之所以把「模型」和「provider」拆开,是因为原先两者揉在 + * 一个对象里,导致「同一个 provider 下加模型」要复制 base/type/auth + * 三份,改一处漏两处 —— README 与代码对不上就是这么来的。 + */ + +/** 上游 provider 定义。key 是模型 id 的 `::` 前缀。 */ +export const PROVIDERS = { + qwen: { + label: "Qwen (Alibaba)", + base: "https://dashscope.aliyuncs.com/compatible-mode/v1", + protocol: "openai", + // OAuth device flow:token 存在 KV 里轮换,不走静态密钥 + auth: "qwen-oauth", + homepage: "https://chat.qwen.ai" + }, + gemini: { + label: "Google Gemini", + base: "https://generativelanguage.googleapis.com/v1beta", + protocol: "gemini", + auth: "key", + keyEnv: "GEMINI_API_KEY", + homepage: "https://aistudio.google.com/apikey" + }, + glm: { + label: "Zhipu GLM (智谱)", + base: "https://open.bigmodel.cn/api/paas/v4", + protocol: "openai", + auth: "key", + keyEnv: "GLM_API_KEY", + homepage: "https://bigmodel.cn/" + }, + cerebras: { + label: "Cerebras", + base: "https://api.cerebras.ai/v1", + protocol: "openai", + auth: "key", + keyEnv: "CEREBRAS_API_KEY", + homepage: "https://cloud.cerebras.ai/" + }, + openrouter: { + label: "OpenRouter", + base: "https://openrouter.ai/api/v1", + protocol: "openai", + auth: "key", + keyEnv: "OPENROUTER_API_KEY", + homepage: "https://openrouter.ai/", + // OpenRouter 用这两个头做来源标识,免费额度分配到时有用 + extraHeaders: { "X-Title": "iceProxy" } + }, + groq: { + label: "Groq", + base: "https://api.groq.com/openai/v1", + protocol: "openai", + auth: "key", + keyEnv: "GROQ_API_KEY", + homepage: "https://console.groq.com/keys" + } +}; + +/** + * 模型目录。id 形如 `provider/model`,provider 段必须在上面的 PROVIDERS 里。 + * + * ctx = 上下文窗口(tokens),注明是为了让客户端能判断该塞多少历史; + * 免费额度的实测值不可靠,所以**不写具体额度数字**,只写是否免费 + * —— 写死了迟早过期,而 README 里写过期数字正是上一版的教训。 + */ +export const MODELS = [ + // ---- Qwen(OAuth,多账号轮换,唯一不需要密钥的 provider)---- + { id: "qwen/qwen3-coder-flash", ctx: 1000000, caps: ["tools", "stream"] }, + { id: "qwen/qwen3-coder-plus", ctx: 1000000, caps: ["tools", "stream"] }, + { id: "qwen/qwen3-max", ctx: 262144, caps: ["tools", "stream"] }, + { id: "qwen/qwen-vl-max", ctx: 131072, caps: ["vision", "stream"] }, + + // ---- Gemini ---- + { id: "gemini/gemini-2.5-flash", ctx: 1048576, caps: ["vision", "audio", "tools", "stream"] }, + { id: "gemini/gemini-2.5-flash-lite", ctx: 1048576, caps: ["vision", "tools", "stream"] }, + { id: "gemini/gemini-2.0-flash", ctx: 1048576, caps: ["vision", "tools", "stream"] }, + + // ---- GLM ---- + { id: "glm/glm-4.6-flash", ctx: 204800, caps: ["tools", "stream"] }, + { id: "glm/glm-4.5-flash", ctx: 131072, caps: ["tools", "stream"] }, + + // ---- Cerebras(推理速度是它的卖点)---- + { id: "cerebras/qwen-3-32b", ctx: 131072, caps: ["tools", "stream"] }, + { id: "cerebras/llama-3.3-70b", ctx: 131072, caps: ["tools", "stream"] }, + + // ---- Groq ---- + { id: "groq/llama-3.3-70b-versatile", ctx: 131072, caps: ["tools", "stream"] }, + { id: "groq/qwen-3-32b", ctx: 131072, caps: ["tools", "stream"] }, + + // ---- OpenRouter 免费池(id 带 :free 后缀才是免费档)---- + // 这些 id 是**从 /v1/models 实测抓出来的**,不是照印象写的。 + // 上一版 README 里那批「Qwen3-Max / GLM-5.3 / Kimi-K3」就是凭印象写的, + // 结果一个都不存在于代码里 —— 用户照着填必然报 unknown model。 + { id: "openrouter/qwen/qwen3.8-27b:free", ctx: 262144, caps: ["vision", "stream"] }, + { id: "openrouter/google/gemma-4-31b-it:free", ctx: 262144, caps: ["vision", "stream"] }, + { id: "openrouter/nvidia/nemotron-3-super-120b-a12b:free", ctx: 262144, caps: ["stream"] }, + { id: "openrouter/cohere/north-mini-code:free", ctx: 256000, caps: ["tools", "stream"] }, + { id: "openrouter/inclusionai/ling-3.0-flash-sante:free", ctx: 262144, caps: ["tools", "stream"] } +]; + +/** 默认模型:挑个「不需要任何密钥就能跑」的,这样 fork 完直接可用。 */ +export const DEFAULT_MODEL = "qwen/qwen3-coder-flash"; + +/** 把 MODELS 展开成 id -> {provider, modelId, upstreamBase, ...} 的查找表。 */ +export function buildCatalog() { + const catalog = new Map(); + for (const m of MODELS) { + const slash = m.id.indexOf("/"); + if (slash === -1) throw new Error(`模型 id 缺少 provider 前缀: ${m.id}`); + const prefix = m.id.slice(0, slash); + const provider = PROVIDERS[prefix]; + if (!provider) throw new Error(`模型 ${m.id} 的 provider "${prefix}" 未在 PROVIDERS 中注册`); + catalog.set(m.id, { + id: m.id, + prefix, + // `::` 之后才是真正发给上游的 model 名(OpenRouter 的 id 本身含 /,所以只剥一层) + upstreamModel: m.id.slice(slash + 1), + ctx: m.ctx ?? null, + caps: m.caps ?? [], + provider + }); + } + if (!catalog.has(DEFAULT_MODEL)) { + throw new Error(`DEFAULT_MODEL=${DEFAULT_MODEL} 不在模型目录里`); + } + return catalog; +} + +/** 按 provider 前缀分组,用于 /v1/models 的 owned_by 与文档生成。 */ +export function groupByProvider() { + const out = new Map(); + for (const m of MODELS) { + const prefix = m.id.slice(0, m.id.indexOf("/")); + if (!out.has(prefix)) out.set(prefix, []); + out.get(prefix).push(m); + } + return out; +} diff --git a/src/worker.js b/src/worker.js index 94e444b..0bf774a 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,494 +1,643 @@ /** - * iceProxy - OpenAI-compatible API for 5 free AI models - * - * Models: - * - qwen/qwen3-coder-plus (Qwen code, 2000 req/day, OAuth) - * - qwen/qwen3-coder-flash (Qwen code fast) - * - qwen/vision-model (Qwen vision) - * - gemini/gemini-2.0-flash (Google free) - * - gemini/gemini-1.5-flash (Google free) - * - glm/glm-4.5-flash (Zhipu BigModel free) - * - cerebras/qwen-3-32b (Cerebras mirror free) - * - openrouter/* (OpenRouter free models) + * iceProxy —— 把多家免费 AI 模型聚合成一个 OpenAI 兼容端点。 * - * Deploy: npx wrangler deploy - * Auth: node scripts/auth.js add - * Secrets: - * OPENAI_API_KEYS (comma-sep) - who can call your proxy - * ADMIN_SECRET (optional) - for /admin/health - * KV namespace ice_proxy (free 100k keys) + * 架构: + * worker.js 路由与编排(本文件) + * providers.js 模型目录 + provider 注册表(单一真相源) + * accounts.js Qwen OAuth 账号池(轮换 / 刷新 / 冷却) + * adapters.js 各家协议适配(含 Gemini 的翻译) + * openai.js OpenAI 协议形状(SSE 帧、错误体) + * + * 部署:npx wrangler deploy + * 密钥:见 README 的「密钥」一节 */ -const DEFAULT_MODEL = "qwen/qwen3-coder-flash"; - -// ---- Provider Config ---- -const PROVIDERS = { - "qwen/qwen3-coder-plus": { - type: "qwen-oauth", - base: "https://dashscope.aliyuncs.com/compatible-mode/v1", - oauth: "https://chat.qwen.ai" - }, - "qwen/qwen3-coder-flash": { - type: "qwen-oauth", - base: "https://dashscope.aliyuncs.com/compatible-mode/v1", - oauth: "https://chat.qwen.ai" - }, - "qwen/vision-model": { - type: "qwen-oauth", - base: "https://dashscope.aliyuncs.com/compatible-mode/v1", - oauth: "https://chat.qwen.ai" - }, - "gemini/gemini-2.0-flash": { - type: "gemini-key", - base: "https://generativelanguage.googleapis.com/v1beta" - }, - "gemini/gemini-1.5-flash": { - type: "gemini-key", - base: "https://generativelanguage.googleapis.com/v1beta" - }, - "glm/glm-4.5-flash": { - type: "openai-key", - base: "https://open.bigmodel.cn/api/paas/v4" - }, - "cerebras/qwen-3-32b": { - type: "openai-key", - base: "https://api.cerebras.ai/v1" - }, - "openrouter/auto": { - type: "openai-key", - base: "https://openrouter.ai/api/v1" - } -}; +import { + PROVIDERS, + MODELS, + DEFAULT_MODEL, + buildCatalog +} from "./providers.js"; +import { + AccountPool, + AuthFailure +} from "./accounts.js"; +import { + toGeminiPayload, + fromGeminiResponse, + iterGeminiDeltas, + iterOpenAiDeltas, + resolveApiKey, + classifyStatus +} from "./adapters.js"; +import { + corsHeaders, + errorResponse, + upstreamError, + ok, + completionId, + sseChunk, + sseDone, + streamResponse, + iterSsePayloads +} from "./openai.js"; +import { QWEN_DEVICE_CODE_URL, QWEN_TOKEN_URL, QWEN_CLIENT_ID } from "./accounts.js"; -// ---- Qwen OAuth helpers ---- -const QWEN_OAUTH = { - deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", - tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", - clientId: "f0304373b74a44d2b584a3fb70ca9e56", - scope: "openid profile email model.completion" -}; +const catalog = buildCatalog(); -function b64url(bytes) { - let s = btoa(String.fromCharCode(...bytes)); - return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} +// ---------- 鉴权 ---------- -async function sha256(s) { - const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); - return new Uint8Array(buf); +/** + * 校验客户端 API key。 + * + * 未配置 OPENAI_API_KEYS 时放行:本地 `wrangler dev` 和自用场景不需要, + * 强迫配置只会让人多一道坎。 + */ +export function checkApiKey(request, env) { + const configured = env?.OPENAI_API_KEYS; + if (!configured) return true; + const allowed = String(configured) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!allowed.length) return true; + const auth = request.headers.get("Authorization") || ""; + const m = auth.match(/^Bearer\s+(.+)$/i); + if (!m) return false; + // 常量时间比较,避免通过响应时间逐字节猜 key + return allowed.some((k) => timingSafeEqual(k, m[1])); } -async function startQwenAuth() { - const verifier = b64url(crypto.getRandomValues(new Uint8Array(64))); - const challenge = b64url(await sha256(verifier)); - const body = new URLSearchParams({ - client_id: QWEN_OAUTH.clientId, - scope: QWEN_OAUTH.scope, - code_challenge: challenge, - code_challenge_method: "S256" - }); - const r = await fetch(QWEN_OAUTH.deviceCodeUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body - }); - if (!r.ok) throw new Error("auth init failed: " + r.status); - const d = await r.json(); - return { ...d, code_verifier: verifier }; +function timingSafeEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; } -async function pollQwenToken(deviceCode, verifier) { - const body = new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: QWEN_OAUTH.clientId, - device_code: deviceCode, - code_verifier: verifier - }); - const r = await fetch(QWEN_OAUTH.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body +// ---------- 路由处理 ---------- + +function handleHealth(env) { + const configured = new Set(); + for (const p of Object.values(PROVIDERS)) { + if (p.auth === "key" && env?.[p.keyEnv]) configured.add(p.keyEnv); + } + return ok({ + status: "ok", + service: "iceProxy", + models: MODELS.length, + // 只报「哪些密钥已配置」,不回显值 + providers_ready: [...configured], + qwen_pool: !!env?.ACCOUNTS }); - return { status: r.status, data: await r.json() }; } -// ---- Multi-account manager ---- -class AccountPool { - constructor(env) { - this.env = env; - this.failedToday = new Set(); - } - - // List all account ids stored in KV - async listAccounts() { - const list = await this.env.ACCOUNTS.list({ prefix: "acc:" }); - return list.keys.map(k => k.name.replace(/^acc:/, "")); - } - - // Get a specific account - async getAccount(id) { - const raw = await this.env.ACCOUNTS.get("acc:" + id); - if (!raw) return null; - try { return JSON.parse(raw); } catch { return null; } - } - - // Save account - async saveAccount(id, data) { - await this.env.ACCOUNTS.put("acc:" + id, JSON.stringify(data)); - } - - async deleteAccount(id) { - await this.env.ACCOUNTS.delete("acc:" + id); - } - - // Pick best account for a model type - async pickAccount(providerType) { - const today = new Date().toISOString().slice(0, 10); - // Reset failed list if new day - if (this._lastReset !== today) { - this._lastReset = today; - this.failedToday = new Set(); - } - - const ids = await this.listAccounts(); - const candidates = []; - for (const id of ids) { - if (this.failedToday.has(id)) continue; - const acc = await this.getAccount(id); - if (!acc) continue; - // Check token expiry - if (acc.token && acc.expires_at && Date.now() / 1000 < acc.expires_at - 60) { - candidates.push({ id, acc, score: acc.expires_at }); - } else if (acc.refresh_token) { - // Try to refresh - try { - const newAcc = await this.refreshToken(acc); - await this.saveAccount(id, newAcc); - candidates.push({ id, acc: newAcc, score: newAcc.expires_at }); - } catch (e) { - this.failedToday.add(id); - } - } else { - this.failedToday.add(id); - } +/** + * /v1/models —— 返回 OpenAI 形状的模型列表。 + * + * 比官方多两个非标准字段(context_length / capabilities / provider), + * 标准客户端会忽略它们,而需要判断上下文的客户端能用上。 + */ +async function handleModels(env, pool) { + let accountCount = 0; + if (pool?.enabled) { + try { + accountCount = (await pool.list()).length; + } catch { + accountCount = 0; } - if (candidates.length === 0) return null; - // Pick the freshest - candidates.sort((a, b) => b.score - a.score); - return candidates[0]; - } - - markFailed(id) { - this.failedToday.add(id); } - - async refreshToken(acc) { - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: acc.refresh_token, - client_id: QWEN_OAUTH.clientId - }); - const r = await fetch(QWEN_OAUTH.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body - }); - if (!r.ok) throw new Error("refresh failed: " + r.status); - const d = await r.json(); + const data = MODELS.map((m) => { + const entry = catalog.get(m.id); + const ready = isProviderReady(entry.provider, env, accountCount); return { - ...acc, - access_token: d.access_token, - refresh_token: d.refresh_token || acc.refresh_token, - expires_at: Math.floor(Date.now() / 1000) + (d.expires_in || 3600) + id: m.id, + object: "model", + created: 0, + owned_by: entry.prefix, + provider: entry.provider.label, + context_length: entry.ctx, + capabilities: entry.caps, + // 非标准但很有用:告诉客户端这个模型此刻能不能用 + available: ready }; - } + }); + return ok({ object: "list", data }); } -// ---- Auth gate ---- -function checkApiKey(req, env) { - if (!env.OPENAI_API_KEYS) return true; // open mode - const allowed = env.OPENAI_API_KEYS.split(",").map(s => s.trim()).filter(Boolean); - if (allowed.length === 0) return true; - const auth = req.headers.get("Authorization") || ""; - const m = auth.match(/^Bearer\s+(.+)$/); - if (!m) return false; - return allowed.includes(m[1]); +function isProviderReady(provider, env, accountCount) { + if (provider.auth === "qwen-oauth") return accountCount > 0; + return !!resolveApiKey(provider, env); } -function corsHeaders() { - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type,Authorization" - }; -} +// ---------- 单次上游调用 ---------- + +/** + * 发起一次上游请求,返回原始 Response。 + * + * 这里**不再改写 stream 字段** —— 客户端要流式就传流式,要非流式就传非流式。 + * 上一版在 openai-compat 分支硬编码 `stream: false`,是「流式请求收到 + * 非流式响应」这个 bug 的根因。 + */ +async function callUpstream({ entry, body, env, pool, stream }) { + const { provider, upstreamModel } = entry; -// ---- Provider calls ---- -async function callQwenOAuth(req, env, pool) { - const acc = await pool.pickAccount("qwen"); - if (!acc) { - throw new Error("no Qwen account available. Add one via /v1/auth/start"); + if (provider.auth === "qwen-oauth") { + const acc = await pool.pick(); + if (!acc) throw new AuthFailure("no_qwen_account"); + let account = acc.acc; + let accountId = acc.id; + if (acc.needsRefresh) { + try { + account = await pool.refresh(acc.id, acc.acc); + } catch (e) { + await pool.penalize(acc.id, acc.acc, e instanceof AuthFailure ? "auth_failure" : "network"); + throw e; + } + } + const upstream = { ...body, model: upstreamModel, stream }; + const resp = await fetch(`${provider.base}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${account.access_token}` + }, + body: JSON.stringify(upstream) + }); + return { resp, accountId, account }; } - const modelId = req.model.replace(/^qwen\//, ""); - const upstream = { - ...req, - model: modelId, - stream: !!req.stream - }; - const r = await fetch("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", { + + if (provider.protocol === "gemini") { + const key = resolveApiKey(provider, env); + if (!key) throw new AuthFailure(`missing_key:${provider.keyEnv}`); + const payload = toGeminiPayload(body); + const method = stream ? "streamGenerateContent" : "generateContent"; + const suffix = stream ? "&alt=sse" : ""; + const resp = await fetch( + `${provider.base}/models/${upstreamModel}:${method}?key=${encodeURIComponent(key)}${suffix}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + return { resp, gemini: true }; + } + + // openai 兼容:GLM / Cerebras / Groq / OpenRouter + const key = resolveApiKey(provider, env); + if (!key) throw new AuthFailure(`missing_key:${provider.keyEnv}`); + const upstream = { ...body, model: upstreamModel, stream }; + delete upstream.provider; + const resp = await fetch(`${provider.base}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": "Bearer " + acc.acc.access_token + Authorization: `Bearer ${key}`, + ...(provider.extraHeaders || {}) }, body: JSON.stringify(upstream) }); - if (!r.ok) { - const text = await r.text(); - if (r.status === 429 || r.status === 401) { - pool.markFailed(acc.id); - } - throw new Error("qwen upstream " + r.status + ": " + text); - } - return r; + return { resp }; } +/** 从请求体里挑出该转发给上游的字段,避免把客户端私有字段透传过去。 */ +export function buildUpstreamBody(body) { + const out = {}; + const pass = [ + "messages", + "temperature", + "top_p", + "max_tokens", + "max_completion_tokens", + "stop", + "presence_penalty", + "frequency_penalty", + "seed", + "response_format", + "tools", + "tool_choice", + "parallel_tool_calls", + "reasoning_effort", + "user" + ]; + for (const k of pass) if (body[k] !== undefined) out[k] = body[k]; + // 只保留 user/assistant/system/tool 角色,且 content 必须是字符串或数组 + out.messages = (Array.isArray(body.messages) ? body.messages : []) + .filter((m) => m && typeof m === "object" && m.role) + .map((m) => ({ ...m, content: m.content ?? "" })); + return out; +} + +// ---------- 候选链(跨 provider 回退)---------- + /** - * 把 OpenAI 格式的 messages 转成 Gemini 的 contents。 + * 决定这次请求可以依次尝试哪些 provider。 * - *

此前的实现只取 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 账号即可, +# 那条链路不需要任何静态密钥。