Skip to content

Commit 3cd5a6f

Browse files
committed
feat(provider): 按渠道出站代理,支持 HTTP/SOCKS5(P1-6)
PROVIDER_PROXIES(启动期项,默认 "" 直连,行为不变)为每个渠道单独指定 出站代理:格式 `渠道=代理URL;渠道2=代理URL2`,渠道取 KNOWN_PROVIDERS, 协议 http/https/socks5/socks5h(SOCKS 由 httpx[socks] → socksio 提供)。 - provider/proxy.py:严格解析 parse_provider_proxies(未知渠道/非法协议/ 缺 = 一律 ValueError 启动失败,只容忍空段)+ build_client 工厂 (trust_env=False,不吃环境代理,只经显式 proxy= 生效) - 6 个 provider client + 3 个 OAuth 流增 proxy 参数,惰性构造 httpx 时透传; main.build_app / _upstream_auth 在装配处按渠道注入 - 覆盖该渠道全部出站请求:聊天、额度/模型拉取、后台任务(CB 子客户端复用 _short 自动跟随)、OAuth 登录 - 为什么启动期:代理作用于连接池,热更需重建在途连接池,风险/测试量大; 与上游端点同类 测试:tests/test_provider_proxy.py 30 例;后端 2103 passed / 100% 行分支。 文档(README/README.en/PROPOSAL/TECHNICAL)与 compose 同步。
1 parent 09e3d76 commit 3cd5a6f

20 files changed

Lines changed: 459 additions & 39 deletions

‎PROPOSAL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
| Q61 | API Key 模型白名单 + 到期时间(P0-3) | `api_keys` 增 `allowed_models TEXT NOT NULL DEFAULT ''`(fnmatch glob,逗号分隔,`''`=不限制)与 `expires_at INTEGER`(epoch 秒,`NULL`=永不过期),`SCHEMA_VERSION` 15→16,`_MIGRATION_COLUMNS` 幂等补列(老库补列后行为不变)。策略是纯函数(`auth/access.py`:`normalize_allowed_models` / `model_allowed`);`ApiKeyPrincipal` 增 `allowed_models`,`deps._api_key_principal` 在鉴权当场判过期(过期与「Key 不存在」统一 401 文案,不泄露可枚举信息),三个 /v1 出口(chat / responses / messages)在解析后校验白名单(省略模型名时按 `default_model` 判定,不能借空名绕过),`/v1/models` 也按白名单过滤展示。匹配大小写不敏感,`模型@渠道` 的后缀不参与匹配。**不做**每 Key 配额(与 Q37 结论一致)。管理台 UI 暂未加字段(可用 `POST /api/api-keys` 直接传 `allowed_models` / `expires_at`),后续按需补 |
7070
| Q62 | 跨渠道 fallback 兼容组(P1-5) | 主渠道全不可用时按「兼容组」回退到同义模型。动机来源见 `docs/competitor-comparison.md`(P1)。配置热更项 `MODEL_FALLBACK_GROUPS`(默认 `""` 关闭),格式 `组名=成员1,成员2;组名2=成员3`。纯函数 `model_resolver.parse_fallback_groups`(宽容解析,坏段跳过、重名后者覆盖)与 `ordered_fallback_chain`(组名只是**入口别名、不进链**;请求成员时该成员置首、组内其余成员按配置顺序跟随;大小写不敏感)。执行层 `Executor._fallback_chain` 把组解析成 `ModelTarget` 链:`@渠道` 与 API Key 渠道绑定(`target.forced`)**不参与回退**(用户已把渠道钉死);目录可用时剔除不在任何候选渠道登记的回退成员(「兼容组白名单」,避免给不存在的模型白打上游),目录未就绪则全部放行交给执行层兜底候选。`complete` 逐链项调 `_complete_model`(原逻辑整体下移),捕获 `NoHealthyCredential` / `InvalidRequest` 继续下一项,整链耗尽以最后一项错误抛出。流式 `stream` 逐链项跑 `_stream_loop`:**仅在尚未产出任何响应帧前**允许切换(已出帧后换模型会让客户端看到两个模型的混合输出),用私有信号 `_ModelExhausted` 在链内传递「本项未出帧即用尽」,整链耗尽才用最后链项的 `translator` 产出终帧;注入的 translator(responses / anthropic)跨链项复用同一实例。`preflight` 按整条链判断是否有注册上游。无 schema 变更,compose 透传 |
7171
| Q63 | 运维告警(P1-7) | 后台周期评估四类风险,命中落库 + 可选 webhook(用户选定「Webhook + 站内」)。**规则**(纯函数 `tasks/alerting.evaluate_alerts`,阈值 ≤0 即关闭该规则):① 池耗尽 `pool_empty`(`total>0` 且 `ready < ALERT_POOL_READY_MIN`,severity critical——服务活着但用不了,`/health` 探针看不出来);② 任务连续失败 `task_failed`(`TaskStatusStore` 新增连续失败计数,成功一轮清零,`failing(threshold)` 只列真跑过且达阈值的 key,避免「从未运行」误报);③ token 临近到期 `token_expiring`(`CredentialRepository.expiring_tokens` 收 `(now, now+窗口]`,`token_expires_at` 列 NULL 时从密文按需派生,0=未知不报,硬禁用凭证排除);④ 上游错误率 `error_rate`(`StatsCollector.window_error_rate` 读 `usage_events` 明细而非小时汇总——告警窗是分钟级,小时粒度要么整点才更新、要么跨小时口径错乱;样本数 ≥ `ALERT_ERROR_RATE_MIN_REQUESTS` 才判)。**投递**:每条命中落 `alert_events`(`AlertRepository.record/recent/last_ts/prune`,`SCHEMA_VERSION` 16→17,新表只进 schema.sql 无需迁移动作)供管理台「运维告警」页(admin-only `GET /api/alerts`)回看;配置 `ALERT_WEBHOOK_URL` 时逐地址 POST JSON,多个逗号分隔、全部成功才算 delivered。**静默去重**:同一 `(rule, scope)` 在 `ALERT_SILENCE_MINUTES` 窗内只落库/推送一次(`last_ts` 判),窗口过后仍命中再报一次——否则池耗尽这类持续状态会每轮刷屏。**隔离**:webhook 失败只记 `delivery_error`、绝不抛错(否则「webhook 挂了」会被误报成「告警任务连续失败」制造假信号)。**为什么落库**(与进程内的 `TaskStatusStore` 不同):告警的价值在「错过的那段时间发生了什么」,夜里池空/任务连挂,运维醒来要能看见;保留期由 `RetentionTask` 按明细同一策略(90 天)清理。**不迁**:告警恢复(resolved)事件、告警确认/静默操作(记录只读)。10 个热更项全部归到「运维告警」任务卡片(`task="alert"`),compose 透传;新增 `AlertTask` 接入 `TaskRunner`,与运行态共享同一 `TaskStatusStore`(自建 store 会让「任务连续失败」规则永远读到 0) |
72+
| Q64 | 按渠道出站代理(P1-6) | `PROVIDER_PROXIES`(启动期项,默认 `""` 直连,行为不变)为每个渠道单独指定出站代理:格式 `渠道=代理URL;渠道2=代理URL2`,协议 `http/https/socks5/socks5h`(SOCKS 由 `httpx[socks]` 提供)。**作用范围**:该渠道**全部**出站请求——聊天流、额度/模型拉取、后台任务(签到/成长/刷新/活跃上报,经共享 `_short` 客户端)与 OAuth 登录(`CodeBuddyOAuth`/`QoderOAuth`/`CodeArtsOAuth` 也收 `proxy`)。**注入点**:各 provider client 增 `proxy` 参数,惰性构造 `httpx.AsyncClient` 时经 `provider/proxy.build_client` 传入 `proxy=`;`main.build_app` 与 `_upstream_auth` 在装配处按渠道取值注入。**为什么启动期而非热更**:代理作用于连接池,运行中改值需重建在途连接池(涉及 6 个客户端 + 3 个 OAuth 流),风险与测试量都大;与上游端点同属启动期传输层配置。**为什么严格解析**(未知渠道/非法协议/缺 `=` 一律 `ValueError` 启动失败,只容忍空段):代理常带合规/隐私意图,「以为走了代理其实直连」比启动报错更糟;渠道集合由调用方传入(`KNOWN_PROVIDERS`),不硬编码。**为什么 `trust_env=False`**:既有约定,不吃 `HTTP_PROXY` 等环境变量,避免部署环境全局代理意外劫持带 Token 的上游请求;按渠道代理只经 `proxy=` 显式生效。无 schema 变更,compose 透传;新增 `tests/test_provider_proxy.py`(30 例) |
7273

7374
## 2. 目标与非目标
7475

‎README.en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ credential pool, unified scheduling, and per-user usage stats.
4040
- **Catalog persisted across restarts**: besides the in-process TTL cache, every successful fetch is snapshotted to `DATA_DIR/model_catalog.json` and **read back synchronously at startup**, so the model→channel alias table is usable in the service's first second. Flat model names no longer wait for the background warm-up (zen's free-model liveness probes take 10+ seconds) — previously a flat-name request in that window fanned out to every channel and really hit upstreams that do not serve the model (CodeBuddy answering `11102`, TRAE `4001`). Aliases are also published **per channel** as each fetch lands, and the fallback cache for a failing channel now survives a restart. A background task (`MODEL_CATALOG_MINUTES`, default 30, floor 5) keeps the catalog fresh even when nobody calls `/v1/models` — otherwise an API-only deployment (client caches its own model list) lets both the alias table and the snapshot go stale
4141
- **Cross-channel fallback groups**: `MODEL_FALLBACK_GROUPS` (hot-updatable, default off) maps a group name to interchangeable models, e.g. `fast=glm-4.6,glm-5`. When the requested model's channels are all unavailable, the executor retries the group's other members in order (each member runs the full pick / cooldown / rotation / affinity / stats path). Streaming switches models **only before any response frame has been emitted** — a half-sent reply cannot be rolled back. `@channel` and API-key channel binding disable fallback; members absent from the model catalog are skipped (catalog not ready → all allowed)
4242
- **Operations alerts**: a background task periodically evaluates four risks — **pool exhausted** (usable credentials below `ALERT_POOL_READY_MIN`), **a background task failing repeatedly** (`ALERT_TASK_FAILURES` consecutive failures, reset by one success), **a credential's token nearing expiry** (`ALERT_TOKEN_EXPIRY_HOURS`), and **an upstream error-rate spike** (`ALERT_ERROR_RATE_THRESHOLD` within `ALERT_ERROR_RATE_WINDOW_MINUTES`, over `usage_events` detail, with a minimum sample of `ALERT_ERROR_RATE_MIN_REQUESTS`). Every hit is persisted to `alert_events` and shown on the admin **Operations alerts** page; with `ALERT_WEBHOOK_URL` set (comma-separated for multiple) it also POSTs a JSON payload. A hit is reported once per `ALERT_SILENCE_MINUTES` window so a persistent condition does not flood the log or the chat channel; a failed webhook delivery is recorded but never fails the task. Records follow the same 90-day retention as request detail
43+
- **Per-channel outbound proxy**: `PROVIDER_PROXIES` (startup-only, default direct) routes a channel's outbound traffic through its own proxy, e.g. `codebuddy=http://127.0.0.1:7890;qoder=socks5://127.0.0.1:1080`. Channels are `codebuddy/trae/zen/kilo/qoder/codearts`; schemes are `http/https/socks5/socks5h` (SOCKS via `httpx[socks]`). It covers **all** of that channel's outbound requests — chat streaming, quota/model fetches, background tasks (check-in / growth / refresh / activity) and OAuth login. Parsing is strict: an unknown channel, an unsupported scheme or a malformed segment fails startup rather than silently going direct. Environment proxies (`HTTP_PROXY`, …) are never honoured (`trust_env=False`), so only an explicit entry here takes effect; changing it needs a restart (it binds the connection pools)
4344

4445
## Quick start
4546

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ curl http://127.0.0.1:8000/v1/chat/completions \
190190
- **累计全文 SSE**:上游流式 `text` 是**累计全文(替换语义)**而非增量,本服务在解析层还原为增量事件,对客户端透明。
191191
- **节流与并发**:真实账号渠道,默认 `CODEARTS_CHAT_MIN_INTERVAL=5`(独立节流器);上游硬限**每账号并发会话数 3**,故 pacer 另配在途上限 `CODEARTS_MAX_CONCURRENCY=3`。实测该限制更接近「每账号每约 60s 最多 3 个会话」(会话在流结束后仍滞留数十秒),故再叠加滑动窗口 `CODEARTS_REQUEST_WINDOW_SECONDS=60`,把突发也挡在 400 之前(`400 TM.00001041`)。均可在「任务与配置」热更。
192192

193+
### 按渠道出站代理(可选)
194+
195+
`PROVIDER_PROXIES` 给每个渠道单独指定出站代理,适合「某渠道需经代理才能访问」的场景:
196+
197+
```
198+
PROVIDER_PROXIES="codebuddy=http://127.0.0.1:7890;qoder=socks5://127.0.0.1:1080"
199+
```
200+
201+
- 格式 `渠道=代理URL;渠道2=代理URL2`,渠道取 `codebuddy/trae/zen/kilo/qoder/codearts`;协议支持 `http/https/socks5/socks5h`(SOCKS 由 `httpx[socks]` 提供);留空 = 全部直连(默认,行为不变)。
202+
- 作用于该渠道**全部出站请求**:聊天流、额度/模型拉取、后台任务(签到/成长/刷新/活跃上报)与 OAuth 登录,都走同一代理。
203+
- **启动期项**:代理作用于连接池,改后需重启后端;不做管理台热更(与上游端点同类)。
204+
- **严格解析**:未知渠道 / 非法协议 / 缺 `=` 一律启动失败——代理常带合规/隐私意图,「以为走了代理其实直连」比启动报错更糟。
205+
- 环境变量里的 `HTTP_PROXY` 等一律**不采信**(`trust_env=False`,防部署环境的全局代理意外劫持带 Token 的上游请求);只有这里显式配置才生效。
206+
193207
### Responses API(Codex CLI)
194208

195209
`POST /v1/responses` 提供 Responses 子集,供 [Codex CLI](https://github.com/openai/codex) 这类只走 Responses 的客户端接入。与 `/v1/chat/completions` 共用同一套选号 / 冷却 / 轮换 / 统计与会话粘性,只换入站映射与出口翻译(实现与取舍见 [TECHNICAL.md §3.7](TECHNICAL.md)):
@@ -379,6 +393,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
379393
| `QODER_ALLOWED_ENDPOINTS` | 国内 openapi+gateway + 国际版(见 compose) | Qoder 端点白名单,带 COSY 签名的请求只发往白名单内地址 |
380394
| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap 引擎地址;改动时必须同时把它加入 `CODEARTS_ALLOWED_ENDPOINTS` |
381395
| `CODEARTS_ALLOWED_ENDPOINTS` | snap 引擎 + STS + 福利网关 + 门户(见 compose) | CodeArts 端点白名单,AK/SK 签名请求只发往白名单内地址 |
396+
| `PROVIDER_PROXIES` | `""` | **按渠道出站代理**(启动期项,改后需重启)。格式 `渠道=代理URL;渠道2=代理URL2`,渠道取 `codebuddy/trae/zen/kilo/qoder/codearts`,协议支持 `http/https/socks5/socks5h`;留空 = 全部直连。作用于该渠道的聊天、额度/模型拉取、后台任务与 OAuth 登录全部出站请求(含 SOCKS5,依赖 `httpx[socks]`)。解析严格:未知渠道 / 非法协议 / 缺 `=` 一律启动失败,避免「以为走了代理其实直连」。`trust_env=False` 不受环境代理影响,只有这里显式配置才生效 |
382397
| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB/TRAE 聊天节流器的最小间隔(秒):按凭证分桶、**桶内允许并发**(同渠道同模型并发不排队、立即发出),只在同凭证「上一请求已结束、紧接着又来一个」的顺序连发时补足间隔;`0` 关闭 |
383398
| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen 聊天最小间隔(秒),独立于 CB/TRAE 的节流器,默认关闭。zen 是匿名免费层、无账号级频率风控;若与 CB/TRAE 共享,zen 会排在它们之后空等满 5s(并发/连发时每个请求 +5s),故不共享 |
384399
| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo 聊天最小间隔(秒),独立于 zen / CB/TRAE 的节流器,默认关闭。同为匿名免费层,与 zen 各自独立、互不排队 |
@@ -526,6 +541,7 @@ M0–M3 及后续迭代全部完成,`main` 分支可运行,当前版本 v0.2
526541
- **B7 竞品能力补齐(P0)**:Anthropic `/v1/messages` 出口(Claude Code)、上下文压缩(按模型目录输入上限裁剪过长对话)、API Key 模型白名单 + 到期时间(对比与迁移分档见 `docs/competitor-comparison.md`)
527542
- **B8 智能路由(P1)**:跨渠道 fallback 兼容组(`MODEL_FALLBACK_GROUPS`,主渠道全不可用时按组顺序回退、仅在未出帧前切换)
528543
- **B9 运维告警(P1)**:后台周期评估四类风险(凭证池耗尽 / 后台任务连续失败 / token 临近到期 / 上游错误率骤升),命中落 `alert_events` 并在管理台「运维告警」页回看,可选推送 webhook(`ALERT_WEBHOOK_URL`),同一告警在静默窗内只报一次
544+
- **B10 按渠道代理(P1)**:`PROVIDER_PROXIES` 为每个渠道单独指定出站代理(HTTP/SOCKS5),作用于该渠道全部出站请求(聊天 / 额度 / 模型 / 后台任务 / OAuth 登录);留空直连,默认行为不变
529545

530546
规划与实测收窄的完整记录见 `PROPOSAL.md`(Q1–Q54)与 `TECHNICAL.md`(§3.1–§3.17、§6.1–§6.4)。
531547

‎TECHNICAL.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PROPOSAL.md 定方向,本文档定实现。每个模块标注来源决策(Q
1212
| 包管理 | uv(venv + pyproject.toml + uv.lock) | 锁定版本见 `uv.lock` | T-Q1 |
1313
| Web | FastAPI + Uvicorn | 0.141 / 0.52 | Q2=A |
1414
| 配置 | pydantic-settings | 2.15 | T-Q3 |
15-
| HTTP | httpx 双客户端(流式/短请求分离) | 0.28.1 | T-Q4 |
15+
| HTTP | httpx 双客户端(流式/短请求分离);`httpx[socks]` 提供按渠道 SOCKS5 代理 | 0.28.1 / socksio 1.0 | T-Q4 |
1616
| 数据库 | 标准库 sqlite3(WAL)+ 手写 SQL | 内置 | T-Q2 |
1717
| 加密 | cryptography Fernet(凭证列) | 50.0 | T-Q2 |
1818
| 测试 | pytest + pytest-cov + respx + 文件化 fixture | pytest 9.1 / respx 0.23.1 | T-Q5 |
@@ -57,6 +57,7 @@ coding2api/
5757
│ │ └── access.py # API Key 来源 IP 白名单(B3.5,纯函数)
5858
│ ├── provider/
5959
│ │ ├── base.py # Provider 协议、Event、ErrKind、Quota、HealthScore
60+
│ │ ├── proxy.py # 按渠道出站代理(P1-6):解析 PROVIDER_PROXIES + build_client
6061
│ │ ├── codebuddy/
6162
│ │ │ ├── client.py # 上游 HTTP + SSE 流 + 额度探测
6263
│ │ │ ├── events.py # OpenAI 风格 SSE → Event
@@ -455,7 +456,7 @@ response.completed | response.incomplete
455456

456457
| 类别 | 例子 | 位置 | 变更方式 |
457458
|---|---|---|---|
458-
| 启动期不可变项 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / `CODEBUDDY_ALLOWED_ENDPOINTS` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单**,管理台改不了 |
459+
| 启动期不可变项 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / `CODEBUDDY_ALLOWED_ENDPOINTS` / `PROVIDER_PROXIES` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单**,管理台改不了 |
459460
| 运行时可覆盖项 | 见 `runtime_settings.HOT_SETTINGS`(35 项) | `RuntimeSettings` 覆盖层 | 管理台改,立即生效 |
460461

461462
启动期项拒绝热更的原因:它们决定进程如何启动(监听地址、加密密钥、上游白名单),运行期变更只会让「当前进程」与「磁盘配置」静默分叉,而分叉后的行为无法从任一处推断。
@@ -810,6 +811,22 @@ UA 版本走 `ZEN_OPENCODE_VERSION` 配置(上游改阈值改 env,不硬编
810811

811812
---
812813

814+
### 3.23 按渠道出站代理(P1-6)
815+
816+
**动机**:某些渠道可能只在特定网络路径可达(区域限制 / 需经代理),而 `trust_env=False` 的既有约定让 `HTTP_PROXY` 等环境代理一律失效;需要一个**按渠道**、显式可控的出口。
817+
818+
**配置**(`PROVIDER_PROXIES`,启动期项,默认 `""` 直连):`渠道=代理URL;渠道2=代理URL2`,渠道取 `KNOWN_PROVIDERS`(`codebuddy/trae/zen/kilo/qoder/codearts`),协议 `http/https/socks5/socks5h`(SOCKS 由 `httpx[socks]` → `socksio` 提供)。
819+
820+
- **纯函数层**(`provider/proxy.py`):`parse_provider_proxies(raw, known)` **严格**解析——未知渠道 / 非法协议 / 缺 `=` / 空 URL 一律 `ValueError`,只忽略空段(容忍结尾 `;`),渠道名小写、重名后者覆盖。为什么严格而非宽容(对比 §3.21 fallback 组的宽容):代理常带合规 / 隐私意图,「以为走了代理其实直连」是静默的安全问题,启动报错远好过静默直连。渠道集合由调用方传入,不硬编码。
821+
- **工厂层**:`build_client(timeout, proxy)` 统一构造 `httpx.AsyncClient(timeout=..., trust_env=False, proxy=proxy)`。`trust_env=False` 是既有约定(不吃环境代理,防部署环境全局代理意外劫持带 Token 的上游请求);按渠道代理只经显式 `proxy=` 生效。
822+
- **注入点**:各 provider client(`TraeClient`/`CodeBuddyClient`/`ZenClient`/`KiloClient`/`QoderClient`/`CodeArtsClient`)与三个 OAuth 流(`CodeBuddyOAuth`/`QoderOAuth`/`CodeArtsOAuth`)增 `proxy` 参数,惰性构造 httpx 客户端时透传。`main.build_app` 在 provider 装配处、`_upstream_auth` 在 OAuth 装配处按渠道取值注入。
823+
- **覆盖面**:该渠道**全部**出站请求——聊天流、额度 / 模型拉取、后台任务(签到 / 成长 / 刷新 / 活跃上报:CodeBuddy 的这些子客户端复用 `client._short`,代理自动跟随)与 OAuth 登录。
824+
- **为什么启动期而非热更**:代理作用于连接池,运行中改值需重建在途连接池(涉及 6 个客户端 + 3 个 OAuth 流),风险与测试量都大;与上游端点同属启动期传输层配置(§3.1 端点是启动期)。
825+
826+
无 schema 变更,compose 透传 `PROVIDER_PROXIES`;`tests/test_provider_proxy.py`(30 例)覆盖解析 / 工厂 / 6 客户端 + 3 OAuth 透传 / main 装配接线。
827+
828+
---
829+
813830
## 4. Provider 协议(Q16=A 细接口)
814831

815832
```python

0 commit comments

Comments
 (0)