feat: DashScope 模型代理服务初始提交

支持 Anthropic 和 OpenAI 兼容协议的模型池反代,自动切换免费额度耗尽的模型。
This commit is contained in:
oy-paddy
2026-06-11 15:05:25 +08:00
commit d1021a00a0
18 changed files with 3140 additions and 0 deletions
+455
View File
@@ -0,0 +1,455 @@
# Q/A 与方案决策记录
本文记录本地 DashScope 反代服务的需求讨论、关键 Q/A、方案选择和最终共识,方便后续继续维护。
## 1. 初始需求
用户有多个 DashScope Anthropic 兼容模型 ID,希望对外只暴露一个固定 URL 和一个代理 key。
上游地址:
```text
https://dashscope.aliyuncs.com/apps/anthropic
```
典型免费额度耗尽错误:
```json
{
"request_id": "xxx",
"code": "AccessDenied",
"message": "The free tier of the model has been exhausted. If you wish to continue access the model on a paid basis, please disable the \"use free tier only\" mode in the management console."
}
```
判断条件:
```text
HTTP 状态码 = 403
code = AccessDenied
message 包含 free tier 和 exhausted
```
最终目标:
1. 客户端只配置一个本地代理 URL。
2. 客户端只使用一个代理 key。
3. 真实 DashScope API Key 不暴露给客户端。
4. 模型进入冷却时自动切换,不需要手动改 model。
## 2. 技术选型
### Q:用什么框架搭建?
A:用户指定使用 Hono.js。
最终方案:
```text
Node.js + TypeScript + Hono.js
```
原因:
- 项目小,Hono 足够轻量。
- Node 20+ 原生支持 `fetch` 和 Web Stream。
- 适合做 Anthropic 兼容 HTTP 反代。
## 3. 项目位置
### Q:放在哪里?
A:单独新建项目,不放到原 uni-app 项目里。
最终路径:
```text
proxy-server
```
## 4. 对外接口
### Q:本地对外 URL 是什么?
A:默认根据 `.env``PORT` 决定。
当前给 Pi CLI 使用的配置:
```env
PORT=3300
PROXY_API_KEY=sk-001
```
本地 URL
```text
http://localhost:3300
```
Anthropic Messages 接口:
```text
http://localhost:3300/v1/messages
```
客户端请求头二选一:
```http
Authorization: Bearer sk-001
```
或:
```http
x-api-key: sk-001
```
## 5. 代理是否修改 Prompt
### Q:代理有没有加提示词、改 system 或 messages
A:没有。
当前代理只修改请求体里的:
```json
"model"
```
不会修改:
```text
system
messages
tools
tool_choice
thinking
max_tokens
temperature
任何 prompt 内容
```
Pi CLI 中模型回答“我是 Claude,由 Anthropic 开发”不代表代理加了提示词。更可能来自:
1. Pi CLI 自己的系统提示词和代理框架上下文。
2. DashScope Anthropic 兼容接口下模型的身份回答习惯。
## 6. 单个真实 key + 多模型阶段
### Q:最早版本怎么切换?
A:最早版本只有一个真实 DashScope key,多个 `MODEL_IDS`。现在即使只有一个真实 key,也统一写在 `DASHSCOPE_API_KEYS` 里。
配置示例:
```env
DASHSCOPE_API_KEYS=sk-xxx
MODEL_IDS=qwen3.7-max,qwen-plus,qwen-max
```
切换逻辑:
1. 使用当前模型转发。
2. 如果命中免费额度耗尽 403,冷却这个模型。
3. 同一个请求改用下一个模型重试。
4. 所有模型都不可用时返回 `503`
## 7. 多 Key + 多模型阶段
### Q:多 key 的情况下怎么切?
A:所有 key 共用同一组 `MODEL_IDS`。切换规则是 key 优先。
最终配置:
```env
DASHSCOPE_API_KEYS=sk-key-1,sk-key-2,sk-key-3
MODEL_IDS=model-a,model-b,model-c
```
最终切换规则:
1. 优先使用当前 key。
2. 当前 key 下某个模型触发免费额度耗尽 403,只冷却这个 `key + model` 组合。
3. 继续尝试当前 key 下的下一个模型。
4. 只有当前 key 下所有模型都不可用,才切换到下一个 key。
5. 所有 key+model 组合都不可用时返回 `503`
这是本项目最重要的调度共识。
## 8. 冷却时间
### Q:冷却多久?
A:用户确认冷却时间为 1 个月。
最终配置:
```env
MODEL_COOLDOWN_SECONDS=2592000
```
说明:
```text
2592000 秒 = 30 天
```
## 9. 状态持久化选择
### Q:为什么引入持久化状态?
A:服务重启后不能丢失冷却状态。
如果只用内存,重启后所有模型都会重新变成可用,可能继续打到已经进入免费额度冷却期的 key+model。
### Q:选什么存储?
A:最终选 JSON 状态文件。
原因:
- 本地部署,不需要额外安装 MySQL/Postgres/Redis。
- 只有冷却状态和调度游标,不需要关系型查询能力。
- 一个文件即可持久化,适合本地工具。
- 避免 `better-sqlite3` 原生 binding 在 Windows 上安装或打包失败。
- 默认路径清晰,方便备份和检查。
最终配置:
```env
STATE_PATH=./data/proxy-state.json
```
## 10. 状态文件隐私原则
### Q:状态文件会存真实 DashScope key 吗?
A:不会。
状态文件只存:
```text
SHA-256(key)
```
也就是 `key_hash`
真实 key 只存在 `.env` 里。
## 11. 状态文件结构
### Q`modelState` 存什么?
A:每条记录对应一个 DashScope key 与 `MODEL_ID` 组合。
例如:
```env
DASHSCOPE_API_KEYS=key1,key2
MODEL_IDS=modelA,modelB,modelC
```
会生成:
```text
2 * 3 = 6 条 modelState 记录
```
字段详见:
```text
docs/state-file.md
```
### Q`runtimeState` 存什么?
A:只存调度游标,不存 key、不存 prompt、不存错误详情。
当前记录类型:
```text
key_cursor
model_cursor:<key_hash>
```
含义:
- `key_cursor`:当前优先使用 `.env` 里第几个 `DASHSCOPE_API_KEYS` 条目。
- `model_cursor:<key_hash>`:某个 key 下当前优先使用 `MODEL_IDS` 里的第几个模型。
## 12. 游标策略共识
### Q:为什么 `model_cursor` 不直接存 modelId
A:当前版本先按顺序来,使用游标绑定 `.env` 中的配置顺序。
共识:
1. 当前版本保持 `key_cursor``model_cursor:<key_hash>`
2. 它们都依赖 `.env``DASHSCOPE_API_KEYS``MODEL_IDS` 的顺序。
3. 不要随便调整这两个列表的顺序。
4. 如果后续调整顺序,建议同时删除或重置 `data/proxy-state.json`
5. 后续如果需要更稳,再升级为 `current_key_hash``current_model_id:<key_hash>`
## 13. Pi CLI 接入
### Q:怎么让 Pi CLI 使用本地代理?
A:在 `~/.pi/agent/models.json` 增加本地 provider。
Provider 名称:
```text
local-dashscope-proxy
```
Base URL
```text
http://127.0.0.1:3300
```
API 类型:
```text
anthropic-messages
```
代理 key
```text
sk-001
```
同时在 `~/.pi/agent/settings.json` 设置默认 provider
```text
defaultProvider = local-dashscope-proxy
defaultModel = qwen3.7-max
```
使用方式:
```bash
pi
```
或:
```bash
pi --provider local-dashscope-proxy --model qwen3.7-max
```
## 14. 清理旧 Pi Provider
### Q:为什么删除 `dashscope` provider 下的两个模型?
A:用户希望 Pi CLI 里不再出现旧的直接 DashScope provider
```text
qwen3.7-max [dashscope]
qwen3.7-max[1M] [dashscope]
```
已删除 `~/.pi/agent/models.json` 中的 `dashscope` provider,保留本地代理 provider。
## 15. 当前模型列表
当前 `MODEL_IDS` 共 14 个:
```text
qwen3.7-max-2026-06-08
qwen3.7-plus-2026-05-26
qwen3.7-plus
qwen3.7-max-preview
qwen3.7-max-2026-05-17
qwen3.7-max
qwen3.7-max-2026-05-20
qwen3.6-27b
qwen3.6-max-preview
qwen3.6-flash
qwen3.6-35b-a3b
qwen3.6-flash-2026-04-16
qwen3.6-plus
qwen3.6-plus-2026-04-02
```
数据库已同步初始化这些模型状态。
## 16. 请求日志
### Q:Hono 是否打印请求日志?
A:已增加统一请求日志中间件。
日志示例:
```text
[request] GET /health status=200 duration=2ms bytes=- proxyKey=- model=- attempts=- ua="curl/8.7.1"
```
成功代理 `/v1/messages` 时会打印:
```text
proxyKey=<key hash 前缀>
model=<实际使用的模型 ID>
attempts=<本次请求尝试次数>
```
不会打印:
```text
Authorization
x-api-key
DASHSCOPE_API_KEYS
请求 body
messages / prompt
```
## 17. 当前文档
使用说明:
```text
docs/usage.md
```
状态文件说明:
```text
docs/state-file.md
```
Q/A 与方案决策记录:
```text
docs/qa-and-decisions.md
```
## 18. OpenAI Chat Completions 入口
### Q:为什么又增加 OpenAI 协议入口?
APi CLI 的官方 DeepSeek provider 使用 `openai-completions`,说明 Pi 对 OpenAI Chat Completions 兼容链路支持成熟。为了让本地代理也能按同一类方式接入 Qwen,新增:
```text
POST /v1/chat/completions
GET /v1/models
```
上游默认使用:
```text
OPENAI_UPSTREAM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
```
当前保留原有 Anthropic Messages 入口:
```text
POST /v1/messages
```
两条链路共用同一套 DashScope key 池、模型池、冷却状态和免费额度耗尽切换逻辑。
服务端是反代转发,不需要安装 `openai` SDK`openai` SDK 只适合客户端调用代理时使用。
+116
View File
@@ -0,0 +1,116 @@
# 状态文件说明
本服务使用 JSON 文件保存模型冷却状态。这样服务重启后,已经进入冷却期的 DashScope key 与 `MODEL_ID` 组合不会丢失。
默认状态文件路径:
```env
STATE_PATH=./data/proxy-state.json
```
状态文件不会保存明文 DashScope API Key。代码会对每个 key 计算 SHA-256,并把结果写入 `modelState`
## 文件结构
状态文件是一个版本化 JSON 对象:
```json
{
"version": 1,
"modelState": {},
"runtimeState": {}
}
```
## modelState
`modelState` 保存每个 DashScope key 与 `MODEL_ID` 组合的状态。
例如配置:
```env
DASHSCOPE_API_KEYS=key1,key2
MODEL_IDS=modelA,modelB,modelC
```
那么 `modelState` 会有 `2 * 3 = 6` 条记录。
字段说明:
| 字段 | 说明 |
| --- | --- |
| `keyHash` | DashScope API Key 的 SHA-256 hash。用于区分不同 key,但不保存明文 key。 |
| `modelId` | 模型 ID,来自 `MODEL_IDS`。 |
| `cooldownUntil` | 冷却截止时间,Unix 毫秒时间戳。`0` 表示没有冷却。如果这个值大于当前时间,该 key+model 组合不可用。 |
| `failureCount` | 该 key+model 组合触发免费额度耗尽 403 的次数。 |
| `lastError` | 最近一次免费额度耗尽时,上游返回的错误信息。 |
| `lastUsedAt` | 最近一次成功使用该 key+model 组合的时间,Unix 毫秒时间戳。 |
| `updatedAt` | 最近一次状态更新时间,Unix 毫秒时间戳。 |
## runtimeState
`runtimeState` 保存调度游标,避免服务每次重启后都从第一个 key、第一条 model 开始打。
当前会使用的记录:
| `name` | 说明 |
| --- | --- |
| `key_cursor` | 当前优先使用的 DashScope API Key 下标。 |
| `model_cursor:<key_hash>` | 某个 key 当前优先使用的模型下标。 |
每条记录包含:
| 字段 | 说明 |
| --- | --- |
| `value` | 状态值。当前主要保存数字游标,但统一用文本存储。 |
| `updatedAt` | 最近一次更新时间,Unix 毫秒时间戳。 |
## 写入策略
状态变更会同步写入文件。写入时先写临时文件,再用 `rename` 替换正式状态文件,降低进程中断时写坏文件的风险。
如果启动时发现状态文件不是有效 JSON,或结构不符合当前版本,服务会把原文件重命名为:
```text
proxy-state.json.bak.<timestamp>
```
然后创建新的空状态。
## 调度规则
当前实现是“key 优先”的切换策略:
1. 先使用 `key_cursor` 指向的 key。
2. 在这个 key 内部,从该 key 的 `model_cursor` 指向的模型开始尝试。
3. 如果某个模型返回免费额度耗尽 403,只冷却这个 `keyHash + modelId` 组合。
4. 继续尝试同一个 key 下的下一个可用模型。
5. 只有当前 key 下所有模型都不可用时,才切换到下一个 key。
6. 如果所有 key+model 组合都不可用,服务返回 `503`
## 冷却规则
默认冷却时间是 1 个月:
```env
MODEL_COOLDOWN_SECONDS=2592000
```
当某个 key+model 组合进入冷却:
```text
cooldownUntil = 当前时间 + MODEL_COOLDOWN_SECONDS
failureCount = failureCount + 1
lastError = 上游错误信息
updatedAt = 当前时间
```
当某个 key+model 组合成功返回:
```text
lastUsedAt = 当前时间
lastError = null
updatedAt = 当前时间
```
成功返回不会清零 `failureCount`,这个字段保留历史失败次数。
+338
View File
@@ -0,0 +1,338 @@
# 使用说明
这个项目是一个本地 DashScope Anthropic 兼容反代服务。客户端只需要配置一个本地 URL 和一个代理 key,真实 DashScope key 和模型池都放在服务端。
## 1. 安装依赖
```bash
pnpm install
```
运行时依赖不包含原生编译模块,Windows 上首次安装不需要额外安装 C++ 编译环境。
## 2. 配置 .env
复制配置模板:
```bash
cp .env.example .env
```
核心配置:
```env
PORT=3300
PROXY_API_KEY=sk-001
DASHSCOPE_API_KEYS=sk-key-1,sk-key-2,sk-key-3
UPSTREAM_BASE_URL=https://dashscope.aliyuncs.com/apps/anthropic
OPENAI_UPSTREAM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDS=qwen3.7-max,qwen-plus,qwen-max
MODEL_COOLDOWN_SECONDS=2592000
STATE_PATH=./data/proxy-state.json
UPSTREAM_AUTH_MODE=authorization
CORS_ORIGIN=*
```
说明:
| 配置 | 说明 |
| --- | --- |
| `PORT` | 本地服务端口。你当前给 Pi CLI 用的是 `3300`。 |
| `PROXY_API_KEY` | 客户端调用本地代理时使用的 key。 |
| `DASHSCOPE_API_KEYS` | 多个真实 DashScope API Key,英文逗号分隔。 |
| `UPSTREAM_BASE_URL` | DashScope Anthropic 兼容接口上游地址。 |
| `OPENAI_UPSTREAM_BASE_URL` | DashScope OpenAI Chat Completions 兼容接口上游地址。 |
| `MODEL_IDS` | 所有 key 共用的模型 ID 列表,英文逗号分隔。 |
| `MODEL_COOLDOWN_SECONDS` | 触发免费额度耗尽后冷却多久。当前默认 `2592000` 秒,也就是 30 天。 |
| `STATE_PATH` | JSON 状态文件路径。用于保存冷却状态和调度游标。 |
## 3. 初始化状态文件
启动服务时会自动创建状态文件。
也可以手动初始化:
```bash
pnpm build
node dist/index.js
```
看到服务启动成功后按 `Ctrl+C` 停止即可。状态文件默认会生成在:
```text
data/proxy-state.json
```
状态文件结构见:
```text
docs/state-file.md
```
## 4. 启动服务
开发模式:
```bash
pnpm dev
```
生产模式:
```bash
pnpm build
pnpm start
```
启动成功后会看到类似日志:
```text
[proxy] listening on http://localhost:3300
[proxy] upstream base: https://dashscope.aliyuncs.com/apps/anthropic
[proxy] openai upstream base: https://dashscope.aliyuncs.com/compatible-mode/v1
[proxy] api keys loaded: 3
[proxy] models per key: 3
[proxy] applied model ids:
[proxy] - qwen3.7-max
[proxy] - qwen-plus
[proxy] - qwen-max
[proxy] reminder: 请在 DashScope/百炼控制台为以上模型开启“模型用完即停”。
[proxy] state file: ./data/proxy-state.json
```
## 5. 客户端调用
本地代理地址:
```text
http://localhost:3300
```
Anthropic Messages 接口:
```text
http://localhost:3300/v1/messages
```
请求头可以用二选一:
```http
Authorization: Bearer sk-001
```
或:
```http
x-api-key: sk-001
```
测试请求:
```bash
curl http://localhost:3300/v1/messages \
-H "Authorization: Bearer sk-001" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "client-model-will-be-overwritten",
"max_tokens": 256,
"messages": [
{
"role": "user",
"content": "你好"
}
]
}'
```
请求体里的 `model` 会被代理覆盖成当前可用模型。
OpenAI Chat Completions 接口:
```text
http://localhost:3300/v1/chat/completions
```
OpenAI-compatible base URL
```text
http://localhost:3300/v1
```
测试请求:
```bash
curl http://localhost:3300/v1/chat/completions \
-H "Authorization: Bearer sk-001" \
-H "Content-Type: application/json" \
-d '{
"model": "client-model-will-be-overwritten",
"messages": [
{
"role": "user",
"content": "你好"
}
],
"stream": true
}'
```
查看 OpenAI 风格模型列表:
```bash
curl http://localhost:3300/v1/models \
-H "Authorization: Bearer sk-001"
```
本服务端只是反代转发,使用 Node 原生 `fetch` 即可,不需要安装 `openai` SDK。`openai` SDK 是客户端接入时才需要的。
## 6. Pi CLI 接入
Pi CLI 的 provider 已配置为:
```text
local-dashscope-proxy
```
指向:
```text
http://127.0.0.1:3300
```
默认使用:
```text
provider: local-dashscope-proxy
model: qwen3.7-max
```
使用前先启动代理服务:
```bash
pnpm dev
```
然后运行:
```bash
pi
```
也可以显式指定:
```bash
pi --provider local-dashscope-proxy --model qwen3.7-max
```
查看 Pi 是否识别 provider
```bash
pi --list-models local --offline
```
如果想让 Pi CLI 按 OpenAI Chat Completions 协议接入,可以在 `~/.pi/agent/models.json` 增加类似 provider
```json
{
"providers": {
"local-dashscope-openai-proxy": {
"baseUrl": "http://127.0.0.1:3300/v1",
"api": "openai-completions",
"apiKey": "sk-001",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": true,
"maxTokensField": "max_tokens",
"thinkingFormat": "qwen"
},
"models": [
{
"id": "qwen3.7-max",
"name": "Qwen 3.7 Max (Local OpenAI Proxy)",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 131072,
"maxTokens": 32768,
"thinkingLevelMap": {
"off": null,
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "high"
},
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
}
]
}
}
}
```
## 7. 查看服务状态
健康检查:
```bash
curl http://localhost:3300/health
```
模型池状态:
```bash
curl http://localhost:3300/models/status \
-H "Authorization: Bearer sk-001"
```
返回中重要字段:
| 字段 | 说明 |
| --- | --- |
| `totalKeys` | 当前加载的 DashScope key 数量。 |
| `modelsPerKey` | 每个 key 下的模型数量。 |
| `totalSlots` | key+model 总组合数。 |
| `availableSlots` | 当前可用的 key+model 组合数。 |
| `models[].keyHash` | key hash 前缀,用于区分 key,不是明文 key。 |
| `models[].id` | 模型 ID。 |
| `models[].available` | 当前组合是否可用。 |
| `models[].cooldownUntil` | 冷却截止时间。 |
| `models[].failureCount` | 累计触发免费额度耗尽次数。 |
## 8. 切换规则
服务按这个规则自动切换:
1. 优先使用当前 key。
2. 当前 key 下某个模型触发免费额度耗尽 403,只冷却这个 key+model。
3. 继续尝试当前 key 的下一个模型。
4. 当前 key 下所有模型都冷却后,才切换到下一个 key。
5. 所有 key+model 都冷却时,返回 `503`
## 9. 常见操作
重新生成状态文件:
```bash
rm data/proxy-state.json
pnpm dev
```
只想清空冷却状态,也可以删除状态文件后重新启动。注意这会丢失所有历史冷却和失败计数。
查看状态文件:
```text
data/proxy-state.json
```
状态文件和自动备份文件不需要提交。