Skip to content

Commit daa81e7

Browse files
committed
B3.5: /healthz 池健康计数 + 多 Key 出口(渠道绑定 / 来源 IP 白名单)
/healthz(新增,无鉴权): - {status, service, version, credentials:{total,ready,cooling,paused,disabled}} - ready 复用调度器 Candidate.is_selectable,五类互斥且合计 = total;判定优先级 disabled → paused → cooling → ready。计划只列 4 类,补 paused 是因为 B3.1/Q33 已把「系统禁用」与「用户暂停」拆开,混桶会让计数口径失真。 - /health 保留为纯存活探针;/healthz 额外回答「池子还能用吗」,供监控在 ready=0(活着但用不了)时告警。 api_keys 增列 provider_binding / allowed_ips(SCHEMA_VERSION 12→13,走 _MIGRATION_COLUMNS,两列 NOT NULL DEFAULT '' 保证老库补列后行为不变): - 写入时校验:非法 CIDR 或未知渠道一律 400;白名单规范化(10.0.0.1 → /32)。 - 来源 IP 在鉴权当场判定(auth/access.py 纯函数):白名单为空 = 不限制; 非空但一条都解析不出 = 拒绝(fail closed);命中失败 403 并给出可读原因。 - 默认不信 X-Forwarded-For(客户端可写),仅 TRUST_PROXY=true 时采信且取 最后一个条目——只适用于「本服务前恰好一层受信反代」。 - 渠道绑定在 executor 收窄候选并置 forced:模型归属别家渠道时 400 并指出实际 归属,@Provider 与绑定冲突同样 400,目录未就绪时保守放行不打白工。 - api_key_user 由「返回用户名」升级为 ApiKeyPrincipal(含绑定);verify 保留为 只回用户名的薄封装,不动既有调用方。 前端:创建表单加渠道下拉与 IP 白名单输入,列表加「渠道 / 来源 IP」两列(空值 分别显示「自动」「不限制」)。文档同步 PROPOSAL Q37、TECHNICAL §3.11、 README/README.en 的配置表与新小节。 验证:ruff + pytest 1309 passed(行/分支 100%)+ 前端 tsc/vitest(147)/build; 真库副本 11→13 升级(存量 3 Key 补列后仍为空串)、/healthz 计数与池状态逐条 对齐、非法 CIDR/渠道 400、白名单外 403、绑定冲突 400;浏览器实测创建 + 列表渲染(含 7 列布局)。
1 parent 65ea631 commit daa81e7

27 files changed

Lines changed: 964 additions & 55 deletions

‎PROPOSAL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
| Q34 | 运行时配置热更 | **推翻 Q11 的「无设置页」**:新增 `runtime_settings` 表 + `RuntimeSettings` 覆盖层 + 管理台第 7 页「运行时配置」。白名单 13 项(默认模型 / 模型黑名单 / 到期两个窗口 / 会话粘性 TTL / 成长不可逆开关 / 成长与探测周期 / 两个节流窗口 / CB 聊天最小间隔 / 活跃上报开关与时点)改完立即生效,无需重启;**DB 覆盖值优先于 .env**,UI 与日志明示,可「恢复默认」清掉覆盖。启动期项(密钥 / 端口 / 数据目录 / 上游白名单)不进白名单——它们决定进程如何启动,运行期改只会让内存与磁盘静默分叉 |
4343
| Q35 | token 到期展示与预警 | `credentials` 增列 `token_expires_at` / `token_issued_at`(`SCHEMA_VERSION` 10→11)。到期时间优先取凭证显式 `expires_at`,缺失/非法时回落到 access token 的 **JWT `exp`**;签发时间取 JWT `iat`(新增渠道中立的 `provider/token_expiry.py`)——**实测 CodeBuddy 的 token 响应(OAuth 登录与刷新)不带任何到期字段**,只看 `expires_at` 会恒为 0,既让管理台看不到到期、也让 `needs_refresh` 永不触发(token 过期即被 401 硬禁用,且 revive 不自愈)。两边都取不到时为 0 = 未知,**不猜本地 TTL**。进度条满量程取 token 自身寿命(`exp - iat`),拿不到 `iat` 就不画条。老库不批量回填:列表读到时按需从密文派生,写回后走列值 |
4444
| Q36 | 积分变动流水 | 新增 `credit_events` 表(`SCHEMA_VERSION` 11→12),在额度探测写回的**同一事务**里比对余额、只增记一条。**计划原文要求 `source` 标注来源(签到/成长/对话),但实测三类证据都拿不到真实归因**:diff 只能看到区间净变化,这段区间里签到、成长领取与对话消耗可能同时发生;`growth_events` 无积分快照;上游接口本就不打日志。故 `source` **改为只表达归因已知度**——`observed`(常规探测区间)/ `sync`(首次建立基线),另加 `window_start` 记录变化覆盖时段,前端文案一律说「净变化」而非「签到 +N」。余额未变不记(避免每轮 0 行淹没);任一端未知仍记但 `delta` 为空(「变未知」是该追的异常,绝不量化成 0)。保留期与 `usage_events` 一致(90 天) |
45+
| Q37 | 池健康与多 Key 出口 | `GET /healthz` 返回 `{status, service, version, credentials:{total,ready,cooling,paused,disabled}}`(保留 `GET /health` 作纯存活探针):`ready` 复用调度器的 `Candidate.is_selectable` 口径,五类互斥且合计 = total——**计划原文只列 4 类**,但项目已明确区分「系统禁用」与「用户暂停」(见 Q33/B3.1),少一类会让计数对不上,故补 `paused`。`api_keys` 增 `provider_binding`(`codebuddy`/`trae`/空=自动)与 `allowed_ips`(逗号分隔 IP/CIDR,空=不限制),`SCHEMA_VERSION` 12→13;`deps.api_key_user` 升级为返回 `ApiKeyPrincipal`(用户名 + 绑定),并**在鉴权当场**判定来源 IP。IP 白名单**默认不信 `X-Forwarded-For`**(客户端可写),仅 `TRUST_PROXY=true` 时采信,且取 XFF **最后一个**条目(`$proxy_add_x_forwarded_for` 语义下那是紧邻受信代理所见地址)——因此只适用于「本服务前恰好一层受信反代」的部署。绑定渠道在 `executor` 收窄候选上游:模型归属别家渠道时 400 并给出实际归属,目录未就绪时保守放行。**不做**每 Key 配额/多租户(与 Q10 冲突) |
4546

4647
## 2. 目标与非目标
4748

@@ -259,7 +260,7 @@ DDL 以 [src/db/schema.sql](../src/db/schema.sql) 为准,补充实现细节见
259260

260261
## 7. API 契约
261262

262-
外部(API Key 鉴权):`POST /v1/chat/completions`(流式 + 非流式)、`POST /v1/responses`(Responses API,Codex CLI;与 chat 共用同一调度/选号/统计链路)、`GET /v1/models`(扁平模型名 + `providers` 字段)、`GET /v1/user/balance`(DeepSeek 兼容余额,读探测缓存聚合,不实时打上游)、`GET /health`。
263+
外部(API Key 鉴权):`POST /v1/chat/completions`(流式 + 非流式)、`POST /v1/responses`(Responses API,Codex CLI;与 chat 共用同一调度/选号/统计链路)、`GET /v1/models`(扁平模型名 + `providers` 字段)、`GET /v1/user/balance`(DeepSeek 兼容余额,读探测缓存聚合,不实时打上游)、`GET /health`(纯存活)、`GET /healthz`(存活 + 凭证池计数,无鉴权)。
263264

264265
管理台(会话 Cookie):凭证管理、API Key 管理、用量统计、Playground 等,admin 管凭证与全量统计,普通用户仅见自己的数据。凭证运维端点含 `POST /api/credentials/{id}/checkin`(签到)、`GET|POST /api/credentials/{id}/growth`(成长中心状态与手动执行,仅 CodeBuddy)。回调(无鉴权,TRAE 浏览器 302 不带 key):`GET /authorize`。
265266

‎README.en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ upstream channels, with a shared credential pool, unified scheduling, and per-us
2626
- **Hardened admin surface**: login rate limiting (global/IP/username + PBKDF2 concurrency cap), CSRF checks on writes, request body limits, security headers, Host allowlist
2727
- **Model catalog hygiene**: `MODEL_BLOCKLIST` filters placeholder/legacy models; cached model list as fallback when upstreams fail; credit rates and token limits passed through to `/v1/models` and the Playground
2828
- **Per-credential pause**: the admin credential menu's "Pause" removes one credential from *chat traffic only* — quota probing, token refresh, daily check-in, growth-center and activity-report tasks keep running (they only honor the system hard-disable `disabled`). Unlike "Disabled", which means the upstream rejected the session and requires re-login plus "Restore"
29+
- **Pool health endpoint**: `GET /healthz` reports `{status, service, version, credentials:{total,ready,cooling,paused,disabled}}` (unauthenticated) so monitors can alert when the pool is exhausted (`ready=0`, i.e. alive but unusable) — the bare `GET /health` stays as a pure liveness probe. The five buckets are mutually exclusive and sum to `total`, using the scheduler's own "selectable" definition
30+
- **Per-key routing policy**: an API key can be bound to one provider (`provider_binding`) and/or restricted to source IPs (`allowed_ips`, comma-separated IP/CIDR, empty = unrestricted). A bound key that requests a model owned by the other provider gets a 400 naming the real owner instead of silently re-routing or wasting an upstream call. Source IPs are checked at auth time; `X-Forwarded-For` is **ignored by default** (clients can forge it) and only honored with `TRUST_PROXY=true`, where the *last* XFF entry is used — so that flag fits exactly one trusted reverse proxy in front. No per-key quotas / multi-tenancy
2931
- **Runtime-configurable settings**: 13 settings (default model, model blocklist, both expiry windows, conversation-sticky TTL, irreversible growth actions, growth/probe intervals, both pacer bounds, CodeBuddy chat interval, activity-report toggle/hour) can be changed from the admin UI's *Runtime settings* page and take effect immediately — no restart. **DB overrides win over `.env`**; the page marks each row as "DB override" and offers "Reset to default" to fall back to `.env`. Startup-only knobs (`APP_SECRET`, `PORT`, `DATA_DIR`, allowlists) are deliberately excluded
3032
- **Token-expiry visibility**: each credential shows a remaining-lifetime bar plus its last-renewal time, red-flagged below `TOKEN_EXPIRY_WARNING_SECONDS`. The expiry comes from the credential's explicit `expires_at` and **falls back to the access token's JWT `exp`** — CodeBuddy's token responses carry no expiry at all (measured), so without the fallback the value is always 0 and CodeBuddy tokens would never pre-refresh (only hard-disabled on a 401). Last-renewal comes from the JWT `iat`, and the bar is scaled to that token's own lifetime (`exp - iat`) — CodeBuddy lives 50+ days and TRAE ~12, so a fixed scale would pin the former at 100%. Missing `iat` → no bar, numbers only. Both sources missing → shown as unknown, never guessed
3133
- **Credit-change log**: the per-credential "credit record" drawer lists the **net change between consecutive quota probes** (`credit_events`, same 90-day retention as usage detail). It deliberately does **not** attribute changes to check-in / growth / chat: upstream logs nothing for those calls, so a diff cannot tell who added the points. The UI says "net change", never "check-in +5". First probe only records a baseline (`sync`); unchanged balances are skipped; a balance going *unknown* still records a row with no delta, because that is an anomaly worth chasing rather than "no change"

‎README.md‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,44 @@ curl http://127.0.0.1:8000/v1/user/balance -H "Authorization: Bearer sk-你的ke
116116

117117
池内凭证从未探测成功时 `balance_known` 为 `false`(余额未知,不是 0)。管理台右上角「功能说明」按钮可随时查各功能入口。
118118

119-
### 暂停单个凭证
119+
### 池健康检查
120+
121+
`GET /health` 只回 `{"status":"ok"}`(进程存活,适合容器存活探针);`GET /healthz`
122+
额外给出凭证池计数,供外部监控在池子耗尽时提前告警——那是「服务活着但用不了」的
123+
状态,存活探针看不出来。两个端点都不鉴权。
124+
125+
```json
126+
{
127+
"status": "ok",
128+
"service": "coding2api",
129+
"version": "0.1.2",
130+
"credentials": {"total": 5, "ready": 4, "cooling": 1, "paused": 0, "disabled": 0}
131+
}
132+
```
120133

134+
五类计数互斥且合计 = `total`:`ready` 为当前可被调度选中的凭证(`disabled` /
135+
`paused` / `cooling` 依次优先归入各自桶,与调度器同一口径)。`ready=0` 时对话请求
136+
会直接返回 503,值得配置告警。
137+
138+
### API Key 的渠道绑定与来源 IP 白名单
139+
140+
创建 Key 时可以限定它只能走某个渠道、只能从某些 IP 调用,适合「按出口分发 Key」:
141+
一个 Key 给团队用,另一个只给某台服务器 / 某个客户端。
142+
143+
- **渠道绑定**:选 CodeBuddy 或 TRAE 后,该 Key 的请求只在对应渠道的凭证里选号;
144+
请求的模型属于另一渠道时直接 400 并指出实际归属(不会静默改道,也不会白打一次
145+
上游)。留空 = 自动(默认,跨渠道选健康凭证)。`模型@渠道` 的强制指定与绑定冲突
146+
时同样 400。
147+
- **来源 IP 白名单**:逗号分隔的 IP 或 CIDR(如 `203.0.113.9,10.0.0.0/8`),留空 =
148+
不限制。写入时会校验并规范化(`10.0.0.1` 存为 `10.0.0.1/32`),非法值当场 400。
149+
来源 IP 不在白名单内时返回 403。
150+
151+
**默认不采信 `X-Forwarded-For`**(该头由客户端可写,信它等于白名单形同虚设)。
152+
只有在 `TRUST_PROXY=true` 时才按 XFF 判定,且取**最后一个**条目——那是紧邻本服务的
153+
受信代理实际看到的地址。因此该开关只适用于「本服务前面恰好一层受信反代」的部署;
154+
多层反代或直连请保持默认 `false`。
155+
156+
### 暂停单个凭证
121157
凭证列表行内菜单的「暂停」只把该凭证摘出**对话流量**:后台的额度探测、token
122158
预刷新、每日签到、成长中心、活跃上报照常运行(这些任务只认系统硬禁用 `disabled`)。
123159
适合「这个号先别接聊天、但积分还要继续领」的场景;「取消暂停」立即放回池子。
@@ -206,6 +242,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
206242
| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL:优先按请求体显式会话标识(`conversation_id`/`conversationId`/`prompt_cache_key`,metadata 或顶层),无则回落消息前缀指纹,多轮请求固定用同一凭证(手动 pin 的凭证优先,粘性让位);带 `user_id` 时不派生前缀兜底键(避免并行对话误钉同一号);凭证出错仍会轮换,成功后重新粘定;`≤0` 关闭 |
207243
| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,hunyuan-image-*` | 模型列表黑名单(fnmatch,仅影响列表展示,直连指定不受影响);默认值按两边上游实测清单补入内部/不可用模型(`default` 零内容、`hunyuan-image-*` 400 11103),刻意不含 `*-volc` 与 `aquila`/`sagitta`/`seed-code-pro-0430`(实测可正常 chat)(见 TECHNICAL.md §3.5) |
208244
| `ALLOWED_HOSTS` | 空 | Host 白名单,防 DNS rebinding |
245+
| `TRUST_PROXY` | `false` | 是否采信 `X-Forwarded-For` 判定 API Key 的来源 IP(`allowed_ips` 白名单用)。默认关闭——该头由客户端可写;仅在「本服务前恰好一层受信反代」时开启,届时取 XFF 最后一个条目(见上文「API Key 的渠道绑定与来源 IP 白名单」) |
209246
| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | CodeBuddy 上游地址;改动时必须同时把它加入 `CODEBUDDY_ALLOWED_ENDPOINTS` |
210247
| `CODEBUDDY_ALLOWED_ENDPOINTS` | 官方两站(见 compose) | 上游端点白名单,真实 Token 只发往白名单内地址 |
211248
| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CodeBuddy 聊天最小间隔(秒),与 TRAE 共享节流;`0` 关闭 |

‎TECHNICAL.md‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ coding2api/
8888
│ │ ├── refresh.py # 每 60 分钟;REFRESH_SKEW_HOURS 窗口内预刷新
8989
│ │ ├── retention.py # 每 5 分钟:小时汇总重算(幂等)+ 90 天前明细 / 积分流水清理
9090
│ │ └── runner.py # 后台任务调度,接入应用生命周期
91+
│ ├── auth/
92+
│ │ └── access.py # API Key 来源 IP 白名单解析/判定(B3.5,纯函数)
9193
│ ├── stats/
9294
│ │ ├── collector.py # usage_events 写入(脱敏)+ 小时汇总双写/重算
9395
│ │ └── query.py # overview / by-provider / timeline / events 查询(前者读小时汇总,events JOIN credentials 带凭证昵称)
@@ -99,7 +101,7 @@ coding2api/
99101
│ ├── balance.py # GET /v1/user/balance(DeepSeek 兼容余额,读探测缓存聚合)
100102
│ ├── authorize.py # GET /authorize(TRAE 回调落点)
101103
│ ├── admin_credentials.py # 凭证 CRUD / toggle / pin / probe / checkin / 成长中心 / 账号切换
102-
│ ├── admin_keys.py # API Key CRUD
104+
│ ├── admin_keys.py # API Key CRUD(含渠道绑定 / IP 白名单校验,B3.5)
103105
│ ├── admin_settings.py # 运行时配置:GET 快照 / PUT 覆盖(admin + CSRF,B3.2)
104106
│ ├── admin_stats.py # 统计查询(overview / by-provider / timeline / model-timeline)
105107
│ ├── admin_auth.py # 登录 / 登出 / 会话;上游登录 start/poll/cancel
@@ -544,6 +546,56 @@ UI 与日志都必须明示「DB 覆盖 .env」,否则用户改 `.env` 不生
544546

545547
---
546548

549+
### 3.11 池健康与多 Key 出口(B3.5)
550+
551+
**`GET /healthz`**:返回 `{status, service, version, credentials:{total, ready, cooling,
552+
paused, disabled}}`,无鉴权;`GET /health` 保留为纯存活探针。两者分工明确——存活探针
553+
回答「进程还在吗」,`/healthz` 额外回答「凭证池还能用吗」,而 `ready=0` 是「活着但
554+
用不了」的状态,存活探针看不出来,需要在监控侧单独告警。
555+
556+
计数**复用调度器的 `Candidate.is_selectable`**(`CredentialRepository.pool_counts`),
557+
不另写 SQL——两套口径一旦分叉,健康检查会报出与实际选号不符的「可用数」。五类互斥、
558+
合计 = `total`,判定优先级 `disabled → paused → cooling → ready`(与调度器一致:系统
559+
禁用/用户暂停优先于冷却)。计划原文只列 4 类,补 `paused` 是因为项目已明确区分
560+
「系统禁用」与「用户暂停」(Q33/B3.1),少一类计数就对不上。
561+
562+
**多 Key 出口**:`api_keys` 增两列(`SCHEMA_VERSION` 12→13):
563+
564+
| 列 | 取值 | 空值语义 |
565+
|---|---|---|
566+
| `provider_binding` | `codebuddy` / `trae` | 空 = 自动(跨渠道选健康凭证,原行为) |
567+
| `allowed_ips` | 逗号分隔 IP/CIDR | 空 = 不限制来源 IP |
568+
569+
来源 IP 判定在 `deps.api_key_user`(鉴权**当场**判掉,不往上传递):
570+
571+
- **默认不采信 `X-Forwarded-For`**:该头由客户端可写,信它等于白名单形同虚设。
572+
只有 `TRUST_PROXY=true` 才采信,且取**最后一个**条目——`$proxy_add_x_forwarded_for`
573+
语义下那是紧邻的受信代理实际看到的地址,而第一个条目是客户端自己写的。
574+
因此该开关只适用于「本服务前恰好一层受信反代」,多层或直连必须保持关闭。
575+
- 策略是纯函数(`auth/access.py`,不依赖框架):写入时用 `normalize_allowed_ips`
576+
校验并规范化(`10.0.0.1` → `10.0.0.1/32`),非法值 400;读取路径宽松解析,脏条目
577+
丢弃,若整份白名单一条都解析不出则**拒绝**(fail closed,不因脏数据敞开)。
578+
- 白名单命中失败返回 **403**(`forbidden`)——Key 本身有效,是来源不被允许;
579+
与 401「凭证无效」区分,便于调用方排查。
580+
- `api_key_user` 由「返回用户名」升级为返回 `ApiKeyPrincipal(username, key_id,
581+
provider_binding)`(4 处出口调用同步调整)。`ApiKeyRepository.verify` 保留为
582+
只回用户名的薄封装,避免破坏既有调用方。
583+
584+
**渠道绑定的执行**在 `Executor._apply_binding`(`resolve_target` 内),与 `@provider`
585+
强制指定共用收窄路径:
586+
587+
- 绑定把候选固定为该渠道并置 `forced`(跳过模型目录收窄,归属已在此判定)。
588+
- 模型目录能证明模型属于别家渠道 → 400 并给出实际归属;这比落到「无可用凭证」
589+
503 更准确——前者是「你请求错了」,后者读起来像服务坏了。
590+
- 请求里 `@provider` 与绑定冲突 → 400,绝不静默改道。
591+
- 目录未就绪(拉取失败)时不做归属判断,保守放行给下游选号,避免用缓存外的
592+
信息误拒。
593+
- 流式同样生效:`preflight` 带绑定,冲突在 200 响应头发出前就 400。
594+
595+
**不做**每 Key 配额/多租户(与 Q10「不做配额」冲突)。
596+
597+
---
598+
547599
## 4. Provider 协议(Q16=A 细接口)
548600

549601
```python

‎docker-compose.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ services:
3838
# 才会真正进容器;漏透传会让 .env 里的设置静默失效(无任何报错)。
3939
# Host 白名单(防 DNS rebinding);空 = 本地回环 + PUBLIC_BASE_URL 主机
4040
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-}
41+
# 是否采信 X-Forwarded-For 判定来源 IP(API Key 的 allowed_ips 用)。
42+
# 默认 false;仅在本服务前恰好一层受信反代时开启,否则客户端可伪造。
43+
TRUST_PROXY: ${TRUST_PROXY:-false}
4144
# 上游端点白名单(逗号分隔);改 CODEBUDDY_API_ENDPOINT 时必须同时含它
4245
CODEBUDDY_ALLOWED_ENDPOINTS: ${CODEBUDDY_ALLOWED_ENDPOINTS:-https://copilot.tencent.com,https://www.codebuddy.ai}
4346
# CodeBuddy 聊天最小间隔(秒):与 TRAE 共享节流,0 关闭

0 commit comments

Comments
 (0)