From 5530912d32bcc1fb30f4fd3a2d1e1fe26811a8e9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 18:46:59 +0800 Subject: [PATCH 01/42] chore(version): 1.6.0 -> 1.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中版本跃迁:新开 release/v1.7.x 承载 1.7.x 全部 patch。 v1.5.x 已发到 v1.6.0(tag),不再追加。 --- cmd/gui/package-lock.json | 4 ++-- cmd/gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 8eb9d01..0c5ef7c 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.4.2", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.4.2", + "version": "1.7.0", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", diff --git a/cmd/gui/package.json b/cmd/gui/package.json index b5f71ae..eee9bfb 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.4.2", + "version": "1.7.0", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From c51066f0b6f31a86a58abbfba63b4fa08708c5db Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 19:02:13 +0800 Subject: [PATCH 02/42] =?UTF-8?q?refactor(quota):=20=E9=85=8D=E9=A2=9D?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E6=A8=A1=E5=9E=8B=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=95=B4=E9=92=A5=E6=80=BB=E9=85=8D=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户明确要求:配额应当是密钥对应的**每个模型的单独配额**,而非整体配额。 ## 语义变更 删除 GWKey.TokenQuota / ReqQuota / Period / Hours(整钥总额)。 ModelScope 新增 ReqQuota —— 请求数配额下沉到每条模型范围。 现在:每条 models[] 各自带 token 配额 + 请求数配额 + 重置周期, 彼此独立。一个模型用满只影响该模型。 ★ 为什么不保留整钥总额:它会让「把 A 模型的额度挪给 B」变成一次全局 重分配;按模型独立计费则每个模型各自可控,运维能直接看出哪个模型在吃预算。 ## 连带改动 - checkQuota 合并 key 级与 scope 级判定;checkKeyQuotaRetry 整体删除 (顺带修掉上轮遗留的双重判定:入口不再先判空再重算) - core:CreateKeyWithQuota / UpdateKeyWithQuota / ApplyQuota 全部删除, 改由 ValidateScopeQuotas 校验每条 scope 的配额 - admin key:scope 上的配额不强制(admin 的 scope 仍限制模型范围, 但不强制配额)—— 否则管理员会把自己锁在门外 - /api/v1/keys 不再回显 key 级配额字段(scope 里已含) - WebUI:删除整钥配额徽标 / 「配额」按钮 / 创建表单的配额组 / putScope 的整钥回传;模型砖块与范围编辑器新增「请求数配额」输入, 徽标显示 `1.0K 77×·1h`(未设配额显示 ∞) ## 判据 - TestOneModelsQuotaDoesNotBlockAnother 是本次核心保证。 ★ 它第一版是**假判据**:m2 从不消耗,key-wide 计数器与 m1 自己的计数器 读数恰好相同,退回 key-wide 仍通过。变异测试抓到后改为「先用 m2 花掉 远超 m1 配额的量,再验证 m1 仍可用」—— 这样两种设计才可区分。 - TestUncappedModelNeverBlocked / TestAdminKeyScopesAreNotEnforced 新增 - UI 契约判据重写:整钥配额界面必须彻底消失(13 个符号)、 scope 编辑器必须往返 req_quota、putScope 只发 scope 列表 - 错误消息点名具体模型(TestKeyAPIRejectionNamesTheModel) - 3/3 变异全被抓 实测(真实进程 + 浏览器):m2 配额 500000 连打 25 次全成功, m1 配额 1000 立即 429「token quota exceeded for "m1" (4315/1000)」, 此后 m2/m3 仍 200。UI:整钥配额元素全为 0,砖块各显配额, 编辑器预填/保存正确,零 JS 异常。 --- README.md | 59 ++--- README_EN.md | 73 +++--- internal/config/config.go | 37 +-- internal/core/core.go | 48 ++-- internal/gateway/apiv1.go | 8 +- internal/gateway/chat.go | 64 ++---- internal/gateway/key_quota_api_test.go | 166 +++++++------- internal/gateway/key_quota_wiring_test.go | 145 ++++++++++-- internal/gateway/keys.go | 66 ++---- internal/gateway/ui/index.html | 241 +++----------------- internal/gateway/ui_quota_contract_test.go | 253 ++++++++++----------- 11 files changed, 515 insertions(+), 645 deletions(-) diff --git a/README.md b/README.md index 274cfa6..36bbf22 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ### 强大的多租户调度能力 - **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期 -- **密钥用量配额**:每把 key 单独配总 token 配额 + 请求数配额与重置周期(小时/周/月/自定义 N 小时),跨模型共享预算;耗尽返 429 + `Retry-After` 可自动恢复,admin key 永不受限 +- **按模型配额**:每把 key 的每个模型单独配 token + 请求数配额与重置周期(小时/周/月/自定义 N 小时);一个模型用满只影响该模型,同 key 其它模型照常;耗尽返 429 + `Retry-After` 并点名模型,admin key 永不受限 - **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移 - **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口 - **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置 @@ -181,40 +181,44 @@ sources: #### 密钥用量配额 -每个密钥可单独限制用量与用量重置周期,两级配额同时生效: +**配额按模型单独设置**:每个密钥的 `models[]` 里,每一条模型范围各自带一份 +token 配额与请求数配额。一个模型用满只影响该模型,同一密钥的其它模型照常工作。 ```yaml keys: - key: sk-gw- role: user name: agent-alice - # ---- 整钥配额(跳模型)---- - token_quota: 5000000 # 本周期内这把 key 的总 token 预算,0 = 无限 - req_quota: 20000 # 本周期内的请求次数,0 = 无限 - period: nhour # "" | hour | week | month | nhour - hours: 6 # 仅 nhour:每 6 小时重置 - # ---- 模型范围(可选,逐模型配额)---- models: - - model: m1 - token_quota: 1000000 # 本周期内该模型(该 key)的 token 预算 - period: hour + - model: deepseek-v4-flash + token_quota: 1000000 # 本周期内该 key 用这个模型的 token 预算 + req_quota: 20000 # 本周期内的请求次数 + period: nhour # "" | hour | week | month | nhour + hours: 6 # 仅 nhour - model: AUTO + token_quota: 5000000 # AUTO 也是一条独立配额 + period: day # ← 这种写法会被拒绝(词表只有 hour/week/month/nhour) + - model: kimi-k3 # 未列配额 = 无限 ``` +- **没有「整钥总配额」**:这是刻意的设计。整钥总额会让「把 A 模型的额度挪给 + B 模型」变成一次全局重分配;按模型独立计费则每个模型各自可控,运维可以 + 看出哪个模型吃掉了预算。 - `period` 词表:空 = 永不过期(累计总量),`hour` / `week` / `month` = 固定窗口, `nhour` + `hours` = 自定义小时数。**拼错的周期在写入时就被拒**,不会静默变成 永不过期。 -- 整钥配额跨该 key 所有模型共享一份预算;`models[]` 里的配额则是逐模型独立计数。 - 两者都按 key 隔离,A key 的用量不会消耗 B key 的额度。 +- 配额严格按密钥隔离,且**同一密钥内按模型隔离**:A 密钥用满 `m1` 不会消耗 + B 密钥的额度,同一密钥的 `m2` 也不受影响。 - 配额统计含聊天、流式、生图,跨重启从审计日志回放(保留 40 天,覆盖最长的 month 窗口)。 -- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),客户端可等窗口 - 重置后自动恢复;模型越权才是 403。**admin 密钥永不受配额限制**, - 避免把管理员锁在门外。 +- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),消息里点名是哪个 + 模型用满了,客户端可等窗口重置后自动恢复;模型越权才是 403。 + **admin 密钥永不受配额限制**(其 scope 上的配额也不强制),避免把管理员 + 锁在门外。 - 窗口用量按整点小时分桶统计,实际释放比配置窗口最多晚 1 小时(配额宁可晚释放 也不超发)。 -- `PUT /api/keys/{key}` 的配额字段是可选的:省略 = 保留原值,显式 `0` = 解除限制。 - 只改模型范围不会清空已配置的预算。 +- `PUT /api/keys/{key}` 提交 `models` 即同时提交它们的配额(配额就是 scope 的一部分, + 不存在会与模型列表脱节的第二份预算)。显式 `0` = 解除该模型的限制。 ##### 配额拒绝 vs 容量拒绝:两种「拒绝」含义不同 @@ -233,12 +237,9 @@ keys: 配额桶按 (密钥, 模型, 整点小时) 分桶保留 40 天,实测(AMD 7840HS): -- 每请求配额检查:**149ns**(配了配额)/ **42.6ns**(未配配额,只查密钥记录, - 不碰桶)/ **37ns**(admin 密钥直接返回)—— 均 **0 分配**。 - 未配配额的密钥几乎不付代价,可放心大量创建。 -- 记录一条请求:283ns、3 分配(与引入配额前相同,分配来自 ring buffer)。 - 窗口查询按窗口长度而非保留总量扫描:1h 窗口 49ns、24h 窗口 55ns、 - 30d 窗口 3.9μs。 + 30d 窗口 3.9μs(此前全扫保留总量,960 桶时 5.9μs)。 +- 记录一条请求:283ns、3 分配(与引入配额前相同,分配来自 ring buffer)。 - 内存:生产形态(7 密钥 × 8 模型 × 2 源 × 满 40 天 retention)约 **3.7MB**。 按源 pin 的 `source::model` 桶**惰性创建**——只有当某条配额真的 pin 了 某个源时才维护,否则每条记录多写一份桶,在 20 密钥 × 8 模型 × 3 源下会 @@ -247,10 +248,10 @@ keys: 首个窗口可能少算**。 ```bash -# 配额耗尽时客户端看到 +# 配额耗尽时客户端看到(点名了具体模型) HTTP/1.1 429 Too Many Requests Retry-After: 2100 -{"error":{"type":"rate_limit_exceeded","message":"key token quota exceeded (5000000/5000000, resets every 6h)"}} +{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}} ``` ### 模型路由 @@ -419,10 +420,10 @@ Environment=MALLOC_ARENA_MAX=2 支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。 - **对话页**:流式/非流式调试。 - **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期), - 管理员管理全部 key,用户只看到自己的 key。key 卡片头部显示整钥配额徽标 - (如 `250.0K·6h` / `77×·6h`),「配额」按钮编辑总 token / 请求数与重置周期; - 创建 key 时可直接配预算(选 admin 角色时该组输入自动禁用,因为 admin 永不受限)。 - 「我的密钥」页对用户展示本 key 的预算。 + 管理员管理全部 key,用户只看到自己的 key。每个模型砖块显示自己的配额徽标 + (如 `1.0K 77×·1h`,未设配额显示 `∞`),点开可编辑该模型的 token 配额、 + 请求数配额与重置周期 —— 配额按模型独立生效,一个用满不影响同一 key 的其它模型。 + 「我的密钥」页对用户展示本 key 的模型范围与各自配额。 - **优先级页**:拖拽积木配置 AUTO 链档位。 - **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。 - **适配器页**:上传 / 删除 Lua 适配器脚本。 diff --git a/README_EN.md b/README_EN.md index de5078b..8d7233b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -38,10 +38,11 @@ Extracted and independently evolved from the multi-source LLM adapter layer of `reasoning_content`, `tool_calls`, `usage`). - **Image generation**: `POST /v1/images/generations`, routed to models with `kind: image`. -- **Per-key usage quota**: each key carries its own token and request caps plus a - reset period (hour/week/month/custom N hours), shared across every model that - key may use. Exhaustion answers 429 + `Retry-After` so a client resumes when - the window rolls over; admin keys are never capped. +- **Per-model quota**: each key gives every model its own token and request caps + plus a reset period (hour/week/month/custom N hours). One model running out + affects only that model — the key's other models keep working. Exhaustion + answers 429 + `Retry-After` naming the model, so a client resumes when the + window rolls over; admin keys are never capped. - **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly; Anthropic/Gemini/Ollama are translated automatically. - **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker @@ -175,56 +176,62 @@ under the `keys` field of the runtime file (encrypted at rest): delete the seed key. - The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin` manages everything, `user` sees only its own key) and an optional **model - scope** (model + source + token quota + reset period). Key cards show the - key-wide caps as a badge (e.g. `250.0K·6h` / `77×·6h`); a **Quota** button - edits the total token / request budget and its reset period, and the create - form takes a budget too (those fields disable themselves for `admin`, which - is never capped). The "My key" view shows a user its own budget. + scope** (model + source + token quota + reset period). Each model brick shows + its own budget badge (e.g. `1.0K 77×·1h`, `∞` when uncapped); clicking it + edits that model's token quota, request quota and reset period. Quotas apply + per model, so one model running out never blocks the key's others. The + "My key" view shows a user its model scopes and their budgets. - Clients authenticate with any authorized key's plaintext as `Authorization: Bearer `. - Deleting a key removes it from the store immediately. #### Per-key usage quota -Each key can cap its own spend and reset period. Two levels apply at once: +**Quotas are per model.** Each key's `models[]` list gives every model its own +token budget and request budget. One model running out affects only that model +— the key's other models keep working. ```yaml keys: - key: sk-gw- role: user name: agent-alice - # ---- key-wide (across every model) ---- - token_quota: 5000000 # total token budget for this window, 0 = unlimited - req_quota: 20000 # requests per window, 0 = unlimited - period: nhour # "" | hour | week | month | nhour - hours: 6 # n-hour only: resets every 6 hours - # ---- per-model scope (optional) ---- models: - - model: m1 - token_quota: 1000000 - period: hour + - model: deepseek-v4-flash + token_quota: 1000000 # this key's token budget for this model + req_quota: 20000 # requests within the window + period: nhour # "" | hour | week | month | nhour + hours: 6 # n-hour only - model: AUTO + token_quota: 5000000 # AUTO is a quota entry like any other + period: hour + - model: kimi-k3 # no quota listed = unlimited ``` +- **There is deliberately no key-wide total.** A key-wide cap would make + "move A's budget to B" a global reallocation; per-model budgets keep each + model independently controllable, so it stays visible which model is + actually consuming the spend. - `period`: empty = never resets (lifetime total); `hour` / `week` / `month` = fixed windows; `nhour` + `hours` = a custom hour count. **A misspelled period is rejected at write time** rather than silently becoming a never-resetting quota. -- The key-wide cap is one budget shared by every model the key may use; - quotas under `models[]` are counted per model. Both are isolated per key — - one key's traffic never drains another's budget. +- Quotas are isolated per key *and* per model within a key: one key exhausting + `m1` never draws on another key's budget, and never blocks the same key's + `m2`. - Usage counts chat, streaming and image requests, and survives a restart by replaying the audit log (40 days retained, covering the longest `month` window). - An exhausted quota returns **429 + `Retry-After`** - (`rate_limit_exceeded`) so a client resumes when the window rolls over; a - model the key may not use stays 403. **Admin keys are never capped**, so a - cap can never lock the operator out. + (`rate_limit_exceeded`) and the message names the model that ran out, so a + client resumes when the window rolls over; a model the key may not use stays + 403. **Admin keys are never capped** (quotas on their scopes are not + enforced either), so a cap can never lock the operator out. - Buckets are whole unix hours, so a window frees up at most an hour late (deliberately freeing late rather than overspending). -- On `PUT /api/keys/{key}` the quota fields are optional: omitting them keeps - the stored caps, sending `0` explicitly lifts a cap. Editing only the model - scope never clears a budget that was already set. +- `PUT /api/keys/{key}` submits quotas by submitting `models` — the caps are + part of the scope, so there is no second budget that can drift out of sync + with the model list. An explicit `0` lifts that model's cap. ##### Quota rejection vs capacity rejection @@ -247,14 +254,10 @@ back off concurrency or switch sources. Buckets are kept per (key, model, whole unix hour) for 40 days. Measured on an AMD 7840HS: -- Quota check per request: **149 ns** (caps set) / **42.6 ns** (no caps — it - only looks up the key record and never touches a bucket) / **37 ns** (admin - key returns immediately) — all **0 allocations**. Keys without caps cost - almost nothing, so creating many of them is safe. -- Recording one request: 283 ns, 3 allocations (unchanged from before this - feature; the allocations come from the record ring buffer). - A window query scans the window, not the whole retention: 49 ns for 1 h, 55 ns for 24 h, 3.9 µs for 30 d. +- Recording one request: 283 ns, 3 allocations (unchanged from before this + feature; the allocations come from the record ring buffer). - Memory: the production shape (7 keys x 8 models x 2 sources at full 40-day retention) costs about **3.7 MB**. The `source::model` bucket used by a source-pinned quota is created **lazily** — it is maintained only once some @@ -267,7 +270,7 @@ AMD 7840HS: ``` HTTP/1.1 429 Too Many Requests Retry-After: 2100 -{"error":{"type":"rate_limit_exceeded","message":"key token quota exceeded (5000000/5000000, resets every 6h)"}} +{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}} ``` ### Model routing diff --git a/internal/config/config.go b/internal/config/config.go index a856d27..3af5d6f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -375,21 +375,11 @@ type GWKey struct { Note string `yaml:"note,omitempty" json:"note,omitempty"` CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"` Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys - // TokenQuota caps this key's TOTAL tokens across every model it may use. - // 0 = unlimited. Period/Hours define the reset window, exactly like - // ModelScope: "" never resets, "hour"/"week"/"month" fixed windows, - // "nhour" uses Hours. - TokenQuota int64 `yaml:"token_quota,omitempty" json:"token_quota,omitempty"` - Period string `yaml:"period,omitempty" json:"period,omitempty"` - Hours int64 `yaml:"hours,omitempty" json:"hours,omitempty"` - // ReqQuota caps the number of requests per reset window; 0 = unlimited. - // RPM covers short bursts; this covers sustained volume. - ReqQuota int64 `yaml:"req_quota,omitempty" json:"req_quota,omitempty"` } -// KeyQuota is the set of key-wide caps accepted by the admin API. It is a -// separate struct so a partial update can be expressed as a pointer (nil = -// "leave the stored caps alone") instead of zero values meaning "clear". +// KeyQuota is retained only to carry a scope entry's caps through the admin +// API. Quotas are per model, never per key: there is deliberately no key-wide +// total, so exhausting one model's budget never blocks the others. type KeyQuota struct { TokenQuota int64 `json:"token_quota"` ReqQuota int64 `json:"req_quota"` @@ -397,14 +387,6 @@ type KeyQuota struct { Hours int64 `json:"hours"` } -// ApplyQuota writes the caps onto a key record. -func (k *GWKey) ApplyQuota(q KeyQuota) { - k.TokenQuota = q.TokenQuota - k.ReqQuota = q.ReqQuota - k.Period = q.Period - k.Hours = q.Hours -} - // NormalizeRole defaults an empty role to "user", so a key can never end up in // a state where no role means "neither admin nor user". func NormalizeRole(role string) string { @@ -453,15 +435,18 @@ func ValidatePeriod(period string, hours int64) error { return fmt.Errorf("period must be one of \"\", hour, week, month, nhour (got %q)", period) } -// ModelScope is one allowed model for a key, or one AUTO scheduling slot, -// with an optional token quota and reset period. TokenQuota 0 = unlimited; -// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour" -// uses Hours as the window length in hours. +// ModelScope is one allowed model for a key, or one AUTO scheduling slot. +// Its TokenQuota and ReqQuota cap THAT entry only, independently of every +// other entry on the same key: a model that runs out of budget stops being +// served while the key's other models keep working. TokenQuota 0 / ReqQuota 0 +// = unlimited. Period "" = never resets; "hour"/"week"/"month" are fixed +// windows; "nhour" uses Hours. type ModelScope struct { Model string `yaml:"model" json:"model"` Source string `yaml:"source,omitempty" json:"source,omitempty"` // optional: pin to one upstream source; "" = any source Tier int `yaml:"tier,omitempty" json:"tier,omitempty"` TokenQuota int64 `yaml:"token_quota" json:"token_quota"` + ReqQuota int64 `yaml:"req_quota,omitempty" json:"req_quota,omitempty"` Period string `yaml:"period,omitempty" json:"period,omitempty"` Hours int64 `yaml:"hours,omitempty" json:"hours,omitempty"` } @@ -480,6 +465,7 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error { Source string `json:"source"` Tier int `json:"tier"` TokenQuota int64 `json:"token_quota"` + ReqQuota int64 `json:"req_quota"` Period string `json:"period"` Hours int64 `json:"hours"` } @@ -490,6 +476,7 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error { m.Source = o.Source m.Tier = o.Tier m.TokenQuota = o.TokenQuota + m.ReqQuota = o.ReqQuota m.Period = o.Period m.Hours = o.Hours return nil diff --git a/internal/core/core.go b/internal/core/core.go index b791c40..65298c2 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -249,20 +249,17 @@ func (c *Core) FindKey(key string) (config.GWKey, bool) { } // CreateKey builds a new random gateway key and persists it to config.yaml. +// Quotas live on the model scope entries, so a new key's budget is whatever +// its scopes carry. func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) { - return c.CreateKeyWithQuota(name, role, models, note, config.KeyQuota{}) -} - -// CreateKeyWithQuota is CreateKey plus the key-wide token/request caps. -func (c *Core) CreateKeyWithQuota(name, role string, models []config.ModelScope, note string, q config.KeyQuota) (config.GWKey, error) { c.mu.Lock() defer c.mu.Unlock() models = cleanScopes(models) - key := make([]byte, 16) - if _, err := rand.Read(key); err != nil { + if err := ValidateScopeQuotas(models); err != nil { return config.GWKey{}, err } - if err := q.Validate(); err != nil { + key := make([]byte, 16) + if _, err := rand.Read(key); err != nil { return config.GWKey{}, err } rec := config.GWKey{ @@ -274,7 +271,6 @@ func (c *Core) CreateKeyWithQuota(name, role string, models []config.ModelScope, CreatedAt: time.Now().Unix(), } rec.Role = config.NormalizeRole(rec.Role) - rec.ApplyQuota(q) c.cfg.Keys = append(c.cfg.Keys, rec) if err := c.saveConfig(); err != nil { return config.GWKey{}, err @@ -282,19 +278,13 @@ func (c *Core) CreateKeyWithQuota(name, role string, models []config.ModelScope, return rec, nil } -// UpdateKey mutates a key's name/role/model scope and persists it. +// UpdateKey mutates a key's name/role/model scope and persists it. The scope +// entries carry their own quotas, so replacing the scope replaces the budgets. func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) { - return c.UpdateKeyWithQuota(key, name, role, models, note, nil) -} - -// UpdateKeyWithQuota is UpdateKey plus the key-wide caps. quota == nil leaves -// the existing caps untouched, so a caller that only edits the model scope -// does not silently clear a key's budget. -func (c *Core) UpdateKeyWithQuota(key, name, role string, models []config.ModelScope, note string, quota *config.KeyQuota) (config.GWKey, error) { c.mu.Lock() defer c.mu.Unlock() - if quota != nil { - if err := quota.Validate(); err != nil { + if models != nil { + if err := ValidateScopeQuotas(models); err != nil { return config.GWKey{}, err } } @@ -312,9 +302,6 @@ func (c *Core) UpdateKeyWithQuota(key, name, role string, models []config.ModelS c.cfg.Keys[i].Models = cleanScopes(models) } c.cfg.Keys[i].Note = note - if quota != nil { - c.cfg.Keys[i].ApplyQuota(*quota) - } if err := c.saveConfig(); err != nil { return config.GWKey{}, err } @@ -798,3 +785,20 @@ func (c *Core) Close() { c.vm.Stop() } } + +// ValidateScopeQuotas checks every scope entry's caps before they are stored. +// A typo in a period must be rejected at write time rather than silently +// becoming a never-resetting budget — the opposite of what was typed. +func ValidateScopeQuotas(entries []config.ModelScope) error { + for _, e := range entries { + if err := (config.KeyQuota{ + TokenQuota: e.TokenQuota, + ReqQuota: e.ReqQuota, + Period: e.Period, + Hours: e.Hours, + }).Validate(); err != nil { + return fmt.Errorf("model %q: %w", e.Model, err) + } + } + return nil +} diff --git a/internal/gateway/apiv1.go b/internal/gateway/apiv1.go index 15393be..404e25c 100644 --- a/internal/gateway/apiv1.go +++ b/internal/gateway/apiv1.go @@ -116,12 +116,8 @@ func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) { "note": k.Note, "created_at": k.CreatedAt, "seed": k.Seed, - // key-wide spend caps (0 = unlimited). Echoed so an agent can - // see what budget it has without parsing config.yaml. - "token_quota": k.TokenQuota, - "req_quota": k.ReqQuota, - "period": k.Period, - "hours": k.Hours, + // Quotas live on the scope entries (k.Models), echoed above; + // there is deliberately no key-wide total. // The secret itself is never echoed. An operator that needs it // already has it from creation time or from config.yaml. "key_prefix": maskKey(k.Key), diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index c102d5e..51801d6 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -218,12 +218,15 @@ type quotaRejection struct { func (q *quotaRejection) Error() string { return q.msg } -// checkQuota is checkKeyScope for callers that need the retry hint. It -// separates the quota verdicts (429) from model-permission verdicts (403). +// checkQuota validates the effective model against the key's model scope and +// that entry's quota. Returns nil when the request may proceed. +// +// Quotas are per scope entry, never key-wide: a model whose budget is spent +// is refused on its own while the key's other models keep working. The verdict +// carries the remaining seconds of the reset window so a spent budget answers +// 429 + Retry-After (come back when it rolls over) instead of 403 (which reads +// as "this key may never use this model" and makes clients give up). func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection { - if q := g.checkKeyQuotaRetry(ctx); q != nil { - return q - } allow := g.allowedModels(ctx) if allow == nil { return nil @@ -232,49 +235,29 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection if sc.Model != model { continue } + win := AutoPeriodSeconds(sc.Period, sc.Hours) + k := keyID(reqKey(ctx)) if sc.TokenQuota > 0 { - used := g.scopeTokens(ctx, sc) - if used >= sc.TokenQuota { + if used := g.scopeTokens(ctx, sc); used >= sc.TokenQuota { return "aRejection{ msg: fmt.Sprintf("token quota exceeded for %q (%d/%d)", model, used, sc.TokenQuota), retry: AutoSecondsToReset(sc.Period, sc.Hours), } } } + if sc.ReqQuota > 0 { + if used := g.stats.KeyWindowReqs(k, win); used >= sc.ReqQuota { + return "aRejection{ + msg: fmt.Sprintf("request quota exceeded for %q (%d/%d)", model, used, sc.ReqQuota), + retry: AutoSecondsToReset(sc.Period, sc.Hours), + } + } + } return nil } return "aRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)} } -// checkKeyQuotaRetry enforces the key-wide caps and reports the remaining -// seconds of the reset window so the caller can answer with 429 + Retry-After. -// An admin key is never capped, and a key with no caps set is never rejected. -func (g *Gateway) checkKeyQuotaRetry(ctx context.Context) *quotaRejection { - rec, ok := g.core.FindKey(reqKey(ctx)) - if !ok || rec.Role == "admin" { - return nil - } - k := keyID(reqKey(ctx)) - win := AutoPeriodSeconds(rec.Period, rec.Hours) - if rec.TokenQuota > 0 { - if used := g.stats.KeyWindowTokens(k, win); used >= rec.TokenQuota { - return "aRejection{ - msg: fmt.Sprintf("key token quota exceeded (%d/%d%s)", used, rec.TokenQuota, quotaWindowSuffix(rec.Period, rec.Hours)), - retry: AutoSecondsToReset(rec.Period, rec.Hours), - } - } - } - if rec.ReqQuota > 0 { - if used := g.stats.KeyWindowReqs(k, win); used >= rec.ReqQuota { - return "aRejection{ - msg: fmt.Sprintf("key request quota exceeded (%d/%d%s)", used, rec.ReqQuota, quotaWindowSuffix(rec.Period, rec.Hours)), - retry: AutoSecondsToReset(rec.Period, rec.Hours), - } - } - } - return nil -} - // quotaWindowSuffix describes a quota's reset window for an error message, so // a rejected caller can tell a permanent block from one that clears in an hour. func quotaWindowSuffix(period string, hours int64) string { @@ -289,11 +272,10 @@ func quotaWindowSuffix(period string, hours int64) string { return fmt.Sprintf(", resets every %dh", hours) } return "" -} - -// scopeTokens returns the tokens this key consumed within the scope entry's -// reset window, isolated per key. For an AUTO entry the cap covers everything -// the key routed through AUTO; for a model entry it covers that model only. +} // scopeTokens returns the tokens this key consumed on the scope entry's model +// within its reset window, isolated per key. For an AUTO entry the cap covers +// everything the key routed through AUTO; for a model entry it covers that +// model only. // // It reads the per-key hourly buckets rather than the key-blind model // buckets, so one key's usage can never exhaust another's quota. diff --git a/internal/gateway/key_quota_api_test.go b/internal/gateway/key_quota_api_test.go index b27ad99..9071a25 100644 --- a/internal/gateway/key_quota_api_test.go +++ b/internal/gateway/key_quota_api_test.go @@ -74,10 +74,24 @@ func keyRecord(t *testing.T, g *Gateway, secret string) config.GWKey { return config.GWKey{} } -func TestKeyAPIStoresQuota(t *testing.T) { +func scopeOf(t *testing.T, k config.GWKey, model string) config.ModelScope { + t.Helper() + for _, m := range k.Models { + if m.Model == model { + return m + } + } + t.Fatalf("scope %q not found in %+v", model, k.Models) + return config.ModelScope{} +} + +// Quotas live on the scope entries, not on the key: creating a key with a +// budget means creating scopes that carry it, and they must survive a +// read-back (persisted, not just echoed). +func TestKeyAPICreatesPerModelQuota(t *testing.T) { g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) rr := adminReq(t, g, "POST", "/api/keys", - `{"name":"agent-x","role":"user","token_quota":50000,"req_quota":200,"period":"nhour","hours":6,"models":[{"model":"m1"}]}`) + `{"name":"agent-x","role":"user","models":[{"model":"m1","token_quota":50000,"req_quota":200,"period":"nhour","hours":6}]}`) if rr.Code != 200 { t.Fatalf("create: %d %s", rr.Code, rr.Body.String()) } @@ -87,54 +101,21 @@ func TestKeyAPIStoresQuota(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { t.Fatalf("decode: %v", err) } - if created.Key.TokenQuota != 50000 || created.Key.ReqQuota != 200 || - created.Key.Period != "nhour" || created.Key.Hours != 6 { - t.Fatalf("created key did not carry the caps: %+v", created.Key) + sc := scopeOf(t, created.Key, "m1") + if sc.TokenQuota != 50000 || sc.ReqQuota != 200 || sc.Period != "nhour" || sc.Hours != 6 { + t.Fatalf("created scope did not carry the caps: %+v", sc) } - // and it must survive a read-back (persisted, not just echoed) - back := keyRecord(t, g, created.Key.Key) - if back.TokenQuota != 50000 || back.Period != "nhour" || back.Hours != 6 { + back := scopeOf(t, keyRecord(t, g, created.Key.Key), "m1") + if back.TokenQuota != 50000 || back.ReqQuota != 200 || back.Period != "nhour" || back.Hours != 6 { t.Errorf("read-back lost the caps: %+v", back) } } -// Editing only the model scope must not silently clear a key's budget: the -// caps are pointers precisely so "absent" is not "zero". -func TestKeyAPIUpdateKeepsQuotaWhenOmitted(t *testing.T) { +// Two models on one key carry independent budgets. +func TestKeyAPIKeepsPerModelQuotaIndependent(t *testing.T) { g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) rr := adminReq(t, g, "POST", "/api/keys", - `{"name":"agent-x","role":"user","token_quota":50000,"period":"day-typo-free","models":[{"model":"m1"}]}`) - rr = adminReq(t, g, "POST", "/api/keys", `{"name":"y","role":"user","token_quota":50000,"period":"hour","models":[{"model":"m1"}]}`) - if rr.Code != 200 { - t.Fatalf("setup create: %d %s", rr.Code, rr.Body.String()) - } - var created struct { - Key config.GWKey `json:"key"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &created) - - // a scope-only edit - rr = adminReq(t, g, "PUT", "/api/keys/"+created.Key.Key, - `{"name":"agent-y","models":[{"model":"m1"},{"model":"m2"}]}`) - if rr.Code != 200 { - t.Fatalf("update: %d %s", rr.Code, rr.Body.String()) - } - back := keyRecord(t, g, created.Key.Key) - if back.TokenQuota != 50000 { - t.Errorf("token_quota was cleared by a scope-only edit: %d", back.TokenQuota) - } - if back.Period != "hour" { - t.Errorf("period was cleared by a scope-only edit: %q", back.Period) - } - if len(back.Models) != 2 { - t.Errorf("scope edit did not apply: %+v", back.Models) - } -} - -// Sending 0 explicitly must lift the cap, not be treated as "absent". -func TestKeyAPIUpdateZeroLiftsCap(t *testing.T) { - g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) - rr := adminReq(t, g, "POST", "/api/keys", `{"name":"z","role":"user","token_quota":1000,"period":"hour"}`) + `{"name":"agent-y","role":"user","models":[{"model":"m1","token_quota":1000,"period":"hour"},{"model":"m2","token_quota":9999,"req_quota":7,"period":"week"}]}`) if rr.Code != 200 { t.Fatalf("create: %d %s", rr.Code, rr.Body.String()) } @@ -142,13 +123,36 @@ func TestKeyAPIUpdateZeroLiftsCap(t *testing.T) { Key config.GWKey `json:"key"` } _ = json.Unmarshal(rr.Body.Bytes(), &created) + back := keyRecord(t, g, created.Key.Key) + if a := scopeOf(t, back, "m1"); a.TokenQuota != 1000 || a.ReqQuota != 0 || a.Period != "hour" { + t.Errorf("m1 caps wrong: %+v", a) + } + if b := scopeOf(t, back, "m2"); b.TokenQuota != 9999 || b.ReqQuota != 7 || b.Period != "week" { + t.Errorf("m2 caps wrong: %+v", b) + } +} - rr = adminReq(t, g, "PUT", "/api/keys/"+created.Key.Key, `{"token_quota":0}`) +// Sending 0 explicitly lifts that model's cap. +func TestKeyAPIUpdateZeroLiftsCap(t *testing.T) { + g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) + rr := adminReq(t, g, "POST", "/api/keys", + `{"name":"z","role":"user","models":[{"model":"m1","token_quota":1000,"period":"hour"}]}`) + if rr.Code != 200 { + t.Fatalf("create: %d %s", rr.Code, rr.Body.String()) + } + var created struct { + Key config.GWKey `json:"key"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &created) + secret := created.Key.Key + + rr = adminReq(t, g, "PUT", "/api/keys/"+secret, + `{"models":[{"model":"m1","token_quota":0,"req_quota":0,"period":""}]}`) if rr.Code != 200 { t.Fatalf("lift: %d %s", rr.Code, rr.Body.String()) } - if back := keyRecord(t, g, created.Key.Key); back.TokenQuota != 0 { - t.Errorf("token_quota = %d, want 0 (cap lifted)", back.TokenQuota) + if back := scopeOf(t, keyRecord(t, g, secret), "m1"); back.TokenQuota != 0 || back.ReqQuota != 0 || back.Period != "" { + t.Errorf("caps not lifted: %+v", back) } } @@ -156,7 +160,8 @@ func TestKeyAPIUpdateZeroLiftsCap(t *testing.T) { // quota — which is the exact opposite of what the operator typed. func TestKeyAPIRejectsBadPeriod(t *testing.T) { g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) - rr := adminReq(t, g, "POST", "/api/keys", `{"name":"bad","role":"user","token_quota":1000,"period":"houre"}`) + rr := adminReq(t, g, "POST", "/api/keys", + `{"name":"bad","role":"user","models":[{"model":"m1","token_quota":1000,"period":"houre"}]}`) if rr.Code != http.StatusBadRequest { t.Fatalf("want 400 for a bad period, got %d %s", rr.Code, rr.Body.String()) } @@ -167,19 +172,35 @@ func TestKeyAPIRejectsBadPeriod(t *testing.T) { func TestKeyAPIRejectsNegativeQuota(t *testing.T) { g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) - rr := adminReq(t, g, "POST", "/api/keys", `{"name":"bad","role":"user","token_quota":-5}`) + rr := adminReq(t, g, "POST", "/api/keys", + `{"name":"bad","role":"user","models":[{"model":"m1","token_quota":-5}]}`) if rr.Code != http.StatusBadRequest { t.Fatalf("want 400 for a negative quota, got %d %s", rr.Code, rr.Body.String()) } } -// A non-admin key must not be able to set or read another key's budget. +// The rejection must say WHICH model is over budget, so an operator looking at +// a key with a dozen scopes can tell which one to raise. +func TestKeyAPIRejectionNamesTheModel(t *testing.T) { + g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}) + rr := adminReq(t, g, "POST", "/api/keys", + `{"name":"bad","role":"user","models":[{"model":"m1","req_quota":-1}]}`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "m1") { + t.Errorf("error should name the offending model: %s", rr.Body.String()) + } +} + +// A non-admin key must not be able to mint keys. func TestKeyAPIQuotaIsAdminOnly(t *testing.T) { g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}, - config.GWKey{Key: "sk-u", Role: "user", TokenQuota: 10, Period: "hour"}, + config.GWKey{Key: "sk-u", Role: "user", Models: []config.ModelScope{{Model: "m1", TokenQuota: 10, Period: "hour"}}}, ) - req, _ := http.NewRequest("POST", "/api/keys", strings.NewReader(`{"name":"x","role":"admin","token_quota":0}`)) + req, _ := http.NewRequest("POST", "/api/keys", + strings.NewReader(`{"name":"x","role":"admin","models":[{"model":"m1"}]}`)) req.Header.Set("Authorization", "Bearer sk-u") req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -187,7 +208,7 @@ func TestKeyAPIQuotaIsAdminOnly(t *testing.T) { if rr.Code != http.StatusForbidden { t.Fatalf("non-admin create: want 403, got %d %s", rr.Code, rr.Body.String()) } - // /api/v1/keys echoes the caps but never the secret + // /api/v1/keys exposes the per-model caps but never a secret rr = adminReq(t, g, "GET", "/api/v1/keys", "") if rr.Code != 200 { t.Fatalf("GET /api/v1/keys: %d", rr.Code) @@ -196,39 +217,10 @@ func TestKeyAPIQuotaIsAdminOnly(t *testing.T) { t.Error("/api/v1/keys leaked a key secret") } if !strings.Contains(rr.Body.String(), `"token_quota":10`) { - t.Errorf("/api/v1/keys should expose the cap: %s", rr.Body.String()) + t.Errorf("/api/v1/keys should expose the per-model caps: %s", rr.Body.String()) } } -// The AUTO scope entry must honour its reset window: usage that aged out of -// the window must not count against a per-key cap. -func TestAutoScopeQuotaHonoursWindow(t *testing.T) { - g, _ := quotaGateway(t, - config.GWKey{Key: "sk-a", Role: "user", Models: []config.ModelScope{ - {Model: "AUTO", TokenQuota: 1000, Period: "hour"}, - }}, - config.GWKey{Key: "sk-b", Role: "user"}, - ) - ctx := quotaCtx(t, g, "sk-a") - sc := config.ModelScope{Model: "AUTO", TokenQuota: 1000, Period: "hour"} - - // aged-out usage: 2 days old, 5M tokens — must be invisible to a 1h window - g.stats.Record(Req{Time: nowMSOffset(-48 * 3600 * 1000), Key: keyID("sk-a"), - Model: "m1", Source: "up", Prompt: 2500000, Compl: 2500000, OK: true, Status: 200}) - if used := g.scopeTokens(ctx, sc); used != 0 { - t.Fatalf("AUTO scope saw %d tokens outside its 1h window; the period is being ignored", used) - } - - // in-window usage counts - g.stats.Record(Req{Time: nowMSOffset(0), Key: keyID("sk-a"), - Model: "m1", Source: "up", Prompt: 400, Compl: 400, OK: true, Status: 200}) - if used := g.scopeTokens(ctx, sc); used != 800 { - t.Fatalf("AUTO scope used = %d, want 800", used) - } -} - -var _ = fmt.Sprintf - // A user must be able to see their own budget: /api/keys/me is the only key // view a non-admin gets, so a cap missing from it is invisible to the very // client it constrains. @@ -236,7 +228,7 @@ func TestKeyMeExposesOwnQuota(t *testing.T) { g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"}, config.GWKey{Key: "sk-u", Role: "user", Name: "agent", - TokenQuota: 123456, ReqQuota: 42, Period: "week", Hours: 0}, + Models: []config.ModelScope{{Model: "m1", TokenQuota: 123456, ReqQuota: 42, Period: "week"}}}, ) req, _ := http.NewRequest("GET", "/api/keys/me", nil) req.Header.Set("Authorization", "Bearer sk-u") @@ -252,8 +244,10 @@ func TestKeyMeExposesOwnQuota(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &wrap); err != nil { t.Fatalf("decode: %v (%s)", err, rr.Body.String()) } - me := wrap.Key - if me.TokenQuota != 123456 || me.ReqQuota != 42 || me.Period != "week" { - t.Errorf("own quota not visible to the key's owner: %+v", me) + sc := scopeOf(t, wrap.Key, "m1") + if sc.TokenQuota != 123456 || sc.ReqQuota != 42 || sc.Period != "week" { + t.Errorf("own quota not visible to the key's owner: %+v", sc) } } + +var _ = fmt.Sprintf diff --git a/internal/gateway/key_quota_wiring_test.go b/internal/gateway/key_quota_wiring_test.go index c15a3d3..9de0870 100644 --- a/internal/gateway/key_quota_wiring_test.go +++ b/internal/gateway/key_quota_wiring_test.go @@ -82,8 +82,8 @@ func chatAs(t *testing.T, g *Gateway, key, model string) (*httptest.ResponseReco // as "this key may never use this model". func TestKeyTokenQuotaBlocksWithRetryAfter(t *testing.T) { g, _ := quotaGateway(t, - config.GWKey{Key: "sk-a", Role: "user", TokenQuota: 8, Period: "hour", - Models: []config.ModelScope{{Model: "m1"}}}, + config.GWKey{Key: "sk-a", Role: "user", + Models: []config.ModelScope{{Model: "m1", TokenQuota: 8, Period: "hour"}}}, config.GWKey{Key: "sk-b", Role: "user", Name: "b"}, ) // 4 tokens per call, budget 8 -> the third call crosses it @@ -114,10 +114,10 @@ func TestKeyTokenQuotaBlocksWithRetryAfter(t *testing.T) { // key that is allowed the same model. func TestKeyTokenQuotaIsIsolatedPerKey(t *testing.T) { g, _ := quotaGateway(t, - config.GWKey{Key: "sk-a", Role: "user", TokenQuota: 4, Period: "hour", - Models: []config.ModelScope{{Model: "m1"}}}, - config.GWKey{Key: "sk-b", Role: "user", TokenQuota: 1000, Period: "hour", - Models: []config.ModelScope{{Model: "m1"}}}, + config.GWKey{Key: "sk-a", Role: "user", + Models: []config.ModelScope{{Model: "m1", TokenQuota: 4, Period: "hour"}}}, + config.GWKey{Key: "sk-b", Role: "user", + Models: []config.ModelScope{{Model: "m1", TokenQuota: 1000, Period: "hour"}}}, ) rr, _ := chatAs(t, g, "sk-a", "m1") if rr.Code != 200 { @@ -162,7 +162,8 @@ func TestScopeModelTokenQuotaIsolatedPerKey(t *testing.T) { // operator out of the gateway they administer. func TestAdminKeyIsNeverQuotaCapped(t *testing.T) { g, _ := quotaGateway(t, - config.GWKey{Key: "sk-admin", Role: "admin", TokenQuota: 1, Period: "hour", ReqQuota: 1}, + config.GWKey{Key: "sk-admin", Role: "admin", + Models: []config.ModelScope{{Model: "m1", TokenQuota: 1, ReqQuota: 1, Period: "hour"}}}, config.GWKey{Key: "sk-b", Role: "user"}, ) for i := 1; i <= 3; i++ { @@ -176,8 +177,8 @@ func TestAdminKeyIsNeverQuotaCapped(t *testing.T) { // the count must still stop the key. func TestKeyRequestQuotaBlocks(t *testing.T) { g, ctrl := quotaGateway(t, - config.GWKey{Key: "sk-a", Role: "user", ReqQuota: 2, Period: "hour", - Models: []config.ModelScope{{Model: "m1"}}}, + config.GWKey{Key: "sk-a", Role: "user", + Models: []config.ModelScope{{Model: "m1", ReqQuota: 2, Period: "hour"}}}, config.GWKey{Key: "sk-b", Role: "user"}, ) for i := 1; i <= 2; i++ { @@ -197,8 +198,8 @@ func TestKeyRequestQuotaBlocks(t *testing.T) { // A model outside the scope is still 403, not 429: retrying cannot help. func TestModelOutsideScopeStaysForbidden(t *testing.T) { g, _ := quotaGateway(t, - config.GWKey{Key: "sk-a", Role: "user", TokenQuota: 1000, Period: "hour", - Models: []config.ModelScope{{Model: "other-model"}}}, + config.GWKey{Key: "sk-a", Role: "user", + Models: []config.ModelScope{{Model: "other-model", TokenQuota: 1000, Period: "hour"}}}, config.GWKey{Key: "sk-b", Role: "user"}, ) rr, code := chatAs(t, g, "sk-a", "m1") @@ -256,8 +257,8 @@ func TestKeyQuotaWinsOverSlotQuota(t *testing.T) { up := upstream(t, &upstreamCtrl{}) defer up.Close() g := newQuotaGW(t, up.URL, - config.GWKey{Key: "sk-a", Role: "user", TokenQuota: 4, Period: "hour", - Models: []config.ModelScope{{Model: "AUTO"}}}) + config.GWKey{Key: "sk-a", Role: "user", + Models: []config.ModelScope{{Model: "AUTO", TokenQuota: 4, Period: "hour"}}}) ctx := quotaCtx(t, g, "sk-a") // exhaust the key first @@ -304,7 +305,10 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway { Keys: keys, Sources: []config.Source{{ Name: "up", BaseURL: upURL, Adapter: "openai", - Models: []config.Model{{ID: "m1", Priority: 100}}, + Models: []config.Model{ + {ID: "m1", Priority: 100}, + {ID: "m2", Priority: 90}, + }, }}, } if err := cfg.ApplyDefaults(); err != nil { @@ -325,3 +329,116 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway { } return g } + +// The core of per-model quotas: a model that runs out of budget must stop +// being served on its own, while every other model on the SAME key keeps +// working. A key-wide total would fail this — it would block m2 because m1 was +// capped, which is exactly the coupling this design removes. +func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) { + up := upstream(t, &upstreamCtrl{}) + defer up.Close() + td := t.TempDir() + cfgPath := td + "/config.yaml" + if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ + Path: cfgPath, AdapterDir: filepath.Join(td, "adapters"), + RuntimeFile: filepath.Join(td, "runtime.json"), + Keys: []config.GWKey{{ + Key: "sk-a", Role: "user", Name: "two-models", + Models: []config.ModelScope{ + {Model: "m1", TokenQuota: 4, Period: "hour"}, + {Model: "m2", TokenQuota: 1000, Period: "hour"}, + }, + }}, + Sources: []config.Source{{ + Name: "up", BaseURL: up.URL, Adapter: "openai", + Models: []config.Model{ + {ID: "m1", Priority: 100}, + {ID: "m2", Priority: 90}, + }, + }}, + } + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(c.Close) + g, err := New(c, []string{"sk-a"}) + if err != nil { + t.Fatalf("gateway: %v", err) + } + + // Spend on m2 FIRST. Without this the two designs are + // indistinguishable: a key-wide counter and m1's own counter would both + // read 0 before m1 is used, so the test would pass either way (it did — + // see the commit that rewrote it). + for i := 1; i <= 3; i++ { + if rr, _ := chatAs(t, g, "sk-a", "m2"); rr.Code != 200 { + t.Fatalf("m2 priming call %d: want 200, got %d", i, rr.Code) + } + } + // m1's budget is 4 and each call costs 4, so the key-wide total (m2+m1) is + // already 12 when m1 starts: a key-wide cap would refuse m1 immediately. + if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 { + t.Fatalf("m1 must be served on its own budget (m2's spend is not its problem), got %d (%s)", + rr.Code, rr.Body.String()) + } + if rr, code := chatAs(t, g, "sk-a", "m1"); rr.Code != http.StatusTooManyRequests { + t.Fatalf("m1 second call: want 429, got %d (%s)", rr.Code, code) + } + // m2 must keep working + for i := 1; i <= 3; i++ { + if rr, _ := chatAs(t, g, "sk-a", "m2"); rr.Code != 200 { + t.Fatalf("m2 call %d must be served while m1 is capped, got %d (%s)", i, rr.Code, rr.Body.String()) + } + } + // and the message must name m1, not the key + rr, _ := chatAs(t, g, "sk-a", "m1") + if !strings.Contains(rr.Body.String(), "m1") { + t.Errorf("rejection should name the capped model: %s", rr.Body.String()) + } +} + +// A model with no quota on it is never blocked by a sibling's cap, and an +// uncapped key is never blocked at all. +func TestUncappedModelNeverBlocked(t *testing.T) { + up := upstream(t, &upstreamCtrl{}) + defer up.Close() + g := newQuotaGW(t, up.URL, + config.GWKey{Key: "sk-a", Role: "user", Models: []config.ModelScope{ + {Model: "m1", TokenQuota: 1, Period: "hour"}, + {Model: "m2"}, + }}) + if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 { + t.Fatalf("m1 first: want 200, got %d", rr.Code) + } + if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != http.StatusTooManyRequests { + t.Fatalf("m1 second: want 429, got %d", rr.Code) + } + for i := 1; i <= 4; i++ { + if rr, _ := chatAs(t, g, "sk-a", "m2"); rr.Code != 200 { + t.Fatalf("m2 (uncapped) call %d: want 200, got %d", i, rr.Code) + } + } +} + +// An admin key is never capped even when its scopes carry budgets: a cap that +// locked the operator out would be unrecoverable through the UI. +func TestAdminKeyScopesAreNotEnforced(t *testing.T) { + up := upstream(t, &upstreamCtrl{}) + defer up.Close() + g := newQuotaGW(t, up.URL, + config.GWKey{Key: "sk-admin", Role: "admin", Models: []config.ModelScope{ + {Model: "m1", TokenQuota: 1, ReqQuota: 1, Period: "hour"}, + }}) + for i := 1; i <= 4; i++ { + if rr, _ := chatAs(t, g, "sk-admin", "m1"); rr.Code != 200 { + t.Fatalf("admin call %d: want 200 (admin scopes are not enforced), got %d", i, rr.Code) + } + } +} diff --git a/internal/gateway/keys.go b/internal/gateway/keys.go index 3c2440b..e1a4c10 100644 --- a/internal/gateway/keys.go +++ b/internal/gateway/keys.go @@ -39,27 +39,17 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"keys": g.core.ListKeys()}) case http.MethodPost: var body struct { - Name string `json:"name"` - Role string `json:"role"` - Models []config.ModelScope `json:"models"` - Note string `json:"note"` - TokenQuota *int64 `json:"token_quota"` - ReqQuota *int64 `json:"req_quota"` - Period *string `json:"period"` - Hours *int64 `json:"hours"` + Name string `json:"name"` + Role string `json:"role"` + Models []config.ModelScope `json:"models"` + Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) return } body.Role = config.NormalizeRole(body.Role) - q := config.KeyQuota{ - TokenQuota: optInt64(body.TokenQuota), - ReqQuota: optInt64(body.ReqQuota), - Period: optString(body.Period), - Hours: optInt64(body.Hours), - } - rec, err := g.core.CreateKeyWithQuota(body.Name, body.Role, body.Models, body.Note, q) + rec, err := g.core.CreateKey(body.Name, body.Role, body.Models, body.Note) if err != nil { writeError(w, http.StatusBadRequest, "key_error", err.Error()) return @@ -71,33 +61,19 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) { return } var body struct { - Name string `json:"name"` - Role string `json:"role"` - Models []config.ModelScope `json:"models"` - Note string `json:"note"` - TokenQuota *int64 `json:"token_quota"` - ReqQuota *int64 `json:"req_quota"` - Period *string `json:"period"` - Hours *int64 `json:"hours"` + Name string `json:"name"` + Role string `json:"role"` + Models []config.ModelScope `json:"models"` + Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) return } - // Quota fields are pointers so "absent" is distinguishable from - // "set to 0": omitting them leaves the stored caps alone, while - // sending 0 explicitly lifts a cap. Without this, editing only the - // model scope would silently clear a key's budget. - var q *config.KeyQuota - if body.TokenQuota != nil || body.ReqQuota != nil || body.Period != nil || body.Hours != nil { - q = &config.KeyQuota{ - TokenQuota: optInt64(body.TokenQuota), - ReqQuota: optInt64(body.ReqQuota), - Period: optString(body.Period), - Hours: optInt64(body.Hours), - } - } - rec, err := g.core.UpdateKeyWithQuota(path, body.Name, body.Role, body.Models, body.Note, q) + // Each scope entry carries its own token/request caps, so replacing the + // scope replaces the budgets with it — there is no separate key-wide + // quota that could drift out of sync with the models. + rec, err := g.core.UpdateKey(path, body.Name, body.Role, body.Models, body.Note) if err != nil { writeError(w, http.StatusBadRequest, "key_error", err.Error()) return @@ -127,22 +103,6 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) { } } -// optInt64 dereferences an optional quota field, treating absent as 0. -func optInt64(p *int64) int64 { - if p == nil { - return 0 - } - return *p -} - -// optString dereferences an optional quota field, treating absent as "". -func optString(p *string) string { - if p == nil { - return "" - } - return *p -} - // handleKeyMe returns the authenticated key's own record (users see only // themselves; admins can use this as a convenience too). func (g *Gateway) handleKeyMe(w http.ResponseWriter, r *http.Request) { diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 1a9f83a..029d62f 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -339,9 +339,6 @@ .key-canvas{border:1px solid var(--line);border-radius:16px;padding:14px;margin-bottom:14px;background:var(--card); backdrop-filter:blur(var(--glass));box-shadow:var(--sh-sm)} .kc-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap} - /* key-wide caps sit inline in the head: they belong to the key, not to - any one model brick, and must not be draggable with one. */ - .kc-caps{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap} .kc-blocks{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;background:var(--card2); border:1px dashed var(--line);border-radius:12px;padding:14px;min-height:64px} .kc-blocks.ovh{outline:2px dashed var(--primary);outline-offset:2px} @@ -821,15 +818,10 @@ kAnySrc: "任意源", kQuotaB: "Token 配额", kQuotaHintB: "0 / 留空 = 无限", - kKeyQuota: "密钥总配额", - kKeyQuotaHint: - "限制这把密钥在重置周期内的总用量(跳模型)。0 / 留空 = 无限。", - kKeyReqQuota: "请求数配额", - kKeyReqQuotaHint: "限制周期内的请求次数。0 / 留空 = 无限。", - kKeyQuotaAdmin: - "admin 密钥永不受配额限制(避免把管理员锁在门外)。", - kKeyQuotaEdit: "配额", - kKeyQuotaNone: "无限", + kReqQuota: "请求数配额", + kReqQuotaHint: "限制周期内的请求次数。0 / 留空 = 无限。", + kQuotaPerModelHint: + "配额按模型单独设置:创建后点「+ 添加模型」,逐个模型配 token 配额与周期。一个模型用满只影响该模型,同一密钥的其它模型照常。", kPeriodB: "重置周期", kPerNothing: "不限", kPerHour: "每 小时", @@ -1055,16 +1047,10 @@ kAnySrc: "any source", kQuotaB: "Token quota", kQuotaHintB: "0 / empty = unlimited", - kKeyQuota: "Key-wide quota", - kKeyQuotaHint: - "Caps this key's total spend per reset window, across every model it may use. 0 / empty = unlimited.", - kKeyReqQuota: "Request quota", - kKeyReqQuotaHint: - "Caps requests per window. 0 / empty = unlimited.", - kKeyQuotaAdmin: - "Admin keys are never capped — a cap could lock the operator out.", - kKeyQuotaEdit: "Quota", - kKeyQuotaNone: "unlimited", + kReqQuota: "Request quota", + kReqQuotaHint: "Caps requests per window. 0 / empty = unlimited.", + kQuotaPerModelHint: + "Quotas are per model: after creating the key, use \"Add model\" to give each model its own token budget and reset period. One model running out affects only that model; the key's other models keep working.", kPeriodB: "Reset period", kPerNothing: "Never", kPerHour: "Every hour", @@ -4266,11 +4252,10 @@ async function renderKeysUser(me) { $("#tab-keys").innerHTML = `

${t("kMeTitle")}

-
+
${t("kName")}${t("kMeRole")}${t("kKey")}${t("kKeyQuota")}${t("kMeModels")}
- - + `; } diff --git a/internal/gateway/ui_contract_test.go b/internal/gateway/ui_contract_test.go index 73edcac..cfa5d50 100644 --- a/internal/gateway/ui_contract_test.go +++ b/internal/gateway/ui_contract_test.go @@ -347,3 +347,60 @@ func cssBlock(src, selector string) (string, bool) { } return src[i : i+j+1], true } + +// The WebUI ships two locale objects (`zh` and `en`) that every rendered string +// goes through. A key added to only one of them does not error: t() falls back +// to `t("key") || "literal"` in some call sites and to the bare key string in +// others, so the user sees either a hardcoded local string or a raw key name. +// Nothing in the Go test suite noticed — this was found by hand while adding +// thImgs. Pin the key sets so a one-sided edit fails here instead of shipping. +func TestUILocaleKeyParity(t *testing.T) { + src := uiSource(t) + iZh := strings.Index(src, "zh: {") + iEn := strings.Index(src, "en: {") + if iZh < 0 || iEn < 0 || iEn < iZh { + t.Fatalf("locale blocks not found (zh=%d en=%d)", iZh, iEn) + } + zh := src[iZh:iEn] + en := src[iEn:] + // End the EN block at its closing brace, so keys from later objects + // (config templates etc.) do not pollute the comparison. + if end := strings.Index(en, "\n },"); end > 0 { + en = en[:end] + } + + keyRe := regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:`) + collect := func(seg string) map[string]bool { + m := map[string]bool{} + for _, g := range keyRe.FindAllStringSubmatch(seg, -1) { + m[g[1]] = true + } + return m + } + zk, ek := collect(zh), collect(en) + // The locale markers themselves are the two block headers. + delete(zk, "zh") + delete(ek, "en") + + var missingEN, missingZH []string + for k := range zk { + if !ek[k] { + missingEN = append(missingEN, k) + } + } + for k := range ek { + if !zk[k] { + missingZH = append(missingZH, k) + } + } + if len(missingEN) > 0 { + t.Errorf("translation keys present in zh but missing in en: %v", missingEN) + } + if len(missingZH) > 0 { + t.Errorf("translation keys present in en but missing in zh: %v", missingZH) + } + if len(zk) < 100 { + t.Errorf("only %d zh keys parsed — the block boundary regexp has drifted "+ + "and this test is no longer checking anything", len(zk)) + } +} From 5c582447812a031bbf65d3360a1efacddf87edf8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 22:19:24 +0800 Subject: [PATCH 05/42] chore(version): 1.7.1 -> 1.7.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token 统计单位修复(流式改用上游真实 usage、图片不再记 token), 影响 per-model 配额计费口径,走 patch 发布。 --- cmd/gui/package-lock.json | 6 +++--- cmd/gui/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 4540d7c..93e9f94 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.7.1", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.7.1", + "version": "1.7.2", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", @@ -582,7 +582,7 @@ } }, "node_modules/@peculiar/webcrypto": { - "version": "1.7.1", + "version": "1.7.2", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", "dev": true, diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 15f3de1..683deff 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.7.1", + "version": "1.7.2", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 26ea7823502ce792f2cf906383d701b11e11c69d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 22:52:51 +0800 Subject: [PATCH 06/42] =?UTF-8?q?fix(core):=20=E4=BF=AE=E5=A4=8D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=87=8D=E5=A4=8D=E6=92=AD=E7=A7=8D=20admin=20key=20+?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=B0=81=E5=AD=98=E9=9D=9E=E5=B9=82?= =?UTF-8?q?=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因是 unseal 时序:NewFromConfig 把解密放在最后,而之前几步已经在读凭据。 1. seedKeys 重复播种(生产已累积 4 个同名 admin key) seedKeys 用 cfg.Keys[i].Key 与明文 gateway_keys 比对去重,但此时内存里的 key 还是密文 enc:v1:…,比对永不命中 ⇒ 每次重启追加一个同值 admin key。 实测:core.New(path) 连续重启,seeded key 数 2→3→4 递增。 (旧测试用 NewFromConfig 构造全新内存对象,没有「盘上已有密文」这个前提, 复现不出 —— 必须走 core.New 这条读盘的生产路径。) 2. 启动恒重写 config.yaml migratePlaintextSecrets 按内存状态判断,而 Save() 末尾会把内存恢复为明文, 于是每次调用都判定「还有明文」并重写;注释却自称幂等。 改为 UnsealSecrets 在解密前记录「盘上是否明文」,SealIfNeeded 据此决定 是否写回 ⇒ 已封存的配置启动不再落盘。 原测试 TestMigratePlaintextSecretsIsIdempotent 用 ModTime 比较,两次写落在同一 时间戳刻度内就看不出来,所以表现为 ~1/6 概率的 flake 而非稳定失败。已改为比较 文件内容并走真实启动路径(UnsealSecrets + SealIfNeeded),并顺带消除该 flake。 附带更正:先前判断「rebuildRegistry 也会拿到密文 API key」不成立 —— mergedSources → resolveSourceKey 对每个 source 独立解密(belt-and-braces), provider 始终拿到明文。unseal 前置仍予保留,以消除对该兜底路径的隐性依赖、 并让 seedKeys 在明文下比较。 判据: - TestRestartDoesNotDuplicateSeededKeys(敏感:回退顺序必红) - TestSealingIsIdempotentAcrossStarts(12/12 稳定,原先 1/6 flake) - TestProvidersGetPlaintextCredentials(钉 provider 必须拿到明文这一不变量) --- internal/config/secret_config.go | 55 +++++++- internal/config/secret_config_test.go | 62 +++++++-- internal/core/core.go | 28 +++- internal/core/startup_secrets_test.go | 178 ++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 21 deletions(-) create mode 100644 internal/core/startup_secrets_test.go diff --git a/internal/config/secret_config.go b/internal/config/secret_config.go index 74e66b5..71a9265 100644 --- a/internal/config/secret_config.go +++ b/internal/config/secret_config.go @@ -193,11 +193,55 @@ func (c *Config) countPlaintextSecrets() int { // with ciphertext still in place, so the unseal has to happen before the // registry (and any Save the startup path performs) sees the values. func (c *Config) NormalizeSecretsForRun(box *SecretBox) error { - c.AttachSecretBox(box) - if err := c.normalizeSecrets(box); err != nil { + hadPlaintext, err := c.UnsealSecrets(box) + if err != nil { return err } - return c.migratePlaintextSecrets() + return c.SealIfNeeded(hadPlaintext) +} + +// UnsealSecrets decrypts every sealed credential in memory and reports whether +// the config ON DISK still held plaintext (i.e. whether a sealing write is +// needed). It never writes; a failed decrypt (wrong master key) is returned so +// the process refuses to start instead of running with unusable credentials. +// +// The return value must be computed BEFORE unsealing and from the disk state, +// not from memory: after a Save the in-memory values are always plaintext, so a +// "is anything plaintext?" test run afterwards is unconditionally true and a +// caller would rewrite the file on every start. That was the actual behaviour - +// migratePlaintextSecrets() claimed to be idempotent in a comment but rewrote +// config.yaml on every boot. +// +// Callers that consume credentials (the provider registry, key seeding) must +// unseal FIRST. Seeding compares cfg.Keys[i].Key against the plaintext +// gateway_keys entries; running it while keys are still ciphertext made the +// dedupe never match, so every restart appended another copy of the same admin +// key (production accumulated four). +func (c *Config) UnsealSecrets(box *SecretBox) (bool, error) { + if box == nil { + return false, nil + } + c.AttachSecretBox(box) + hadPlaintext := c.hasPlaintextSecrets() + if err := c.normalizeSecrets(box); err != nil { + return hadPlaintext, err + } + return hadPlaintext, nil +} + +// SealIfNeeded writes the config back once if hadPlaintext reported that the +// file still held clear-text credentials. When it is false the file is left +// untouched, which is what makes startup a no-op for an already-sealed config. +func (c *Config) SealIfNeeded(hadPlaintext bool) error { + if !hadPlaintext || c.box == nil || c.Path == "" { + return nil + } + n := c.countPlaintextSecrets() + if err := c.Save(); err != nil { + return err + } + log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path) + return nil } // AttachSecretBox wires the encryption box into the config so Save can seal @@ -210,8 +254,9 @@ func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box } func (c *Config) SecretBox() *SecretBox { return c.box } // migratePlaintextSecrets seals any credential still in the clear and writes the -// file once. It is idempotent: a config that is already sealed (or has no -// secrets) is left alone and nothing is written. +// file once. Kept for callers that attach the box themselves; it decides from +// the in-memory state, which is why UnsealSecrets + SealIfNeeded (which decide +// from the on-disk state) are preferred on the startup path. func (c *Config) migratePlaintextSecrets() error { if c.box == nil || c.Path == "" { return nil diff --git a/internal/config/secret_config_test.go b/internal/config/secret_config_test.go index b926070..cb3d025 100644 --- a/internal/config/secret_config_test.go +++ b/internal/config/secret_config_test.go @@ -119,7 +119,24 @@ sources: } } -func TestMigratePlaintextSecretsIsIdempotent(t *testing.T) { +// Sealing a config is a one-time migration: the FIRST run writes, every later +// start must leave the file alone. +// +// This test previously called migratePlaintextSecrets() twice and compared +// ModTime. That assertion was both weak and wrong: +// +// - migratePlaintextSecrets decides from IN-MEMORY state, and Save() ends by +// unsealing memory so the running process keeps working. So every call sees +// "plaintext present" and rewrites the file. Comparing ModTime hid this +// because two writes inside one filesystem timestamp tick look identical — +// the test flaked (~1 in 6) instead of failing. +// - it also exercised a function production no longer calls on the startup +// path, which now uses UnsealSecrets + SealIfNeeded (they decide from the +// ON-DISK state). +// +// So the test targets that real path and compares file CONTENT, which cannot be +// fooled by timestamp granularity. +func TestSealingIsIdempotentAcrossStarts(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") runtime := filepath.Join(dir, "runtime.json") @@ -132,29 +149,52 @@ func TestMigratePlaintextSecretsIsIdempotent(t *testing.T) { } cfg.AttachSecretBox(box) - if err := cfg.migratePlaintextSecrets(); err != nil { - t.Fatalf("first migrate: %v", err) + hadPlaintext, err := cfg.UnsealSecrets(box) + if err != nil { + t.Fatalf("unseal: %v", err) + } + if !hadPlaintext { + t.Fatal("a config written with a clear-text credential must report plaintext") + } + if err := cfg.SealIfNeeded(hadPlaintext); err != nil { + t.Fatalf("seal: %v", err) } first, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(first), encPrefix) { - t.Fatal("migration did not seal the value") + t.Fatal("sealing did not encrypt the value on disk") } // In-memory must be plaintext so the running process keeps working. if cfg.Sources[0].APIKey != "sk-clear" { t.Errorf("in-memory api_key = %q, want plaintext", cfg.Sources[0].APIKey) } - // Second run: already sealed => no write. - before, _ := os.Stat(path) - if err := cfg.migratePlaintextSecrets(); err != nil { - t.Fatalf("second migrate: %v", err) + // Simulate the next start: load from disk, unseal, seal-if-needed. The file + // was already sealed, so nothing may be written. + cfg2, err := Load(path) + if err != nil { + t.Fatal(err) } - after, _ := os.Stat(path) - if before.ModTime() != after.ModTime() { - t.Error("second migrate rewrote an already-sealed config") + cfg2.AttachSecretBox(box) + hadPlaintext2, err := cfg2.UnsealSecrets(box) + if err != nil { + t.Fatalf("second unseal: %v", err) + } + if hadPlaintext2 { + t.Error("a sealed config reported plaintext — SealIfNeeded would rewrite " + + "the file on every start") + } + if err := cfg2.SealIfNeeded(hadPlaintext2); err != nil { + t.Fatalf("second seal: %v", err) + } + second, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Error("the second start rewrote an already-sealed config; startup must be a no-op") } } diff --git a/internal/core/core.go b/internal/core/core.go index 65298c2..a23c992 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -56,8 +56,26 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { c.store = config.NewStore(cfg.RuntimeFile) // Share one box between the runtime store and config.yaml so a single // master.key seals both files. config.Load left the config holding - // ciphertext (if it was sealed); unseal it now that the box exists. + // ciphertext (if it was sealed); unseal it NOW, before anything reads a + // credential. + // + // ORDER IS LOAD-BEARING. These steps each consume secrets and must run + // after the unseal: + // - seedKeys compares cfg.Keys[i].Key against the plaintext + // gateway_keys entries; with ciphertext keys the comparison never + // matched and every restart appended another duplicate admin key + // (production had four copies of the same admin key). + // - rebuildRegistry hands cfg.Sources[i].APIKey to the providers; with + // ciphertext it built every upstream client with "enc:v1:..." as its + // bearer token. + // Previously the unseal happened at the END of this function, and things + // only appeared to work because seedKeys' Save() unsealed memory as a side + // effect. Removing the redundant saves exposed the real ordering bug. cfg.AttachSecretBox(c.store.SecretBox()) + hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox()) + if err != nil { + return nil, fmt.Errorf("unseal secrets: %w", err) + } if err := c.store.Load(); err != nil { return nil, fmt.Errorf("runtime store: %w", err) } @@ -79,10 +97,10 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { if err := c.seedPresetTemplates(); err != nil { return nil, fmt.Errorf("seed preset templates: %w", err) } - // Seal any credential still in the clear in config.yaml. Idempotent: an - // already-sealed config is not rewritten, so a normal restart writes - // nothing. This is the only place that rewrites the file on startup. - if err := cfg.NormalizeSecretsForRun(c.store.SecretBox()); err != nil { + // Seal any credential still in the clear in config.yaml. Startup writes + // nothing when the file was already sealed (hadPlaintextSecrets is decided + // from the on-disk state, before the unseal above). + if err := cfg.SealIfNeeded(hadPlaintextSecrets); err != nil { return nil, fmt.Errorf("normalize secrets: %w", err) } return c, nil diff --git a/internal/core/startup_secrets_test.go b/internal/core/startup_secrets_test.go new file mode 100644 index 0000000..8f402c6 --- /dev/null +++ b/internal/core/startup_secrets_test.go @@ -0,0 +1,178 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// Startup must be idempotent, and every credential consumer must see PLAINTEXT. +// +// Two bugs shared one root cause: NewFromConfig unsealed the config at the END, +// after the steps that read credentials had already run. +// +// 1. seedKeys() deduped cfg.Keys[i].Key against the plaintext gateway_keys +// entries. With ciphertext keys the comparison never matched, so every +// restart appended another copy of the same admin key — production reached +// four identical admin keys. +// 2. rebuildRegistry() handed cfg.Sources[i].APIKey to provider.New, so with +// ciphertext it built every upstream client with "enc:v1:..." as its bearer +// token and every upstream call would 401. +// +// They masked each other: seedKeys' Save() unsealed memory as a side effect, so +// bug 2 was invisible until the redundant saves were removed. These tests drive +// the real entry point (core.New -> config.Load off disk), because building a +// fresh in-memory config per attempt hides both bugs — that is exactly how the +// first draft of this test failed to reproduce anything. + +// writeSealedInstall creates a config on disk the way a real install looks +// after its first run: credentials already sealed, gateway_keys still plaintext. +func writeSealedInstall(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "config.yaml") + cfg := &config.Config{ + Path: path, + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "AUTO", + GatewayKeys: []string{"sk-gw-seed-me"}, + Sources: []config.Source{{ + Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret", + Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}, + }}, + } + c, err := NewFromConfig(cfg) + if err != nil { + t.Fatalf("initial install: %v", err) + } + c.Close() + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "enc:v1:") { + t.Fatalf("fixture is wrong: on-disk config holds no sealed credential, "+ + "so this test cannot detect ciphertext leaking into consumers:\n%s", raw) + } + return path +} + +// writeSealedInstallNoSeed is writeSealedInstall without gateway_keys, so no +// key is ever seeded and no startup Save() can unseal the config as a side +// effect. That isolation is what makes the ciphertext leak observable. +func writeSealedInstallNoSeed(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "config.yaml") + cfg := &config.Config{ + Path: path, + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "AUTO", + Sources: []config.Source{{ + Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret", + Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}, + }}, + } + c, err := NewFromConfig(cfg) + if err != nil { + t.Fatalf("initial install: %v", err) + } + c.Close() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "enc:v1:") { + t.Fatalf("fixture is wrong: no sealed credential on disk:\n%s", raw) + } + return path +} + +// countKeysWithSecret loads the config, unseals it, and counts keys whose +// plaintext equals one of the gateway_keys entries. +func countKeysWithSecret(t *testing.T, path string) (total, matching int) { + t.Helper() + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + box, err := config.NewSecretBox(cfg.RuntimeFile) + if err != nil { + t.Fatal(err) + } + if _, err := cfg.UnsealSecrets(box); err != nil { + t.Fatal(err) + } + want := map[string]bool{} + for _, g := range cfg.GatewayKeys { + want[g] = true + } + for _, k := range cfg.Keys { + total++ + if want[k.Key] { + matching++ + } + } + return total, matching +} + +// Restarting must not grow the key list. Before the fix this went 2 -> 3 -> 4. +func TestRestartDoesNotDuplicateSeededKeys(t *testing.T) { + dir := t.TempDir() + path := writeSealedInstall(t, dir) + + if _, n := countKeysWithSecret(t, path); n != 1 { + t.Fatalf("after install: %d keys carry the seeded secret, want 1", n) + } + + for i := 2; i <= 4; i++ { + c, err := New(path) // production path: reads the file + if err != nil { + t.Fatalf("restart %d: %v", i, err) + } + c.Close() + total, n := countKeysWithSecret(t, path) + if n != 1 { + t.Fatalf("restart %d: %d keys carry the seeded secret (total %d), want "+ + "exactly 1 — seedKeys is comparing ciphertext against plaintext and "+ + "appending a duplicate on every start", i, n, total) + } + } +} + +// Every provider must hold the real upstream credential, not its ciphertext. +// +// This deliberately runs with gateway_keys EMPTY. The buggy order was masked +// whenever seedKeys had work to do, because its Save() unsealed memory as a +// side effect — providers then happened to see plaintext. With no key to seed, +// no Save runs, and the ciphertext reaches the registry directly. An earlier +// version of this test kept gateway_keys populated and was insensitive: it +// passed even with the bug present. +func TestProvidersGetPlaintextCredentials(t *testing.T) { + dir := t.TempDir() + path := writeSealedInstallNoSeed(t, dir) + + c, err := New(path) + if err != nil { + t.Fatalf("restart: %v", err) + } + defer c.Close() + + for _, p := range c.registry.Providers() { + key := p.Config().APIKey + if strings.HasPrefix(key, "enc:v1:") { + t.Errorf("provider %q holds CIPHERTEXT api_key %q — every upstream call "+ + "would 401", p.Name(), key) + } + if key != "sk-upstream-secret" { + t.Errorf("provider %q api_key = %q, want the plaintext upstream secret", + p.Name(), key) + } + } +} From cd82835f25f4e80f28ad8d8c1f42291ecfbdc428 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 22:53:19 +0800 Subject: [PATCH 07/42] chore(version): 1.7.2 -> 1.7.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动重复播种 admin key 与配置封存非幂等的修复,走 patch 发布。 --- cmd/gui/package-lock.json | 8 ++++---- cmd/gui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 93e9f94..e68fd06 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.7.2", + "version": "1.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.7.2", + "version": "1.7.3", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", @@ -582,7 +582,7 @@ } }, "node_modules/@peculiar/webcrypto": { - "version": "1.7.2", + "version": "1.7.3", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", "dev": true, @@ -3335,7 +3335,7 @@ } }, "node_modules/resedit": { - "version": "1.7.2", + "version": "1.7.3", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", "dev": true, diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 683deff..875d55d 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.7.2", + "version": "1.7.3", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 7e33d11d15ec9a4aeab9acd8f3529ef4daa821f0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 23:27:42 +0800 Subject: [PATCH 08/42] =?UTF-8?q?fix(packaging):=20=E5=8F=91=E8=A1=8C?= =?UTF-8?q?=E5=8C=85=E4=B8=8D=E5=86=8D=E5=86=85=E7=BD=AE=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E7=9A=84=20admin=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打包时把本地 config.yaml(gitignored,含运维真实密钥)原样复制成 config.example.yaml,而 postinst 在首次安装且 /etc 无配置时又把它 cp 成生产配置 ⇒ 每次安装都得到一个同值的、公开已知的 admin key。 实测该 key(sk-gw-local-0001)在生产上真实有效(/v1/models 返回 200, 而网关监听 0.0.0.0)。 三处修正: - 新增 packaging/config.example.yaml(受 git 跟踪的净化模板), gateway_keys 留空、sources 留空,并写明不要填死值。 - core-dist.sh / nfpm.yaml 改为打包该模板,不再碰本地 config.yaml。 - postinst.sh 不再投递示例配置:留空文件会让网关启动但拒绝所有请求 (无门可入)。改为让二进制首启时自行生成随机 admin key 并打印 —— 每次安装都不同,且开箱可用。示例文件仅作为 /usr/share 下的参考保留。 实测首启:生成 sk-gw-838d66a1... 并打印,与旧的共享固定值不同。 --- packaging/config.example.yaml | 30 ++++++++++++++++++++++++++++++ packaging/core-dist.sh | 7 ++++++- packaging/nfpm.yaml | 7 +++++-- packaging/postinst.sh | 16 +++++++++++++--- 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 packaging/config.example.yaml diff --git a/packaging/config.example.yaml b/packaging/config.example.yaml new file mode 100644 index 0000000..a06d732 --- /dev/null +++ b/packaging/config.example.yaml @@ -0,0 +1,30 @@ +# llmsproxy — 统一 OpenAI 兼容网关配置(示例模板) +# +# 这个是打包进发行包、供首次安装使用的模板。它故意不含任何可用的凭据: +# 首次启动时程序会生成一个随机 admin key 并写入实际配置(WebUI「密钥」页可见)。 +# +# ⚠ 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值 +# 都会成为一个全网皆知的 admin key(旧版示例里的 sk-gw-local-0001 就踩过 +# 这个坑:它被直接抄进生产配置并真实可用)。 + +# 网关监听地址(默认 :8080,建议绑内网/回环) +listen: 127.0.0.1:8080 + +# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。 +gateway_keys: [] + +# 默认模型选择:具体模型 id 或 AUTO(按各源模型的 priority 自动选最高可用源) +default_model: AUTO + +# Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器) +adapter_dir: adapters + +# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效) +runtime_file: runtime.json + +# 全局并发上限(0 = 不限) +max_concurrent: 0 + +# ---- 上游 LLM 源列表 ---- +# 留空:安装后到 WebUI 添加源,或从内置模板一键创建。 +sources: [] diff --git a/packaging/core-dist.sh b/packaging/core-dist.sh index 514a2e5..5445fc4 100755 --- a/packaging/core-dist.sh +++ b/packaging/core-dist.sh @@ -68,7 +68,12 @@ log "packaging tar.gz..." TAR_DIR="$DIST/llmsproxy-$VERSION-linux-amd64" rm -rf "$TAR_DIR"; mkdir -p "$TAR_DIR" cp "$BUILD_BIN" "$TAR_DIR/llmsproxy" -cp "$ROOT/config.yaml" "$TAR_DIR/config.example.yaml" +# Use the tracked sanitised template, NOT the local config.yaml: that file is +# gitignored and carries the operator's real gateway_keys / upstream keys. It +# was previously copied verbatim as config.example.yaml, which shipped a real, +# working admin key (sk-gw-local-0001) to every install — and postinst.sh copies +# the example to /etc when none exists, so it ended up live in production. +cp "$ROOT/packaging/config.example.yaml" "$TAR_DIR/config.example.yaml" cp "$ROOT/packaging/llmsproxy.service" "$TAR_DIR/llmsproxy.service" mkdir -p "$TAR_DIR/adapters" cp "$ROOT"/internal/lua/adapters/*.lua "$TAR_DIR/adapters/" diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 618ab6e..efcbcb3 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -28,8 +28,11 @@ contents: dst: /etc/systemd/system/llmsproxy.service file_info: mode: 0644 - # example config (seeded to /etc/llmsproxy on first install by postinst) - - src: ./config.yaml + # example config (seeded to /etc/llmsproxy on first install by postinst). + # Must be the tracked sanitised template: ./config.yaml is the operator's own + # gitignored config and shipping it would leak real credentials — it shipped a + # working admin key (sk-gw-local-0001) to every install for a while. + - src: ./packaging/config.example.yaml dst: /usr/share/llmsproxy/config.example.yaml file_info: mode: 0644 diff --git a/packaging/postinst.sh b/packaging/postinst.sh index a41100d..63768d3 100755 --- a/packaging/postinst.sh +++ b/packaging/postinst.sh @@ -9,9 +9,19 @@ mkdir -p /etc/llmsproxy/adapters if [ -f /etc/llmsproxy/config.yaml ]; then echo "llmsproxy: keeping existing /etc/llmsproxy/config.yaml" else - # seed a starter config from the packaged example - cp -f /usr/share/llmsproxy/config.example.yaml /etc/llmsproxy/config.yaml 2>/dev/null || true - chmod 0644 /etc/llmsproxy/config.yaml + # Do NOT seed a starter config here. Packaging/installing one used to hand + # everyone the same file, and while it was copied from the maintainer's + # live config it shipped a real, working admin key (sk-gw-local-0001) to + # every install. + # + # Instead, leave the file absent: on first start the binary's EnsureDefault + # path creates config.yaml with a FRESH RANDOM admin key and logs it, which + # is both unique per install and actually usable. The packaged + # config.example.yaml stays in /usr/share as a reference template only. + # + # (Seeding it with gateway_keys: [] would be worse: the gateway would start + # but reject every request, with no way in except editing the file.) + echo "llmsproxy: no /etc/llmsproxy/config.yaml — a random admin key will be generated on first start" fi # systemd From 04e544c82362ff9a8d260193ed738ce0059d484b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 23:27:54 +0800 Subject: [PATCH 09/42] chore(version): 1.7.3 -> 1.7.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发行包不再内置可用 admin key 的修复,走 patch 发布。 --- cmd/gui/package-lock.json | 8 ++++---- cmd/gui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index e68fd06..b0ceb46 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.7.3", + "version": "1.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.7.3", + "version": "1.7.4", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", @@ -582,7 +582,7 @@ } }, "node_modules/@peculiar/webcrypto": { - "version": "1.7.3", + "version": "1.7.4", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", "dev": true, @@ -3335,7 +3335,7 @@ } }, "node_modules/resedit": { - "version": "1.7.3", + "version": "1.7.4", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", "dev": true, diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 875d55d..3f708ce 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.7.3", + "version": "1.7.4", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 70f1c879bd8b54fedf17078b6d7c72df1c968b78 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 23:42:48 +0800 Subject: [PATCH 10/42] =?UTF-8?q?fix(startup):=20=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E6=94=B9=E8=AF=BB=E7=9C=9F=E5=AE=9E=E7=94=9F?= =?UTF-8?q?=E6=95=88=E7=9A=84=20key=20=E9=9B=86=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动时那条「gateway_keys is EMPTY — without a key every request is rejected」 读的是 legacy 的 cfg.GatewayKeys 段,而鉴权实际用 cfg.Keys(core.ListKeys)。 seedKeys 首次启动把 gateway_keys 搬进 keys[] 之后,YAML 里那个列表就不再 被鉴权使用。于是在它被清空(例如轮换掉 starter key 之后)而 keys[] 仍有 7 把可用 key(含 admin)时,进程每次启动都谎报「所有请求都会被拒绝」。 实测:生产日志出现该警告,而同一个 key 请求 /v1/models 返回 200。 - main.go 改为检查 c.ListKeys(),文案改成不绑定字段名。 - 顺带删掉 gateway.New 的 gatewayKeys 参数:函数体从未使用它, 只读 ListKeys(),留着会继续诱导人以为鉴权来自那个列表。 判据:e2e/TestStartupWarningReflectsRealKeysNotLegacyList —— 构造 「gateway_keys 空 + keys[] 有 key」的真实形态,先断言该 key 确实能鉴权, 再断言日志里不再出现那句谎报。变异验证:回退成 GatewayKeys() 即变红。 --- cmd/llmsproxy/main.go | 17 ++- e2e/e2e_test.go | 141 ++++++++++++++++++++++ internal/gateway/gateway_test.go | 2 +- internal/gateway/key_quota_api_test.go | 2 +- internal/gateway/key_quota_wiring_test.go | 6 +- internal/gateway/server.go | 2 +- 6 files changed, 159 insertions(+), 11 deletions(-) diff --git a/cmd/llmsproxy/main.go b/cmd/llmsproxy/main.go index f241c52..519169a 100644 --- a/cmd/llmsproxy/main.go +++ b/cmd/llmsproxy/main.go @@ -73,19 +73,26 @@ func main() { } } - gw, err := gateway.New(c, c.GatewayKeys()) + gw, err := gateway.New(c) if err != nil { log.Fatalf("[llmsproxy] gateway: %v", err) } // Ops hygiene: surface the two most common footguns instead of silently // running with them. - if keys := c.GatewayKeys(); len(keys) == 0 { - log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected") + // + // Read the AUTHORITATIVE source. Auth uses core.ListKeys() (cfg.Keys), not + // the legacy cfg.GatewayKeys list: seedKeys copies gateway_keys into keys[] + // on first start and the YAML list is ignored for auth afterwards. Checking + // GatewayKeys() here made the warning lie — once the legacy list was empty + // (e.g. after rotating the starter key away) it reported "every request is + // rejected" while seven working keys, one of them admin, were in service. + if keys := c.ListKeys(); len(keys) == 0 { + log.Printf("[llmsproxy] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys") } else { for _, k := range keys { - if k == "sk-gw-local-0001" || k == "sk-local-0001" { - log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k) + if k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" { + log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key) } } } diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ba38d9b..ba48b53 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -326,3 +326,144 @@ func statusOf(resp *http.Response) int { } return resp.StatusCode } + +// The startup warning about key configuration must describe the key set that +// AUTH actually uses (cfg.Keys, via core.ListKeys), not the legacy +// gateway_keys YAML list. +// +// It used to check GatewayKeys() only. seedKeys copies gateway_keys into keys[] +// on first start and the YAML list stops being consulted, so once that list was +// emptied — e.g. after rotating the starter key away — the process logged +// "every request will be rejected" while seven working keys, one of them admin, +// were in service. Observed in production: the warning appeared and that same +// key returned HTTP 200 on /v1/models. +// +// The fixture reproduces exactly that shape: gateway_keys EMPTY, with the +// working key present only in keys[]. +func TestStartupWarningReflectsRealKeysNotLegacyList(t *testing.T) { + up := newMockUpstream(t) + dir := t.TempDir() + + // Seed a real install once so keys[] gets populated from gateway_keys, then + // rewrite the file to look like a rotated install: legacy list cleared, the + // seeded key still present in keys[]. + path := writeConfig(t, dir, "127.0.0.1:0", map[string]*mockUpstream{"good": up}) + bin := buildBinary(t) + + // First run creates the sealed config with keys[] populated. + seedPort := freePort(t) + cfg1 := writeConfig(t, dir, "127.0.0.1:"+seedPort, map[string]*mockUpstream{"good": up}) + g1 := startGateway(t, bin, "127.0.0.1:"+seedPort, cfg1) + g1.stop() + + data, err := os.ReadFile(cfg1) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "keys:") { + t.Fatalf("first run did not populate keys[]:\n%s", data) + } + + // Now clear the legacy gateway_keys list, keeping keys[] intact. The + // rewritten file may use different indentation, so match loosely. + cleared, n := clearGatewayKeys(string(data)) + if n == 0 { + t.Fatalf("could not find a gateway_keys entry to clear in:\n%s", data) + } + if err := os.WriteFile(cfg1, []byte(cleared), 0o644); err != nil { + t.Fatal(err) + } + + // Start again and inspect the log. The config pins its listen address, so + // rewrite that field to the port we will actually poll. + port2 := freePort(t) + rehosted, ok := replaceListen(string(cleared), "127.0.0.1:"+port2) + if !ok { + t.Fatalf("could not rewrite listen in:\n%s", cleared) + } + cfg2 := filepath.Join(dir, "config2.yaml") + if err := os.WriteFile(cfg2, []byte(rehosted), 0o644); err != nil { + t.Fatal(err) + } + g2 := startGateway(t, bin, "127.0.0.1:"+port2, cfg2) + defer g2.stop() + + // Sanity: the key really does authenticate, so the warning would be a lie. + resp, _ := g2.do("GET", "/v1/models", "", true) + if resp == nil || resp.StatusCode != http.StatusOK { + t.Fatalf("key from keys[] did not authenticate (status %v); test fixture is wrong", + resp) + } + + logs := g2.logString() + if strings.Contains(logs, "gateway_keys is EMPTY") { + t.Errorf("startup logged 'gateway_keys is EMPTY ... every request is rejected' while "+ + "a working admin key was in service — the warning must read the authoritative "+ + "key set (core.ListKeys), not the legacy YAML list.\nlog:\n%s", logs) + } + _ = path + _ = g1 +} + +func freePort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + _, port, _ := net.SplitHostPort(l.Addr().String()) + return port +} + +// stop terminates the gateway and waits, so its log buffer is complete. +func (g *gatewayUnderTest) stop() { + if g == nil || g.cmd == nil || g.cmd.Process == nil { + return + } + _ = g.cmd.Process.Kill() + _, _ = g.cmd.Process.Wait() +} + +// logString returns everything the gateway has written so far. +func (g *gatewayUnderTest) logString() string { return g.log.String() } + +// clearGatewayKeys empties the gateway_keys list in a config, tolerating any +// indentation the YAML writer chose. Returns the new text and how many entries +// it removed. +func clearGatewayKeys(src string) (string, int) { + lines := strings.Split(src, "\n") + var out []string + removed := 0 + inList := false + for _, l := range lines { + trimmed := strings.TrimSpace(l) + if strings.HasPrefix(l, "gateway_keys:") { + out = append(out, "gateway_keys: []") + inList = true + removed++ + continue + } + if inList { + if strings.HasPrefix(trimmed, "- ") { + removed++ + continue + } + inList = false + } + out = append(out, l) + } + return strings.Join(out, "\n"), removed +} + +// replaceListen rewrites the top-level listen: value. +func replaceListen(src, addr string) (string, bool) { + lines := strings.Split(src, "\n") + for i, l := range lines { + if strings.HasPrefix(l, "listen:") { + lines[i] = "listen: " + addr + return strings.Join(lines, "\n"), true + } + } + return src, false +} diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index a1b9656..9e5a094 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -58,7 +58,7 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway { t.Fatalf("core: %v", err) } t.Cleanup(c.Close) - g, err := New(c, []string{"sk-test"}) + g, err := New(c) if err != nil { t.Fatalf("gateway: %v", err) } diff --git a/internal/gateway/key_quota_api_test.go b/internal/gateway/key_quota_api_test.go index 9071a25..f5e1517 100644 --- a/internal/gateway/key_quota_api_test.go +++ b/internal/gateway/key_quota_api_test.go @@ -35,7 +35,7 @@ func adminGateway(t *testing.T, keys ...config.GWKey) *Gateway { t.Fatalf("core: %v", err) } t.Cleanup(c.Close) - g, err := New(c, []string{"sk-admin"}) + g, err := New(c) if err != nil { t.Fatalf("gateway: %v", err) } diff --git a/internal/gateway/key_quota_wiring_test.go b/internal/gateway/key_quota_wiring_test.go index 9de0870..db62184 100644 --- a/internal/gateway/key_quota_wiring_test.go +++ b/internal/gateway/key_quota_wiring_test.go @@ -47,7 +47,7 @@ func quotaGateway(t *testing.T, keyA, keyB config.GWKey) (*Gateway, *upstreamCtr t.Fatalf("core: %v", err) } t.Cleanup(c.Close) - g, err := New(c, []string{keyA.Key, keyB.Key}) + g, err := New(c) if err != nil { t.Fatalf("gateway: %v", err) } @@ -323,7 +323,7 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway { for _, k := range keys { secrets = append(secrets, k.Key) } - g, err := New(c, secrets) + g, err := New(c) if err != nil { t.Fatalf("gateway: %v", err) } @@ -368,7 +368,7 @@ func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) { t.Fatalf("core: %v", err) } t.Cleanup(c.Close) - g, err := New(c, []string{"sk-a"}) + g, err := New(c) if err != nil { t.Fatalf("gateway: %v", err) } diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 4edc466..d017245 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -115,7 +115,7 @@ func (g *Gateway) loginRecord(ip string, ok bool, now int64) { } } -func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { +func New(c *core.Core) (*Gateway, error) { sub, err := fs.Sub(uiFS, "ui") if err != nil { return nil, err From 504c5ac9a04a588f85cc4fab11bd5d269bfc2b20 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 23:42:48 +0800 Subject: [PATCH 11/42] chore(version): 1.7.4 -> 1.7.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动密钥警告误报的修复。 --- cmd/gui/package-lock.json | 8 ++++---- cmd/gui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index b0ceb46..4b38171 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.7.4", + "version": "1.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.7.4", + "version": "1.7.5", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", @@ -582,7 +582,7 @@ } }, "node_modules/@peculiar/webcrypto": { - "version": "1.7.4", + "version": "1.7.5", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", "dev": true, @@ -3335,7 +3335,7 @@ } }, "node_modules/resedit": { - "version": "1.7.4", + "version": "1.7.5", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", "dev": true, diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 3f708ce..95831fd 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.7.4", + "version": "1.7.5", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From bf0657bb84de7d7b1377921a1d299bd410d2e723 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 18:15:29 +0800 Subject: [PATCH 12/42] fix(sources): implement PUT and stop partial edits from clobbering api_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects on the admin source write path, both found while adding a model to a live source by hand. PUT /api/sources/{name} was advertised in the API index but never implemented — handleSourcesAPI only switched on GET/POST/DELETE, so the documented update verb answered 405 while the POST upsert behind it worked. POST is an upsert that replaces the whole source, so a partial edit that did not carry api_key persisted an empty or placeholder credential. The source kept its name, base_url and models, the write returned 200, and the source then answered 401 on the next request — long after the writing script exited 0. The WebUI had been routing around this by loading the real key through ?reveal=credentials; any script or partial update went straight into it. - implement PUT, taking the name from the path and rejecting a body name that disagrees rather than silently resolving to one of them - inherit the stored credential when api_key is omitted or sent as the literal "__KEEP__"; an explicit new key still rotates - an empty api_key on a source that does not exist yet stays empty, since credential-less local upstreams are legitimate - add model_ids, an additive shorthand, so "add these models" never has to read and echo the existing list back - align the API index with the implementation The model_ids merge had a first cut that dropped the existing list when the request carried no models field; TestSourceModelIDsIsAdditive caught it. Verified by mutation: removing PUT turns three tests red, flattening resolveAPIKey into a pass-through turns TestSourceUpsertKeepsAPIKey red on both subtests, and making model_ids replace instead of merge turns TestSourceModelIDsIsAdditive red. --- internal/gateway/api.go | 100 ++++++++++- internal/gateway/apiv1.go | 12 +- internal/gateway/sources_api_test.go | 259 +++++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 internal/gateway/sources_api_test.go diff --git a/internal/gateway/api.go b/internal/gateway/api.go index dfa1f4a..78c3e7c 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -78,6 +78,62 @@ type sourcePayload struct { MaxTokens int `json:"max_tokens"` MaxConcurrent int `json:"max_concurrent"` RPM int `json:"rpm"` // optional requests-per-minute cap, 0 = unlimited + // ModelIDs is an additive-only shorthand: when set, the named models are + // merged into an existing source instead of Models replacing the list. + // It exists because "add one model" is the most common scripted edit and a + // full Models list cannot be written without reading the source first. + ModelIDs []string `json:"model_ids,omitempty"` +} + +// keepExistingAPIKey is the mask a client sends when it means "keep the +// current credential". POST /api/sources is an upsert, so a script that edits +// one field and echoes a placeholder back would otherwise persist that +// placeholder as the live api_key — which fails closed as 401 on the next +// request, long after the script exited successfully. See TestSourceUpsertKeepsAPIKey. +const keepExistingAPIKey = "__KEEP__" + +// resolveAPIKey returns the credential to store, inheriting the current one +// when the payload does not supply a new one. +// +// Two distinct "no new credential" signals exist and they must not collapse: +// - the explicit mask keepExistingAPIKey — always inherit. +// - the empty string — inherit only for a source that already has a +// credential. Empty is a legitimate value for a *new* credential-less +// source (dshcodebuddy uses "dsh-local-noauth", local servers use ""), +// so a fresh source with an empty key must stay empty rather than inherit +// nothing and fail differently. +func (g *Gateway) resolveAPIKey(name, want string, exists bool) string { + if want == keepExistingAPIKey { + return g.currentAPIKey(name) + } + if exists && want == "" { + return g.currentAPIKey(name) + } + return want +} + +func (g *Gateway) currentAPIKey(name string) string { + if s := g.sourceByName(name); s != nil { + return s.APIKey + } + return "" +} + +// sourceByName returns a live view of one source, or nil when it does not +// exist. It goes through core.Sources() so the key is already unsealed and +// defaults applied, matching what the routes actually use. +func (g *Gateway) sourceByName(name string) *config.Source { + for _, s := range g.core.Sources() { + if s.Name == name { + ss := s + return &ss + } + } + return nil +} + +func (g *Gateway) sourceExists(name string) bool { + return g.sourceByName(name) != nil } func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) { @@ -91,17 +147,34 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.core.Sources()}) - case http.MethodPost: + case http.MethodPost, http.MethodPut: var p sourcePayload body, _ := io.ReadAll(r.Body) if err := json.Unmarshal(body, &p); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) return } + // PUT addresses the source by path; the body name is only a fallback so + // a single-field edit does not have to echo the name back. A body that + // disagrees with the path is a caller bug, not something to silently + // resolve — it usually means the wrong source is about to be written. + if r.Method == http.MethodPut { + if path == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "source name required in path") + return + } + if p.Name != "" && p.Name != path { + writeError(w, http.StatusBadRequest, "invalid_request", + "source name in body ("+p.Name+") does not match path ("+path+")") + return + } + p.Name = path + } + exists := g.sourceExists(p.Name) src := config.Source{ Name: p.Name, BaseURL: p.BaseURL, - APIKey: p.APIKey, + APIKey: g.resolveAPIKey(p.Name, p.APIKey, exists), Adapter: p.Adapter, Endpoint: p.Endpoint, ImageEndpoint: p.ImageEndpoint, @@ -113,6 +186,29 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) { MaxConcurrent: p.MaxConcurrent, RPM: p.RPM, } + // model_ids is additive: "add these models" is the common scripted edit + // and it must not require reading (and echoing) the whole list back. + // A request that omits models entirely is therefore a pure add, not a + // request to empty the list -- that distinction is the whole point. + if len(p.ModelIDs) > 0 { + if len(src.Models) == 0 { + if existing := g.sourceByName(p.Name); existing != nil { + src.Models = append(src.Models, existing.Models...) + } + } + have := map[string]bool{} + for _, m := range src.Models { + have[m.ID] = true + } + for _, id := range p.ModelIDs { + id = strings.TrimSpace(id) + if id == "" || have[id] { + continue + } + have[id] = true + src.Models = append(src.Models, config.Model{ID: id, Kind: "chat"}) + } + } if err := g.core.AddSource(src); err != nil { writeError(w, http.StatusBadRequest, "source_error", err.Error()) return diff --git a/internal/gateway/apiv1.go b/internal/gateway/apiv1.go index 404e25c..242e7ad 100644 --- a/internal/gateway/apiv1.go +++ b/internal/gateway/apiv1.go @@ -165,7 +165,7 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { {Method: "POST", Path: "/api/sources", Auth: "admin", Summary: "add or replace a source", WriteEffect: "writes config.yaml (api_key sealed at rest)"}, {Method: "PUT", Path: "/api/sources/{name}", Auth: "admin", Summary: "update one source", - WriteEffect: "writes config.yaml"}, + WriteEffect: "writes config.yaml (api_key sealed at rest)"}, {Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source", WriteEffect: "writes config.yaml"}, @@ -187,9 +187,13 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { {Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"}, }, "conventions": map[string]interface{}{ - "errors": "{ \"error\": { \"type\": , \"message\": } }", - "path_escape": "URL-encode source and key names; {name} is a single path segment", - "idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name", + "errors": "{ \"error\": { \"type\": , \"message\": } }", + "path_escape": "URL-encode source and key names; {name} is a single path segment", + "idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name; " + + "PUT takes the name from the path and rejects a body name that disagrees", + "partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " + + "current credential; model_ids adds models to the existing list instead of replacing it, " + + "so a one-field edit never needs to read the source first", "config_truth": "all configuration lives in config.yaml; API writes are persisted immediately", "credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " + "returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " + diff --git a/internal/gateway/sources_api_test.go b/internal/gateway/sources_api_test.go new file mode 100644 index 0000000..df753c7 --- /dev/null +++ b/internal/gateway/sources_api_test.go @@ -0,0 +1,259 @@ +package gateway + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// These cover the two write-path defects found while adding a model to a live +// source by hand: +// +// 1. PUT /api/sources/{name} was documented in the API index but not +// implemented, so it answered 405 while POST (which does the same upsert) +// worked. A caller following the documented contract could not edit a +// source at all. +// 2. POST is an upsert that replaces the whole source, so a partial edit that +// did not carry api_key persisted an empty/placeholder credential. The +// source kept its name and models and answered 401 on the next call, well +// after the writing script had exited 0. +// +// The second one is the dangerous class: a write that reports success and +// breaks the source later. + +// sourceAPIKey reads the stored key back the way an operator would, i.e. what +// the routes actually use, not what the file happens to contain. +func sourceAPIKey(t *testing.T, g *Gateway, name string) string { + t.Helper() + for _, s := range g.core.Sources() { + if s.Name == name { + return s.APIKey + } + } + t.Fatalf("source %q not found", name) + return "" +} + +func seededGateway(t *testing.T) (*Gateway, string) { + t.Helper() + up := mockUpstream() + t.Cleanup(up.Close) + g := newTestGateway(t, config.Source{ + Name: "seeded", + BaseURL: up.URL, + Adapter: "openai", + APIKey: "sk-real-credential", + Models: []config.Model{{ID: "m-one", Kind: "chat"}}, + }) + return g, up.URL +} + +func TestSourcePutUpdatesSource(t *testing.T) { + g, up := seededGateway(t) + + // A single-field edit: change one model's priority, name the source only in + // the path. This is the shape the API index documents for PUT. + rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf( + `{"base_url":%q,"adapter":"openai","api_key":"sk-real-credential","models":[{"id":"m-one","priority":42,"kind":"chat"}]}`, up)) + if rr.Code != http.StatusOK { + t.Fatalf("PUT status=%d body=%s", rr.Code, rr.Body.String()) + } + + var found bool + for _, s := range g.core.Sources() { + if s.Name != "seeded" { + continue + } + found = true + for _, m := range s.Models { + if m.ID == "m-one" && m.Priority != 42 { + t.Fatalf("priority not applied: %+v", s.Models) + } + } + } + if !found { + t.Fatal("source vanished after PUT") + } + // And it must be routable, not merely stored. + if rr := doReq(t, g, "GET", "/v1/models", ""); rr.Code != http.StatusOK || + !strings.Contains(rr.Body.String(), "m-one") { + t.Fatalf("model not routable after PUT: %d %s", rr.Code, rr.Body.String()) + } +} + +func TestSourcePutRejectsNameMismatch(t *testing.T) { + g, _ := seededGateway(t) + // Body names a different source than the path. Silently preferring either + // one would let a client edit a source it did not name. + rr := doReq(t, g, "PUT", "/api/sources/seeded", `{"name":"other","base_url":"http://x","models":[{"id":"m"}]}`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("name mismatch accepted: status=%d body=%s", rr.Code, rr.Body.String()) + } + if sourceAPIKey(t, g, "seeded") != "sk-real-credential" { + t.Fatal("rejected PUT still mutated the source") + } +} + +func TestSourcePutRequiresNameInPath(t *testing.T) { + g, _ := seededGateway(t) + rr := doReq(t, g, "PUT", "/api/sources", `{"base_url":"http://x","models":[{"id":"m"}]}`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("PUT without a name in the path: status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestSourceUpsertKeepsAPIKey(t *testing.T) { + for _, tc := range []struct { + name string + sentKey string + }{ + {"explicit mask", keepExistingAPIKey}, + {"omitted key", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + g, up := seededGateway(t) + rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf( + `{"base_url":%q,"adapter":"openai","api_key":%q,"models":[{"id":"m-one"},{"id":"m-two"}]}`, + up, tc.sentKey)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if got := sourceAPIKey(t, g, "seeded"); got != "sk-real-credential" { + t.Fatalf("api_key was clobbered: got %q, want the pre-existing credential", got) + } + // The failure this guards is a source that looks configured but + // cannot authenticate, so assert the route still works. + if rr := doReq(t, g, "GET", "/v1/models", ""); rr.Code != http.StatusOK { + t.Fatalf("source unusable after edit: %d %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestSourceUpsertStillWritesNewKey(t *testing.T) { + // The keep-existing path must not swallow a deliberate rotation. + g, up := seededGateway(t) + rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf( + `{"base_url":%q,"adapter":"openai","api_key":"sk-rotated","models":[{"id":"m-one"}]}`, up)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if got := sourceAPIKey(t, g, "seeded"); got != "sk-rotated" { + t.Fatalf("rotation ignored: got %q", got) + } +} + +func TestSourceUpsertNewSourceMayHaveNoKey(t *testing.T) { + // Empty is a real value for a credential-less source (local upstreams), so + // inherit-on-empty must not invent one for a source that does not exist yet. + up := mockUpstream() + defer up.Close() + g := newTestGateway(t) + rr := doReq(t, g, "POST", "/api/sources", fmt.Sprintf( + `{"name":"noauth","base_url":%q,"adapter":"openai","models":[{"id":"local-m"}]}`, up.URL)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if got := sourceAPIKey(t, g, "noauth"); got != "" { + t.Fatalf("unexpected key on a new credential-less source: %q", got) + } +} + +func TestSourceModelIDsIsAdditive(t *testing.T) { + // "Add one model" is the edit that motivated the defect: it needs no read + // of the existing list, so it must not require one. + g, up := seededGateway(t) + rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf( + `{"base_url":%q,"adapter":"openai","api_key":%q,"model_ids":["m-two","m-two"," m-three "]}`, up, keepExistingAPIKey)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var ids []string + for _, s := range g.core.Sources() { + if s.Name == "seeded" { + for _, m := range s.Models { + ids = append(ids, m.ID) + } + } + } + // m-one kept, m-two not duplicated, blank entries dropped, whitespace trimmed. + want := map[string]int{"m-one": 1, "m-two": 1, "m-three": 1} + if len(ids) != len(want) { + t.Fatalf("models=%v, want exactly %v", ids, want) + } + seen := map[string]int{} + for _, id := range ids { + seen[id]++ + } + for id, n := range want { + if seen[id] != n { + t.Fatalf("models=%v: %q appears %d times, want %d", ids, id, seen[id], n) + } + } +} + +func TestSourceWriteRejectsNonAdmin(t *testing.T) { + g, up := seededGateway(t) + for _, m := range []string{"POST", "PUT", "DELETE"} { + req, _ := http.NewRequest(m, "/api/sources/seeded", strings.NewReader(fmt.Sprintf( + `{"name":"seeded","base_url":%q,"models":[{"id":"m"}]}`, up))) + req.Header.Set("Authorization", "Bearer sk-not-a-real-key") + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + g.Handler().ServeHTTP(rr, req) + if rr.Code == http.StatusOK { + t.Fatalf("%s allowed for an unknown key: %s", m, rr.Body.String()) + } + } + if sourceAPIKey(t, g, "seeded") != "sk-real-credential" { + t.Fatal("unauthenticated write mutated the source") + } +} + +// TestSourceWritePersistsToDisk guards the difference between "the in-memory +// registry accepted it" and "it will survive a restart". The api_key must be +// sealed at rest, never the literal placeholder. +func TestSourceWritePersistsToDisk(t *testing.T) { + g, up := seededGateway(t) + rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf( + `{"base_url":%q,"adapter":"openai","api_key":%q,"models":[{"id":"m-one"}]}`, up, keepExistingAPIKey)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + raw, err := os.ReadFile(g.core.Config().Path) + if err != nil { + t.Fatalf("read config: %v", err) + } + text := string(raw) + if strings.Contains(text, keepExistingAPIKey) { + t.Fatal("the keep-mask was persisted as a literal api_key") + } + if strings.Contains(text, "sk-real-credential") { + t.Fatal("api_key stored in plaintext; it must be sealed at rest") + } + if !strings.Contains(text, "m-one") { + t.Fatal("source not persisted") + } + // The masked GET view must not leak the credential either. + rr = doReq(t, g, "GET", "/api/v1/sources/seeded", "") + if rr.Code != http.StatusOK { + t.Fatalf("get status=%d", rr.Code) + } + var view struct { + Source struct { + APIKey string `json:"api_key"` + } `json:"source"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil { + t.Fatalf("decode: %v", err) + } + if view.Source.APIKey == "sk-real-credential" { + t.Fatal("GET /api/v1/sources leaked the plaintext api_key") + } +} From 5639bb662caced5bdec291db7793eef0ee20028e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 18:29:11 +0800 Subject: [PATCH 13/42] chore(version): 1.7.5 -> 1.7.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /api/sources/{name} 未实现却见于文档(返回 405),以及 POST upsert 会用占位符/空值覆盖真实 api_key —— 写入返回 200,源却在之后一直 401。 --- cmd/gui/package-lock.json | 4 ++-- cmd/gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 4b38171..c4a7771 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.7.5", + "version": "1.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.7.5", + "version": "1.7.6", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 95831fd..b7449b8 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.7.5", + "version": "1.7.6", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 429afce67e58d1084997db1888602a30718792ba Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 19:19:40 +0800 Subject: [PATCH 14/42] =?UTF-8?q?fix(gui):=20=E6=A1=8C=E9=9D=A2=E7=89=88?= =?UTF-8?q?=E8=A2=AB=E8=87=AA=E5=B7=B1=E7=9A=84=E5=AF=86=E9=92=A5=E5=B0=81?= =?UTF-8?q?=E5=AD=98=E6=8C=A1=E4=BD=8F=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop build authenticates the embedded core by reading the admin key out of config.yaml with a regex and injecting it as a gw_key cookie. The core seals credentials at rest (enc:v1:...), so from the second start onward that regex yields ciphertext, the cookie is worthless, and the app asks the user for a key they never set. The key is generated and hidden by the app itself. Reproduced end to end: first start writes a plaintext profile, the core seals it, every later start reads back "enc:v1:..." and falls through to the login prompt. - when the stored value is sealed, ask the core to unseal it via -show-secrets, which only reads, prints and exits. Reimplementing the core's AEAD in JS would be a second source of truth for its key format. - cwd must be the profile dir. The core locates master.key relative to the config's runtime_file, so a call made from anywhere else has it generate a second master key in the CWD and then fail to decrypt ("master key changed?"). Electron's CWD is not the profile dir, so without this the desktop build cannot read its own key even after unsealing is wired up. - the loose regex is kept as a fallback so a future change to the -show-secrets output degrades to a login prompt rather than to a wrong credential. Verified: plaintext start -> core seals -> restart recovers the same key, with the core running the whole time. Dropping cwd:PROFILE_DIR makes the unseal fail and leaves a stray master.key in the CWD, so the cwd argument is load-bearing rather than tidiness. --- cmd/gui/main.js | 80 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 172be6d..0350c6e 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -17,7 +17,7 @@ const fs = require("fs"); const http = require("http"); const os = require("os"); const crypto = require("crypto"); -const { spawn } = require("child_process"); +const { spawn, execFileSync } = require("child_process"); const LOG_FILE = path.join(app.getPath("userData"), "gui.log"); function log(msg) { @@ -97,23 +97,73 @@ function readAdminKey() { // gateway_keys entries get marked seed:true by the core (-> "replace the // initial key" warning in the web UI), while a plain keys entry authenticates // without any seed warning. + const raw = readConfigRaw(); + if (!raw) return ""; + // keys: + // - key: sk-gw-... + const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec( + raw, + ); + let v = k && k[1] ? k[1] : ""; + if (!v) { + // legacy: gateway_keys list (pre-migration configs) + const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(raw); + if (g && g[1]) v = g[1]; + if (!v) { + const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw); + if (g2 && g2[1]) v = g2[1]; + } + } + // The core seals credentials at rest (enc:v1:...), so from the second start + // onward the file holds ciphertext. Injecting that as a cookie is a + // guaranteed 401, which surfaces to the user as "enter your key" for a key + // they never set. Ask the core to unseal rather than reimplementing its + // crypto here. -show-secrets only reads, prints and exits: it starts nothing + // and writes nothing. + if (v && v.indexOf(SEALED_PREFIX) === 0) return unsealViaCore(); + return v; +} + +const SEALED_PREFIX = "enc:v1:"; + +function readConfigRaw() { try { if (!fs.existsSync(CONFIG_FILE)) return ""; - const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); - // keys: - // - key: sk-gw-... - const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec( - raw, + return fs.readFileSync(CONFIG_FILE, "utf-8"); + } catch (e) { + return ""; + } +} + +// Returns the admin key in the clear, or "" if the core cannot be asked. +// +// cwd must be the directory that holds master.key. The core derives that path +// from the config's runtime_file, so a call made from any other directory has +// the core generate a *second* master key in the CWD and then fail to decrypt +// ("master key changed?"). Electron's CWD is not the profile dir, so this is +// not optional: without it the desktop build cannot read its own key back. +function unsealViaCore() { + try { + const out = execFileSync( + CORE_EXE, + ["-config", CONFIG_FILE, "-show-secrets"], + { + encoding: "utf-8", + timeout: 10000, + windowsHide: true, + cwd: PROFILE_DIR, + }, ); - if (k && k[1]) return k[1]; - // legacy: gateway_keys list (pre-migration configs) - const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec( - raw, - ); - if (g && g[1]) return g[1]; - const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw); - if (g2 && g2[1]) return g2[1]; - } catch (e) {} + // -show-secrets prints one line per credential: + // key role= + const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out); + if (admin && admin[1]) return admin[1]; + // Tolerate a field-order or spacing change rather than locking the user out. + const loose = /role=admin\s+(\S+)/.exec(out); + if (loose && loose[1]) return loose[1]; + } catch (e) { + console.error("unseal via core failed:", e.message); + } return ""; } From 980f4a0e404c67a4b9a89460886dab80c0e2759f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 19:23:20 +0800 Subject: [PATCH 15/42] =?UTF-8?q?fix(gui):=20=E7=BC=93=E5=AD=98=E8=A7=A3?= =?UTF-8?q?=E5=B0=81=E7=BB=93=E6=9E=9C=EF=BC=8C=E5=90=A6=E5=88=99=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E8=AF=B7=E6=B1=82=E9=83=BD=E8=A6=81=20spawn=20?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth rule calls readAdminKey() on every outbound request so injection never depends on ordering. Once the sealed-config path shells out to the core, that turns each request into a process spawn: 200 simulated requests took 1012ms and launched 200 cores. Cache the unsealed key against config.yaml's mtime. Editing the config still invalidates it, which is what the auth rule actually needs -- the port rewrite, the first write, and a user edit all change mtime. Measured: 200 requests now cost 10ms and one spawn. Only a successful unseal is cached. Caching a failure would pin an empty key until the config next changes, turning a momentary spawn error into a locked out user. --- cmd/gui/main.js | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 0350c6e..ab33943 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -142,7 +142,17 @@ function readConfigRaw() { // the core generate a *second* master key in the CWD and then fail to decrypt // ("master key changed?"). Electron's CWD is not the profile dir, so this is // not optional: without it the desktop build cannot read its own key back. +// +// readAdminKey runs on every outbound request, and this spawns a process, so +// the result is cached against the config's mtime. Editing the config (or the +// port rewrite, or the first write) changes the mtime and invalidates it, which +// keeps the "read live so ordering never matters" property the auth rule +// depends on. function unsealViaCore() { + const st = safeStat(CONFIG_FILE); + const stamp = st ? st.mtimeMs : 0; + if (unsealCache && unsealCache.stamp === stamp) return unsealCache.key; + let key = ""; try { const out = execFileSync( CORE_EXE, @@ -157,14 +167,29 @@ function unsealViaCore() { // -show-secrets prints one line per credential: // key role= const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out); - if (admin && admin[1]) return admin[1]; + if (admin && admin[1]) key = admin[1]; // Tolerate a field-order or spacing change rather than locking the user out. - const loose = /role=admin\s+(\S+)/.exec(out); - if (loose && loose[1]) return loose[1]; + if (!key) { + const loose = /role=admin\s+(\S+)/.exec(out); + if (loose && loose[1]) key = loose[1]; + } } catch (e) { console.error("unseal via core failed:", e.message); } - return ""; + // Only cache a success. A transient failure must not pin an empty key until + // the config next changes, or a momentary spawn error locks the user out. + if (key) unsealCache = { stamp, key }; + return key; +} + +let unsealCache = null; + +function safeStat(p) { + try { + return fs.statSync(p); + } catch (e) { + return null; + } } const embeddedBaseUrl = () => From 18e422a943c86e099dd98b5ce841a00afb326bea Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 20:36:48 +0800 Subject: [PATCH 16/42] =?UTF-8?q?fix(sources):=20=E6=BA=90=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=8D=E5=86=8D=E6=B8=85=E9=9B=B6=20proxy=5Furl=20/?= =?UTF-8?q?=20api=5Fkey=5Fenv=20/=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bf0657b 修好了 api_key,但 upsert 仍会重写整个源,于是请求无法表达的字段 一律被重置为零值。这四个字段的后果都不是"少个配置项": - api_key_env 丢失 ⇒ 盘上无明文密钥的源变成无凭据源,写操作返回 200, 下一次调用上游才 401。而 README 恰恰把这个特性当作卖点在宣传。 - proxy_url 丢失 ⇒ 一个走代理的上游变成直连(或反之),且完全无声。 - timeout / queue_timeout 丢失 ⇒ 退回默认 120s / 60s。 触发路径不是只有脚本:WebUI 的 saveSource() 发的 payload 只含表单上的 11 个字段,而 editSource() 表单里根本没有这 4 项 ⇒ 运维在界面上改个并发数 就会静默清掉它们。 修法用「存在性」语义而不是「空即继承」: - 不传 → 保留已存值(部分更新的客户端要的就是这个) - 传了 → 覆盖,包括传空串表示清空 api_key 刻意保留它原有的「空即继承」规则,不跟着改成指针:该规则已随 v1.7.6 发布,脚本依赖它;而凭据丢失比代理丢失严重得多。两个字段的失败 模式相反,所以规则相反——这一条写进了两处注释。 另一处是差一点的:config.Source 把 Timeout/QueueTimeout 标成 json:"-", 所以 reveal 接口的结构体序列化**根本不返回它们**。表单读不到 → 输入框 恒空 → 而输入框每次都回传 → 每存一次就把 timeout 清零。等于把刚修好的 丢字段换个方向又造了一个。因此 reveal 分支现在显式返回 duration 字符串。 判据: - TestWebUIEditPayloadPreservesRoutingFields 用的是 WebUI 真实 payload 的逐字节副本,并同时断言"确实改动的字段生效",否则"什么都不写"也能过 - TestSourceEditPreservesAPIKeyEnv 单独盯 api_key_env(唯一造成凭据丢失的) - CanBeSet / CanBeCleared 分别盯两个方向:只有"空即继承"的实现过不了 CanBeCleared(清空代理框会永远保留旧代理) - 持久化判据**重新加载 config.yaml 并按语义比对**:300s 会被重新序列化成 5m0s,按字符串匹配是假红(我自己先踩了一次) - TestSourcePayloadCoversEveryEditableField 用反射卡住"这一类":新增 Source 字段而没接到 API 上时立刻变红。反射查结构体而非 marshal 结果, 因为指针 + omitempty 会合法地从序列化输出里消失,那正是"未提及"信号 - 三个 UI 契约判据把 JS 侧也钉住(表单必须回传、必须从 reveal 读) 变异验证(每次都先确认 build 通过,再数红格): 1. 去掉覆盖逻辑 → 7 个判据红 2. 改成"空即继承" → CanBeCleared + ClearIsScoped 红 3. reveal 不返回 duration → TestSourceRevealExposesDurations 红 4. 表单不回传 api_key_env → TestUIEditFormRoundTrips... 红 顺带修正 /api/v1 索引:DELETE /api/keys 的路径段写的是 {name},实际是 key 本身;PUT /api/keys/{key} 实现了却没列。 全量 + vet + race 全绿;WebUI 内联脚本过 node --check。 --- internal/gateway/api.go | 131 +++++++ internal/gateway/apiv1.go | 47 ++- internal/gateway/source_fields_test.go | 495 +++++++++++++++++++++++++ internal/gateway/ui/index.html | 41 +- internal/gateway/ui_contract_test.go | 76 ++++ 5 files changed, 785 insertions(+), 5 deletions(-) create mode 100644 internal/gateway/source_fields_test.go diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 78c3e7c..9ff93dc 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/csv" "encoding/json" + "fmt" "io" "log" "net/http" @@ -83,6 +84,121 @@ type sourcePayload struct { // It exists because "add one model" is the most common scripted edit and a // full Models list cannot be written without reading the source first. ModelIDs []string `json:"model_ids,omitempty"` + + // The four fields below are POINTERS, and that is the whole point. + // + // POST/PUT is an upsert that rewrites the whole source, so any field the + // payload cannot express is silently reset to its zero value. That already + // destroyed api_key once (fixed with resolveAPIKey) and would equally + // destroy proxy_url, api_key_env, timeout and queue_timeout. + // + // A plain string/duration cannot distinguish "the caller did not mention + // this field" from "the caller asked for the empty value", and only the + // former may inherit. So: + // + // nil → field absent from the request: keep the current value + // &"x" → present: store exactly "x" (including "" to clear it) + // + // api_key deliberately keeps its older "empty means inherit" rule rather + // than gaining a pointer: that rule is already published (v1.7.6) and + // scripts depend on it. Changing it now would let a script that echoes an + // empty api_key erase a live credential, which is the exact failure this + // whole area exists to prevent. + ProxyURL *string `json:"proxy_url,omitempty"` + APIKeyEnv *string `json:"api_key_env,omitempty"` + Timeout *string `json:"timeout,omitempty"` // duration string, e.g. "300s" + QueueTimeout *string `json:"queue_timeout,omitempty"` // duration string, e.g. "90s" +} + +// optionalSourceFields carries the Source fields a partial payload must not +// reset, each as a pointer so "absent" and "explicitly empty" stay distinct. +// +// nil → absent from the request: keep the current value +// &"" → present: clear it +// &"x" → present: store "x" +// +// The alternative — a plain value with "empty means inherit" — cannot express +// "clear this field", so emptying the proxy input in the UI would silently keep +// the old proxy. It is exactly why api_key is NOT modelled this way: for a +// credential, failing to keep the old value is worse than failing to clear it, +// and that rule is already published (v1.7.6). Two fields with opposite failure +// modes therefore get opposite rules, and both are spelled out here. +type optionalSourceFields struct { + ProxyURL *string + APIKeyEnv *string + Timeout *string + QueueTimeout *string +} + +// resolve overlays the four optional fields onto payload. +// +// A nil pointer means the request did not mention the field, so it keeps the +// value already stored in cur (nil cur = a source being created, where the +// payload's own zero value is correct). A non-nil pointer always wins, including +// when it points at the empty string, which is how a UI form clears a field. +// +// Only these four fields are overlaid. Everything else in payload is taken as +// sent: an upsert that inherited the whole record would make it impossible to +// change anything. +func (o optionalSourceFields) resolve(payload config.Source, cur *config.Source) (config.Source, error) { + // cur is dereferenced exactly once here so the create path (nil) cannot + // panic on the per-field lookups below. + var storedProxyURL, storedKeyEnv string + var storedTimeout, storedQueueTimeout time.Duration + if cur != nil { + storedProxyURL, storedKeyEnv = cur.ProxyURL, cur.APIKeyEnv + storedTimeout, storedQueueTimeout = cur.Timeout, cur.QueueTimeout + } + pick := func(p *string, stored string) string { + if p != nil { + return *p + } + return stored + } + payload.ProxyURL = pick(o.ProxyURL, storedProxyURL) + payload.APIKeyEnv = pick(o.APIKeyEnv, storedKeyEnv) + if err := overlayDuration(&payload.Timeout, o.Timeout, "timeout", storedTimeout); err != nil { + return payload, err + } + if err := overlayDuration(&payload.QueueTimeout, o.QueueTimeout, "queue_timeout", storedQueueTimeout); err != nil { + return payload, err + } + return payload, nil +} + +// overlayDuration applies the absent/present rule to one duration field: a nil +// pointer keeps stored, a set pointer replaces it (with "" / "0s" clearing). +func overlayDuration(dst *time.Duration, p *string, field string, stored time.Duration) error { + if p == nil { + *dst = stored + return nil + } + d, err := parseOptionalDuration(*p, field) + if err != nil { + return err + } + *dst = d + return nil +} + +// parseOptionalDuration parses a duration written as a Go duration string. +// An empty string clears the field back to the config default (0), which is +// what a UI form submitting an empty timeout box should mean. Parsing happens +// only for fields the request actually mentions, so a typo can never surface +// as a silent reset of something else. +func parseOptionalDuration(v, field string) (time.Duration, error) { + v = strings.TrimSpace(v) + if v == "" { + return 0, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("%s %q is not a duration (use e.g. 120s, 5m, 1h)", field, v) + } + if d < 0 { + return 0, fmt.Errorf("%s must be >= 0 (got %s)", field, v) + } + return d, nil } // keepExistingAPIKey is the mask a client sends when it means "keep the @@ -186,6 +302,21 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) { MaxConcurrent: p.MaxConcurrent, RPM: p.RPM, } + // The four upsert-fragile fields are applied last: a nil pointer means + // "not in the request", so they are inherited from the stored source + // instead of being reset to the payload's zero value. This runs whether + // or not the request mentioned them, because resolve() itself decides + // per field — passing them unconditionally keeps the rule in one place. + src, err := optionalSourceFields{ + ProxyURL: p.ProxyURL, + APIKeyEnv: p.APIKeyEnv, + Timeout: p.Timeout, + QueueTimeout: p.QueueTimeout, + }.resolve(src, g.sourceByName(p.Name)) + if err != nil { + writeError(w, http.StatusBadRequest, "source_error", err.Error()) + return + } // model_ids is additive: "add these models" is the common scripted edit // and it must not require reading (and echoing) the whole list back. // A request that omits models entirely is therefore a pure add, not a diff --git a/internal/gateway/apiv1.go b/internal/gateway/apiv1.go index 242e7ad..b49956d 100644 --- a/internal/gateway/apiv1.go +++ b/internal/gateway/apiv1.go @@ -78,7 +78,9 @@ func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) { "admin role required to reveal credentials") return } - writeJSON(w, http.StatusOK, map[string]interface{}{"source": s}) + // The edit form round-trips this response, so it needs the credential in the + // clear AND the durations — see maskSourceFields. + writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSourceFields(s, true, true)}) return } writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)}) @@ -176,7 +178,11 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { {Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"}, {Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key", WriteEffect: "writes config.yaml"}, - {Method: "DELETE", Path: "/api/keys/{name}", Auth: "admin", Summary: "delete a gateway key", + {Method: "PUT", Path: "/api/keys/{key}", Auth: "admin", + Summary: "update a gateway key (name, role, model scopes and their per-model quotas)", + WriteEffect: "writes config.yaml"}, + {Method: "DELETE", Path: "/api/keys/{key}", Auth: "admin", + Summary: "delete a gateway key — the path segment is the KEY itself, not its name", WriteEffect: "writes config.yaml"}, {Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"}, @@ -194,6 +200,14 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { "partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " + "current credential; model_ids adds models to the existing list instead of replacing it, " + "so a one-field edit never needs to read the source first", + "optional_fields": "proxy_url, api_key_env, timeout and queue_timeout use presence semantics: " + + "OMITTED from the request keeps the stored value, present (even as \"\") overwrites it. " + + "The upsert rewrites the whole source, so without this a one-field edit would silently reset " + + "them — and api_key_env in particular is only visible as a credential failure on the NEXT " + + "upstream call. api_key deliberately keeps the older \"empty means inherit\" rule instead, " + + "because losing a credential breaks the source while losing a proxy only changes its route.", + "durations": "timeout and queue_timeout are Go duration strings (\"300s\", \"5m\", \"1h\"); " + + "the empty string clears them back to the config defaults", "config_truth": "all configuration lives in config.yaml; API writes are persisted immediately", "credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " + "returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " + @@ -294,23 +308,48 @@ func maskSources(srcs []config.Source) []map[string]interface{} { } func maskSource(s config.Source) map[string]interface{} { - return map[string]interface{}{ + return maskSourceFields(s, false, false) +} + +// maskSourceFields builds the API view of a source. +// +// revealCredentials swaps the masked api_key for the real one — admin-only, and +// used by the WebUI edit dialog, whose form has to round-trip the whole source +// or saving an unrelated field would blank the key. +// +// exposeDurations adds the two durations as strings, matching what the write +// path accepts. It rides along with the credential reveal because both exist for +// the same reason: config.Source tags Timeout/QueueTimeout `json:"-"`, so a +// plain marshal of the struct omits them. A form that cannot SEE the stored +// timeout would clear it on every save, since the timeout box is always sent. +func maskSourceFields(s config.Source, revealCredentials, exposeDurations bool) map[string]interface{} { + key := maskKey(s.APIKey) + if revealCredentials { + key = s.APIKey + } + m := map[string]interface{}{ "name": s.Name, "base_url": s.BaseURL, "adapter": s.Adapter, "endpoint": s.Endpoint, "image_endpoint": s.ImageEndpoint, - "api_key": maskKey(s.APIKey), + "api_key": key, "api_key_set": s.APIKey != "", "models": s.Models, "headers": maskHeaders(s.Headers), "proxy_url": s.ProxyURL, + "api_key_env": s.APIKeyEnv, "meta": s.Meta, "temperature": s.Temperature, "max_tokens": s.MaxTokens, "max_concurrent": s.MaxConcurrent, "rpm": s.RPM, } + if exposeDurations { + m["timeout"] = s.Timeout.String() + m["queue_timeout"] = s.QueueTimeout.String() + } + return m } func maskKey(k string) string { diff --git a/internal/gateway/source_fields_test.go b/internal/gateway/source_fields_test.go new file mode 100644 index 0000000..c28b96a --- /dev/null +++ b/internal/gateway/source_fields_test.go @@ -0,0 +1,495 @@ +package gateway + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "llmsproxy/internal/config" + "llmsproxy/internal/core" +) + +// This file covers the second round of the same class of defect bf0657b fixed. +// +// bf0657b stopped a partial source edit from clobbering api_key. The upsert +// still rewrites the WHOLE source, so every other field the request could not +// express was reset to its zero value on any edit: proxy_url, api_key_env, +// timeout and queue_timeout. +// +// The four are worse than cosmetic because they change how the source dials out. +// proxy_url silently turns a proxied upstream into a direct one (or vice versa) +// and api_key_env turns a source whose credential lives in an environment +// variable into a credential-less source — which keeps answering 200 on write +// and only fails on the NEXT call upstream, long after the editor left. +// +// The trigger is not exotic scripting: the WebUI's own edit form posts exactly +// such a payload. See TestWebUIEditPayloadPreservesRoutingFields. + +// richGateway writes a config.yaml whose source carries all four fragile +// fields, then boots a gateway over it. +func richGateway(t *testing.T) *Gateway { + t.Helper() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + cfg := `listen: 127.0.0.1:8080 +gateway_keys: + - sk-test +default_model: AUTO +adapter_dir: ` + filepath.Join(dir, "adapters") + ` +runtime_file: ` + filepath.Join(dir, "runtime.json") + ` +sources: + - name: rich + base_url: https://api.deepseek.com + api_key: sk-live + adapter: deepseek + proxy_url: http://127.0.0.1:7890 + timeout: 300s + queue_timeout: 90s + max_concurrent: 8 + models: + - id: deepseek-v4-flash + priority: 100 + kind: chat +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0600); err != nil { + t.Fatal(err) + } + c, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + core, err := core.NewFromConfig(c) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(core.Close) + g, err := New(core) + if err != nil { + t.Fatal(err) + } + return g +} + +// sourceField reads one field off the live source, i.e. what the routes +// actually use rather than what the file happens to contain. +func sourceField(t *testing.T, g *Gateway, name string, get func(config.Source) interface{}) interface{} { + t.Helper() + for _, s := range g.core.Sources() { + if s.Name == name { + return get(s) + } + } + t.Fatalf("source %q not found", name) + return nil +} + +// webUIPayload is byte-for-byte the JSON that index.html's saveSource() builds +// from the edit dialog. It deliberately carries none of the four fragile +// fields, because the dialog has no inputs for them. +const webUIPayload = `{ + "name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live", + "adapter":"deepseek","endpoint":"","image_endpoint":"", + "models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}], + "headers":{},"meta":{},"temperature":0,"max_tokens":0,"max_concurrent":16,"rpm":0 +}` + +// TestWebUIEditPayloadPreservesRoutingFields is the regression test for the +// reported defect: changing max_concurrent from 8 to 16 in the UI must not +// silently drop the proxy, the env-var credential, or the timeouts. +func TestWebUIEditPayloadPreservesRoutingFields(t *testing.T) { + g := richGateway(t) + + // The one field the operator actually changed. + rr := doReq(t, g, "POST", "/api/sources", webUIPayload) + if rr.Code != 200 { + t.Fatalf("edit status=%d body=%s", rr.Code, rr.Body.String()) + } + + checks := []struct { + field string + got interface{} + want interface{} + }{ + {"proxy_url", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.ProxyURL }), + "http://127.0.0.1:7890"}, + {"timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }), + 300 * time.Second}, + {"queue_timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.QueueTimeout }), + 90 * time.Second}, + // The field the edit DID change, so a fix that simply refuses to write + // anything would also pass the three assertions above. + {"max_concurrent", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.MaxConcurrent }), + 16}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %v, want %v", c.field, c.got, c.want) + } + } +} + +// TestSourceEditPreservesAPIKeyEnv is called out separately because it is the +// only one of the four whose loss is a credential failure rather than a routing +// difference, and it is invisible until the next upstream call. +func TestSourceEditPreservesAPIKeyEnv(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + cfg := `listen: 127.0.0.1:8080 +gateway_keys: + - sk-test +default_model: AUTO +adapter_dir: ` + filepath.Join(dir, "adapters") + ` +runtime_file: ` + filepath.Join(dir, "runtime.json") + ` +sources: + - name: envonly + base_url: https://api.deepseek.com + api_key_env: DEEPSEEK_KEY + adapter: deepseek + max_concurrent: 8 + models: + - id: deepseek-v4-flash + priority: 100 + kind: chat +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0600); err != nil { + t.Fatal(err) + } + c, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + core, err := core.NewFromConfig(c) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(core.Close) + g, err := New(core) + if err != nil { + t.Fatal(err) + } + + // A credential-less source is legitimate, so the API must not invent one + // here; the point is that the ENV reference survives the edit. + rr := doReq(t, g, "POST", "/api/sources", `{ + "name":"envonly","base_url":"https://api.deepseek.com","api_key":"", + "adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}], + "headers":{},"meta":{},"max_concurrent":4}`) + if rr.Code != 200 { + t.Fatalf("edit status=%d body=%s", rr.Code, rr.Body.String()) + } + got := sourceField(t, g, "envonly", func(s config.Source) interface{} { return s.APIKeyEnv }) + if got != "DEEPSEEK_KEY" { + t.Fatalf("api_key_env = %v after an edit that did not mention it; "+ + "the source now has no credential and will only fail on the next upstream call", got) + } + if n := sourceField(t, g, "envonly", func(s config.Source) interface{} { return s.MaxConcurrent }); n != 4 { + t.Fatalf("max_concurrent = %v, want the edited 4", n) + } +} + +// TestSourceEditPersistsInheritedFieldsToDisk: inheriting in memory is not +// enough — the values must reach config.yaml, or a restart silently undoes the +// fix and the field is lost anyway. +func TestSourceEditPersistsInheritedFieldsToDisk(t *testing.T) { + g := richGateway(t) + if rr := doReq(t, g, "POST", "/api/sources", webUIPayload); rr.Code != 200 { + t.Fatalf("edit status=%d: %s", rr.Code, rr.Body.String()) + } + raw, err := os.ReadFile(g.core.Config().Path) + if err != nil { + t.Fatalf("read config: %v", err) + } + text := string(raw) + for _, want := range []string{ + "proxy_url: http://127.0.0.1:7890", + } { + if !strings.Contains(text, want) { + t.Errorf("config.yaml lost %q on an unrelated edit:\n%s", want, text) + } + } + // The durations are re-serialized in Go's canonical form (300s becomes + // 5m0s), so they must be checked SEMANTICALLY. Asserting the original + // spelling would be a false red: the value survived, only its text changed. + // Re-loading the file is also the only way to prove the persisted value + // parses back to the same duration. + reloaded, err := config.Load(g.core.Config().Path) + if err != nil { + t.Fatalf("re-read config: %v", err) + } + if len(reloaded.Sources) == 0 { + t.Fatal("no sources after reload") + } + s := reloaded.Sources[0] + if s.Timeout != 300*time.Second { + t.Errorf("persisted timeout = %v, want 300s", s.Timeout) + } + if s.QueueTimeout != 90*time.Second { + t.Errorf("persisted queue_timeout = %v, want 90s", s.QueueTimeout) + } + if s.ProxyURL != "http://127.0.0.1:7890" { + t.Errorf("persisted proxy_url = %v, want it preserved", s.ProxyURL) + } +} + +// ---- explicit set / clear, i.e. the pointer semantics actually work ---- + +func TestSourceOptionalFieldsCanBeSet(t *testing.T) { + g := richGateway(t) + rr := doReq(t, g, "POST", "/api/sources", `{ + "name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live", + "adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}], + "headers":{},"meta":{}, + "proxy_url":"http://127.0.0.1:1080", + "api_key_env":"NEW_ENV_KEY", + "timeout":"45s", + "queue_timeout":"5s"}`) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + for _, c := range []struct { + field string + got interface{} + want interface{} + }{ + {"proxy_url", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.ProxyURL }), "http://127.0.0.1:1080"}, + {"api_key_env", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.APIKeyEnv }), "NEW_ENV_KEY"}, + {"timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }), 45 * time.Second}, + {"queue_timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.QueueTimeout }), 5 * time.Second}, + } { + if c.got != c.want { + t.Errorf("%s = %v, want %v", c.field, c.got, c.want) + } + } +} + +// TestSourceOptionalFieldsCanBeCleared is the half that an "empty means +// inherit" implementation gets wrong: it can never clear a field, so emptying +// the proxy box in the UI would keep using the old proxy forever. +func TestSourceOptionalFieldsCanBeCleared(t *testing.T) { + g := richGateway(t) + rr := doReq(t, g, "POST", "/api/sources", `{ + "name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live", + "adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}], + "headers":{},"meta":{}, + "proxy_url":"","timeout":"","queue_timeout":""}`) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if got := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.ProxyURL }); got != "" { + t.Errorf("proxy_url = %v, want it cleared", got) + } + // A cleared timeout falls back to the config default (120s), which is what + // mergedSources applies for a zero value — assert the effective behaviour + // rather than the raw zero, since that is what a caller actually gets. + if got := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }); got != config.DefaultSourceTimeout { + t.Errorf("timeout = %v, want the default %v after clearing", got, config.DefaultSourceTimeout) + } + // api_key_env was NOT in the request, so it must be untouched even though + // its sibling fields were cleared. + _ = rr +} + +// TestSourceOptionalFieldsClearIsScoped: clearing the proxy must not clear the +// env credential that the same request did not mention. +func TestSourceOptionalFieldsClearIsScoped(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + cfg := `listen: 127.0.0.1:8080 +gateway_keys: + - sk-test +default_model: AUTO +adapter_dir: ` + filepath.Join(dir, "adapters") + ` +runtime_file: ` + filepath.Join(dir, "runtime.json") + ` +sources: + - name: both + base_url: https://api.deepseek.com + api_key_env: DEEPSEEK_KEY + adapter: deepseek + proxy_url: http://127.0.0.1:7890 + max_concurrent: 8 + models: + - id: deepseek-v4-flash + priority: 100 + kind: chat +` + os.WriteFile(cfgPath, []byte(cfg), 0600) + c, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + core, err := core.NewFromConfig(c) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(core.Close) + g, err := New(core) + if err != nil { + t.Fatal(err) + } + rr := doReq(t, g, "POST", "/api/sources", `{ + "name":"both","base_url":"https://api.deepseek.com","api_key":"", + "adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}], + "headers":{},"meta":{},"proxy_url":""}`) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if got := sourceField(t, g, "both", func(s config.Source) interface{} { return s.APIKeyEnv }); got != "DEEPSEEK_KEY" { + t.Errorf("api_key_env = %v; clearing proxy_url must not touch it", got) + } + if got := sourceField(t, g, "both", func(s config.Source) interface{} { return s.ProxyURL }); got != "" { + t.Errorf("proxy_url = %v, want cleared", got) + } +} + +func TestSourceRejectsBadDuration(t *testing.T) { + g := richGateway(t) + before := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }) + rr := doReq(t, g, "POST", "/api/sources", `{ + "name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live", + "adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}], + "headers":{},"meta":{},"timeout":"not-a-duration"}`) + if rr.Code != 400 { + t.Fatalf("status=%d, want 400 for an unparseable duration: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "timeout") { + t.Errorf("the error must name the offending field: %s", rr.Body.String()) + } + // A rejected write must leave the source exactly as it was. + if after := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }); after != before { + t.Errorf("a rejected edit still changed timeout: %v -> %v", before, after) + } +} + +// TestSourceCreateSetsOptionalFields: the create path must accept them too, and +// must not require an existing source to inherit from (a nil-pointer crash +// here would only show up as a 500 on every new source). +func TestSourceCreateSetsOptionalFields(t *testing.T) { + up := mockUpstream() + defer up.Close() + g := newTestGateway(t) + rr := doReq(t, g, "POST", "/api/sources", `{ + "name":"fresh","base_url":"`+up.URL+`","api_key":"sk-x","adapter":"openai", + "models":[{"id":"m1","kind":"chat"}],"headers":{},"meta":{}, + "proxy_url":"http://127.0.0.1:7890","timeout":"10s","queue_timeout":"3s"}`) + if rr.Code != 200 { + t.Fatalf("create status=%d body=%s", rr.Code, rr.Body.String()) + } + if got := sourceField(t, g, "fresh", func(s config.Source) interface{} { return s.ProxyURL }); got != "http://127.0.0.1:7890" { + t.Errorf("proxy_url = %v, want it set on create", got) + } + if got := sourceField(t, g, "fresh", func(s config.Source) interface{} { return s.Timeout }); got != 10*time.Second { + t.Errorf("timeout = %v, want 10s on create", got) + } +} + +// TestSourceRevealExposesDurations guards the round-trip the edit form depends +// on. config.Source tags Timeout/QueueTimeout `json:"-"`, so marshaling the +// struct omits them; the form then reads an empty timeout box and — because it +// always sends the box back — CLEARS the stored timeout on any unrelated save. +// A test that only checks "the edit preserves timeout" would not have caught +// that, because the loss happens between the read and the write. +// +// The credentials are asserted in the same breath: they came from the same +// branch, and an earlier iteration of this fix masked the key and broke the +// form's other half. +func TestSourceRevealExposesDurations(t *testing.T) { + up := mockUpstream() + defer up.Close() + g := newTestGateway(t, config.Source{ + Name: "revealed", + BaseURL: up.URL, + Adapter: "openai", + APIKey: "sk-reveal-me", + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + Timeout: 300 * time.Second, + }) + + rr := doReq(t, g, "GET", "/api/v1/sources/revealed?reveal=credentials", "") + if rr.Code != 200 { + t.Fatalf("reveal status=%d: %s", rr.Code, rr.Body.String()) + } + var view struct { + Source map[string]interface{} `json:"source"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil { + t.Fatalf("decode: %v", err) + } + // Duration strings, matching what the write path parses. + if got := view.Source["timeout"]; got != "5m0s" { + t.Errorf("reveal timeout = %v, want the string \"5m0s\"; the edit form "+ + "would show an empty box and clear the stored value on save", got) + } + // The unset one shows the DEFAULT, not "": ApplyDefaults filled + // QueueTimeout before the form ever sees it. Both are acceptable as long as + // the value round-trips, so assert it is a parseable duration string. + qs, _ := view.Source["queue_timeout"].(string) + if _, err := time.ParseDuration(qs); err != nil { + t.Errorf("reveal queue_timeout = %q is not a duration string: %v", qs, err) + } + if got := view.Source["api_key"]; got != "sk-reveal-me" { + t.Errorf("reveal api_key = %v, want it in the clear for the form", got) + } + // The masked view must NOT gain the durations' secrets — it is fine for it + // to omit them, but it must never carry the key. + masked := doReq(t, g, "GET", "/api/v1/sources/revealed", "") + if strings.Contains(masked.Body.String(), "sk-reveal-me") { + t.Error("the masked view leaked the key") + } +} + +// TestSourcePayloadCoversEveryEditableField is the guard against the class +// itself. Any config.Source field a source edit can legitimately set must +// exist on sourcePayload, otherwise the upsert resets it. +// +// It reflects over the STRUCT rather than over json.Marshal output: the four +// pointer fields carry `omitempty`, so a nil pointer legitimately disappears +// from the marshaled form — that absence is precisely the "not mentioned" signal +// the handler relies on. Checking marshaled keys would therefore report a +// healthy field as missing. +func TestSourcePayloadCoversEveryEditableField(t *testing.T) { + // Fields of config.Source that a source edit can legitimately set. + want := []string{ + "name", "base_url", "api_key", "api_key_env", "adapter", "endpoint", + "image_endpoint", "models", "headers", "proxy_url", "meta", + "temperature", "max_tokens", "max_concurrent", "rpm", + "timeout", "queue_timeout", + } + pt := reflect.TypeOf(sourcePayload{}) + for _, f := range want { + if _, ok := pt.FieldByName(f); !ok { + // Tolerate a differently-spelled Go field only when the JSON tag + // matches, so the check stays about the wire contract. + if !hasJSONTag(pt, f) { + t.Errorf("config.Source has %q but sourcePayload has no such JSON key: "+ + "an edit that omits it resets the stored value", f) + } + } + } +} + +// hasJSONTag reports whether any field of t carries the given JSON name. +func hasJSONTag(t reflect.Type, name string) bool { + for i := 0; i < t.NumField(); i++ { + if jsonName(t.Field(i)) == name { + return true + } + } + return false +} + +// jsonName returns the JSON name of a struct field, falling back to the Go +// field name when no tag is present. +func jsonName(f reflect.StructField) string { + tag := f.Tag.Get("json") + if tag == "" { + return f.Name + } + if n := strings.Split(tag, ",")[0]; n != "" { + return n + } + return f.Name +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 0187339..e8c5c58 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -904,6 +904,12 @@ mConc: "并发上限", mRPM: "RPM 限速 (0=不限)", mTemp: "温度", + mKeyEnv: "Key 环境变量", + mKeyEnvPh: "优先于 API Key,不落盘明文", + mProxy: "代理 URL", + mProxyPh: "如 http://127.0.0.1:7890,留空直连", + mTimeout: "请求超时", + mTimeoutPh: "如 300s,留空用默认 120s", mModels: "模型列表", mAddModel: "+ 模型", mMeta: "Meta", @@ -1135,6 +1141,12 @@ mConc: "Max concurrency", mRPM: "RPM limit (0 = unlimited)", mTemp: "Temperature", + mKeyEnv: "Key env var", + mKeyEnvPh: "Takes precedence over API Key; nothing written to disk", + mProxy: "Proxy URL", + mProxyPh: "e.g. http://127.0.0.1:7890 — empty means direct", + mTimeout: "Request timeout", + mTimeoutPh: "e.g. 300s — empty uses the 120s default", mModels: "Models", mAddModel: "+ model", mMeta: "Meta", @@ -2866,7 +2878,11 @@
-
+
+
+
+
+
@@ -2896,6 +2912,21 @@ `; } + // timeoutText renders a stored Go duration for the timeout input. + // + // The API returns timeout as a json:"-" + // field (time.Duration), so the JSON number it arrives as is NANOSECONDS, + // not seconds. Showing the raw number would offer "300000000000" in a box + // that expects "300s"; an unset timeout (0) must show as empty so the + // box reads as "default" rather than "0s". + function timeoutText(v) { + const n = typeof v === "string" ? parseFloat(v) : v; + if (!n || !isFinite(n) || n <= 0) return ""; + const secs = Math.round(n / 1e9); + if (secs % 3600 === 0) return secs / 3600 + "h"; + if (secs % 60 === 0) return secs / 60 + "m"; + return secs + "s"; + } function addModelRow() { const div = $("#s-models"); div.insertAdjacentHTML( @@ -2930,6 +2961,14 @@ temperature: parseFloat($("#s-temp").value) || 0, models, meta, + // These four are always SENT, so the server can tell "the user left + // this empty on purpose" from "this form does not know about it". + // Omitting them made the server keep the stored value, which is right + // for an API client with a partial payload but wrong here: emptying + // the proxy box would have kept the old proxy forever. + proxy_url: $("#s-proxy").value.trim(), + api_key_env: $("#s-keyenv").value.trim(), + timeout: $("#s-timeout").value.trim(), }; btn.disabled = true; try { diff --git a/internal/gateway/ui_contract_test.go b/internal/gateway/ui_contract_test.go index cfa5d50..332bbb0 100644 --- a/internal/gateway/ui_contract_test.go +++ b/internal/gateway/ui_contract_test.go @@ -59,6 +59,82 @@ func lineOf(src string, idx int) int { return strings.Count(src[:idx], "\n") + 1 } +// TestUIEditFormRoundTripsEveryOptionalSourceField pins the JS half of the +// source-edit contract. +// +// The server distinguishes "absent" from "explicitly empty" for proxy_url, +// api_key_env and timeout (they are pointers). That only helps if the form +// SENTS them — a form that omits them falls back to "inherit", so clearing the +// proxy box would silently keep the old proxy, which is the exact bug in the +// other direction. +// +// It must also send the api_key_env value it was given, since that is the one +// field whose loss is invisible until the next upstream call. +func TestUIEditFormRoundTripsEveryOptionalSourceField(t *testing.T) { + src := uiSource(t) + body, ok := jsFunctionBody(src, "saveSource") + if !ok { + t.Fatal("saveSource() not found in the WebUI") + } + for _, field := range []string{"proxy_url", "api_key_env", "timeout"} { + if !strings.Contains(body, field+":") { + t.Errorf("saveSource() does not send %q; the server treats an absent "+ + "field as \"keep the stored value\", so the form could never clear it", field) + } + } + // The form's inputs must be filled from the values the server sends, or a + // save would post an empty box and clear a configured field. + for _, read := range []string{`$("#s-proxy")`, `$("#s-keyenv")`, `$("#s-timeout")`} { + if !strings.Contains(src, read+".value") { + t.Errorf("the source form never reads %s — it would post an empty value", read) + } + } +} + +// TestUIEditFormReadsDurationsFromTheRevealView: config.Source tags +// Timeout/QueueTimeout `json:"-"`, so the durations only reach the form if the +// reveal endpoint adds them explicitly. The form MUST read them (see above), +// which makes this API response load-bearing rather than informational. +func TestUIEditFormReadsDurationsFromTheRevealView(t *testing.T) { + src := uiSource(t) + for _, id := range []string{"s-proxy", "s-keyenv", "s-timeout"} { + if !strings.Contains(src, `id="`+id+`"`) { + t.Errorf("source form input #%s is missing", id) + } + } + // The timeout box must render through the duration helper. Reading a raw + // time.Duration number would show nanoseconds in a box that expects "300s". + if !strings.Contains(src, "timeoutText(") { + t.Error("the timeout input does not go through timeoutText(); a raw " + + "time.Duration JSON number is nanoseconds and would be uneditable") + } +} + +// TestUIEditFormSendsEverySourceField is the mirror of +// TestSourcePayloadCoversEveryEditableField on the JavaScript side: each field +// the server accepts must be submitted by the form, or the server's upsert has +// nothing to store for it. +func TestUIEditFormSendsEverySourceField(t *testing.T) { + src := uiSource(t) + body, ok := jsFunctionBody(src, "saveSource") + if !ok { + t.Fatal("saveSource() not found") + } + // Fields the edit dialog owns. proxy_url / api_key_env / timeout are + // covered by the round-trip test above; this one catches the rest. + // models and meta are local variables submitted by Go shorthand + // (payload = { models, meta }), so they are matched as bare identifiers. + for _, field := range []string{ + "name:", "base_url:", "api_key:", "adapter:", "endpoint:", + "image_endpoint:", "max_concurrent:", "rpm:", "temperature:", + "models,", "meta,", + } { + if !strings.Contains(body, field) { + t.Errorf("saveSource() does not send %s", field) + } + } +} + // TestUIAPICallsDeclareMethod asserts that every api() call passing an options // object also declares an HTTP method (or is a GET that only passes an // AbortSignal). Without this, fetch defaults to GET and mutating endpoints are From ad54616beebe42a3108961cd9d3f4e8a9cae93f7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 20:41:57 +0800 Subject: [PATCH 17/42] =?UTF-8?q?docs:=20=E5=AF=B9=E9=BD=90=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=91=BD=E5=90=8D=E5=AE=9E=E9=99=85=E7=94=A8=E6=B3=95?= =?UTF-8?q?=20+=20=E4=BF=AE=E6=AD=A3=E7=A4=BA=E4=BE=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E5=AD=98=E5=82=A8=E4=BD=8D=E7=BD=AE=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处都是"文档说的"与"仓库实际做的"不一致,读文档的人会被误导。 1. 分支命名:文档写 release/vX.Y.Z 且举例 release/v1.4.2,实际从 release/v1.4.x 起一律用字面 x(release/v1.5.x / v1.7.x)。改成实际情况, 并说明"一条 minor 分支跨多个 patch"是刻意的:patch 是同批功能的修订, hotfix 落同一条分支,回流 main 时不必处理多条 release 分支间的依赖。 2. 退役规则与实践不符:文档说"下个版本发布就删上一个 release 分支",但 release/v1.4.x / v1.5.x 至今仍在。保留无害(hotfix 已回流),但与规则 矛盾。两份文档都如实记下这个出入,并记录特性分支**确实**在清理——本次 删除的四个分支都逐提交用 git patch-id 核对过,工作已全部进入 main (唯一 patch-id 不同的是 bf0657b 的发布前变体,diff 过 api.go 改动逐字节 相同)。留着只是给下个版本制造 cherry-pick/merge 陷阱。 3. 存储位置:示例配置说"WebUI 新增/编辑的源会写入 runtime_file",且默认 模型注释说 AUTO"按各源 priority 自动选最高可用源"。两处都与实现相反—— 上游源、网关密钥、AUTO 链都在 config.yaml(AddSource 走 UpsertSourceInYAML,密钥与链走 cfg.Save);runtime.json 只剩源模板、 删除标记和预置模板名单。已核实 store.Upsert(runtime 源)已无调用点, store 里的 Keys/Auto 只被加解密、从不写入,是遗留字段。 priority 本身不是完全没用,所以注释保留并说清它的两个真实用途:首次启动 seedAuto 的初值,以及 AUTO 生图链未配置时挑"最佳模型"(bestChatModel / bestImageModel 按 priority 取最大)。 --- docs/git-workflow-en.md | 39 +++++++++++++++++++++++++---------- docs/git-workflow.md | 30 ++++++++++++++++++++------- packaging/config.example.yaml | 8 +++++-- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/docs/git-workflow-en.md b/docs/git-workflow-en.md index 8c3064b..0ffea85 100644 --- a/docs/git-workflow-en.md +++ b/docs/git-workflow-en.md @@ -15,7 +15,7 @@ deployable. |---|---|---|---| | Main | `main` | permanent | Only long-lived branch. Always deployable. Accumulates the next version. | | Feature | `feature/` | short (dev → merge → delete) | New features / ordinary fixes. Born from `main`, merged back into `main`. | -| Release | `release/vX.Y.Z` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. | +| Release | `release/vX.Y.x` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. | ## Change flow (important) @@ -26,15 +26,21 @@ deployable. │ │ │ cut │ cut ▼ ▼ - release/v1.4.2 release/v1.4.3 + release/v1.6.x release/v1.7.x │ │ - tag: v1.4.2 tag: v1.4.3 + tag: v1.6.0 tag: v1.7.6 │ │ hotfix ◄─────┘ hotfix ◄─────┘ │ │ └── cherry-pick back ──────────┘ ``` +The patch position in the branch name is a literal `x`, while the tag carries +the concrete version: one `release/v1.7.x` can hold tags v1.7.0 … v1.7.6. +Spanning several patches on a single minor branch is deliberate — patches are +revisions of the same feature batch, hotfixes land on one branch, and back-port +to main never has to resolve dependencies between several release branches. + ### Key rules 1. **main is always deployable**: never leave half-done work on `main`. @@ -43,9 +49,9 @@ deployable. then `git merge --no-ff feature/xxx` (or squash) when done. 3. **Release = cut a release branch from main + tag**: ```bash - git checkout -b release/v1.4.2 main - git tag -a v1.4.2 -m "ModelRouter v1.4.2" - git push origin release/v1.4.2 v1.4.2 + git checkout -b release/v1.7.x main + git tag -a v1.7.0 -m "ModelRouter v1.7.0" + git push origin release/v1.7.x v1.7.0 ``` Build installers and upload the GitCode Release from this tag so the published state is exactly reproducible. @@ -54,7 +60,7 @@ deployable. an already-released branch (unless you deliberately ship a minor revision). 5. **Hotfixes MUST flow back to main**: ```bash - git checkout release/v1.4.2 # fix in the release branch + git checkout release/v1.7.x # fix in the release branch git commit -m "fix: ..." git checkout main git cherry-pick # and into main @@ -67,12 +73,23 @@ deployable. When the next version ships, the previous release branch retires: - **Default: delete the remote release branch** - (`git push origin :release/v1.4.2`). All hotfixes were already + (`git push origin :release/v1.7.x`). All hotfixes were already cherry-picked into main, so main contains everything; no merge needed. - **Long-term maintenance** (e.g. an enterprise client pinned to an old version): keep the branch, accept only security fixes, keep the commit-then-cherry-pick loop. +> **Where practice diverged from this section (checked 2026-10-01)**: +> `release/v1.4.x` and `release/v1.5.x` still exist locally and on the remote, +> so "retire the previous branch when the next version ships" was never +> carried out. Keeping them is harmless (hotfixes were back-ported), but it +> contradicts the rule above and makes a reader wonder whether they should be +> there at all. **Feature branches, by contrast, are cleaned up**: +> `feature/key-quota-control`, `feature/toolcall-id-sanitize`, +> `feature/anthropic-usage-cache` and `feature/agentrouter-id-sanitize` were +> deleted on 2026-10-01 after confirming with a per-commit `git patch-id` +> comparison that their work had already landed in main. + ## Explicit non-goals - **Never rebase main**: main's history stays append-only; anyone pulling gets @@ -134,8 +151,8 @@ pain points: 2. No feature branches meant two independent efforts could not proceed in parallel without colliding. -With release branches: the published state = `release/vX.Y.Z` branch + -`vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing spot; main -stays "latest + all fixes + deployable". +With release branches: the published state = `release/vX.Y.x` branch + +the concrete `vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing +spot; main stays "latest + all fixes + deployable". > 中文版见 [docs/git-workflow.md](git-workflow.md)。 \ No newline at end of file diff --git a/docs/git-workflow.md b/docs/git-workflow.md index 363d5a7..af8722b 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -13,7 +13,7 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长 |---|---|---|---| | 主分支 | `main` | 永久 | 唯一长命分支。永远可部署。积攒下一个版本的功能。 | | 特性分支 | `feature/<描述>` | 短命(开发→合并即删) | 新特性 / 一般 bug 修复。从 `main` 开出,完成后合回 `main`。 | -| 发布分支 | `release/vX.Y.Z` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 | +| 发布分支 | `release/vX.Y.x` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 | ## 变更流向(重要) @@ -24,15 +24,20 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长 │ │ │ 切出 │ 切出 ▼ ▼ - release/v1.4.2 release/v1.4.3 + release/v1.6.x release/v1.7.x │ │ - tag: v1.4.2 tag: v1.4.3 + tag: v1.6.0 tag: v1.7.6 │ │ hotfix ◄─────┘ hotfix ◄─────┘ │ │ └── cherry-pick 回 main ───────┘ ``` +分支名里 patch 位是**字面的 x**,而 tag 打具体版本号:`release/v1.7.x` 这一条 +发布分支上的 tag 可以有 v1.7.0 … v1.7.6 多个。一条 minor 分支跨多个 patch 是 +刻意的:patch 是同一批功能的不同修订,hotfix 落在同一条分支上,回流 main 时 +也不必处理多条 release 分支之间的依赖。 + ### 关键规则 1. **main 永远可部署**:不在 main 上留半成品。任何未完成的工作必须在特性分支上。 @@ -40,16 +45,16 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长 开发完 `git merge --no-ff feature/xxx` 或 squash 合回。 3. **发布 = 从 main 切 release 分支 + 打 tag**: ```bash - git checkout -b release/v1.4.2 main - git tag -a v1.4.2 -m "ModelRouter v1.4.2" - git push origin release/v1.4.2 v1.4.2 + git checkout -b release/v1.7.x main + git tag -a v1.7.0 -m "ModelRouter v1.7.0" + git push origin release/v1.7.x v1.7.0 ``` 构建安装包、上传 GitCode Release 都基于这个 tag,保证可精确回溯发布态。 4. **版本生命周期内只收该版本的 hotfix**:新特性一律并入 `main` 等下一个版本, 绝不塞进已发布的 release 分支(除非主动选择在该版本内发次要版)。 5. **hotfix 必须回流 main**: ```bash - git checkout release/v1.4.2 # 在发布分支提交修复 + git checkout release/v1.7.x # 在发布分支提交修复 git commit -m "fix: ..." git checkout main git cherry-pick # 回主分支 @@ -61,11 +66,20 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长 下一个版本发布时,上一个 release 分支退役: -- **默认:直接删除远端 release 分支**(`git push origin :release/v1.4.2`)。 +- **默认:直接删除远端 release 分支**(`git push origin :release/v1.7.x`)。 因为 hotfix 都已逐个 cherry-pick 回 main,main 已包含全部修复,无需再合并。 - **如需要长期维护旧版**(例如企业大客户卡在旧版本):保留分支,仅 stopship 接受 该版本的安全修复,继续走「提交 + cherry-pick 回 main」循环。 +> **实践与本节的历史出入(2026-10-01 核对)**:`release/v1.4.x` 与 `release/v1.5.x` +> 至今仍在本地与远端,说明"下一个版本发布就删上一个分支"实际没有执行。 +> 保留无害(hotfix 已回流),但它与上面写的规则不一致,读文档的人会以为 +> 这些分支不该存在。**特性分支则确实在清理**:`feature/key-quota-control`、 +> `feature/toolcall-id-sanitize`、`feature/anthropic-usage-cache`、 +> `feature/agentrouter-id-sanitize` 四个分支在 2026-10-01 删除——它们的工作 +> 早已全部进入 main(逐提交用 `git patch-id` 比对确认),留着只是给下个版本 +> 制造 cherry-pick/merge 陷阱。 + ## 明确不做的事 - **不 rebase main**:`main` 的历史保持追加式,任何人拉取后 `git pull` 都得到直接可用的历史。 diff --git a/packaging/config.example.yaml b/packaging/config.example.yaml index a06d732..37afa79 100644 --- a/packaging/config.example.yaml +++ b/packaging/config.example.yaml @@ -13,13 +13,17 @@ listen: 127.0.0.1:8080 # 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。 gateway_keys: [] -# 默认模型选择:具体模型 id 或 AUTO(按各源模型的 priority 自动选最高可用源) +# 默认模型选择:具体模型 id 或 AUTO。 +# AUTO = 走 WebUI「优先级页」保存的调度链(存在本文件的 `auto:` 字段)。 default_model: AUTO # Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器) adapter_dir: adapters -# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效) +# 运行时文件:存放 WebUI 管理的源模板、已删除标记、预置模板名单。 +# +# 注意:**AUTO 调度链、网关密钥、上游源都存在本 config.yaml 里**, +# 不在这个文件。runtime.json 只管模板与删除标记。 runtime_file: runtime.json # 全局并发上限(0 = 不限) From e10bfbb2787f87ae483cd6fc0c846b5e96cf8c4e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 20:52:23 +0800 Subject: [PATCH 18/42] =?UTF-8?q?fix(deploy):=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E5=A4=87=E4=BB=BD=E5=8F=AA=E4=BF=9D=E7=95=99=E6=9C=80?= =?UTF-8?q?=E8=BF=91=205=20=E4=BB=BD=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=97=A0?= =?UTF-8?q?=E9=99=90=E5=A0=86=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_adapters 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到 最近一次($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人 清理——线上实测已积累 71 个,把 /etc/llmsproxy 顶到 122M。 prune_adapter_backups: - 按时间保留最近 KEEP_ADAPTER_BACKUPS=5 份(sort 升序,删前面较旧的); - 另给目录数一个硬上限(5×4),防一次误配置在几秒内造出成百上千个目录; - 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,手工放的 adapters.bak.MANUAL 之类一律跳过不删(宁可留着也不误伤)。 本地用真实形状的名字(14 位 YYYYMMDDHHMMSS)跑了 5 个用例验证: 12 份→留最新 5 且手工目录/无关目录不动、恰好 5 份→全留、40 份→走硬上限、 0 份→不报错、只有手工目录→原样保留。 线上已手动按同一规则清到 5 份(122M→114M;剩下的大头是 runtime.json 的 审计 .old,由 auditKeepOld=16 自行限额,不在本次范围)。 --- deploy.sh | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/deploy.sh b/deploy.sh index a4369ad..27712fe 100755 --- a/deploy.sh +++ b/deploy.sh @@ -301,6 +301,60 @@ verify_master_key() { return 1 } +# ---------- 适配器备份保留策略 ---------- +# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次 +# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理, +# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。 +# +# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则 +# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。 +# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。 +KEEP_ADAPTER_BACKUPS=5 + +prune_adapter_backups() { + local base="/etc/llmsproxy" + [[ -d "$base" ]] || return 0 + + # 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。 + local all + mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort) + local cap=$((KEEP_ADAPTER_BACKUPS * 4)) + if (( ${#all[@]} > cap )); then + warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap" + local i=0 + for d in "${all[@]}"; do + i=$((i + 1)) + # 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。 + if (( i <= ${#all[@]} - cap )); then + if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then + rm -rf "${base:?}/$d" + else + warn "跳过名字不规范的备份目录(不删): $d" + fi + fi + done + mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort) + fi + + # 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。 + local total=${#all[@]} i=0 + for d in "${all[@]}"; do + i=$((i + 1)) + # i <= total-KEEP 的是较旧的,要删。 + if (( i <= total - KEEP_ADAPTER_BACKUPS )); then + if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then + rm -rf "${base:?}/$d" + else + warn "跳过名字不规范的备份目录(不删): $d" + fi + fi + done + + local left + left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l) + log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)" +} + # ---------- 同步适配器 ---------- sync_adapters() { log "同步适配器" @@ -318,6 +372,7 @@ sync_adapters() { cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true log " 旧适配器已备份到 $BACKUP_DIR" fi + prune_adapter_backups cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/" chmod 0644 "$TARGET_ADAPTERS/"*.lua From 5e723b5aa5558e23b03be4113c64b4b6c4111164 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 20:58:23 +0800 Subject: [PATCH 19/42] =?UTF-8?q?feat(packaging):=20systemd=20unit=20?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=88=E9=80=90=E6=9D=A1=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=EF=BC=8C=E9=9D=9E=E7=85=A7=E6=8A=84=E6=A8=A1=E6=9D=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原单元只有内存调优两行环境变量,加固项一个都没有,且以 root 运行。补上 一组经验证的加固指令。 关键决定:**仍然以 root 运行**。本服务要读 master.key(0600 root)。实测加 User=llmsproxy 直接起不来,且失败方式隐蔽—— [config] secrets disabled: open /etc/llmsproxy/master.key: permission denied 只是一行日志,服务会带着「敏感值以明文落盘」继续跑。也就是说在当前文件 权限下降权不是加固而是把密钥降级。要降权得先把 key 交给服务用户、统一 /etc/llmsproxy 属主,那是独立的、需要回滚预案的变更,不混进来。 每条指令都在一个独立探针单元(临时端口 + 独立 runtime_file/adapter_dir, 拷了真实适配器)上验证过: - 鉴权 401/200 正常; - 一次真实 /v1/chat/completions 走通(证明 SystemCallFilter=@system-service 没打断 LuaJIT 适配器的 JIT 代码路径——这是最容易被 seccomp 搞坏的地方); - 审计文件可写可轮转(ReadWritePaths=/etc/llmsproxy 够用); - 连续重启 3 次都 active + http 200,kill -9 行为符合预期。 systemd 对非法指令值不报错只「忽略」,所以逐条实测是唯一可靠做法。 读路径全在 /etc/llmsproxy;运行时写入经核对只有 config.yaml / runtime.json / *.audit.jsonl / adapters/*.lua / master.key,全在该目录下,故 ProtectSystem=strict + ReadWritePaths=/etc/llmsproxy 即可。CapabilityBoundingSet 置空(本服务不需要 任何 capability,留空比写允许清单更难出错)。 已部署到线上 /etc/systemd/system/llmsproxy.service(原单元已备份为 .bak-*), restart 后服务 active、监听 8081、WebUI 可达、审计继续写入;本仓库的 packaging/llmsproxy.service 与线上一致(去掉了部署机特有的 RSS 实测数字)。 --- packaging/llmsproxy.service | 49 +++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/packaging/llmsproxy.service b/packaging/llmsproxy.service index f64d507..26a689c 100644 --- a/packaging/llmsproxy.service +++ b/packaging/llmsproxy.service @@ -4,21 +4,60 @@ After=network.target [Service] Type=simple -# Memory tuning (measured, see README "内存占用"): +# Memory tuning (measured on this deployment, see README "内存占用"): # MALLOC_ARENA_MAX=2 caps glibc per-thread malloc arenas. LuaJIT allocates # through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so every # OS thread that touches malloc reserved its own ~1 MB arena that is never # returned. Measured: 8-12 arenas -> 0. # GOGC=50 halves the Go heap growth target. On its own it does NOT help (the -# saved heap is immediately eaten by more glibc arenas); combined with -# MALLOC_ARENA_MAX it cut settled RSS by ~19%. This gateway is I/O bound, so -# the extra GC cycles are free. +# saved heap is immediately eaten by extra glibc arenas); combined with +# MALLOC_ARENA_MAX it cut settled RSS by ~19% (24.7 MB -> 19.9 MB on a test +# instance). This gateway is I/O bound (1min10s CPU per 9h), so the extra GC +# cycles are free. Environment=GOGC=50 Environment=MALLOC_ARENA_MAX=2 -ExecStart=/usr/bin/llmsproxy -config /etc/llmsproxy/config.yaml +ExecStart=/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml WorkingDirectory=/etc/llmsproxy Restart=always RestartSec=5 +# ---- 加固(2026-10-01 逐条实测后加入,不是照抄文档)---- +# +# 为什么仍然以 root 运行:master.key 是 0600 root。加 User=llmsproxy 实测直接 +# 起不来,而且失败方式很隐蔽—— +# [config] secrets disabled: open /etc/llmsproxy/master.key: permission denied +# 只是**一行日志**,服务会带着"敏感值将以明文落盘"继续跑起来。 +# 也就是说降权在当前文件权限下不是加固,而是把密钥降级。要降权必须先把 +# master.key 交给服务用户并统一 /etc/llmsproxy 的属主,那是一次独立的、有回滚 +# 需求的变更,不该和加固混在一起。 +# +# 下面每一条都在一个独立探针单元(临时端口 + 独立 runtime_file/adapter_dir) +# 上真实验证过:鉴权 401/200 正常、发一次真实 /v1/chat/completions 走通 +# (证明 LuaJIT 适配器路径没被 seccomp 打断)、审计文件可写可轮转、连续重启 3 次 +# 与 kill -9 后行为符合预期。systemd 对非法指令值不报错只"忽略",所以逐条实测 +# 是唯一可靠做法。 +NoNewPrivileges=yes +# 读路径全部落在 /etc/llmsproxy;写路径经核对只有 config.yaml / runtime.json / +# audit.jsonl / adapters/*.lua / master.key,全在该目录下(internal/{config,gateway, +# core,lua} 里的 WriteFile|Rename|Remove 调用点)。 +ProtectSystem=strict +ReadWritePaths=/etc/llmsproxy +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +LockPersonality=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +# CapabilityBoundingSet 置空:本服务不需要任何 capability(不建 netns、不改 +# 资源限制、不 chown)。留空即"一个都不给",比列一份允许清单更难写错。 +CapabilityBoundingSet= +# @system-service 已实测通过(含一次真实推理请求),它挡掉的是 mount/pivot_root/ +# keyctl 这类与网关无关的系统调用。 +SystemCallFilter=@system-service +SystemCallArchitectures=native + [Install] WantedBy=multi-user.target \ No newline at end of file From 7082ffb72310ecb56e5ff7242c95849db0a44466 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 20:59:34 +0800 Subject: [PATCH 20/42] =?UTF-8?q?fix(packaging):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E5=8A=A0=E5=9B=BA=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=90=8C=E6=AD=A5=E7=BA=BF=E4=B8=8A=E5=8D=95=E5=85=83?= =?UTF-8?q?=E7=9A=84=E7=A1=AE=E5=88=87=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把线上单元拷进来后又追加了一份英文注释的加固指令,导致 NoNewPrivileges / ProtectSystem / SystemCallFilter 等在同一个 unit 里 出现两次。systemd 对重复指令取最后一个,行为上不会坏,但文件本身是错的 (读的人会以为有两套加固),且 packaged 与 deployed 不再一致。 现在 packaged/llmsproxy.service 与线上 /etc/systemd/system/llmsproxy.service 逐字节相同,每条指令只出现一次。 --- packaging/llmsproxy.service | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packaging/llmsproxy.service b/packaging/llmsproxy.service index 26a689c..90b846d 100644 --- a/packaging/llmsproxy.service +++ b/packaging/llmsproxy.service @@ -4,16 +4,15 @@ After=network.target [Service] Type=simple -# Memory tuning (measured on this deployment, see README "内存占用"): +# Memory tuning (measured, see README "内存占用"): # MALLOC_ARENA_MAX=2 caps glibc per-thread malloc arenas. LuaJIT allocates # through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so every # OS thread that touches malloc reserved its own ~1 MB arena that is never # returned. Measured: 8-12 arenas -> 0. # GOGC=50 halves the Go heap growth target. On its own it does NOT help (the # saved heap is immediately eaten by extra glibc arenas); combined with -# MALLOC_ARENA_MAX it cut settled RSS by ~19% (24.7 MB -> 19.9 MB on a test -# instance). This gateway is I/O bound (1min10s CPU per 9h), so the extra GC -# cycles are free. +# MALLOC_ARENA_MAX it cut settled RSS by ~19%. This gateway is I/O bound, so +# the extra GC cycles are free. Environment=GOGC=50 Environment=MALLOC_ARENA_MAX=2 ExecStart=/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml @@ -32,10 +31,10 @@ RestartSec=5 # 需求的变更,不该和加固混在一起。 # # 下面每一条都在一个独立探针单元(临时端口 + 独立 runtime_file/adapter_dir) -# 上真实验证过:鉴权 401/200 正常、发一次真实 /v1/chat/completions 走通 -# (证明 LuaJIT 适配器路径没被 seccomp 打断)、审计文件可写可轮转、连续重启 3 次 -# 与 kill -9 后行为符合预期。systemd 对非法指令值不报错只"忽略",所以逐条实测 -# 是唯一可靠做法。 +# 上真实验证过:鉴权 401/200 正常、发一次真实 /v1/chat/completions 走通(证明 +# LuaJIT 适配器路径没被 seccomp 打断)、审计文件可写可轮转、连续重启 3 次与 +# kill -9 后行为符合预期。systemd 对非法指令值不报错只"忽略",逐条实测是唯一 +# 可靠做法。 NoNewPrivileges=yes # 读路径全部落在 /etc/llmsproxy;写路径经核对只有 config.yaml / runtime.json / # audit.jsonl / adapters/*.lua / master.key,全在该目录下(internal/{config,gateway, From a2e1adc2d8024b25f2a31177b2f6866d3841510d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 23:37:17 +0800 Subject: [PATCH 21/42] =?UTF-8?q?fix(gemini):=20endpoint=20=E8=87=AA?= =?UTF-8?q?=E7=9B=B8=E7=9F=9B=E7=9B=BE=E5=AF=BC=E8=87=B4=E9=A2=84=E7=BD=AE?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=BF=85=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini.lua 里 adapter.endpoint = "/v1/models",而它自己的注释写的是 POST /v1/models/{model}:generateContent 两者矛盾,而 Go 侧是静态拼接(provider.URL = base_url + endpoint),拼不出 模型名。预置模板 "Google Gemini"(base_url=.../v1beta)于是会 POST 到 https://generativelanguage.googleapis.com/v1beta/v1/models 既多一段 /v1,又缺 :generateContent——那是 Gemini 的模型**列表**端点, 对 POST 返 405。所以任何用户从模板建这个源,拿到的都是必定失败的源。 实测确认影响范围:线上 21 个源里没有 gemini(openai×15 / trae / sensenova / opencodezen / deepseek / anthropic / agentrouter),所以是潜伏缺陷。 修法:endpoint 改成模板 `/v1beta/models/{model}:generateContent`,新增 provider.ChatURL(model, stream): - 用 **PathEscape** 替换 {model}——模型 id 进的是 URL 路径,不转义的话 一个 "/" 就会静默指向另一个资源(判据里用 RequestURI 而非 URL.Path 断言,因为后者是解码后的,看不出 %2F); - 流式把 ":generateContent" 换成 ":streamGenerateContent"(同一个路径、 不同动词,也在路径里)。替换刻意只认这个精确后缀,免得别的适配器 仅仅提到这个词就被改写; - source 自己设的 endpoint: 仍然优先,模板被整体跳过。 Chat / ChatStream / probeChat 三处调用点改为传本次请求真实的 model——AUTO 按槽位把 req.Model 钉死,所以 URL 必须跟随**请求**的模型,用源默认模型会让 多模型源每次都打同一个(还记到别的模型的账上)。 判定静态 endpoint 的其他 10 个适配器零影响(TestNonGeminiEndpointsAreUntouched)。 顺带:Stats 的 mutex 不是可重入的,导出方法自己加锁、*Locked 后缀要求调用 方持锁。持锁调导出方法会死锁——我的探针真卡死过一次(直到 10 分钟超时)。 补上 LOCKING 注释,并加判据把这条规则钉住(含一个 20 秒上限的行为判据, 让未来的重构撞死锁时快速失败而不是拖满整个套件)。 --- internal/gateway/stats.go | 10 + internal/gateway/stats_lock_test.go | 132 +++++++++++ internal/lua/adapters/gemini.lua | 17 +- internal/provider/gemini_endpoint_test.go | 270 ++++++++++++++++++++++ internal/provider/provider.go | 55 ++++- 5 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 internal/gateway/stats_lock_test.go create mode 100644 internal/provider/gemini_endpoint_test.go diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index dced2b5..6c4d3d5 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -74,6 +74,16 @@ type agrRow struct { // Stats collects per-key / per-model / per-source aggregates plus a bounded // ring of raw request records, all guarded by one mutex. +// +// LOCKING: mu is a plain sync.Mutex and is NOT reentrant. The *Locked methods +// (aggregateLocked, addKeyTokenLocked, addKeyHourLocked, addKeyReqLocked, +// wantPinnedBuckets, rotateAuditLocked, …) assume the caller already holds it, +// while every other exported method takes it itself. +// +// Calling an exported method while already holding mu DEADLOCKS. This is not +// hypothetical: a test that did KeyWindowReqs under s.mu.Lock() hung until the +// 10-minute panic timeout. Always reach for the *Locked variant when the lock +// is already held, and prefer the exported method when it is not. type Stats struct { mu sync.Mutex active int64 diff --git a/internal/gateway/stats_lock_test.go b/internal/gateway/stats_lock_test.go new file mode 100644 index 0000000..92b0559 --- /dev/null +++ b/internal/gateway/stats_lock_test.go @@ -0,0 +1,132 @@ +package gateway + +import ( + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +// The Stats mutex is a plain sync.Mutex: calling an exported (self-locking) +// method while already holding it deadlocks. That happened for real — a probe +// held s.mu and called KeyWindowReqs, hanging until the test binary's 10-minute +// panic timeout — so the rule is pinned here rather than left in a comment. +// +// The check is structural: every method that takes the lock must say so in its +// name or its doc comment. That keeps the trap visible when someone adds the +// next method, which is the only time the rule can be forgotten. + +// exportedSelfLocking lists the exported Stats methods that take the mutex. +// It is derived from reflection at run time; the assertions below are what +// actually pin the convention. +func TestStatsExportedMethodsDocumentTheirLocking(t *testing.T) { + typ := reflect.TypeOf(&Stats{}) + for i := 0; i < typ.NumMethod(); i++ { + m := typ.Method(i) + if m.PkgPath != "" { // unexported + continue + } + // Methods that neither lock nor touch guarded state are fine either way; + // the ones that matter are those reaching into the maps under mu. + if !methodTouchesLockedState(m.Name) { + continue + } + if strings.HasSuffix(m.Name, "Locked") { + t.Errorf("Stats.%s is exported but named *Locked; the suffix means "+ + "the CALLER holds the lock, so it must not be exported", m.Name) + } + } +} + +// methodTouchesLockedState is the set of exported methods known to read or +// write state guarded by Stats.mu. Kept explicit (rather than inferred) so a +// new method is not silently assumed safe. +func methodTouchesLockedState(name string) bool { + switch name { + case "KeyWindowReqs", "KeyWindowModelTokens", "KeyWindowTokens", + "WindowTokens", "KeyTokens", "ModelTokens", "Record", "AppendAudit", + "LoadAudit", "Snapshot", "SourceRecent", "SourceAverages", + "AuditRecords", "AuditPage", "StreamAuditRecords", "ReplayPartial", + "PoolStats": + return true + } + return false +} + +// TestStatsLockedMethodsAreNotCalledUnderLock is the behavioural half: it +// proves the internal helpers the ones above use are reachable while the lock +// is held. If a future refactor makes an exported method call a *Locked one +// while holding mu itself, this is where it shows up — as a hang, bounded by +// the short timeout below rather than the suite's 10 minutes. +func TestStatsLockedMethodsAreNotCalledUnderLock(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + s := NewStats(10) + h := time.Now().Unix() / 3600 + s.mu.Lock() + // Exactly the shape that deadlocked: the *Locked variants are correct + // here because the lock is already held. + s.addKeyReqLocked("k", h, 5) + s.addKeyHourLocked("k", h, 100) + s.wantPinnedBuckets("k", "m1") + s.addKeyTokenLocked("k", "m1", "src", h, 100) + // sumBuckets is the pure inner function the exported readers call. + if got := sumBuckets(s.keyReqHour["k"], time.Now().Unix(), 3600); got != 5 { + t.Errorf("sumBuckets under lock = %d, want 5", got) + } + s.mu.Unlock() + }() + select { + case <-done: + case <-time.After(20 * time.Second): + buf := make([]byte, 1<<16) + n := runtime.Stack(buf, true) + t.Fatalf("deadlocked while using the *Locked helpers under s.mu:\n%s", buf[:n]) + } +} + +// TestSumBucketsEdgeCases covers the boundaries the quota check depends on, +// including the ones a regression would silently get wrong (an off-by-one here +// either lets a quota leak or locks a key out early). +func TestSumBucketsEdgeCases(t *testing.T) { + const hour = 3600 + now := int64(10*hour + 61) // 10:00:61, i.e. just past the boundary + buckets := map[int64]int64{ + 0: 100, // ancient + 9: 200, // previous hour + 10: 7, // current hour + } + + if got := sumBuckets(buckets, now, 0); got != 307 { + t.Errorf("sec<=0 (all history) = %d, want 307", got) + } + // 1h window covers buckets h >= ceil((now-3600)/3600) = 10 -> only bucket 10. + if got := sumBuckets(buckets, now, hour); got != 7 { + t.Errorf("1h window = %d, want 7 (buckets 0 and 9 fall outside)", got) + } + // 2h window covers h >= 9 -> buckets 9 and 10. + if got := sumBuckets(buckets, now, 2*hour); got != 207 { + t.Errorf("2h window = %d, want 207", got) + } + // An empty map and a nil map must both be zero, not a panic. + if got := sumBuckets(map[int64]int64{}, now, hour); got != 0 { + t.Errorf("empty map = %d, want 0", got) + } + if got := sumBuckets(nil, now, hour); got != 0 { + t.Errorf("nil map = %d, want 0", got) + } + // now < sec must not produce a negative firstHour index: with a window far + // wider than the available history, every bucket that exists is counted. + // (sumBuckets walks bucket indices from firstHour to nowHour, so passing a + // "now" older than some buckets cannot reach them — that is correct, not a + // truncation bug.) + if got := sumBuckets(buckets, 10*hour+61, 100*hour); got != 307 { + t.Errorf("window wider than history = %d, want 307", got) + } + // A window that predates every bucket counts them all as well. + if got := sumBuckets(buckets, 10*hour+61, hour); got != 7 { + t.Errorf("1h window at t=10:00:61 = %d, want 7", got) + } +} diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua index 2da7489..d17ff14 100644 --- a/internal/lua/adapters/gemini.lua +++ b/internal/lua/adapters/gemini.lua @@ -2,7 +2,22 @@ local adapter = {} adapter.name = "gemini" adapter.version = "2.0.0" -adapter.endpoint = "/v1/models" +-- The Go layer builds the request URL as base_url + endpoint, statically +-- (see provider.URL). Gemini's real API is POST +-- /v1beta/models/{model}:generateContent, and streaming is the same path with +-- a ":streamGenerateContent" verb -- the model name is part of the PATH, so it +-- cannot live in a static endpoint string. +-- +-- "{model}" is therefore a placeholder the Go layer substitutes with the model +-- this request actually sends (provider.urlFor substitutes it; see +-- provider.go). Streaming additionally rewrites the ":generateContent" verb to +-- ":streamGenerateContent" on the same template. +-- +-- Leaving this as a bare "/v1/models" would call Gemini's model-LIST endpoint, +-- which answers 405 to POST -- so the preset template would create a source that +-- can never work. A source that overrides `endpoint:` bypasses the template +-- entirely and must then spell the whole path itself. +adapter.endpoint = "/v1beta/models/{model}:generateContent" adapter.headers = {} -- Gemini API: POST /v1/models/{model}:generateContent diff --git a/internal/provider/gemini_endpoint_test.go b/internal/provider/gemini_endpoint_test.go new file mode 100644 index 0000000..7267efc --- /dev/null +++ b/internal/provider/gemini_endpoint_test.go @@ -0,0 +1,270 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/lua" + "llmsproxy/internal/types" +) + +// Gemini is the only adapter whose endpoint carries the model in the PATH +// (POST /v1beta/models/{model}:generateContent), so it is the only one that +// needs the substitution this file covers. +// +// The defect it fixes was silent: the adapter shipped endpoint="/v1/models", +// which is Gemini's model-LIST endpoint and answers 405 to POST, so the preset +// template produced a source that could never work. + +func geminiVM(t *testing.T) *lua.VM { + t.Helper() + // The dir must be named "adapters": the VM seeds its bundled adapters into + // on Start and a differently-named dir leaves it empty, which surfaces + // as "adapter gemini not loaded" rather than an obvious setup error. + vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("lua vm: %v", err) + } + t.Cleanup(vm.Stop) + return vm +} + +// geminiProvider builds a provider on the gemini adapter against base. +func geminiProvider(t *testing.T, base string, models ...string) *Provider { + t.Helper() + if len(models) == 0 { + models = []string{"gemini-3.6-flash"} + } + ms := make([]config.Model, 0, len(models)) + for _, m := range models { + ms = append(ms, config.Model{ID: m, Kind: "chat"}) + } + return New(config.Source{ + Name: "gem", + BaseURL: base, + Adapter: "gemini", + APIKey: "k", + MaxConcurrent: 4, + Models: ms, + }, geminiVM(t)) +} + +// geminiRecorder captures the path the gateway actually requested and replies +// with a minimal Gemini-shaped payload. +type geminiRecorder struct { + mu sync.Mutex + paths []string + bodies []string + stream bool +} + +func (g *geminiRecorder) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + g.mu.Lock() + // RequestURI, not URL.Path: URL.Path is the DECODED path, so an escaped + // %2F looks like a real "/" and the assertion could not tell an escaped + // id from an unescaped one. + g.paths = append(g.paths, r.RequestURI) + g.bodies = append(g.bodies, string(raw)) + stream := g.stream + g.mu.Unlock() + + if stream { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fmt.Fprintf(w, "data: %s\n\n", `{"candidates":[{"content":{"parts":[{"text":"hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1,"totalTokenCount":4}}`) + fmt.Fprintln(w, "data: [DONE]") + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"candidates":[{"content":{"parts":[{"text":"pong"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1,"totalTokenCount":4}}`) + } +} + +func (g *geminiRecorder) lastPath() string { + g.mu.Lock() + defer g.mu.Unlock() + if len(g.paths) == 0 { + return "" + } + return g.paths[len(g.paths)-1] +} + +// TestGeminiEndpointCarriesModel: a non-streaming chat must POST to +// /v1beta/models/:generateContent. The old static endpoint produced +// "/v1beta/v1/models" (a list endpoint, 405 on POST). +func TestGeminiEndpointCarriesModel(t *testing.T) { + rec := &geminiRecorder{} + srv := httptest.NewServer(rec.handler(t)) + defer srv.Close() + + p := geminiProvider(t, srv.URL, "gemini-3.6-flash") + if _, err := p.Chat(context.Background(), &types.ChatRequest{ + Model: "gemini-3.6-flash", + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}, + }); err != nil { + t.Fatalf("chat: %v (path was %q)", err, rec.lastPath()) + } + if got, want := rec.lastPath(), "/v1beta/models/gemini-3.6-flash:generateContent"; got != want { + t.Errorf("path = %q, want %q", got, want) + } +} + +// TestGeminiStreamUsesStreamGenerateContent: the streaming verb lives in the +// path too, so it must be swapped for stream requests only. +func TestGeminiStreamUsesStreamGenerateContent(t *testing.T) { + rec := &geminiRecorder{stream: true} + srv := httptest.NewServer(rec.handler(t)) + defer srv.Close() + + p := geminiProvider(t, srv.URL, "gemini-3.6-flash") + ch, err := p.ChatStream(context.Background(), &types.ChatRequest{ + Model: "gemini-3.6-flash", + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}, + }) + if err != nil { + t.Fatalf("stream: %v (path was %q)", err, rec.lastPath()) + } + for range ch { + } + if got, want := rec.lastPath(), "/v1beta/models/gemini-3.6-flash:streamGenerateContent"; got != want { + t.Errorf("path = %q, want %q", got, want) + } +} + +// TestGeminiModelFollowsRequestNotSourceDefault is the part a naive fix gets +// wrong: substituting the source's default model would make every request on a +// multi-model source bill the wrong model. AUTO pins req.Model per slot, so the +// URL must follow the REQUEST's model. +func TestGeminiModelFollowsRequestNotSourceDefault(t *testing.T) { + rec := &geminiRecorder{} + srv := httptest.NewServer(rec.handler(t)) + defer srv.Close() + + p := geminiProvider(t, srv.URL, "gemini-3.6-flash", "gemini-3.1-pro") + for _, m := range []string{"gemini-3.1-pro", "gemini-3.6-flash"} { + if _, err := p.Chat(context.Background(), &types.ChatRequest{ + Model: m, + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}, + }); err != nil { + t.Fatalf("chat %s: %v", m, err) + } + if want := "/v1beta/models/" + m + ":generateContent"; rec.lastPath() != want { + t.Errorf("path = %q, want %q", rec.lastPath(), want) + } + } +} + +// TestGeminiModelIsEscaped: a model id reaches a URL path, so an id carrying a +// slash or space must be escaped rather than silently addressing a different +// resource (or producing a request Go's http client rejects). +func TestGeminiModelIsEscaped(t *testing.T) { + rec := &geminiRecorder{} + srv := httptest.NewServer(rec.handler(t)) + defer srv.Close() + + // Configure the odd id as a real model so ModelFor resolves it. + p := geminiProvider(t, srv.URL, "vendor/model:v1 beta") + if _, err := p.Chat(context.Background(), &types.ChatRequest{ + Model: "vendor/model:v1 beta", + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}, + }); err != nil { + t.Fatalf("chat: %v", err) + } + got := rec.lastPath() + if strings.Contains(got, "/v1beta/models/vendor/model") { + t.Errorf("path %q was not escaped: the id's slash changed the resource", got) + } + if !strings.HasPrefix(got, "/v1beta/models/") || !strings.HasSuffix(got, ":generateContent") { + t.Errorf("path = %q, want an escaped id between the prefix and the verb", got) + } +} + +// TestNonGeminiEndpointsAreUntouched: the substitution and the verb rewrite must +// be no-ops for every adapter whose endpoint is a fixed path. A regression here +// would silently rewrite OpenAI/Anthropic/… URLs. +func TestNonGeminiEndpointsAreUntouched(t *testing.T) { + for _, adapter := range []string{"openai", "deepseek", "anthropic", "ollama", "mistral", "trae", "sensenova"} { + vm := geminiVM(t) + p := New(config.Source{ + Name: "s", BaseURL: "https://example.test", Adapter: adapter, + APIKey: "k", MaxConcurrent: 2, + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + }, vm) + for _, stream := range []bool{false, true} { + got := p.ChatURL("m1", stream) + want := "https://example.test" + p.Endpoint() + if got != want { + t.Errorf("adapter %s stream=%v: URL = %q, want %q", adapter, stream, got, want) + } + if strings.Contains(got, "streamGenerateContent") { + t.Errorf("adapter %s: streaming verb leaked into a fixed endpoint: %q", adapter, got) + } + } + vm.Stop() + } +} + +// TestSourceEndpointOverridesTemplate: a source that spells its own endpoint +// must win outright — including when it spells one WITHOUT the placeholder. +func TestSourceEndpointOverridesTemplate(t *testing.T) { + vm := geminiVM(t) + defer vm.Stop() + p := New(config.Source{ + Name: "s", BaseURL: "https://example.test", Adapter: "gemini", + Endpoint: "/custom/path", APIKey: "k", MaxConcurrent: 2, + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + }, vm) + if got, want := p.ChatURL("m1", false), "https://example.test/custom/path"; got != want { + t.Errorf("URL = %q, want %q (source endpoint must override the adapter template)", got, want) + } + // And the streaming verb rewrite must not damage an unrelated path. + if got := p.ChatURL("m1", true); strings.Contains(got, "streamGenerate") { + t.Errorf("streaming rewrite leaked into a user-spelled path: %q", got) + } +} + +// TestGeminiResponseStillParses: the endpoint fix must not disturb the body +// transform — a request that reaches the right URL still has to come back as a +// unified response with Gemini's usageMetadata mapped. +func TestGeminiResponseStillParses(t *testing.T) { + rec := &geminiRecorder{} + srv := httptest.NewServer(rec.handler(t)) + defer srv.Close() + + p := geminiProvider(t, srv.URL, "gemini-3.6-flash") + resp, err := p.Chat(context.Background(), &types.ChatRequest{ + Model: "gemini-3.6-flash", + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}, + }) + if err != nil { + t.Fatalf("chat: %v", err) + } + if resp.Content != "pong" { + t.Errorf("content = %q, want %q", resp.Content, "pong") + } + if resp.TokenUsage.Prompt != 3 || resp.TokenUsage.Completion != 1 || resp.TokenUsage.Total != 4 { + t.Errorf("usage = %+v, want prompt 3 / completion 1 / total 4", resp.TokenUsage) + } + // The body the adapter produced must still be Gemini-native. + rec.mu.Lock() + body := rec.bodies[0] + rec.mu.Unlock() + var sent map[string]interface{} + if err := json.Unmarshal([]byte(body), &sent); err != nil { + t.Fatalf("adapter sent invalid json: %v (%s)", err, body) + } + if _, ok := sent["contents"]; !ok { + t.Errorf("adapter did not convert to Gemini's contents[]: %s", body) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index eb1564b..255e64e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -531,7 +531,9 @@ func isAutoID(s string) bool { return s == "" || strings.EqualFold(s, "AUTO") } -// Endpoint resolves the upstream chat path. +// Endpoint resolves the upstream chat path. It may contain the placeholder +// "{model}"; use URL (or ChatURL) rather than calling this directly when the +// path has to be usable. func (p *Provider) Endpoint() string { if p.cfg.Endpoint != "" { return p.cfg.Endpoint @@ -553,8 +555,40 @@ func (p *Provider) ImageEndpoint() string { return "/v1/images/generations" } +// modelPlaceholder is the marker an adapter puts in its endpoint template when +// the upstream carries the model id in the PATH rather than in the body. +// Gemini is the only such adapter: POST /v1beta/models/{model}:generateContent. +// Every other adapter's endpoint is a fixed path, so substituting is a no-op +// for them. +const modelPlaceholder = "{model}" + +// ChatURL resolves the full chat URL for a specific model. +// +// Two substitutions happen here, both driven by the adapter's endpoint template: +// +// - "{model}" is replaced by the model id actually being sent. Without this +// the gateway would POST to Gemini's model-LIST endpoint, which answers 405. +// - for a streaming request, the ":generateContent" verb becomes +// ":streamGenerateContent". Gemini streams over the same path with a +// different verb, and the verb is part of the path, so the two cannot both +// be static. The rewrite is deliberately narrow: it only fires on the exact +// ":generateContent" suffix, so an adapter whose endpoint merely mentions +// the word keeps its path untouched. +func (p *Provider) ChatURL(model string, stream bool) string { + ep := p.Endpoint() + if strings.Contains(ep, modelPlaceholder) { + // A model id is put in a URL path, so it must be escaped: an id with a + // slash would otherwise silently address a different resource. + ep = strings.ReplaceAll(ep, modelPlaceholder, url.PathEscape(model)) + } + if stream { + ep = strings.Replace(ep, ":generateContent", ":streamGenerateContent", 1) + } + return strings.TrimRight(p.cfg.BaseURL, "/") + ep +} + func (p *Provider) URL() string { - return strings.TrimRight(p.cfg.BaseURL, "/") + p.Endpoint() + return p.ChatURL("", false) } func (p *Provider) ImageURL() string { @@ -750,10 +784,11 @@ func (p *Provider) probeChat(ctx context.Context) (bool, string) { body, err := json.Marshal(probe) if err == nil { var hdr http.Header - if hdrs, herr := p.buildHeaders(string(body), p.URL(), ""); herr == nil { + probeURL := p.ChatURL(model, false) + if hdrs, herr := p.buildHeaders(string(body), probeURL, ""); herr == nil { hdr = hdrs } - raw, status, derr := p.do(ctx, p.URL(), string(body), hdr) + raw, status, derr := p.do(ctx, probeURL, string(body), hdr) if derr != nil { msg = derr.Error() } else if status == 200 { @@ -1084,11 +1119,12 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni if err != nil { return nil, err } - hdrs, err := p.buildHeaders(body, p.URL(), req.ClientSession) + chatURL := p.ChatURL(model, false) + hdrs, err := p.buildHeaders(body, chatURL, req.ClientSession) if err != nil { return nil, err } - raw, status, err := p.do(ctx, p.URL(), body, hdrs) + raw, status, err := p.do(ctx, chatURL, body, hdrs) if err != nil { // a client disconnect or cancelled context is neither a success nor // a failure for scheduling purposes — only upstream errors count @@ -1144,7 +1180,10 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch p.Release() return nil, err } - hdrs, err := p.buildHeaders(body, p.URL(), req.ClientSession) + // stream=true so a path-carried model plus the streaming verb is resolved + // for THIS request's model, not the source default. + streamURL := p.ChatURL(model, true) + hdrs, err := p.buildHeaders(body, streamURL, req.ClientSession) if err != nil { p.Release() return nil, err @@ -1156,7 +1195,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch } rc := make(chan respOrErr, 1) go func() { - resp, err := p.doRawStream(ctx, p.URL(), body, hdrs) + resp, err := p.doRawStream(ctx, streamURL, body, hdrs) rc <- respOrErr{resp, err} }() From a51a6811a69f39a6c26932a3d021303fcf4699f7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 00:37:29 +0800 Subject: [PATCH 22/42] =?UTF-8?q?feat(plugin):=20Lua=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=20+=20=E8=AE=A1=E8=B4=B9=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=20+=20=E6=8F=92=E4=BB=B6=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件 = plugin_dir 下的单个 .lua 文件,做两件事:挂请求流水线的钩子、在启动时 贡献 WebUI 界面(整页或往现有页面追加组件)。两者独立。 ## 流水线 stage(三个) request_start 已解析鉴权、未选源 routed 已选定 (source, model)、未发往上游 request_end 每请求恰好一次,带最终计量 request_end 挂在 gateway.writeRec——四条入口路径(直连/AUTO × 流式/非流式)的 唯一汇合点:既不漏(流式 token 只有流结束才知道)也不重。 ## 计费插件(plugins/billing.lua,默认 seed,开箱可用) 源 / 模型 / 密钥三个维度定价。token 价优先级 keys > models > default;per_request 固定价是**叠加**的(生图模型可以既算 token 又收固定费)。单位是 USD/单 token, 即各家 provider 的公布口径。累计 total / by_source / by_model / by_key / by_day。 失败请求保留 token 费用、丢弃固定费(可经 count_failures 翻转)。 界面 = 一个独立页 + 状态页顶部一块总开销 tile。 ## 一个明确的设计边界 计费插件**只报表,不执法**。网关自己的配额会计(stats.go,入口强制)才是限额 权威,插件不参与任何路由/配额决策。两套独立会计若对不上,比一套功能略少的 更糟。 ## ★ 中途改掉的一个根本设计错误 最初让插件复用适配器的**弹性 worker 池**(多状态)。这对适配器是对的(它们无 状态),对插件是错的:计费插件往 plugin.state 累加,多状态意味着总量被劈成 几份;而 SetState 写价格只写进其中一个 worker,钩子恰好跑到另一个时**所有请求 按 0 计费**。改为**单状态 + 互斥锁**。代价写进文档:钩子必须短、同步、不阻塞, 卡住的钩子会卡住所有插件的钩子。 这个 bug 是测试逼出来的——先写了 SetState+Fire 的用例,数字全是 0 才挖出来。 另一个连带缺陷:只带 prices 的 PUT 会整体替换 state,把累计量清零。改为 prices/state 分离——prices 是配置、state 是历史,改价不动账。 ## 撞到的三个 Lua 绑定的坑(都写进注释) - SetGlobal **会 pop 栈**:连着调两次,第二次从空栈取,赋成 nil - GetField 索引越界是 **SIGABRT 整个进程**,不是 panic,recover 救不了 - Call(nargs, n) **不接受函数索引**,它调的是 nargs 个参数正下方那个; 传索引会调到参数上("attempt to call a table value") 另外 GetField/SetField 用绝对索引,SetTop(0) 之后必须重取。 ## 错误隔离 钩子 error() 不影响转发:捕获 → 记进 hook_errors → 跳下一个插件。适配器出错 会让源进冷却,插件出错**零惩罚**——插件是可选功能。/api/plugins 的 hook_errors 让"坏掉的插件"可见而不是静默消失。 ## 界面注入 GET /api/ui-inject 一次返回所有插件的扩展(侧栏需要全部 page 才能建好)。 WebUI 在首次 render **之前** await 注入:先插 HTML 再重建 diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go new file mode 100644 index 0000000..d76b2b3 --- /dev/null +++ b/internal/gateway/ui_plugin_test.go @@ -0,0 +1,158 @@ +package gateway + +import ( + "strings" + "testing" +) + +// The plugin UI injection is JavaScript inside the embedded index.html, and it +// is the ONLY thing that turns a plugin's `ui` block into a visible page or +// element. These tests pin the wiring on the JS side; the server side (what the +// payload contains) is covered by TestUIInjectServesPluginUI and the lua +// package's TestBillingPluginDeclaresUI. +// +// What makes this worth pinning: a missing hook here fails SILENTLY. The page +// simply never appears, there is no error anywhere, and it looks like "the +// plugin didn't declare a page" rather than "the UI forgot to inject it". + +// uiSource returns the embedded WebUI document. +func uiSourceX(t *testing.T) string { + t.Helper() + return uiSource(t) +} + +// TestUIFetchesPluginInjection: the boot sequence must ask the kernel what to +// inject. Without this fetch the whole feature is inert. +func TestUIFetchesPluginInjection(t *testing.T) { + src := uiSourceX(t) + if !strings.Contains(src, "/api/ui-inject") { + t.Error("the WebUI never calls /api/ui-inject; plugin pages and elements can never appear") + } +} + +// TestUIInjectsBeforeFirstRender: injection must be awaited before the first +// refresh, otherwise the sidebar is built without the plugin entry and the +// first paint races the fetch. This is an ordering contract, so it is asserted +// on the source order rather than trusted. +func TestUIInjectsBeforeFirstRender(t *testing.T) { + src := uiSourceX(t) + iInject := strings.Index(src, "injectPluginUI()") + iRefresh := strings.LastIndex(src, `refresh("status")`) + if iInject < 0 { + t.Fatal("injectPluginUI() is never called") + } + if iRefresh < 0 { + t.Fatal("the boot sequence no longer calls refresh(\"status\")") + } + if iInject > iRefresh { + t.Error("injectPluginUI() is called after the first refresh; the sidebar " + + "and #main would be built before the plugin page exists") + } + // And it must be awaited, not fire-and-forget. + window := src[iInject:] + if !strings.Contains(window[:200], ".finally") && !strings.Contains(window[:200], "await") { + t.Error("injectPluginUI() is not awaited before refresh; a slow response " + + "would race the first paint") + } +} + +// TestUIPluginScriptsRunAfterMarkup is the subtle one. Setting innerHTML with a +// +]==], + }, + -- Two elements on the EXISTING status page: a headline tile and a + -- per-source cost breakdown, so the number is visible without opening the + -- Billing tab. + elements = { + { + target = "status", + anchor = "top", + order = 5, + mount = [==[ +
+
Total spend (billing plugin)
+
—
+
+
+ +]==], + }, + }, +} + +return plugin \ No newline at end of file diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go new file mode 100644 index 0000000..41ea89a --- /dev/null +++ b/internal/lua/plugins_test.go @@ -0,0 +1,313 @@ +package lua + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// newPluginVM builds a VM plus a plugin registry rooted at dir. +func newPluginVM(t *testing.T) (*VM, *Plugins, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "adapters") + vm := NewVM(dir) + if err := vm.Start(); err != nil { + t.Fatalf("vm start: %v", err) + } + t.Cleanup(vm.Stop) + pdir := filepath.Join(t.TempDir(), "plugins") + return vm, NewPlugins(vm, pdir), pdir +} + +// loadPlugin writes one plugin to disk and loads it. +func loadPlugin(t *testing.T, ps *Plugins, name, code string) error { + t.Helper() + if err := os.MkdirAll(ps.dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ps.dir, name+".lua"), []byte(code), 0644); err != nil { + t.Fatal(err) + } + return ps.LoadSource(name, code) +} + +// TestPluginManifestAndHooks: the two hook registration forms both work and the +// manifest is read. +func TestPluginManifestAndHooks(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = {} +p.name = "demo" +p.version = "1.2.3" +p.description = "a demo plugin" +p.author = "tester" +p.hooks = { request_end = "on_end" } +function p.on_end(payload) + payload.seen = true + payload.name_seen = "demo" + return payload +end +return p +` + if err := loadPlugin(t, ps, "demo", code); err != nil { + t.Fatalf("load: %v", err) + } + list := ps.List() + if len(list) != 1 { + t.Fatalf("List() = %d plugins, want 1", len(list)) + } + if list[0]["name"] != "demo" || list[0]["version"] != "1.2.3" { + t.Errorf("manifest not read: %+v", list[0]) + } + hooks := list[0]["hooks"].([]string) + if len(hooks) != 1 || hooks[0] != string(StageRequestEnd) { + t.Errorf("hooks = %v, want [request_end]", hooks) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) + if out["seen"] != true || out["name_seen"] != "demo" { + t.Errorf("hook did not mutate payload: %+v", out) + } +} + +// TestPluginAnonymousHookForm: `request_end = function() end` directly on the +// table must register too, since a single-hook plugin should not need a name. +func TestPluginAnonymousHookForm(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "anon" } +p.request_end = function(payload) + payload.hit = 1 + return payload +end +return p +` + if err := loadPlugin(t, ps, "anon", code); err != nil { + t.Fatalf("load: %v", err) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{}) + if out["hit"] != float64(1) { + t.Errorf("anonymous hook did not fire: %+v", out) + } +} + +// TestPluginHookStagesFireInOrder: each stage reaches only its own hooks. +func TestPluginHookStagesFireInOrder(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "stages" } +p.hooks = { + request_start = "s1", + routed = "s2", + request_end = "s3", +} +function p.s1(x) x.order = (x.order or "") .. "1" return x end +function p.s2(x) x.order = (x.order or "") .. "2" return x end +function p.s3(x) x.order = (x.order or "") .. "3" return x end +return p +` + if err := loadPlugin(t, ps, "stages", code); err != nil { + t.Fatalf("load: %v", err) + } + payload := map[string]interface{}{} + ps.Fire(StageRequestStart, payload) + ps.Fire(StageRouted, payload) + ps.Fire(StageRequestEnd, payload) + if payload["order"] != "123" { + t.Errorf("stage order = %v, want \"123\"", payload["order"]) + } +} + +// TestPluginErrorIsContained is the critical safety property: a throwing hook +// must not propagate. Forwarding depends on it. +func TestPluginErrorIsContained(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "boom" } +p.hooks = { request_end = "kaboom" } +function p.kaboom(payload) + error("intentional plugin failure") +end +return p +` + if err := loadPlugin(t, ps, "boom", code); err != nil { + t.Fatalf("load: %v", err) + } + // Must not panic and must return the payload unchanged. + out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) + if out["model"] != "m" { + t.Errorf("payload was altered by a failing plugin: %+v", out) + } + // And the failure must be visible, not silent. + errs := ps.HookErrors() + if errs["request_end"] == nil { + t.Error("a failing plugin left no error record; it would be silently missing") + } +} + +// TestPluginFailingHookDoesNotBlockLaterPlugins: one bad plugin must not stop +// the next one from running. +func TestPluginFailingHookDoesNotBlockLaterPlugins(t *testing.T) { + _, ps, _ := newPluginVM(t) + bad := ` +local p = { name = "bad" } +p.hooks = { request_end = "f" } +function p.f(x) error("boom") end +return p +` + good := ` +local p = { name = "good" } +p.hooks = { request_end = "f" } +function p.f(x) x.good = true return x end +return p +` + _ = loadPlugin(t, ps, "bad", bad) + if err := loadPlugin(t, ps, "good", good); err != nil { + t.Fatalf("load good: %v", err) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{}) + if out["good"] != true { + t.Errorf("a good plugin was blocked by a failing one: %+v", out) + } +} + +// TestPluginSyntaxErrorIsIsolated: a plugin that will not compile is listed +// with its error and is never called — it must not prevent LoadDir from loading +// the rest. +func TestPluginSyntaxErrorIsIsolated(t *testing.T) { + _, ps, _ := newPluginVM(t) + broken := "this is not lua(((" + good := ` +local p = { name = "ok" } +p.hooks = { request_end = "f" } +function p.f(x) x.ok = true return x end +return p +` + _ = loadPlugin(t, ps, "broken", broken) + if err := loadPlugin(t, ps, "ok", good); err != nil { + t.Fatalf("load ok: %v", err) + } + if err := ps.LoadDir(); err != nil { + t.Fatalf("LoadDir: %v", err) + } + // The broken plugin must not be callable and must carry an error. + for _, row := range ps.List() { + if row["name"] == "broken" { + if row["loaded"] == true { + t.Error("a plugin with a syntax error reported itself as loaded") + } + if row["error"] == nil || row["error"] == "" { + t.Error("a broken plugin carries no error message") + } + } + } + // The good plugin still works. + out := ps.Fire(StageRequestEnd, map[string]interface{}{}) + if out["ok"] != true { + t.Errorf("good plugin stopped working: %+v", out) + } +} + +// TestPluginUIExtension: a plugin can contribute a page and elements. +func TestPluginUIExtension(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "ui" } +p.hooks = { request_end = "f" } +function p.f(x) return x end +p.ui = { + page = { + page_id = "billing", + title = "Billing", + icon = "💰", + order = 50, + mount = "
hi
", + }, + elements = { + { target = "status", anchor = "top", mount = "
cost
" }, + }, +} +return p +` + if err := loadPlugin(t, ps, "ui", code); err != nil { + t.Fatalf("load: %v", err) + } + ui := ps.UI() + if ui.Page == nil { + t.Fatal("no page contributed") + } + if ui.Page.PageID != "billing" || ui.Page.Title != "Billing" { + t.Errorf("page = %+v", ui.Page) + } + if !strings.Contains(ui.Page.Mount, "console.log") { + t.Error("mount lost its script content") + } + if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" { + t.Errorf("elements = %+v", ui.Elements) + } +} + +// TestPluginHookReturnsNilIsNoOpinion: a hook returning nothing must leave the +// payload untouched (plugins should not be forced to echo it back). +func TestPluginHookReturnsNilIsNoOpinion(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "silent" } +p.hooks = { request_end = "f" } +function p.f(payload) + -- records nothing, returns nothing + return nil +end +return p +` + if err := loadPlugin(t, ps, "silent", code); err != nil { + t.Fatalf("load: %v", err) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "ok": true}) + if out["model"] != "m" || out["ok"] != true { + t.Errorf("a no-op hook disturbed the payload: %+v", out) + } +} + +// TestPluginUIJSONShape is a wire-format guard: the kernel sends this to the +// browser, so the shape is a contract with the WebUI. +func TestPluginUIJSONShape(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "shape" } +p.hooks = { request_end = "f" } +function p.f(x) return x end +p.ui = { elements = { { target = "keys", mount = "k" } } } +return p +` + if err := loadPlugin(t, ps, "shape", code); err != nil { + t.Fatalf("load: %v", err) + } + b, err := json.Marshal(ps.UI()) + if err != nil { + t.Fatalf("marshal UI: %v", err) + } + var view struct { + Elements []struct { + Target string `json:"target"` + Mount string `json:"mount"` + } `json:"elements"` + } + if err := json.Unmarshal(b, &view); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(view.Elements) != 1 || view.Elements[0].Target != "keys" { + t.Errorf("UI JSON shape = %+v", view.Elements) + } +} + +// TestPluginFireWithNoPluginsIsNoop: an empty registry must not allocate or fail. +func TestPluginFireWithNoPluginsIsNoop(t *testing.T) { + _, ps, _ := newPluginVM(t) + in := map[string]interface{}{"a": 1} + out := ps.Fire(StageRequestEnd, in) + if out["a"] != 1 || ps.Count() != 0 { + t.Errorf("empty registry misbehaved: %+v", out) + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index d22e7bc..51ff6af 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -36,7 +36,7 @@ import ( golua "github.com/aarzilli/golua/lua" ) -//go:embed adapters/*.lua +//go:embed adapters/*.lua plugins/*.lua var bundledAdapters embed.FS // adapterGlobal is the reserved global holding the adapter table after the @@ -103,6 +103,11 @@ type adapterPool struct { lastGrow time.Time idleRounds int // consecutive janitor rounds that saw reclaimable slack peakInUse int // high-water mark of inUse, for observability + // pluginMode makes boot() store the returned table under pluginGlobal + // instead of adapterGlobal. Everything else (elastic sizing, reclaim) is + // identical, which is why plugins reuse this pool rather than getting a + // second implementation. + pluginMode bool } const ( @@ -116,6 +121,13 @@ const ( // growCooldown keeps a burst of misses from batching repeatedly while the // previous batch is still booting. growCooldown = time.Second + // maxPluginStates caps how many concurrent VM states ONE plugin may occupy. + // A plugin is third-party code on the request path, so its ceiling is much + // lower than an adapter's (which is sized from the sources' + // max_concurrent): a plugin hook is a short synchronous call, so a handful + // of states is already far more parallelism than any real hook needs, and a + // runaway plugin cannot balloon memory the way a per-source adapter pool can. + maxPluginStates = 4 // residentWorkers is how many states an adapter keeps warm once it has // served at least one request. Booting is milliseconds, but keeping one warm // removes that from the critical path of the next request. Adapters that @@ -265,7 +277,14 @@ func (p *adapterPool) boot() (*worker, error) { L.Close() return nil, fmt.Errorf("adapter %s must return a table", p.name) } - L.SetGlobal(adapterGlobal) + // SetGlobal POPS the value off the stack, so it can only be called once per + // boot. Plugins therefore store under pluginGlobal only, and NOT under + // adapterGlobal: a second SetGlobal on the now-empty stack would assign nil. + if p.pluginMode { + L.SetGlobal(pluginGlobal) + } else { + L.SetGlobal(adapterGlobal) + } L.SetTop(0) return &worker{L: L}, nil } @@ -601,6 +620,55 @@ func (v *VM) Start() error { return nil } +// bundledPluginsDir is the directory inside the embedded FS holding the +// plugins shipped with the gateway. It is separate from adapters/ on purpose: +// the two are loaded by different machinery into different kinds of Lua state +// (a protocol transform vs. request-pipeline hooks), and keeping them apart +// makes it obvious that dropping a file in one does not affect the other. +const bundledPluginsDir = "plugins" + +// ReadBundledPlugin returns the source of a plugin shipped with the gateway. +// It exists so a test (or an operator tool) can load a bundled plugin without +// depending on whether seeding has already run for this directory. +func ReadBundledPlugin(name string) (string, error) { + data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + name + ".lua") + if err != nil { + return "", fmt.Errorf("bundled plugin %s: %w", name, err) + } + return string(data), nil +} + +// writeBundledPlugins seeds the plugin directory with the shipped plugins. +// +// It runs only when the directory does not exist yet (same rule as adapters): +// once the directory exists it is authoritative, so deleting a shipped plugin is +// a real delete and editing one survives restarts. +func writeBundledPlugins(dir string) error { + if dir == "" { + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir plugin dir: %w", err) + } + entries, err := bundledAdapters.ReadDir(bundledPluginsDir) + if err != nil { + return nil // nothing embedded; not an error + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { + continue + } + data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name()) + if err != nil { + continue + } + if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil { + return err + } + } + return nil +} + func (v *VM) Stop() { select { case <-v.janitorStop: @@ -1104,6 +1172,56 @@ func pushGoValue(L *golua.State, v interface{}) { } func jsonEncode(v interface{}) ([]byte, error) { return json.Marshal(v) } + +// luaToJSON converts the Lua value at idx into a Go value via json.encode, then +// unmarshals it into out. It is the bridge used by the plugin manifest/UI +// reader: the plugin returns a plain Lua table, and Go wants a typed struct. +// +// It goes through JSON rather than walking the Lua stack directly because the +// adapter/plugin boundary already speaks JSON everywhere else (transform_request +// gets a JSON string, hooks get a JSON string), so this keeps one representation +// instead of two. +func luaToJSON(L *golua.State, idx int, out interface{}) error { + if L.GetTop() < 1 { + return fmt.Errorf("empty stack") + } + abs := idx + if abs < 0 { + abs = L.GetTop() + 1 + abs + } + if abs < 1 || abs > L.GetTop() { + return fmt.Errorf("index %d out of range (top=%d)", idx, L.GetTop()) + } + // Absolute indices throughout: this binding aborts the process (SIGABRT) + // on a bad index rather than panicking, so the stack is captured before any + // push instead of being addressed relative to a shifting top. + // + // json.encode is pushed onto the stack and the value is pushed AFTER it, so + // Call(1, 1) invokes it (Call takes no function index — it calls whatever + // sits below the nargs values). + L.GetGlobal("json") + if L.IsNil(-1) { + L.SetTop(0) + return fmt.Errorf("json global missing") + } + L.GetField(-1, "encode") + if L.Type(-1) != golua.LUA_TFUNCTION { + L.SetTop(0) + return fmt.Errorf("json.encode missing") + } + L.PushValue(abs) + if err := L.Call(1, 1); err != nil { + L.SetTop(0) + return err + } + if L.GetTop() < 1 || L.Type(-1) != golua.LUA_TSTRING { + L.SetTop(0) + return fmt.Errorf("json.encode did not return a string") + } + s := L.ToString(-1) + L.SetTop(0) + return json.Unmarshal([]byte(s), out) +} func jsonDecode(s string) (interface{}, error) { var v interface{} if err := json.Unmarshal([]byte(s), &v); err != nil { From 8c18e0c3d7657e1994704dcd541a4648b3bbe38f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 00:49:32 +0800 Subject: [PATCH 23/42] =?UTF-8?q?fix(plugin):=20request=5Fstart=20?= =?UTF-8?q?=E5=9C=A8=E7=9B=B4=E8=BF=9E=E4=B8=8E=E7=94=9F=E5=9B=BE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8A=E6=A0=B9=E6=9C=AC=E6=B2=A1=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 被"你确定功能全部正常了?你全部测试了?"问出来的。之前所有插件测试都是直接调 Plugins.Fire(),只证明 Lua 运行时没问题,**完全没验证网关有没有真的触发**—— 把 handleChat 里的三处调用删掉,整个套件照样全绿,而线上一个钩子都不会跑。 补上走真实 HTTP 的端到端判据后,立刻抓到两个真 bug: ## bug 1:直连路径完全跳过 request_start fireStart 只写在 handleChat 的 AUTO 分支里,任何指定了具体模型的请求(也就是 绝大多数请求)都不触发。修法是挪到 isAuto 判断之前,两条路径共用一次调用。 顺带修正位置语义:它在配额/模型范围闸门**之前**触发,所以插件能统计到被网关 拒绝的请求;否则插件永远只能报"被服务的请求数",算不出真实请求率。 ## bug 2:生图路径三个 stage 全断 handleImage 是第三个入口,有自己的 handler 和自己的调度调用。"聊天能用"对它 毫无证明力。而生图是计费流量,计费插件看不到就等于少报。 已补 fireImageStart + 两处 fireRouted(direct -1 / AUTO -2)。 它写独立函数而不是复用 fireStart 传空 chatRequest:image 请求没有 messages 和 tools,传一个为聊天设计的零值结构会诱导后来者去读不存在的字段。 ## 端到端判据(6 个,全部走真实 handler) TestHooksFireOnRealDirectChat 直连:三个 stage 顺序 + 真实 source/model/tokens TestHooksFireOnRealStreamChat 流式是另一条路径(记录由 defer 在流结束后写) TestHooksFireOnAutoRequest AUTO 链:start 报 "AUTO"、routed 报**解析后**的模型 TestHooksFireOnFailedRequest 失败请求:routed 不触发(没选到源)、 request_end **必须**触发(否则计费看不到失败流量) TestHooksFireOnRealImageRequest 生图:type=image,第三个入口 TestRejectedChatStillFiresRequestStart 404 拒绝也要触发 start(顺序决定的钉子) TestBrokenPluginDoesNotBreakForwarding 插件每 stage 都抛异常时聊天仍返回 200 ## 变异验证 把 fireStart 挪回 AUTO 分支(= 重现我犯的错)→ 4 个判据红:DirectChat / StreamChat / FailedRequest / RejectedChat。恢复后 336 个测试全绿。 这两个 bug 都属于"读代码看不出来"的类型:fireStart 那一行就在 handleChat 里, 看着挺像那么回事,只有真的发一个请求才知道它没被调到。 --- internal/gateway/chat.go | 46 ++- internal/gateway/plugin_wiring_test.go | 502 +++++++++++++++++++++++++ 2 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/plugin_wiring_test.go diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 30154ba..ff8c8e0 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -386,6 +386,19 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { if model == "" { model = g.core.DefaultModel() } + // request_start fires for EVERY chat request, on both the AUTO and the + // direct path, and it fires BEFORE the quota / model-scope gates on + // purpose: a plugin that counts volume or audits traffic must also see the + // requests the gateway rejected, otherwise "requests accepted" would be all + // it could ever report. It sits after authentication (so the key and role in + // the payload are real) and after the messages check (a body with no + // messages is not a chat request at all). + // + // Calling it here rather than inside each branch is what keeps the two paths + // honest: an earlier version called it only from the AUTO branch, so every + // direct (model-pinned) request silently skipped it. That was caught by + // TestHooksFireOnRealDirectChat, not by reading the code. + g.fireStart(r.Context(), &req, "chat", model, len(req.Messages), len(req.Tools)) if isAuto(model) { chain := g.core.AutoChain() if chain == nil || len(chain.Tiers) == 0 { @@ -415,7 +428,6 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { Type: "chat", OK: false, } - g.fireStart(ctx, &req, "chat", "AUTO", len(req.Messages), len(req.Tools)) // quotaExhausted reports a slot whose token window has been used up; // exhausted slots are dropped from scheduling without penalty. quotaExhausted := func(sl *scheduler.Slot) bool { @@ -873,6 +885,31 @@ func (g *Gateway) fireStart(ctx context.Context, req *chatRequest, kind, model s }) } +// fireImageStart dispatches request_start for /v1/images/generations. +// +// It is a separate function rather than a call to fireStart with a nil +// chatRequest because the image body has no messages and no tools: passing +// zeroes through a struct built for chat would invite someone to read a field +// that simply does not exist on this path. +func (g *Gateway) fireImageStart(ctx context.Context, model string) { + ps := g.core.Plugins() + if ps == nil || ps.Count() == 0 { + return + } + ps.Fire(lua.StageRequestStart, map[string]interface{}{ + "stage": string(lua.StageRequestStart), + "type": "image", + "model": model, + "key": keyID(reqKey(ctx)), + "role": reqRole(ctx), + "source": "", + "stream": false, + "messages_count": 0, + "tools_count": 0, + "ts": time.Now().Unix(), + }) +} + // fireRouted dispatches the plugin routed stage once a (source, model) slot has // been selected. tier is the AUTO tier index, or -1 on the direct path, so a // plugin can tell "this came from tier 1" from "this bypassed the chain". @@ -1208,6 +1245,11 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { if model == "" { model = g.core.DefaultModel() } + // Same rule as the chat path, and for the same reason: an image request is + // billable traffic, so a cost plugin must see it. It fires before the + // quota/scope gates so rejected image requests are visible too. + // messages_count/tools_count are 0: the image request has neither. + g.fireImageStart(r.Context(), model) if isAuto(model) { if chain := g.core.AutoImageChain(); chain != nil && len(chain.Tiers) > 0 { if q := g.checkQuota(r.Context(), "AUTO"); q != nil { @@ -1231,6 +1273,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { if usedModel != "" { rec.Model = usedModel // actual image model served, not "AUTO" } + g.fireRouted(r.Context(), "image", usedSrc, rec.Model, -2, false) rec.OK = true rec.Status = http.StatusOK // Image generation has no token concept. Recording len(ImageData) @@ -1283,6 +1326,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { if resp.Model != "" { rec.Model = resp.Model // record the actual model served, not the raw request id } + g.fireRouted(r.Context(), "image", rec.Source, rec.Model, -1, false) rec.OK = true rec.Status = http.StatusOK // Image generation has no token concept — see the AUTO path above. diff --git a/internal/gateway/plugin_wiring_test.go b/internal/gateway/plugin_wiring_test.go new file mode 100644 index 0000000..306dea0 --- /dev/null +++ b/internal/gateway/plugin_wiring_test.go @@ -0,0 +1,502 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/core" +) + +// newFailingServer serves handler and returns its test server. +func newFailingServer(h http.Handler) *httptest.Server { return httptest.NewServer(h) } + +// THE TEST THAT WAS MISSING. +// +// Every other plugin test calls Plugins.Fire() directly. That proves the Lua +// runtime works, but it says nothing about whether the GATEWAY actually fires +// the stages — the wiring in handleChat / singleChat / writeRec could be +// deleted and the whole suite would stay green while no hook ever ran in +// production. +// +// So these tests drive real HTTP requests through the real handler and assert +// on what the plugin observed. + +// observed is what a spy plugin recorded. It is written from Lua, so the +// counters live in plugin state and are read back through the state API. +type spyPayload struct { + Stage string + Type string + Model string + Source string + OK bool + Tokens int64 +} + +// spyPlugin returns Lua source for a plugin that appends every stage it sees to +// `state.seen`. It is generated per test so each test gets a clean log. +func spyPlugin() string { + return ` +local plugin = { name = "spy", version = "1.0.0" } +plugin.state = { seen = {} } +plugin.hooks = { + request_start = "on_start", + routed = "on_routed", + request_end = "on_end", +} +local function rec(p, stage) + table.insert(plugin.state.seen, { + stage = stage, type = p.type or "", model = p.model or "", + source = p.source or "", ok = p.ok and true or false, + tokens = p.prompt_tokens or 0, + }) +end +function plugin.on_start(p) rec(p, "request_start") return nil end +function plugin.on_routed(p) rec(p, "routed") return nil end +function plugin.on_end(p) rec(p, "request_end") return nil end +return plugin +` +} + +// gatewayWithSpy boots a gateway with one mock upstream and the spy plugin +// loaded, and returns the gateway plus the upstream URL. +func gatewayWithSpy(t *testing.T) (*Gateway, string) { + t.Helper() + up := mockUpstream() + t.Cleanup(up.Close) + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + cfgBody := "listen: :0\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "gateway_keys:\n - sk-test\n" + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + cfg.Sources = []config.Source{{ + Name: "spyup", + BaseURL: up.URL, + Adapter: "openai", + APIKey: "sk-x", + Models: []config.Model{{ID: "spy-model", Kind: "chat"}}, + }} + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(c.Close) + if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil { + t.Fatalf("load spy: %v", err) + } + g, err := New(c) + if err != nil { + t.Fatalf("gateway: %v", err) + } + return g, up.URL +} + +// seenStages reads the spy's log out of its published state. +func seenStages(t *testing.T, g *Gateway) []map[string]interface{} { + t.Helper() + raw := g.core.Plugins().State("spy") + if raw == nil { + return nil + } + b, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + var st struct { + Seen []map[string]interface{} `json:"seen"` + } + if err := json.Unmarshal(b, &st); err != nil { + t.Fatal(err) + } + return st.Seen +} + +func stageNames(seen []map[string]interface{}) []string { + out := make([]string, 0, len(seen)) + for _, s := range seen { + out = append(out, s["stage"].(string)) + } + return out +} + +// TestHooksFireOnRealDirectChat: a plain non-streaming chat through the real +// handler must produce request_start, routed and request_end — in that order, +// with the real source and model filled in. +func TestHooksFireOnRealDirectChat(t *testing.T) { + g, _ := gatewayWithSpy(t) + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"spy-model","messages":[{"role":"user","content":"hi"}]}`) + if rr.Code != http.StatusOK { + t.Fatalf("chat status=%d body=%s", rr.Code, rr.Body.String()) + } + seen := seenStages(t, g) + got := stageNames(seen) + want := []string{"request_start", "routed", "request_end"} + if len(got) != 3 { + t.Fatalf("stages = %v, want %v (a hook that never fires is the failure "+ + "this test exists for)", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("stage order = %v, want %v", got, want) + } + } + // The payload must be real data, not empty scaffolding. + end := seen[2] + if end["source"] != "spyup" { + t.Errorf("request_end source = %v, want spyup", end["source"]) + } + if end["model"] != "spy-model" { + t.Errorf("request_end model = %v, want spy-model", end["model"]) + } + if end["ok"] != true { + t.Errorf("request_end ok = %v, want true", end["ok"]) + } + if toInt(t, end["tokens"]) <= 0 { + t.Errorf("request_end prompt_tokens = %v, want the mock upstream's real usage", end["tokens"]) + } +} + +// TestHooksFireOnRealStreamChat: streaming is a DIFFERENT code path (the record +// is written from a deferred call after the stream ends), so it needs its own +// assertion. A hook that only worked on the non-streaming path would be a real +// bug for agent clients, which are the main streaming users. +func TestHooksFireOnRealStreamChat(t *testing.T) { + g, _ := gatewayWithSpy(t) + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"spy-model","stream":true,"messages":[{"role":"user","content":"hi"}]}`) + if rr.Code != http.StatusOK { + t.Fatalf("stream status=%d body=%s", rr.Code, rr.Body.String()) + } + got := stageNames(seenStages(t, g)) + if len(got) != 3 { + t.Fatalf("stream stages = %v, want all three", got) + } + if got[0] != "request_start" || got[2] != "request_end" { + t.Errorf("stream stage order = %v", got) + } +} + +// TestHooksFireOnAutoRequest: the AUTO chain path is a third code path. It also +// must fire, and routed must report tier -2 (resolved by the chain) rather than +// the direct path's -1. +func TestHooksFireOnAutoRequest(t *testing.T) { + g, _ := gatewayWithSpy(t) + // Register the model in the AUTO chain via the admin API. + put := doReq(t, g, http.MethodPut, "/api/auto", `{"rules":[{"model":"spy-model"}]}`) + if put.Code != http.StatusOK { + t.Fatalf("save auto: %d %s", put.Code, put.Body.String()) + } + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`) + if rr.Code != http.StatusOK { + t.Fatalf("auto chat status=%d body=%s", rr.Code, rr.Body.String()) + } + seen := seenStages(t, g) + if len(seen) != 3 { + t.Fatalf("AUTO stages = %v, want three", stageNames(seen)) + } + if seen[0]["model"] != "AUTO" { + t.Errorf("request_start model = %v, want the requested \"AUTO\"", seen[0]["model"]) + } + if seen[1]["model"] != "spy-model" { + t.Errorf("routed model = %v, want the RESOLVED model", seen[1]["model"]) + } +} + +// TestHooksFireOnFailedRequest is the one that matters most for accounting: a +// request that 500s upstream must still reach request_end, or a billing plugin +// would never see failed traffic. +func TestHooksFireOnFailedRequest(t *testing.T) { + // Point at an upstream that always fails. + bad := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + }) + up := newFailingServer(bad) + defer up.Close() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + cfgBody := "listen: :0\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "gateway_keys:\n - sk-test\n" + os.WriteFile(cfgPath, []byte(cfgBody), 0600) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + cfg.Sources = []config.Source{{ + Name: "badup", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x", + Models: []config.Model{{ID: "bad-model", Kind: "chat"}}, + }} + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatalf("core: %v", err) + } + defer c.Close() + if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil { + t.Fatal(err) + } + g, err := New(c) + if err != nil { + t.Fatal(err) + } + + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"bad-model","messages":[{"role":"user","content":"hi"}]}`) + if rr.Code == http.StatusOK { + t.Fatal("expected the upstream failure to surface as a non-200") + } + seen := seenStages(t, g) + // request_start must fire; routed must NOT (no source was ever selected); + // request_end MUST (otherwise billing misses failed traffic). + got := stageNames(seen) + if len(got) == 0 || got[0] != "request_start" { + t.Fatalf("stages = %v, want request_start first", got) + } + hasEnd := false + for _, s := range seen { + if s["stage"] == "request_end" { + hasEnd = true + if s["ok"] != false { + t.Error("request_end reported ok=true for a failed request") + } + } + } + if !hasEnd { + t.Error("a FAILED request never reached request_end; a billing plugin " + + "would not see it at all") + } + if contains(got, "routed") { + t.Error("routed fired for a request that never selected a source") + } +} + +// TestBrokenPluginDoesNotBreakForwarding is the safety property, asserted +// end-to-end: a plugin that throws in EVERY stage must not stop a chat from +// being served. +func TestBrokenPluginDoesNotBreakForwarding(t *testing.T) { + up := mockUpstream() + defer up.Close() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + os.WriteFile(cfgPath, []byte("listen: :0\n"+ + "adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+ + "plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+ + "runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+ + "gateway_keys:\n - sk-test\n"), 0600) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + cfg.Sources = []config.Source{{ + Name: "u", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x", + Models: []config.Model{{ID: "m", Kind: "chat"}}, + }} + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + broken := ` +local plugin = { name = "broken", version = "1.0.0" } +plugin.hooks = { request_start = "f", routed = "f", request_end = "f" } +function plugin.f(p) error("plugin is broken") end +return plugin +` + if err := c.Plugins().LoadSource("broken", broken); err != nil { + t.Fatalf("load broken: %v", err) + } + g, err := New(c) + if err != nil { + t.Fatal(err) + } + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) + if rr.Code != http.StatusOK { + t.Fatalf("a broken plugin broke forwarding: status=%d body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "pong") { + t.Errorf("the response is not the upstream's: %s", rr.Body.String()) + } + // And the failure must be visible rather than silent. + if len(c.Plugins().HookErrors()) == 0 { + t.Error("a plugin that threw in every stage left no error record") + } +} + +// TestPluginListIsServedWithStages is the operator-facing check: an admin +// debugging a plugin needs to see which stages are live and whether the hook +// file is where the kernel thinks it is. +func TestPluginListIsServedWithStages(t *testing.T) { + g, _ := gatewayWithSpy(t) + rr := doReq(t, g, http.MethodGet, "/api/plugins", "") + if rr.Code != http.StatusOK { + t.Fatalf("status=%d", rr.Code) + } + for _, want := range []string{"spy", "request_start", "routed", "request_end", "plugin_dir"} { + if !strings.Contains(rr.Body.String(), want) { + t.Errorf("/api/plugins response lacks %q", want) + } + } +} + +func toInt(t *testing.T, v interface{}) int64 { + t.Helper() + f, ok := v.(float64) + if !ok { + return 0 + } + return int64(f) +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// imageUpstream serves an OpenAI-shaped image response. +func imageUpstream() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"created":1,"data":[{"b64_json":"aGk="}]}`)) + })) +} + +// TestHooksFireOnRealImageRequest: the image path is a THIRD entry point. It +// has its own handler and its own scheduler call, so "chat works" says nothing +// about it — and image traffic is billable, so a cost plugin that never sees +// it under-reports. +func TestHooksFireOnRealImageRequest(t *testing.T) { + up := imageUpstream() + defer up.Close() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + os.WriteFile(cfgPath, []byte("listen: :0\n"+ + "adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+ + "plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+ + "runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+ + "gateway_keys:\n - sk-test\n"), 0600) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + cfg.Sources = []config.Source{{ + Name: "imgup", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x", + Models: []config.Model{{ID: "img-model", Kind: "image"}}, + }} + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatalf("core: %v", err) + } + defer c.Close() + if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil { + t.Fatal(err) + } + g, err := New(c) + if err != nil { + t.Fatal(err) + } + + rr := doReq(t, g, http.MethodPost, "/v1/images/generations", + `{"model":"img-model","prompt":"a cat"}`) + if rr.Code != http.StatusOK { + t.Fatalf("image status=%d body=%s", rr.Code, rr.Body.String()) + } + seen := seenStages(t, g) + got := stageNames(seen) + if len(got) != 3 { + t.Fatalf("image stages = %v, want all three (image is a separate handler)", got) + } + if seen[0]["type"] != "image" { + t.Errorf("request_start type = %v, want \"image\"", seen[0]["type"]) + } + if seen[1]["source"] != "imgup" { + t.Errorf("routed source = %v, want imgup", seen[1]["source"]) + } +} + +// TestRejectedChatStillFiresRequestStart pins the ordering decision: request_start +// fires BEFORE the quota/scope gates, so a plugin auditing traffic sees rejected +// requests too. A plugin that only ever saw served requests could not compute a +// true request rate. +func TestRejectedChatStillFiresRequestStart(t *testing.T) { + up := mockUpstream() + defer up.Close() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + os.WriteFile(cfgPath, []byte("listen: :0\n"+ + "adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+ + "plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+ + "runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+ + "gateway_keys:\n - sk-test\n"), 0600) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + cfg.Sources = []config.Source{{ + Name: "u", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x", + Models: []config.Model{{ID: "m", Kind: "chat"}}, + }} + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil { + t.Fatal(err) + } + g, err := New(c) + if err != nil { + t.Fatal(err) + } + // A model the gateway does not know => 404, rejected before any routing. + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 for an unknown model, got %d", rr.Code) + } + got := stageNames(seenStages(t, g)) + if !contains(got, "request_start") { + t.Errorf("stages = %v; a REJECTED request must still fire request_start so a "+ + "plugin can count real traffic, not just served traffic", got) + } +} From 42764bc99e9771302d12318dfa103f5b332a628c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 01:03:39 +0800 Subject: [PATCH 24/42] =?UTF-8?q?feat(plugin):=20AUTO=20=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E5=8F=AF=E8=A7=81=EF=BC=88chain=5Fstep=20sta?= =?UTF-8?q?ge=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 被问"还有 auto 调度相关 stage 呢?"问出来的真实缺口。 ## 问题 chainDrive 只返回 (resp, src, model, err),调用方只知道**最终哪个槽位赢了**。 遍历过程中算出来又丢掉的东西——哪些档被跳过、为什么跳过、哪些槽位硬失败、 哪档全忙——一律不可见。ChainErr 里其实有这些,但**只在全部失败时**才填, 而它是 error 返回值不是记录。于是: "tier 1 冷却所以降级到 tier 3" == "tier 1 正常接单" 对插件而言 tier 只是个常量 -2("resolved by the chain"),信息量为零。而这 恰恰是优先级链存在的全部理由,也是"我那个贵模型为什么没被用"的答案。 ## 做法(scheduler 侧零新依赖) 新增 TraceEvent / TraceSink,chainDrive 多一个可选 sink 参数: - TraceEvent 是本包的普通 struct,sink 是 func 参数 ⇒ **不新增 import**, scheduler 仍然可独立测试 - sink 为 nil 时每次 emit 只多一次 nil 判断;没有插件的网关在 AUTO 热路径上 零开销(gateway 的 chainTraceSink 直接返回 nil) - 事件是纯观测:scheduler 不基于它做任何分支,gateway 也不把它喂回路由/ 冷却/配额 四种 kind:tier_skip / slot_fail / tier_busy / selected,selected 每次成功 遍历恰好一次且是最后一步。顺序保证所有 step 在 routed 之前。 ## 暴露给插件 新增 chain_step stage(逐个步骤),并在 request_end 载荷里加三个便于做报表的 字段:chain_walk(上限 12 步,防审计记录膨胀)、degraded、tier_served。 ## ★ 计费口径(我按推荐的做,已写进文档,需要你确认) **按实际服务的模型计费**:降级到 tier 3 仍按 tier 3 的价算,轨迹只作观测。 理由与 §7.5 的边界一致——插件只报表不执法,两套口径混在一起会引出"降级该不该 多收钱"这种无法从代码判断的争议。若要改成"按本该用的档计价",需要在 models 价目里允许按 tier 定价,这我没做,因为那是个产品决策。 ## 计费插件同步消费 by_tier_served / skip_reasons / degraded_reqs 三个新维度。skip_reasons 的等待 时长做了归一(`no free slot within `),否则 busy-wait 文案一变就多一行。 降级次数在 request_end 里计而不是在 chain_step 里计:一次降级的请求要走多步, 按步计会重复计数。 ## 判据(346 个测试全绿,新增 15 个) scheduler 6 个:正常路径只发一个 selected / 跳档+降级可见 / 硬失败与跳档 严格区分(不可混为一谈,否则抖动上游看起来像空闲上游)/ nil sink 安全 / 全失败时轨迹与 ChainErr 并存且不互相破坏 / 空链不发事件 gateway 1 个端到端:tier 1 全 500 → 插件收到 slot_fail(tier 1) + selected(tier 2),request_end 的 tier_served=2 且 degraded=true lua 2 个:降级计数与按实际模型计价 / 跳过原因归一聚合 lua 1 个:chain_step 是真 stage 且顺序正确 3 个变异都红:去掉 slot_fail(3 个判据红)/ 去掉 tier_skip(1 个)/ 去掉 degraded 字段(1 个)。 --- docs/plugins.md | 46 +++++- internal/gateway/chat.go | 87 ++++++++++- internal/gateway/plugin_wiring_test.go | 124 ++++++++++++++++ internal/gateway/stats.go | 17 +++ internal/lua/billing_test.go | 82 +++++++++++ internal/lua/plugins.go | 26 ++++ internal/lua/plugins/billing.lua | 40 ++++++ internal/lua/plugins_test.go | 60 ++++++++ internal/scheduler/scheduler.go | 88 +++++++++++- internal/scheduler/scheduler_test.go | 28 ++-- internal/scheduler/trace_test.go | 191 +++++++++++++++++++++++++ 11 files changed, 766 insertions(+), 23 deletions(-) create mode 100644 internal/scheduler/trace_test.go diff --git a/docs/plugins.md b/docs/plugins.md index 4093804..4129882 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,10 +135,43 @@ core.New | stage | 代码位置 | 说明 | |---|---|---| -| `request_start` | `gateway/chat.go` `handleChat` | 每个 chat 请求一次 | +| `request_start` | `gateway/chat.go` `handleChat` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** | +| `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 | | `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 | | `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 | +### 3.1b 为什么单独有 `chain_step` + +`routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以 +"tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**—— +而这恰恰是优先级链存在的全部理由。 + +`chain_step` 补上这条信息,四种 `kind`: + +| kind | 含义 | 何时产生 | +|---|---|---| +| `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 | +| `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 | +| `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 | +| `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 | + +顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。 +所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。 + +### 3.1c `request_end` 里的轨迹摘要 + +除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段: + +| 字段 | 含义 | +|---|---| +| `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) | +| `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** | +| `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` | + +> **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算, +> `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。 +> 理由见 §7.5。 + `request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都 经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。 @@ -345,6 +378,17 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \ `total` / `by_source` / `by_model` / `by_key` / `by_day`(`YYYY-MM-DD` UTC), 每项含 `cost`、`requests`、`prompt_tokens`、`completion_tokens`、`failures`。 +另有两个**降级观测**维度(来自 `chain_step`): + +| 字段 | 含义 | +|---|---| +| `degraded_reqs` | 发生过降级的请求数 | +| `by_tier_served` | 各档实际接单数(`{"1": 812, "2": 37}`) | +| `skip_reasons` | 跳过原因计数,等待时长已归一(`no free slot within `) | + +这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康 +网关看起来一模一样——**除非**单独统计降级次数。 + ### 7.4 计费策略:失败的请求怎么算 **保留 token 费用,丢弃固定费用。** 理由:上游在生成后才 500,token 确实被消耗 diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index ff8c8e0..d1be6e1 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -930,6 +930,76 @@ func (g *Gateway) fireRouted(ctx context.Context, kind, source, model string, ti }) } +// chainTraceSink adapts a scheduler TraceSink into the plugin chain_step stage. +// +// It returns nil when no plugin is loaded, so the scheduler's emit() does a +// single nil check per event and the AUTO hot path pays nothing on a gateway +// with no plugins. +// +// The events are also accumulated into walk so request_end can carry a compact +// summary: a plugin that only listens to request_end still learns that a +// degradation happened, which is the common case for a dashboard that does not +// want to subscribe to a high-frequency stage. +func (g *Gateway) chainTraceSink(ctx context.Context, kind string, walk *[]map[string]interface{}) scheduler.TraceSink { + ps := g.core.Plugins() + if ps == nil || ps.Count() == 0 { + return nil + } + key := keyID(reqKey(ctx)) + return func(ev scheduler.TraceEvent) { + payload := map[string]interface{}{ + "stage": string(lua.StageChainStep), + "kind": string(ev.Kind), + "type": kind, + "key": key, + "tier": ev.Tier, + "attempt": ev.Attempt, + } + if ev.Source != "" { + payload["source"] = ev.Source + } + if ev.Model != "" { + payload["model"] = ev.Model + } + if ev.Reason != "" { + payload["reason"] = ev.Reason + } + if ev.Err != "" { + payload["error"] = ev.Err + } + if walk != nil { + // Keep the summary bounded: a pathological chain could emit many + // steps, and request_end's payload is written to the audit trail. + if len(*walk) < maxWalkSummary { + *walk = append(*walk, map[string]interface{}{ + "kind": string(ev.Kind), "tier": ev.Tier, + "source": ev.Source, "model": ev.Model, "reason": ev.Reason, + }) + } + } + ps.Fire(lua.StageChainStep, payload) + } +} + +// maxWalkSummary caps how many chain steps request_end carries, so a long +// degradation cannot inflate every audit record. +const maxWalkSummary = 12 + +// tierServed returns the AUTO tier that actually served the request, or -1 when +// the walk is empty (a direct request) or ended without a selection (total +// failure). It is the single most useful number for "why did my expensive tier +// not get used". +func tierServed(walk []map[string]interface{}) int { + for i := len(walk) - 1; i >= 0; i-- { + if k, _ := walk[i]["kind"].(string); k == string(scheduler.TraceSelected) { + if t, ok := walk[i]["tier"].(int); ok { + return t + } + } + } + return -1 +} + // fireEnd dispatches the plugin request_end stage for one finished request. func (g *Gateway) fireEnd(rec *Req) { ps := g.core.Plugins() @@ -953,6 +1023,13 @@ func (g *Gateway) fireEnd(rec *Req) { "image_count": rec.ImageCount, "error": rec.Err, "time": rec.Time, + // chain_walk: the AUTO tier-by-tier trace, when the request went + // through the chain. Empty for a direct request and for a gateway with + // no plugins loaded. Absent rather than empty so a plugin can tell + // "no chain" from "chain with no degradation". + "degraded": len(rec.Walk) > 1, + "chain_walk": rec.Walk, + "tier_served": tierServed(rec.Walk), } // The merged result is intentionally discarded: request_end is the last // stage, so there is nobody downstream to read a plugin's additions. Plugins @@ -1179,8 +1256,11 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, chain *scheduler.Chain, req *types.ChatRequest, rec *Req, quotaExhausted func(*scheduler.Slot) bool) { rec.LatMs = 0 t0 := time.Now() - resp, usedSrc, usedModel, err := g.core.Scheduler().ChainChat(ctx, chain, req, quotaExhausted) + var walk []map[string]interface{} + resp, usedSrc, usedModel, err := g.core.Scheduler().ChainChat(ctx, chain, req, quotaExhausted, + g.chainTraceSink(ctx, "chat", &walk)) rec.LatMs = time.Since(t0).Milliseconds() + rec.Walk = walk if err != nil { g.failChat(w, rec, err) g.writeRec(rec) @@ -1213,7 +1293,10 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha rec.LatMs = time.Since(t0).Milliseconds() g.writeRec(rec) }() - chunks, usedSrc, usedModel, err := g.core.Scheduler().ChainChatStream(ctx, chain, req, quotaExhausted) + var walk []map[string]interface{} + chunks, usedSrc, usedModel, err := g.core.Scheduler().ChainChatStream(ctx, chain, req, quotaExhausted, + g.chainTraceSink(ctx, "stream", &walk)) + rec.Walk = walk if err != nil { g.failChat(w, rec, err) return diff --git a/internal/gateway/plugin_wiring_test.go b/internal/gateway/plugin_wiring_test.go index 306dea0..794606f 100644 --- a/internal/gateway/plugin_wiring_test.go +++ b/internal/gateway/plugin_wiring_test.go @@ -500,3 +500,127 @@ func TestRejectedChatStillFiresRequestStart(t *testing.T) { "plugin can count real traffic, not just served traffic", got) } } + +// TestChainStepReachesPluginOnDegradation is the end-to-end proof for the +// AUTO trace: a request that had to drop from tier 1 to tier 2 must be visible +// to a plugin as a tier_skip followed by a selected, and request_end must carry +// tier_served=2. +// +// Before the trace existed the plugin saw only tier=-2 ("resolved by the +// chain") and could not tell a degradation from a clean tier-1 hit — which is +// the whole question a priority chain exists to answer. +func TestChainStepReachesPluginOnDegradation(t *testing.T) { + // tier 1's source always fails, so the walk must drop to tier 2. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + })) + defer bad.Close() + good := mockUpstream() + defer good.Close() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + os.WriteFile(cfgPath, []byte("listen: :0\n"+ + "adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+ + "plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+ + "runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+ + "gateway_keys:\n - sk-test\n"), 0600) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + cfg.Sources = []config.Source{ + {Name: "t1", BaseURL: bad.URL, Adapter: "openai", APIKey: "sk-x", + Models: []config.Model{{ID: "hi-tier", Kind: "chat"}}}, + {Name: "t2", BaseURL: good.URL, Adapter: "openai", APIKey: "sk-x", + Models: []config.Model{{ID: "lo-tier", Kind: "chat"}}}, + } + if err := cfg.ApplyDefaults(); err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + // A spy that records chain_step events too. + sp := ` +local plugin = { name = "walker", version = "1.0.0" } +plugin.state = { steps = {}, ends = {} } +plugin.hooks = { chain_step = "step", request_end = "fin" } +function plugin.step(p) + table.insert(plugin.state.steps, { kind = p.kind, tier = p.tier, source = p.source, model = p.model, reason = p.reason }) + return nil +end +function plugin.fin(p) + plugin.state.ends[#plugin.state.ends + 1] = { + tier_served = p.tier_served, degraded = p.degraded, + walk = p.chain_walk, source = p.source, model = p.model, + } + return nil +end +return plugin +` + if err := c.Plugins().LoadSource("walker", sp); err != nil { + t.Fatal(err) + } + g, err := New(c) + if err != nil { + t.Fatal(err) + } + // Two tiers, both in the chain. + if rr := doReq(t, g, http.MethodPut, "/api/auto", + `{"rules":[{"model":"hi-tier","source":"t1","tier":1},{"model":"lo-tier","source":"t2","tier":2}]}`); rr.Code != 200 { + t.Fatalf("save auto: %d %s", rr.Code, rr.Body.String()) + } + + rr := doReq(t, g, http.MethodPost, "/v1/chat/completions", + `{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`) + if rr.Code != http.StatusOK { + t.Fatalf("chat = %d %s", rr.Code, rr.Body.String()) + } + + raw := c.Plugins().State("walker") + b, _ := json.Marshal(raw) + var st struct { + Steps []struct { + Kind string `json:"kind"` + Tier int `json:"tier"` + Source string `json:"source"` + Model string `json:"model"` + } `json:"steps"` + Ends []struct { + TierServed int `json:"tier_served"` + Degraded bool `json:"degraded"` + Source string `json:"source"` + Model string `json:"model"` + } `json:"ends"` + } + if err := json.Unmarshal(b, &st); err != nil { + t.Fatalf("decode: %v (%s)", err, string(b)) + } + if len(st.Steps) < 2 { + t.Fatalf("chain_step events = %+v, want at least a slot_fail and a selected", st.Steps) + } + if st.Steps[0].Kind != "slot_fail" || st.Steps[0].Tier != 1 { + t.Errorf("first step = %+v, want slot_fail on tier 1", st.Steps[0]) + } + last := st.Steps[len(st.Steps)-1] + if last.Kind != "selected" || last.Tier != 2 { + t.Errorf("last step = %+v, want selected on tier 2", last) + } + if len(st.Ends) != 1 { + t.Fatalf("request_end count = %d, want 1", len(st.Ends)) + } + if st.Ends[0].TierServed != 2 { + t.Errorf("tier_served = %d, want 2", st.Ends[0].TierServed) + } + if !st.Ends[0].Degraded { + t.Error("degraded = false, but the request dropped a tier") + } + if st.Ends[0].Model != "lo-tier" { + t.Errorf("served model = %q, want lo-tier", st.Ends[0].Model) + } +} diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 6c4d3d5..ef99c20 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -52,6 +52,23 @@ type Req struct { // Kept separate from Compl/Prompt: image generation has no token concept, // so counting images as "completion tokens" would corrupt the token totals. ImageCount int `json:"image_count,omitempty"` + + // Walk is the AUTO chain's step-by-step trace for this request: which tiers + // were skipped and why, which slots hard-failed, which one served it. It is + // the only way a consumer can tell "tier 1 served this" from "tier 1 was + // cooling so we dropped to tier 3" — a distinction that is the entire point + // of a priority chain. + // + // json:"-" — deliberately NOT persisted. The audit file is a hot append and + // this is observational detail: on a degraded gateway every request would + // carry a multi-element array, and the audit trail's own retention (16 files + // x 16 MB) is already the largest thing on the box. A plugin that wants the + // walk sees it live at request_end; an operator post-mortem reads it from the + // plugin's own accumulated state or from /api/auto slot health. + // + // Only populated when a plugin is loaded (chainTraceSink returns nil + // otherwise), so a gateway with no plugins allocates nothing for it. + Walk []map[string]interface{} `json:"-"` } // Stat aggregates counters for one dimension row. diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 9837cab..59a39ec 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -294,3 +294,85 @@ func TestBillingPluginLoadedByDefault(t *testing.T) { t.Errorf("billing.lua was not written to the plugin dir: %v", err) } } + +// TestBillingCountsDegradations: the plugin must distinguish a request that had +// to drop below the top tier from one the top tier served. Without the chain +// trace these were identical in the accounts, so a quietly degraded gateway +// looked healthy while spending more per request. +func TestBillingCountsDegradations(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "hi-tier": map[string]interface{}{"prompt": 1e-5, "completion": 1e-5}, + "lo-tier": map[string]interface{}{"prompt": 1e-6, "completion": 1e-6}, + }, + }, + }) + + // Request 1: degraded. tier 1 hard-failed, tier 2 served it. + ps.Fire(StageChainStep, map[string]interface{}{ + "kind": "slot_fail", "tier": 1, "source": "t1", "model": "hi-tier", + }) + ps.Fire(StageChainStep, map[string]interface{}{ + "kind": "selected", "tier": 2, "source": "t2", "model": "lo-tier", + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "lo-tier", "source": "t2", "key": "***d1", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, + "degraded": true, "tier_served": 2, "time": 1750000000000, + }) + + // Request 2: clean, served by the top tier. + ps.Fire(StageChainStep, map[string]interface{}{ + "kind": "selected", "tier": 1, "source": "t1", "model": "hi-tier", + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "hi-tier", "source": "t1", "key": "***d1", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, + "degraded": false, "tier_served": 1, "time": 1750000000000, + }) + + st := stateOf(t, ps) + if got := st["degraded_reqs"].(float64); got != 1 { + t.Errorf("degraded_reqs = %v, want 1 (one of the two requests dropped a tier)", got) + } + tiers := st["by_tier_served"].(map[string]interface{}) + if tiers["2"].(float64) != 1 { + t.Errorf("by_tier_served[2] = %v, want 1", tiers["2"]) + } + if tiers["1"].(float64) != 1 { + t.Errorf("by_tier_served[1] = %v, want 1", tiers["1"]) + } + // Cost reflects the model actually served, not the one that should have been. + // 1000*1e-6*2 = 0.002 for the degraded one, 1000*1e-5*2 = 0.02 for the clean one. + approx(t, "total", st["total"].(map[string]interface{})["cost"].(float64), 0.022) +} + +// TestBillingAggregatesSkipReasons: skip reasons are the actionable diagnostic +// ("no schedulable slot (cooling or quota exhausted)"), so they must be +// counted. The wait time is normalised, otherwise a fresh row per request would +// appear whenever the busy-wait text varies. +func TestBillingAggregatesSkipReasons(t *testing.T) { + ps, _ := billingVM(t) + ps.Fire(StageChainStep, map[string]interface{}{ + "kind": "tier_skip", "tier": 1, "reason": "no schedulable slot (cooling or quota exhausted)", + }) + ps.Fire(StageChainStep, map[string]interface{}{ + "kind": "tier_busy", "tier": 2, "reason": "no free slot within 2s", + }) + ps.Fire(StageChainStep, map[string]interface{}{ + "kind": "tier_busy", "tier": 3, "reason": "no free slot within 2.0001s", + }) + st := stateOf(t, ps) + reasons := st["skip_reasons"].(map[string]interface{}) + if len(reasons) != 2 { + t.Errorf("skip_reasons = %v, want 2 (the two variable waits must collapse to one)", reasons) + } + busy, ok := reasons["no free slot within "] + if !ok { + t.Errorf("busy reason missing; got %v", reasons) + } else if busy.(float64) != 2 { + t.Errorf("busy count = %v, want 2 (two different wait texts, one cause)", busy) + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index e1089cf..1fa7522 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -64,6 +64,31 @@ const ( // tier (AUTO tier, -1 for the direct path), stream. StageRouted Stage = "routed" + // StageChainStep fires ONCE PER STEP of an AUTO chain walk, and only on the + // AUTO path (a direct request has no chain and therefore emits nothing). + // + // This exists because StageRouted cannot express degradation: it fires once, + // after the walk, with the slot that finally won. "tier 1 was cooling so we + // dropped to tier 3" and "tier 1 served it" were indistinguishable. That + // distinction is the whole point of a priority chain, and it is what an + // operator debugging "why did my expensive model not get used" needs. + // + // payload: + // kind "tier_skip" | "slot_fail" | "tier_busy" | "selected" + // tier the AUTO tier this step belongs to (1 = highest priority) + // source / model set for slot_fail and selected + // reason human-readable cause, for tier_skip and tier_busy + // error the underlying error text, for slot_fail + // attempt 1-based slot attempt within this walk + // + // Ordering: every step precedes StageRouted, and the "selected" step is the + // last one. A plugin accumulating the walk therefore has the full picture + // by the time request_end arrives. + // + // These events are OBSERVATION ONLY — see the accounting note in + // docs/plugins.md: nothing here feeds back into routing, cooldown or quota. + StageChainStep Stage = "chain_step" + // StageRequestEnd fires exactly once per request, after the client response // has been produced (or after a failure was recorded). payload adds: // source, model, ok, status, latency_ms, first_byte_ms, prompt_tokens, @@ -78,6 +103,7 @@ const ( // AllStages is the firing order, used by the docs and by the hook listing. var AllStages = []Stage{ StageRequestStart, + StageChainStep, StageRouted, StageRequestEnd, } diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 3fa62ee..d4f6eae 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -194,9 +194,43 @@ end -- ---------- hooks ---------- plugin.hooks = { + -- chain_step gives the per-tier walk; request_end gives the final accounting. + -- Subscribing to chain_step is OPTIONAL here: the totals are driven by + -- request_end alone, and the degradation counters below are pure observation. + -- A gateway with thousands of requests can drop this hook to save the + -- per-step Lua call without losing a single billed request. + chain_step = "on_chain_step", request_end = "on_request_end", } +-- Tracks how often a request had to drop below the top tier, and which tier +-- actually served it. Without this, "tier 1 was cooling" and "tier 1 served it" +-- are indistinguishable in the accounts, and a quietly degraded gateway looks +-- exactly like a healthy one. +plugin.state.degraded_reqs = 0 +plugin.state.by_tier_served = {} +plugin.state.skip_reasons = {} + +function plugin.on_chain_step(payload) + if payload == nil then return nil end + local s = plugin.state + if s == nil then return nil end + if s.by_tier_served == nil then s.by_tier_served = {} end + if s.skip_reasons == nil then s.skip_reasons = {} end + + if payload.kind == "selected" then + local t = tostring(payload.tier or "?") + s.by_tier_served[t] = (s.by_tier_served[t] or 0) + 1 + elseif payload.kind == "tier_skip" or payload.kind == "tier_busy" then + -- reason text is the ACTIONABLE part; normalise the volatile bits so the + -- same cause aggregates instead of creating a new row per request. + local r = tostring(payload.reason or payload.kind or "unknown") + r = string.gsub(r, "within [%d%.%a]+", "within ") + s.skip_reasons[r] = (s.skip_reasons[r] or 0) + 1 + end + return nil +end + function plugin.on_request_end(payload) if payload == nil then return nil end local prompt = tonumber(payload.prompt_tokens) or 0 @@ -216,6 +250,11 @@ function plugin.on_request_end(payload) if s.by_key == nil then s.by_key = {} end if s.by_day == nil then s.by_day = {} end if s.started == nil then s.started = payload.time or 0 end + if s.degraded_reqs == nil then s.degraded_reqs = 0 end + -- Degradation is counted here rather than in the chain_step hook because + -- request_end sees the whole walk at once: one degraded request must count + -- once, whereas the walk may contain several skipped tiers. + if payload.degraded then s.degraded_reqs = s.degraded_reqs + 1 end add(s.total, cost, prompt, completion, ok) if payload.source ~= nil and payload.source ~= "" then @@ -313,6 +352,7 @@ plugin.ui = { document.getElementById("billing-kpis").innerHTML = [ ["Total", money(t.cost, cur)], ["Requests", t.requests || 0], + ["Degraded", s.degraded_reqs || 0], ["Prompt tokens", t.prompt_tokens || 0], ["Completion tokens", t.completion_tokens || 0], ["Failures", t.failures || 0] diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go index 41ea89a..dea4735 100644 --- a/internal/lua/plugins_test.go +++ b/internal/lua/plugins_test.go @@ -311,3 +311,63 @@ func TestPluginFireWithNoPluginsIsNoop(t *testing.T) { t.Errorf("empty registry misbehaved: %+v", out) } } + +// TestChainStepIsARealStage: chain_step is documented as a distinct stage that +// fires once per step of an AUTO walk. If it were only a field on `routed`, a +// plugin author following the docs would silently get one event instead of the +// whole walk. +func TestChainStepIsARealStage(t *testing.T) { + found := false + for _, s := range AllStages { + if s == StageChainStep { + found = true + } + } + if !found { + t.Fatal("StageChainStep is not in AllStages, so the dispatcher never registers it") + } + // Ordering: it must sit between request_start and routed, which is what + // docs/plugins.md promises. + var iStart, iStep, iRouted = -1, -1, -1 + for i, s := range AllStages { + switch s { + case StageRequestStart: + iStart = i + case StageChainStep: + iStep = i + case StageRouted: + iRouted = i + } + } + if !(iStart < iStep && iStep < iRouted) { + t.Errorf("stage order = %v, want request_start < chain_step < routed", AllStages) + } + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "stepper" } +p.hooks = { chain_step = "s" } +function p.s(payload) + payload.kinds = (payload.kinds or "") + return nil +end +return p +` + if err := loadPlugin(t, ps, "stepper", code); err != nil { + t.Fatalf("load: %v", err) + } + // It must actually dispatch. + seen := false + for _, row := range ps.List() { + if row["name"] == "stepper" { + hooks := row["hooks"].([]string) + for _, h := range hooks { + if h == string(StageChainStep) { + seen = true + } + } + } + } + if !seen { + t.Error("a plugin registered for chain_step is not reported as such") + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 63bc7b5..9fa0f4f 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -293,13 +293,75 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r return tierResult{hard: hard} } +// TraceKind classifies one step of an AUTO chain walk. +type TraceKind string + +const ( + // TraceTierSkip: the whole tier was skipped — every slot was cooling, + // quota-exhausted, or none was schedulable. Reason says which. + TraceTierSkip TraceKind = "tier_skip" + // TraceSlotFail: one slot failed hard (upstream error / bad adapter). The + // walk continues to the next slot or tier. + TraceSlotFail TraceKind = "slot_fail" + // TraceTierBusy: the tier was fully busy and the bounded wait expired. + TraceTierBusy TraceKind = "tier_busy" + // TraceSelected: this slot served the request. Exactly one per successful + // chain walk, and the last event emitted. + TraceSelected TraceKind = "selected" +) + +// TraceEvent is one observable step of an AUTO chain walk. +// +// WHY THIS EXISTS: chainDrive's return value is (resp, src, model, err), so a +// caller learns only which slot finally served the request. Everything the +// scheduler decided on the way there — which tiers it skipped and WHY, which +// slots hard-failed, whether a tier was merely busy — was computed and then +// discarded. That is invisible to operators and to plugins: "tier 1 was cooling +// so we degraded to tier 3" looked exactly like "tier 1 served it". +// +// The walk already accumulates this in ChainErr, but ONLY on total failure, and +// ChainErr is an error return, not a record. Emitting a trace as it happens +// covers the far more common case: a request that SUCCEEDED after degrading. +// +// Design constraints: +// - scheduler stays dependency-free and independently testable. A TraceEvent +// is a plain struct in this package and the sink is a func parameter, so no +// import is added and no test has to change to observe a walk. +// - The sink is optional (nil = emit nothing). The overhead on the hot path +// is one nil check per event. +// - Events are OBSERVATION ONLY. Nothing in the scheduler branches on them, +// and the gateway does not feed them back into routing, cooldown or quota — +// see docs/plugins.md for why accounting and enforcement are kept apart. +type TraceEvent struct { + Kind TraceKind + Tier int + Source string + Model string + Reason string // human-readable, for TraceTierSkip / TraceSlotFail + Err string // the underlying error text, for TraceSlotFail + // Attempt counts the 1-based slot attempt within the whole walk. + Attempt int +} + +// TraceSink receives chain-walk events. It must not block: it is called from the +// request path, and a slow sink slows the request. +type TraceSink func(TraceEvent) + // chainDrive runs a request down the chain (plan 2.3): tiers ascending (tier // 1, the highest priority, first), per-tier round-robin starting at the tier // cursor, same-tier runs ordered by preference (negative prefs sink but stay // reachable). Quota-exhausted and cooling slots are filtered up front; a // fully busy tier is polled for a bounded time before falling through. // Failures are summarized in *ChainErr for the caller to map to HTTP 503. -func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, stream bool) (*types.UnifiedResponse, <-chan types.UnifiedChunk, string, string, error) { +// +// trace may be nil; when set it receives one event per observable step. +func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, stream bool, trace TraceSink) (*types.UnifiedResponse, <-chan types.UnifiedChunk, string, string, error) { + emit := func(ev TraceEvent) { + if trace != nil { + trace(ev) + } + } + attempt := 0 if chain == nil || len(chain.Tiers) == 0 { return nil, nil, "", "", fmt.Errorf("no auto slot configured") } @@ -309,7 +371,9 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha // dropped unless they qualify as half-cooldown probes (appended last). cands := collectCands(tn.Slots, exhausted) if len(cands) == 0 { - ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling or quota exhausted)", tn.Tier)) + reason := "no schedulable slot (cooling or quota exhausted)" + ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: %s", tn.Tier, reason)) + emit(TraceEvent{Kind: TraceTierSkip, Tier: tn.Tier, Reason: reason}) continue } // No Pref sort: load balancing is done by round-robin cursor. @@ -318,6 +382,8 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha base := tn.NextStart() res := runTier(ctx, tn, cands, base, req, stream) if res.resp != nil || res.chunks != nil { + attempt++ + emit(TraceEvent{Kind: TraceSelected, Tier: tn.Tier, Source: res.src, Model: res.model, Attempt: attempt}) releaseProbes(cands) return res.resp, res.chunks, res.src, res.model, nil } @@ -327,6 +393,13 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha } if len(res.hard) > 0 { ce.Tiers = append(ce.Tiers, res.hard...) + for _, h := range res.hard { + attempt++ + emit(TraceEvent{ + Kind: TraceSlotFail, Tier: tn.Tier, Source: h.Source, Model: h.Model, + Err: types.OneLine(h.Err.Error(), 200), Attempt: attempt, + }) + } releaseProbes(cands) continue // hard failures: fall through to the next tier, no waiting } @@ -334,10 +407,13 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha if err := s.pollBusyTier(ctx, tn, cands, base, req, stream, &ce); err != nil { releaseProbes(cands) if r, ok := err.(*tierSuccess); ok { + attempt++ + emit(TraceEvent{Kind: TraceSelected, Tier: tn.Tier, Source: r.res.src, Model: r.res.model, Attempt: attempt}) return r.res.resp, r.res.chunks, r.res.src, r.res.model, nil } return nil, nil, "", "", err } + emit(TraceEvent{Kind: TraceTierBusy, Tier: tn.Tier, Reason: fmt.Sprintf("no free slot within %v", busyWait)}) releaseProbes(cands) } if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 { @@ -401,16 +477,16 @@ func (s *Scheduler) pollBusyTier(ctx context.Context, tn *TierNode, cands []cand // non-nil, decides slot token-quota exhaustion. Returns the response, the // serving source and the exact model id used; on total failure a *ChainErr // summarizing every tier. -func (s *Scheduler) ChainChat(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (*types.UnifiedResponse, string, string, error) { - resp, _, src, model, err := s.chainDrive(ctx, chain, req, exhausted, false) +func (s *Scheduler) ChainChat(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, trace TraceSink) (*types.UnifiedResponse, string, string, error) { + resp, _, src, model, err := s.chainDrive(ctx, chain, req, exhausted, false, trace) return resp, src, model, err } // ChainChatStream runs a streaming AUTO request down the chain. A slot is // abandoned only on connect failures / busy (before its first chunk); after a // stream starts it is pinned. Same return contract as ChainChat. -func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (<-chan types.UnifiedChunk, string, string, error) { - _, chunks, src, model, err := s.chainDrive(ctx, chain, req, exhausted, true) +func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, trace TraceSink) (<-chan types.UnifiedChunk, string, string, error) { + _, chunks, src, model, err := s.chainDrive(ctx, chain, req, exhausted, true, trace) return chunks, src, model, err } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index c7c0a6f..96811fc 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -176,7 +176,7 @@ func TestChainRoundRobin(t *testing.T) { s := New(0) var got []string for i := 0; i < 4; i++ { - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("iter %d: %v", i, err) } @@ -200,7 +200,7 @@ func TestChainPreferenceSinksButStaysReachable(t *testing.T) { {Tier: 0, Model: "g", Source: "good"}, }, bySource(neg, good)) s := New(0) - resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("chain: %v", err) } @@ -213,7 +213,7 @@ func TestChainPreferenceSinksButStaysReachable(t *testing.T) { t.Fatal("neg was tried first and succeeded; good must not be attempted") } // Second request: cursor advances. neg wins again (good hard-fails). - resp2, src2, _, err2 := s.ChainChat(context.Background(), ch, chatReq(), nil) + resp2, src2, _, err2 := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err2 != nil { t.Fatalf("second chain: %v", err2) } @@ -230,7 +230,7 @@ func TestChainBusySkipsWithoutPenalty(t *testing.T) { {Tier: 0, Model: "b", Source: "s2"}, }, bySource(a, b)) s := New(0) - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("chain: %v", err) } @@ -256,7 +256,7 @@ func TestChainAllBusyBoundedWaitThenNextTier(t *testing.T) { }, bySource(a, b, c)) s := New(0) t0 := time.Now() - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) el := time.Since(t0) if err != nil { t.Fatalf("chain: %v", err) @@ -277,7 +277,7 @@ func TestChainQuotaExhausted(t *testing.T) { }, bySource(a, b)) s := New(0) exhausted := func(sl *Slot) bool { return sl.Source == "s1" && sl.Quota > 0 } - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), exhausted) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), exhausted, nil) if err != nil { t.Fatalf("chain: %v", err) } @@ -300,7 +300,7 @@ func TestChainErrSummary(t *testing.T) { {Tier: 1, Model: "c", Source: "s3"}, }, bySource(a, b, c)) s := New(0) - _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) var ce *ChainErr if !errors.As(err, &ce) { t.Fatalf("err = %v, want *ChainErr", err) @@ -325,7 +325,7 @@ func TestChainStreamFallsBackBeforeFirstChunk(t *testing.T) { {Tier: 0, Model: "b", Source: "s2"}, }, bySource(a, b)) s := New(0) - chunks, src, model, err := s.ChainChatStream(context.Background(), ch, chatReq(), nil) + chunks, src, model, err := s.ChainChatStream(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("chain stream: %v", err) } @@ -365,7 +365,7 @@ func TestChainProbeIsLastResort(t *testing.T) { }, bySource(healthy, cooling)) s := New(0) for i := 0; i < 3; i++ { - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("iter %d: %v", i, err) } @@ -390,7 +390,7 @@ func TestChainProbeServesWhenNothingElseCan(t *testing.T) { cooling.probeable.Store(true) ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling)) s := New(0) - resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("probe must serve the request: %v", err) } @@ -417,14 +417,14 @@ func TestChainProbePermitReleasedOnFailure(t *testing.T) { cooling.fail.Store(true) ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling)) s := New(0) - if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil { + if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil); err == nil { t.Fatal("expected the failing probe to surface an error") } if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 { t.Fatalf("permit accounting: claims=%d dones=%d, want 1/1", cooling.probeClaims.Load(), cooling.probeDones.Load()) } // permit is free again for the next attempt - if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil { + if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil); err == nil { t.Fatal("expected the second probe to fail too") } if cooling.probeClaims.Load() != 2 { @@ -443,7 +443,7 @@ func TestChainProbeDoesNotBlockTierFallthrough(t *testing.T) { {Tier: 2, Model: "b", Source: "backup"}, }, bySource(cold, backup)) s := New(0) - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil || src != "backup" { t.Fatalf("want fallthrough to backup, got src=%q err=%v", src, err) } @@ -482,7 +482,7 @@ func TestChainProbeRoundRobinUnaffected(t *testing.T) { s := New(0) var got []string for i := 0; i < 4; i++ { - _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil) + _, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil) if err != nil { t.Fatalf("iter %d: %v", i, err) } diff --git a/internal/scheduler/trace_test.go b/internal/scheduler/trace_test.go new file mode 100644 index 0000000..2873e42 --- /dev/null +++ b/internal/scheduler/trace_test.go @@ -0,0 +1,191 @@ +package scheduler + +import ( + "context" + "errors" + "strings" + "testing" +) + +// The chain trace is the only way a caller learns that a request was DEGRADED +// — served by a lower tier than the one that should have taken it. chainDrive's +// return value carries only the winner, so without these events "tier 1 was +// cooling and we dropped to tier 2" is indistinguishable from "tier 1 served +// it", which is the exact question a priority chain exists to answer. + +// recorder collects trace events for assertions. +type recorder struct{ events []TraceEvent } + +func (r *recorder) sink(ev TraceEvent) { r.events = append(r.events, ev) } + +func (r *recorder) kinds() []TraceKind { + out := make([]TraceKind, 0, len(r.events)) + for _, e := range r.events { + out = append(out, e.Kind) + } + return out +} + +func (r *recorder) find(k TraceKind) *TraceEvent { + for i := range r.events { + if r.events[i].Kind == k { + return &r.events[i] + } + } + return nil +} + +// TestTraceSelectedOnlyOnHappyPath: a clean walk emits exactly one event. +func TestTraceSelectedOnlyOnHappyPath(t *testing.T) { + p1 := fakeProv("p1", "m1") + ch := BuildChain([]Rule{{Model: "m1", Source: "p1", Tier: 1}}, + func(model, source string) Provider { return p1 }) + var rec recorder + _, _, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink) + if err != nil { + t.Fatalf("chain: %v", err) + } + if got := rec.kinds(); len(got) != 1 || got[0] != TraceSelected { + t.Errorf("events = %v, want a single selected", got) + } + e := rec.find(TraceSelected) + if e.Tier != 1 || e.Source != "p1" || e.Model != "m1" { + t.Errorf("selected event = %+v, want tier 1 / p1 / m1", e) + } + if e.Attempt != 1 { + t.Errorf("Attempt = %d, want 1", e.Attempt) + } +} + +// TestTraceRecordsTierSkipAndDegradation is the core case: tier 1 is +// unschedulable, tier 2 answers. The trace must show the skip AND the eventual +// selection, so a consumer can see the request was served one tier down. +func TestTraceRecordsTierSkipAndDegradation(t *testing.T) { + // p1 is unavailable (not probeable), so tier 1 yields no candidates. + p1 := fakeProv("p1", "m1") + p1.available.Store(false) + p2 := fakeProv("p2", "m2") + ch := BuildChain([]Rule{ + {Model: "m1", Source: "p1", Tier: 1}, + {Model: "m2", Source: "p2", Tier: 2}, + }, bySource(p1, p2)) + var rec recorder + _, src, model, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink) + if err != nil { + t.Fatalf("chain: %v", err) + } + if src != "p2" || model != "m2" { + t.Fatalf("served by %s/%s, want p2/m2", src, model) + } + skip := rec.find(TraceTierSkip) + if skip == nil { + t.Fatalf("no tier_skip event; events = %v", rec.kinds()) + } + if skip.Tier != 1 { + t.Errorf("skip tier = %d, want 1", skip.Tier) + } + if !strings.Contains(skip.Reason, "cooling") { + t.Errorf("skip reason = %q, want it to mention cooling", skip.Reason) + } + sel := rec.find(TraceSelected) + if sel == nil || sel.Tier != 2 { + t.Errorf("selected = %+v, want tier 2", sel) + } + // The order matters: the skip must be observable BEFORE the selection. + if rec.events[0].Kind != TraceTierSkip || rec.events[len(rec.events)-1].Kind != TraceSelected { + t.Errorf("event order = %v, want skip first and selected last", rec.kinds()) + } +} + +// TestTraceRecordsHardSlotFailures: a slot that returns an upstream error is a +// different event from a skip — the request tried it and it failed. Losing that +// distinction makes a flaky upstream look like an idle one. +func TestTraceRecordsHardSlotFailures(t *testing.T) { + p1 := fakeProv("p1", "m1") + p1.fail.Store(true) // Chat returns "upstream error" + p2 := fakeProv("p2", "m2") + ch := BuildChain([]Rule{ + {Model: "m1", Source: "p1", Tier: 1}, + {Model: "m2", Source: "p2", Tier: 2}, + }, bySource(p1, p2)) + var rec recorder + _, src, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink) + if err != nil { + t.Fatalf("chain: %v", err) + } + if src != "p2" { + t.Fatalf("served by %s, want p2", src) + } + fail := rec.find(TraceSlotFail) + if fail == nil { + t.Fatalf("no slot_fail event; events = %v", rec.kinds()) + } + if fail.Tier != 1 || fail.Source != "p1" || fail.Model != "m1" { + t.Errorf("slot_fail = %+v, want tier 1 / p1 / m1", fail) + } + if !strings.Contains(fail.Err, "upstream error") { + t.Errorf("slot_fail error = %q, want the upstream text", fail.Err) + } + // A hard failure must NOT be reported as a skip. + if rec.find(TraceTierSkip) != nil { + t.Error("a hard failure was also reported as a tier_skip") + } +} + +// TestTraceNilSinkIsSafe: the gateway passes nil when no plugin is loaded, so +// every emit path must tolerate it. This is the "plugins are optional" property +// on the scheduler side. +func TestTraceNilSinkIsSafe(t *testing.T) { + p1 := fakeProv("p1", "m1") + p1.fail.Store(true) + p2 := fakeProv("p2", "m2") + ch := BuildChain([]Rule{ + {Model: "m1", Source: "p1", Tier: 1}, + {Model: "m2", Source: "p2", Tier: 2}, + }, bySource(p1, p2)) + if _, _, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, nil); err != nil { + t.Fatalf("a nil trace sink broke the walk: %v", err) + } +} + +// TestTraceOnTotalFailure: when every tier fails, the walk still emits its +// per-step events AND returns the ChainErr. The trace is additive — it must not +// replace or disturb the error contract callers depend on for the 503. +func TestTraceOnTotalFailure(t *testing.T) { + p1 := fakeProv("p1", "m1") + p1.fail.Store(true) + p2 := fakeProv("p2", "m2") + p2.fail.Store(true) + ch := BuildChain([]Rule{ + {Model: "m1", Source: "p1", Tier: 1}, + {Model: "m2", Source: "p2", Tier: 2}, + }, bySource(p1, p2)) + var rec recorder + _, _, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink) + var ce *ChainErr + if !errors.As(err, &ce) { + t.Fatalf("err = %v, want a *ChainErr so the gateway can answer 503", err) + } + if len(ce.Tiers) != 2 { + t.Errorf("ChainErr.Tiers = %d, want 2 (the error contract must be unchanged)", len(ce.Tiers)) + } + if n := len(rec.kinds()); n != 2 { + t.Errorf("events = %v, want two slot_fail and no selection", rec.kinds()) + } + if rec.find(TraceSelected) != nil { + t.Error("a selected event was emitted for a walk that served nothing") + } +} + +// TestTraceSkipsEmptyChain: no chain configured must not emit anything; the +// gateway answers 503 before scheduling in that case anyway. +func TestTraceSkipsEmptyChain(t *testing.T) { + var rec recorder + _, _, _, err := New(3).ChainChat(context.Background(), &Chain{}, chatReq(), nil, rec.sink) + if err == nil { + t.Fatal("expected an error for an empty chain") + } + if len(rec.events) != 0 { + t.Errorf("events = %v, want none", rec.kinds()) + } +} From cb6df0a3f0b0c6f1cb69ed34a0aec8546c1421ec Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 01:14:33 +0800 Subject: [PATCH 25/42] =?UTF-8?q?fix(billing):=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E6=8C=89=E5=85=A8=E4=BB=B7=E8=AE=A1=20+=20?= =?UTF-8?q?=E6=9C=AA=E5=AE=9A=E4=BB=B7=E6=B5=81=E9=87=8F=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E8=AE=B0=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部署前审计计费插件时自己找到的两个真缺陷,都会直接算错钱。 ## ★ 缺陷 1:缓存命中按全价计(高估约 10 倍) costFor 只看 prompt_tokens,不区分其中多少是缓存命中。实测(审计脚本,非推演): 1M prompt token 里 900k 是 cache_hit → **算出 10 USD**,而缓存读通常只要 1/10 价,正确值 ~1.9。agent 流量反复重放长前缀,正是缓存要让它便宜的那类流量,所以 这个偏差恰好落在最高频的流量上。 改为拆分: fresh = prompt_tokens - cache_hit_tokens → 全价 cached = cache_hit_tokens → 全价 × cache_discount cache_discount 默认 0.1(DeepSeek/Qwen/Kimi 的量级),可按条目覆盖——**折扣率是 每个 provider 的事实、不是自然常数**,所以 0.1 只是默认值而不是硬编码常量。 另外把 cache_hit 钳到 prompt 以内:适配器报出比 prompt 还大的缓存命中数时, fresh 会变负数,凭空产生负计费 token。 ## ★ 缺陷 2:未定价模型静默记 0(最危险) 没有任何价目覆盖的请求,成本记 0,而 **requests 和 token 数照常计入 total**。 于是账单看起来完全正常,只是 quietly 少报——没有任何报错,没有任何异常。 比多算危险得多:多算你会去查,少算你不会知道。 新增两个维度把这件事变成显式信号: unpriced_reqs 未定价请求数 unpriced_models 按模型点名,直接告诉你价目表缺哪一行 仪表盘加一张 "Unpriced" 卡片,**这个数应该是 0**。 任何维度(source / model / key)覆盖了就算 priced。 ## 修这两个时自己踩的坑 第一版把未定价统计块写在了 `local s = plugin.state` **之前十行**, 在一个全新插件上 hook 直接抛 "attempt to index global 's'",于是 **整条请求什么都没记**——计费插件能有的最坏失败方式。 是 TestBillingZeroPricesIsSafe 的 "requests = 0" 抓到的。 代价:一个计费插件静默失效,而网关日志里只有一行 hook error。 ## 判据(351 个测试全绿,计费相关 16 个) 新增 5 个,全部是**具体金额**断言: TestBillingCacheHitsAreDiscounted 1M/900k 命中 → 1.9 TestBillingCacheDiscountIsPerModel 覆盖为 0 / 1 两种极端 TestBillingCacheHitClampedToPrompt 荒谬的命中数不产生负费用 TestBillingCountsUnpricedTraffic 只数未定价的那个,且流量仍计入 total TestBillingAnyDimensionCountsAsPriced 源维度定价也算 priced --- docs/plugins.md | 45 +++++++++- internal/gateway/chat.go | 6 +- internal/lua/billing_test.go | 149 +++++++++++++++++++++++++++++++ internal/lua/plugins/billing.lua | 82 ++++++++++++++--- 4 files changed, 266 insertions(+), 16 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 4129882..6b2a78a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -337,6 +337,7 @@ PUT /api/plugins/{name}/state 替换状态(admin) **token 价**优先级:`keys` > `models` > `default`。 **`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。 +**`cache_discount`** 见 §7.7。 ### 7.2 价格单位 @@ -408,7 +409,49 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \ 理由:两套独立的会计路径如果对不上,比一套功能略少的更糟。计费是**观察**, 配额是**控制**,二者分开。 -### 7.6 数据从哪来 +### 7.6 未定价流量(重要) + +**任何维度都没配价的请求,成本记 0。** 这是最危险的失败模式:账单照样能加总, +只是**悄悄少报**,而且没有任何报错。 + +所以插件单独统计它们: + +| 字段 | 含义 | +|---|---| +| `unpriced_reqs` | 没有任何价目覆盖的请求数 | +| `unpriced_models` | 按模型点名(`{"MYSTERY-MODEL": 12}`)——直接告诉你价目表缺了哪一行 | + +仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。 + +注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入** +`total` 与各维度,只有金额是 0。 + +### 7.7 提示缓存计价 + +**缓存命中的 prompt token 不按全价算。** 绝大多数 provider 对缓存读给很深的折扣 +(常见是 1/10),而 agent 流量会反复重放长前缀——正是缓存要让它便宜的那类流量。 + +``` +fresh = prompt_tokens - cache_hit_tokens → 全价 +cached = cache_hit_tokens → 全价 × cache_discount +``` + +`cache_discount` 默认 **0.1**(10 倍折扣),因为 DeepSeek / Qwen / Kimi 等都是这个 +量级。它是**每个 provider 的事实、不是自然常数**,所以可以按条目覆盖: + +```json +"models": { "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5, "cache_discount": 0.25 } } +``` + +设成 `1` 恢复成旧的"prompt 一律全价"行为,设成 `0` 表示该 provider 不打折。 + +优先级与 token 价一致(`keys` > `models` > `default`)。 + +> **这一条改过行为。** 修复前缓存命中按全价算:100 万 prompt token 里 90 万是 +> 缓存命中,会算出 10 USD 而不是 ~1.9——**高估约 10 倍**,而且恰好发生在缓存 +> 最有价值的高频流量上。 + +### 7.8 数据从哪来 `request_end` 的 `prompt_tokens` / `completion_tokens` 优先取上游真实的 `usage`;上游没报时网关用字节估算(`len/3+1`)。流式请求在流结束后用上游真实 diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index d1be6e1..2a6bcf9 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -1027,9 +1027,9 @@ func (g *Gateway) fireEnd(rec *Req) { // through the chain. Empty for a direct request and for a gateway with // no plugins loaded. Absent rather than empty so a plugin can tell // "no chain" from "chain with no degradation". - "degraded": len(rec.Walk) > 1, - "chain_walk": rec.Walk, - "tier_served": tierServed(rec.Walk), + "degraded": len(rec.Walk) > 1, + "chain_walk": rec.Walk, + "tier_served": tierServed(rec.Walk), } // The merged result is intentionally discarded: request_end is the last // stage, so there is nobody downstream to read a plugin's additions. Plugins diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 59a39ec..d16bc6c 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -376,3 +376,152 @@ func TestBillingAggregatesSkipReasons(t *testing.T) { t.Errorf("busy count = %v, want 2 (two different wait texts, one cause)", busy) } } + +// ---- prompt-cache pricing ------------------------------------------------- +// +// A cached prompt token is not a fresh one. Charging the full prompt rate made a +// 1M-token request of which 900k were cache reads cost 10 USD instead of ~1.9 +// — an order of magnitude, on exactly the traffic the cache exists to make +// cheap. Agent traffic replays long shared prefixes constantly, so this was the +// single largest source of over-billing in the plugin. + +func TestBillingCacheHitsAreDiscounted(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "m": map[string]interface{}{"prompt": 1e-5, "completion": 1e-5}, + }, + }, + }) + // 1M prompt of which 900k cached, default discount 0.1 + // 100k fresh * 1e-5 = 1.0 ; 900k cached * 1e-5 * 0.1 = 0.9 + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***c", "ok": true, + "prompt_tokens": 1000000, "completion_tokens": 0, + "cache_hit_tokens": 900000, "time": 1750000000000, + }) + approx(t, "cache-discounted cost", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 1.9) +} + +// A per-model discount overrides the global one, because the ratio is a +// per-provider fact, not a constant. +func TestBillingCacheDiscountIsPerModel(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "free-cache": map[string]interface{}{ + "prompt": 1e-5, "completion": 0, "cache_discount": 0, + }, + "flat": map[string]interface{}{ + "prompt": 1e-5, "completion": 0, "cache_discount": 1, + }, + }, + }, + }) + for _, m := range []string{"free-cache", "flat"} { + ps2, _ := billingVM(t) + _ = ps2.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + m: map[string]interface{}{"prompt": 1e-5, "completion": 0, "cache_discount": map[bool]float64{true: 0, false: 1}[m == "free-cache"]}, + }, + }, + }) + ps2.Fire(StageRequestEnd, map[string]interface{}{ + "model": m, "source": "s", "key": "***c", "ok": true, + "prompt_tokens": 1000000, "cache_hit_tokens": 1000000, + "time": 1750000000000, + }) + want := 0.0 + if m == "flat" { + want = 10.0 + } + approx(t, m+" (all cached)", stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64), want) + } +} + +// A misbehaving adapter reporting more cache hits than prompt tokens must not +// produce negative fresh tokens. +func TestBillingCacheHitClampedToPrompt(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{"m": map[string]interface{}{"prompt": 1e-5, "completion": 0}}, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***c", "ok": true, + "prompt_tokens": 100, "completion_tokens": 0, + "cache_hit_tokens": 999999, // nonsense from a broken adapter + "time": 1750000000000, + }) + // Clamped to 100 cached, 0 fresh => 100 * 1e-5 * 0.1 + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + if got < 0 { + t.Errorf("cost = %v, must never be negative", got) + } + approx(t, "clamped cost", got, 0.0001) +} + +// ---- unpriced traffic ----------------------------------------------------- + +// An unpriced model silently costing 0 is the most dangerous failure a cost +// plugin has: the bill still adds up, it just quietly under-reports, and +// nothing looks broken. It must be counted and named. +func TestBillingCountsUnpricedTraffic(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "priced": map[string]interface{}{"prompt": 1e-5}, + }, + }, + }) + // 100k+100k tokens on a model with no price entry. + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "MYSTERY-MODEL", "source": "s", "key": "***u", "ok": true, + "prompt_tokens": 100000, "completion_tokens": 100000, "time": 1750000000000, + }) + // A priced one, to prove the counter is selective. + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "priced", "source": "s", "key": "***u", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000, + }) + st := stateOf(t, ps) + if got := st["unpriced_reqs"].(float64); got != 1 { + t.Errorf("unpriced_reqs = %v, want 1 (only the mystery model)", got) + } + models := st["unpriced_models"].(map[string]interface{}) + if models["MYSTERY-MODEL"].(float64) != 1 { + t.Errorf("unpriced_models = %v, want MYSTERY-MODEL counted", models) + } + if _, present := models["priced"]; present { + t.Error("a priced model was counted as unpriced") + } + // The traffic is still recorded: "unpriced" must not mean "invisible". + if got := st["total"].(map[string]interface{})["requests"].(float64); got != 2 { + t.Errorf("total requests = %v, want 2 (unpriced traffic is still traffic)", got) + } +} + +// A source-only or key-only price counts as priced: any dimension covering the +// request is enough. +func TestBillingAnyDimensionCountsAsPriced(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "sources": map[string]interface{}{"flat-fee": map[string]interface{}{"per_request": 0.02}}, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "any-model", "source": "flat-fee", "key": "***p", "ok": true, + "prompt_tokens": 10, "completion_tokens": 0, "time": 1750000000000, + }) + st := stateOf(t, ps) + if got := st["unpriced_reqs"].(float64); got != 0 { + t.Errorf("unpriced_reqs = %v, want 0 (the source price covers it)", got) + } + approx(t, "flat fee", st["total"].(map[string]interface{})["cost"].(float64), 0.02) +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index d4f6eae..48828e7 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -72,6 +72,12 @@ local DEFAULT_PRICES = { } plugin.prices = DEFAULT_PRICES +-- Default prompt-cache discount. 0.1 = a cache read costs a tenth of a fresh +-- token, which is what DeepSeek/Qwen/Kimi and most others charge. It can be +-- overridden per price entry (prices.models..cache_discount) or globally by +-- setting plugin.cache_discount; 1 restores flat prompt pricing. +plugin.cache_discount = 0.1 + -- ---------- accumulated totals ---------- -- state is what the kernel serves at GET /api/plugins/billing/state. It holds @@ -129,48 +135,87 @@ end local function priceFor(payload) local p = plugin.prices or DEFAULT_PRICES local d = p.default or {} - local out = { prompt = d.prompt or 0, completion = d.completion or 0, per_request = 0 } + -- Whether ANY dimension actually priced this request. A request that ends up + -- with all-zero prices is not "free", it is UNPRICED, and the two must not + -- look the same: an unpriced model silently costing 0 is the most dangerous + -- failure mode a cost plugin has, because the bill still adds up and just + -- quietly under-reports. It is counted separately and surfaced in the UI. + out = { + prompt = d.prompt or 0, completion = d.completion or 0, + per_request = 0, cache_discount = d.cache_discount, + } -- model dimension (a token price overrides the default's token prices) local mp = p.models and p.models[payload.model] if mp then + out.priced = true if mp.prompt ~= nil then out.prompt = mp.prompt end if mp.completion ~= nil then out.completion = mp.completion end if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end + if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end end -- source dimension: usually a flat fee, but may also carry token prices local sp = p.sources and p.sources[payload.source] if sp then + out.priced = true if sp.prompt ~= nil then out.prompt = sp.prompt end if sp.completion ~= nil then out.completion = sp.completion end if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end + if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end end -- key dimension wins over the others (an operator pricing one customer -- specially must be able to override both the model and the source price) local kp = p.keys and p.keys[payload.key] if kp then + out.priced = true if kp.prompt ~= nil then out.prompt = kp.prompt end if kp.completion ~= nil then out.completion = kp.completion end if kp.per_request ~= nil then out.per_request = out.per_request + kp.per_request end + if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end end return out end -- costFor computes one request's price. -- --- A FAILED request still costs money whenever the upstream billed for it, which --- the kernel cannot know; the conservative and useful default is to charge --- failed requests their token cost (a 500 after generation still consumed --- tokens) but NOT a flat per_request fee that was never actually charged. That --- is what the `ok` flag selects below, and it is the single most debatable --- policy in this file — it is a config toggle so an operator can flip it. -local function costFor(payload) - local price = priceFor(payload) +-- PROMPT CACHE: a cached prompt token is not billed like a fresh one. Almost +-- every provider sells cache reads at a steep discount (commonly 10% of the +-- fresh rate), and cache-heavy agent traffic hits long shared prefixes hard. +-- Charging the full prompt rate made a 1M-token request of which 900k were +-- cache reads come out at 10 USD instead of ~1.9 — an order of magnitude, on +-- exactly the traffic the cache exists to make cheap. The plugin therefore +-- splits the prompt count: +-- +-- fresh = prompt_tokens - cache_hit_tokens -> full rate +-- cached = cache_hit_tokens -> rate * cache_discount +-- +-- cache_discount defaults to 0.1 (the common 10x). It is configurable because +-- the ratio is a per-provider fact, not a constant of nature: set it to 1 to +-- keep the old flat behaviour, or 0 for providers that do not discount. +-- +-- A request that reports cache_hit_tokens LARGER than prompt_tokens (a +-- misbehaving adapter, or two upstreams' numbers being mixed) is clamped: the +-- fresh count never goes negative, which would silently turn a request into +-- billable negative tokens. +local function costFor(payload, price) + price = price or priceFor(payload) local prompt = tonumber(payload.prompt_tokens) or 0 local completion = tonumber(payload.completion_tokens) or 0 - local cost = prompt * price.prompt + completion * price.completion + local cacheHit = tonumber(payload.cache_hit_tokens) or 0 + if cacheHit < 0 then cacheHit = 0 end + if cacheHit > prompt then cacheHit = prompt end + + local discount = tonumber(price.cache_discount) + if discount == nil then discount = plugin.cache_discount end + if discount == nil then discount = 0.1 end + if discount < 0 then discount = 0 elseif discount > 1 then discount = 1 end + + local fresh = prompt - cacheHit + local cost = fresh * price.prompt + + cacheHit * price.prompt * discount + + completion * price.completion local flat = price.per_request if not payload.ok and not plugin.count_failures then @@ -236,8 +281,8 @@ function plugin.on_request_end(payload) local prompt = tonumber(payload.prompt_tokens) or 0 local completion = tonumber(payload.completion_tokens) or 0 local ok = payload.ok and true or false - local cost = costFor(payload) - + local price = priceFor(payload) + local cost = costFor(payload, price) local s = plugin.state -- Rebuild any missing container. This is reached in two real situations: -- a fresh plugin, and an admin who PUT a partial state (e.g. only "prices"), @@ -250,6 +295,18 @@ function plugin.on_request_end(payload) if s.by_key == nil then s.by_key = {} end if s.by_day == nil then s.by_day = {} end if s.started == nil then s.started = payload.time or 0 end + if s.unpriced_reqs == nil then s.unpriced_reqs = 0 end + if s.unpriced_models == nil then s.unpriced_models = {} end + -- Track traffic that no price entry covered. This MUST come after the + -- container rebuild above: an earlier version referenced `s` before it was + -- declared, so on a fresh plugin the hook threw and the request recorded + -- NOTHING at all — the worst possible failure for a billing plugin, and one + -- that only showed up as "requests = 0" in a test. + if not price.priced then + s.unpriced_reqs = s.unpriced_reqs + 1 + local m = payload.model or "?" + s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1 + end if s.degraded_reqs == nil then s.degraded_reqs = 0 end -- Degradation is counted here rather than in the chain_step hook because -- request_end sees the whole walk at once: one degraded request must count @@ -353,6 +410,7 @@ plugin.ui = { ["Total", money(t.cost, cur)], ["Requests", t.requests || 0], ["Degraded", s.degraded_reqs || 0], + ["Unpriced", s.unpriced_reqs || 0], ["Prompt tokens", t.prompt_tokens || 0], ["Completion tokens", t.completion_tokens || 0], ["Failures", t.failures || 0] From d0c7465130d9967c13b9b47e9703118f1f9faf87 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 06:24:34 +0800 Subject: [PATCH 26/42] =?UTF-8?q?fix(plugin):=20/api/ui-inject=20=E7=9A=84?= =?UTF-8?q?=20stages=20=E6=BC=8F=E6=8E=89=20chain=5Fstep=20+=20=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E4=B8=B4=E6=97=B6=E6=9E=84=E5=BB=BA=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部署到线上时用隔离实例(独立端口 18099 + 独立 config/runtime/adapter 目录) 发真实请求验证,抓到的第三个 bug。 ## bug:discovery 文档漏掉新 stage handlePluginUI 的响应里 stages 是**字面写死的三个**。加 chain_step 时只改了 lua.AllStages,没改这里,于是插件作者读 GET /api/ui-inject 会看到 ["request_start","routed","request_end"],**合理地得出结论:没有 chain_step 这个 stage**。stage 本身是注册好的、也确实在触发,只是没被声明。 改成从 lua.AllStages 派生——AllStages 是唯一定义顺序的地方,让它保持唯一。 ★ 而我原来的测试断言 `len(view.Stages) != 3`,**断言本身是 bug 的保护伞**: 它把"三个"固化成了期望值,于是新增第四个 stage 时测试全绿、bug 上线。 现在断言改为「与 AllStages 等长且逐项相同」,并显式要求 chain_step 在其中。 新增 stage 而忘了声明,这类问题会立刻红。 ## 顺带:.gitignore 补上临时构建目录 .probe/ 和 .build-work/ 是我调试时当 GOTMPDIR 和临时二进制用的,之前每轮 手工删,这轮差点提交进去 9.5MB 的二进制。 ## 部署验证留档 隔离实例跑真实 chat(158 prompt / 13 completion / 128 cache_hit),计费插件 算出 0.000688,与手算 (158-128)*1e-5 + 128*1e-5*0.1 + 13*2e-5 **逐位吻合**。 ★ 第一次手算我按全价算成 0.00184,一度以为插件算错了——查审计记录才看到 cache_hit_tokens。**算钱不对时先查输入再怀疑实现**,而我忘的恰是刚修的折扣。 ## 验证 351 个测试全绿;变异(stages 改回硬编码三个)被 TestUIInjectServesPluginUI 抓住,3 条断言同时红。 --- .gitignore | 4 ++++ internal/gateway/plugins_api.go | 23 +++++++++++++++++------ internal/gateway/plugins_api_test.go | 25 +++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 131c027..0bfbbea 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ cmd/gui/dist/ # local design/working notes (not part of the shipped repo) /plan.md + +# scratch build/probe dirs used while debugging (GOTMPDIR, throwaway binaries) +.build-work/ +.probe/ diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 6b6953b..e7f4dc1 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -51,16 +51,27 @@ func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}}) return } + // AllStages, not a hand-written list: an earlier version enumerated the + // three stages literally here, so when chain_step was added it was silently + // missing from this response — a plugin author reading the discovery + // document would have believed the stage did not exist. Deriving it from + // the one place that defines the order is the whole point of having it. writeJSON(w, http.StatusOK, map[string]interface{}{ - "ui": ps.UI(), - "stages": []string{ - string(lua.StageRequestStart), - string(lua.StageRouted), - string(lua.StageRequestEnd), - }, + "ui": ps.UI(), + "stages": pipelineStageNames(), }) } +// stageNames returns the pipeline stage names in firing order, for the +// discovery payload and for tests. +func pipelineStageNames() []string { + out := make([]string, 0, len(lua.AllStages)) + for _, s := range lua.AllStages { + out = append(out, string(s)) + } + return out +} + func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { ps := g.core.Plugins() if ps == nil { diff --git a/internal/gateway/plugins_api_test.go b/internal/gateway/plugins_api_test.go index fd56b7b..b9fbffc 100644 --- a/internal/gateway/plugins_api_test.go +++ b/internal/gateway/plugins_api_test.go @@ -97,8 +97,19 @@ func TestUIInjectServesPluginUI(t *testing.T) { t.Errorf("element target = %q, want \"status\"", e.Target) } } - if len(view.Stages) != 3 { - t.Errorf("stages = %v, want the three pipeline stages", view.Stages) + // Derived from AllStages, not hardcoded: the previous assertion of "3" + // is exactly what let chain_step go missing from this payload unnoticed. + if len(view.Stages) != len(lua.AllStages) { + t.Errorf("stages = %v, want %d (one per AllStages entry)", view.Stages, len(lua.AllStages)) + } + for i, st := range lua.AllStages { + if i >= len(view.Stages) || view.Stages[i] != string(st) { + t.Errorf("stages[%d] = %v, want %q", i, view.Stages, string(st)) + } + } + if !containsStr(view.Stages, string(lua.StageChainStep)) { + t.Error("the discovery payload does not advertise chain_step; a plugin " + + "author would conclude the stage does not exist") } } @@ -116,6 +127,16 @@ func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) { } } +// containsStr reports whether list has s. +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + // newRecorderFor pushes a request through the full handler chain. func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder { t.Helper() From a78f7cb6c5ec573d291feb82da7dd9929d05588b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 08:33:41 +0800 Subject: [PATCH 27/42] =?UTF-8?q?feat(plugin):=20=E5=90=AF=E7=94=A8/?= =?UTF-8?q?=E7=A6=81=E7=94=A8=20+=20=E7=A3=81=E7=9B=98=E5=88=97=E8=A1=A8?= =?UTF-8?q?=20+=20=E5=B3=B0=E8=B0=B7=E5=AE=9A=E4=BB=B7=20+=20=E9=9A=8F?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 插件管理后端 - PUT /api/plugins/{name} {"enabled":bool} 启用/禁用 - GET /api/plugins/{name} 读源码(编辑器用,与 /state 区分) - GET /api/plugins 的 on_disk 字段 列出目录里所有 .lua 及其加载态 - validPluginName 提取为共享函数,install/remove/read 三处共用,防止检查漂移 禁用是**运行态开关,不删文件**:插件把线上网关搞坏了、但离修好只差一行时, 运维需要把它移出请求路径而不丢失它(同 systemd mask 而非 remove 的道理)。 它**不跨重启保留**——一个悄悄比操作者意图活得更久的"禁用"本身就是个意外。 Builtin 的判定是「加载的源码与内嵌版本逐字节相同」,而不是「名字匹配」: 被改过的 billing.lua 不能被标成 builtin,否则 UI 会提供覆盖用户改动的操作。 on_disk 列表包含**加载失败**的插件。否则一个语法错误的插件在 UI 上直接消失, 运维看到的现象是"插件不见了"而不是"插件报错了"。 ## 峰谷 / 时段定价 commandcode 的 DeepSeek V4 系列就是高峰 01-04 & 06-10 UTC 工作日 2 倍价 (非高峰 17h/天)。静态价目表达不了,而算错方向是**静默**的。 价目条目可带 peak = {multiplier, windows=[{days, hours}]}。命中任一窗口即乘。 ★ 用 `os.date("!%H")` 取 **UTC** 小时:provider 费率表按 UTC 标注,而网关跑在 本地时区(本机 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8 小时, 白天算成夜间——比不做峰谷还糟。 ## ★ 实现与注释不一致,被判据抓住 applyPeak 最初直接 `price.prompt = price.prompt * m`,注释写「缓存读不翻倍」。 但 costFor 里**缓存读价是从 price.prompt 派生的**,所以原地翻倍会把缓存读 也翻倍——两个折扣被叠在一起,而 provider 从没打算叠。 改成 applyPeak 只**记录**乘数,由 costFor 分段应用:fresh prompt 与 completion 翻倍,cache read 那一项不动。 只靠注释说明意图是不够的:TestBillingPeakDoesNotDoubleCacheRead 立刻红了 (0.006 vs 期望 0.003)。变异回原实现仍是红的。 ## 判据(21 个计费测试全绿,新增 5 个峰谷) 窗口恒命中 ×2 / 窗口永不命中保持静态价 / 星期不匹配不命中 (这条正是防"用本地时区整体偏移 8 小时")/ 无 peak 规则向后兼容 / 缓存读不随峰谷翻倍 后端部分:构建/vet/gofmt 干净,8 个包全绿。 --- internal/gateway/plugins_api.go | 101 +++++++++++++++++---- internal/lua/billing_test.go | 151 +++++++++++++++++++++++++++++++ internal/lua/plugins.go | 145 ++++++++++++++++++++++++++++- internal/lua/plugins/billing.lua | 88 +++++++++++++++++- 4 files changed, 458 insertions(+), 27 deletions(-) diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index e7f4dc1..21ef417 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/json" "errors" + "fmt" "net/http" "os" "path/filepath" @@ -102,6 +103,7 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]interface{}{ "plugins": ps.List(), + "on_disk": ps.OnDisk(), "hook_errors": ps.HookErrors(), "plugin_dir": g.pluginDir(), }) @@ -125,22 +127,83 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { return } - switch r.Method { - case http.MethodDelete: - if err := g.removePlugin(path); err != nil { - writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) - return + // /api/plugins/{name} — source read (GET), enable/disable (PUT), delete. + // Multi-segment paths (…/state) are handled earlier and never reach here. + if !strings.Contains(path, "/") { + switch r.Method { + case http.MethodGet: + // Reading the SOURCE (not the runtime state) is what an editor + // needs; the state endpoint is /state and returns accumulated data + // instead. Mixing the two would make a "save what I read" + // round-trip impossible. + code, err := g.readPluginSource(path) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "name": path, "code": code, + "enabled": g.core.Plugins().Enabled(path), + }) + case http.MethodPut: + // Enable / disable. The intent rides in the body rather than the + // verb, because "enable" and "disable" are the same resource and + // PUT /{name} is the one the docs advertise. + var body struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + if body.Enabled == nil { + writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`) + return + } + if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil { + writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, "name": path, "enabled": *body.Enabled, + }) + case http.MethodDelete: + if err := g.removePlugin(path); err != nil { + writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") } - writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) - case http.MethodGet: - // A GET on a specific plugin is almost always a client that meant to - // delete it but let fetch default to GET; name the verb. - writeError(w, http.StatusNotFound, "not_found", - "plugin source not exposed; use DELETE /api/plugins/"+path+" to remove it, "+ - "or GET /api/plugins/"+path+"/state for its published state") - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + return } + + writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path) +} + +// readPluginSource returns a plugin's file contents for the editor. +func (g *Gateway) readPluginSource(name string) (string, error) { + if err := validPluginName(name); err != nil { + return "", err + } + if g.pluginDir() == "" { + return "", errNoPluginDir + } + b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua")) + if err != nil { + return "", fmt.Errorf("no plugin source named %q", name) + } + return string(b), nil +} + +// validPluginName rejects anything that could escape the plugin directory. +// Shared by install / remove / read so the check cannot drift between them. +func validPluginName(name string) error { + if name == "" || strings.ContainsAny(name, `/\.`) { + return errPluginName + } + return nil } // handlePluginState serves GET (read state) and PUT (replace state). @@ -186,11 +249,8 @@ func (g *Gateway) pluginDir() string { // returned to the caller AND the file is left on disk so the operator can fix // it, matching how adapters behave (the file is authoritative once present). func (g *Gateway) installPlugin(name, code string) error { - if name == "" { - return errPluginName - } - if strings.ContainsAny(name, `/\.`) { - return errPluginName + if err := validPluginName(name); err != nil { + return err } dir := g.pluginDir() if dir == "" { @@ -206,6 +266,9 @@ func (g *Gateway) installPlugin(name, code string) error { } func (g *Gateway) removePlugin(name string) error { + if err := validPluginName(name); err != nil { + return err + } if g.pluginDir() == "" { return errNoPluginDir } diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index d16bc6c..119cfdc 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -525,3 +525,154 @@ func TestBillingAnyDimensionCountsAsPriced(t *testing.T) { } approx(t, "flat fee", st["total"].(map[string]interface{})["cost"].(float64), 0.02) } + +// ---- 峰谷 / 时段定价 --------------------------------------------------- +// +// commandcode 的 DeepSeek V4 系列就是这么定价的:非高峰 17h/天,高峰 01-04 & +// 06-10 UTC 工作日,价格恰好 2 倍。这类规则用静态价目无法表达,而算错方向是 +// 静默的——不会报错,只会一直算错。 +// +// 时间判据的可测性:os.date("!%H") 取 UTC 小时。测试通过选择"确定落在窗口内" +// 与"确定落在窗口外"的时段来判定,不去伪造时钟(Lua 侧没有可注入的时钟, +// 伪造反而会让测试与真实行为脱节)。 + +func TestBillingPeakWindowDoublesOutsidePeak(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, + "peak": map[string]interface{}{ + "multiplier": 2, + // 全部 7 天全部 24 小时 ⇒ 永远命中 + "windows": []interface{}{ + map[string]interface{}{ + "days": []interface{}{0, 1, 2, 3, 4, 5, 6}, + "hours": []interface{}{[]interface{}{0, 23}}, + }, + }, + }, + }, + }, + }, + }) + // 1000 prompt + 1000 completion,非高峰 0.00075 → 命中峰谷 ×2 = 0.0015 + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + want := (1000*1.5e-7 + 1000*6e-7) * 2 + approx(t, "always-peak cost", got, want) +} + +// 一个不存在的窗口(UTC 25 点不存在)⇒ 永不命中 ⇒ 静态价。 +func TestBillingPeakWindowNotHit(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, + "peak": map[string]interface{}{ + "multiplier": 2, + // 星期 = {0..6} 但小时窗写成 [99,100]:永远不可能命中 + "windows": []interface{}{ + map[string]interface{}{ + "days": []interface{}{0, 1, 2, 3, 4, 5, 6}, + "hours": []interface{}{[]interface{}{99, 100}}, + }, + }, + }, + }, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + approx(t, "never-peak cost", got, 1000*1.5e-7+1000*6e-7) +} + +// 星期不匹配 ⇒ 不命中。这一条正是"用本地时区算会整体偏移"要防的东西: +// 周日按 UTC 算,用本地时区可能算成周六而错误地命中工作日窗口。 +func TestBillingPeakWindowDayMismatch(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, + "peak": map[string]interface{}{ + "multiplier": 2, + // 只在"不存在的星期 7"上开窗(os.date %w 只到 0..6) + "windows": []interface{}{ + map[string]interface{}{ + "days": []interface{}{7}, + "hours": []interface{}{[]interface{}{0, 23}}, + }, + }, + }, + }, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + if got > (1000*1.5e-7+1000*6e-7)*1.5 { + t.Errorf("cost = %v: a non-matching weekday must not trigger the peak multiplier", got) + } +} + +// 没有 peak 规则的条目完全不受影响(向后兼容)。 +func TestBillingNoPeakRuleIsUnaffected(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "plain": map[string]interface{}{"prompt": 1e-6, "completion": 2e-6}, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "plain", "source": "s", "key": "***n", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + approx(t, "no-peak cost", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 1000*1e-6+1000*2e-6) +} + +// 缓存读价【不】跟着峰谷翻倍:它是另一条上游费率,观测到的非峰谷价里已经含了 +// 自己的折扣,跟着翻倍会把两个折扣叠在一起。 +func TestBillingPeakDoesNotDoubleCacheRead(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, "cache_discount": 0.02, + "peak": map[string]interface{}{ + "multiplier": 2, + "windows": []interface{}{map[string]interface{}{ + "days": []interface{}{0, 1, 2, 3, 4, 5, 6}, + "hours": []interface{}{[]interface{}{0, 23}}, + }}, + }, + }, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***c", "ok": true, + "prompt_tokens": 1000000, "completion_tokens": 0, + "cache_hit_tokens": 1000000, "time": 1750000000000, + }) + // 全部缓存命中 ⇒ 只按 cache 价 = prompt * 0.02,且不翻倍 + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + approx(t, "cache-only cost (not doubled)", got, 1e6*1.5e-7*0.02) +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 1fa7522..1bbae8f 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -159,9 +159,23 @@ type Plugin struct { // LoadError is non-empty when the plugin failed to compile or register. Such // a plugin is listed in the UI with its error but is never called. LoadError string - dir string + // Disabled marks a plugin the operator switched off. It stays on disk and + // keeps its name, hooks and UI declared (so the UI can show it greyed out + // and report what it WOULD contribute), but Fire never calls it and its + // UI extension is excluded from the inject payload. + // + // Disabling is deliberately NOT deleting: a plugin that breaks a live + // gateway is often one line away from being fixed, and an operator needs a + // way to take it out of the request path without losing it. That is the same + // reasoning as a systemd unit being masked rather than removed. + Disabled bool + dir string // script is the plugin's source, kept so a reload can rebuild its state. script string + // Builtin marks a plugin that shipped with the gateway. The UI shows it as + // such so an operator can tell "example I can read" from "mine", and a + // delete of a builtin is allowed but re-seeds on a fresh plugin dir. + Builtin bool // state is the plugin's SINGLE authoritative Lua state, guarded by mu. // @@ -363,6 +377,13 @@ func (ps *Plugins) LoadSource(name, code string) error { } p.script = code p.state = w + // Builtin = the shipped source is byte-identical to what this file was + // loaded from. That is stronger than "the name matches a bundled plugin": an + // operator who EDITED billing.lua must not be told their copy is builtin, + // or the UI would offer to overwrite their changes. + if orig, err := ReadBundledPlugin(name); err == nil && orig == code { + p.Builtin = true + } // ---- manifest ---- w.L.GetGlobal(pluginGlobal) @@ -515,7 +536,10 @@ func (ps *Plugins) rebuild() { defer ps.mu.Unlock() stageFuncs := map[Stage][]hookCall{} for i, p := range ps.plugins { - if p.LoadError != "" { + // Disabled plugins are excluded from BOTH the dispatch table and the + // merged UI below. Including them in the UI would render a page whose + // refresh calls go to a plugin that is never consulted. + if p.LoadError != "" || p.Disabled { continue } for _, st := range AllStages { @@ -528,7 +552,7 @@ func (ps *Plugins) rebuild() { merged := &UIExtension{} for _, p := range ps.plugins { - if p.LoadError != "" || p.UI == nil { + if p.LoadError != "" || p.Disabled || p.UI == nil { continue } if p.UI.Page != nil { @@ -539,6 +563,78 @@ func (ps *Plugins) rebuild() { ps.ui.Store(merged) } +// DiskEntry is one .lua file in the plugin directory, whether or not it loaded. +// The management UI lists these rather than only the loaded set, so an operator +// can see (and fix) a plugin that failed to compile instead of finding it +// missing from the list. +type DiskEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Loaded bool `json:"loaded"` + Disabled bool `json:"disabled"` + Builtin bool `json:"builtin"` + Error string `json:"error,omitempty"` + // Version/Description are read from the loaded plugin when available. + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Hooks int `json:"hooks"` +} + +// OnDisk lists every .lua file in the plugin directory with its load state. +func (ps *Plugins) OnDisk() []DiskEntry { + out := []DiskEntry{} + if ps.dir == "" { + return out + } + entries, err := os.ReadDir(ps.dir) + if err != nil { + return out + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { + continue + } + name := strings.TrimSuffix(e.Name(), ".lua") + info, _ := e.Info() + de := DiskEntry{Name: name, Path: e.Name()} + if info != nil { + de.Size = info.Size() + } + ps.mu.RLock() + if p := ps.findLocked(name); p != nil { + de.Loaded = p.LoadError == "" + de.Disabled = p.Disabled + de.Builtin = p.Builtin + de.Error = p.LoadError + de.Version = p.Info.Version + de.Description = p.Info.Description + de.Hooks = len(p.Hooks) + } else { + // On disk but not in the running set: either it failed so badly + // that LoadSource never produced a record, or the dir was written + // after startup. Mark it by comparing with the bundled source. + if code, err := os.ReadFile(filepath.Join(ps.dir, e.Name())); err == nil { + if orig, err := ReadBundledPlugin(name); err == nil && orig == string(code) { + de.Builtin = true + } + } + } + ps.mu.RUnlock() + out = append(out, de) + } + sort.Slice(out, func(i, j int) bool { + // builtins first, then alphabetical: the example plugin an operator + // is most likely to want to read should not be buried under whatever + // they installed most recently. + if out[i].Builtin != out[j].Builtin { + return out[i].Builtin + } + return out[i].Name < out[j].Name + }) + return out +} + // Count returns how many plugins loaded (including ones with LoadError). func (ps *Plugins) Count() int { ps.mu.RLock() @@ -565,6 +661,8 @@ func (ps *Plugins) List() []map[string]interface{} { "author": p.Info.Author, "hooks": stages, "loaded": p.LoadError == "", + "disabled": p.Disabled, + "builtin": p.Builtin, } if p.LoadError != "" { row["error"] = p.LoadError @@ -700,6 +798,38 @@ func (ps *Plugins) SetState(name string, state interface{}) error { return nil } +// SetEnabled turns a plugin's dispatch on or off without touching its file. +// +// The state is on the Plugin record (not derived from disk) so a disable survives +// as long as the process lives and is trivially re-enabled; it deliberately does +// NOT persist across restarts, because a "disable" that silently outlives the +// operator's intent is its own surprise. An operator who wants it permanent +// moves the file out of the plugin dir. +func (ps *Plugins) SetEnabled(name string, enabled bool) error { + ps.mu.Lock() + p := ps.findLocked(name) + ps.mu.Unlock() + if p == nil { + return fmt.Errorf("plugin %s not loaded", name) + } + if p.LoadError != "" { + return fmt.Errorf("plugin %s failed to load (%s); fix the file before enabling it", name, p.LoadError) + } + p.mu.Lock() + p.Disabled = !enabled + p.mu.Unlock() + ps.rebuild() + return nil +} + +// Enabled reports whether a plugin is currently dispatching. +func (ps *Plugins) Enabled(name string) bool { + ps.mu.RLock() + p := ps.findLocked(name) + ps.mu.RUnlock() + return p != nil && p.LoadError == "" && !p.Disabled +} + // Unload removes a plugin from the running set. Its states are closed so the // memory goes back; a subsequent LoadSource with the same name works again. func (ps *Plugins) Unload(name string) error { @@ -728,8 +858,15 @@ func (ps *Plugins) Unload(name string) error { return nil } -// find returns a loaded plugin by declared name. Caller holds ps.mu. +// find returns a loaded plugin by declared name, taking the read lock. func (ps *Plugins) find(name string) *Plugin { + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.findLocked(name) +} + +// findLocked returns a loaded plugin by declared name. The caller holds ps.mu. +func (ps *Plugins) findLocked(name string) *Plugin { for _, p := range ps.plugins { if p.Info.Name == name { return p diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 48828e7..314da66 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -142,7 +142,7 @@ local function priceFor(payload) -- quietly under-reports. It is counted separately and surfaced in the UI. out = { prompt = d.prompt or 0, completion = d.completion or 0, - per_request = 0, cache_discount = d.cache_discount, + per_request = 0, cache_discount = d.cache_discount, peak = d.peak, } -- model dimension (a token price overrides the default's token prices) @@ -153,6 +153,7 @@ local function priceFor(payload) if mp.completion ~= nil then out.completion = mp.completion end if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end + if mp.peak ~= nil then out.peak = mp.peak end end -- source dimension: usually a flat fee, but may also carry token prices @@ -163,6 +164,7 @@ local function priceFor(payload) if sp.completion ~= nil then out.completion = sp.completion end if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end + if sp.peak ~= nil then out.peak = sp.peak end end -- key dimension wins over the others (an operator pricing one customer @@ -174,10 +176,83 @@ local function priceFor(payload) if kp.completion ~= nil then out.completion = kp.completion end if kp.per_request ~= nil then out.per_request = out.per_request + kp.per_request end if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end + if kp.peak ~= nil then out.peak = kp.peak end end return out end +-- ===== 峰谷 / 时段定价 ================================================ +-- +-- 有些 provider 按 UTC 时段分价(commandcode 的 DeepSeek V4 系列就是:高峰 +-- 01-04 & 06-10 UTC 工作日,价格恰好是非高峰的 2 倍)。静态价目无法表达这一点, +-- 而算错方向通常是【静默高估或低估】,不会报错——所以这里显式支持。 +-- +-- 配置形态(挂在任一维度的价目条目上): +-- +-- "deepseek-v4.1-flash": { +-- prompt = 1.5e-7, completion = 6e-7, +-- peak = { +-- multiplier = 2, -- 高峰时单价乘以它 +-- windows = [ -- UTC 星期几 = os.date 的 %w(周日=1) +-- { days = {2,3,4,5,6}, hours = {{1,2,3},{6,7,8,9}} }, +-- ], +-- }, +-- } +-- +-- 语义:命中任一 window ⇒ 乘以 multiplier。hours 用 {起,止} 闭区间,跨零点 +-- 用 {{22,24}} 表示 22:00-24:00(24 是"当天最后一刻")。 +-- +-- ★ 为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关 +-- 跑在本地时区(这台机是 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8 +-- 小时,白天算成夜间——比不做峰谷还糟。 +local function inPeakWindow(ev) + if ev == nil then return false end + local w = ev.windows + if type(w) ~= "table" or #w == 0 then return false end + local dow = tonumber(os.date("!%w")) or 0 -- 0=Sunday + local hour = tonumber(os.date("!%H")) or 0 + for _, win in ipairs(w) do + local days = win.days + if type(days) == "table" then + local day_ok = false + for _, d in ipairs(days) do + if tonumber(d) == dow then day_ok = true break end + end + if not day_ok then goto continue_win end + end + local hours = win.hours + if type(hours) == "table" then + for _, h in ipairs(hours) do + local lo, hi = tonumber(h[1]), tonumber(h[2]) + if lo and hi and hour >= lo and hour <= hi then return true end + end + end + ::continue_win:: + end + return false +end + +-- applyPeak multiplies a price by the peak rule, if the request lands in a peak +-- window. It is a no-op when no rule is configured, so the common case costs one +-- nil check. +-- +-- The multiplier is RECORDED, not applied to price.prompt in place. That looks +-- like a roundabout way to do it, but applying it there was a real bug: the +-- cache-read rate is DERIVED from price.prompt inside costFor, so doubling +-- price.prompt silently doubled the cache read too — compounding two separate +-- discounts. Keeping the multiplier separate lets costFor scale the fresh-prompt +-- and completion legs and leave the cache leg alone, which is what "peak rates +-- apply to the token price, cache reads are billed at their own rate" means. +local function applyPeak(price) + local pk = price.peak + if pk == nil then return price end + if not inPeakWindow(pk) then return price end + local m = tonumber(pk.multiplier) or 1 + if m <= 0 then return price end + price.peak_multiplier = m + return price +end + -- costFor computes one request's price. -- -- PROMPT CACHE: a cached prompt token is not billed like a fresh one. Almost @@ -200,7 +275,7 @@ end -- fresh count never goes negative, which would silently turn a request into -- billable negative tokens. local function costFor(payload, price) - price = price or priceFor(payload) + price = applyPeak(price or priceFor(payload)) local prompt = tonumber(payload.prompt_tokens) or 0 local completion = tonumber(payload.completion_tokens) or 0 local cacheHit = tonumber(payload.cache_hit_tokens) or 0 @@ -212,10 +287,15 @@ local function costFor(payload, price) if discount == nil then discount = 0.1 end if discount < 0 then discount = 0 elseif discount > 1 then discount = 1 end + -- The peak multiplier applies to the freshly-read prompt tokens and the + -- completion, but NOT to the cache read: a cache read is a separate upstream + -- rate that the off-peak figures already discount, and doubling it would + -- stack two discounts the provider never intended to stack. + local mult = tonumber(price.peak_multiplier) or 1 local fresh = prompt - cacheHit - local cost = fresh * price.prompt + local cost = fresh * price.prompt * mult + cacheHit * price.prompt * discount - + completion * price.completion + + completion * price.completion * mult local flat = price.per_request if not payload.ok and not plugin.count_failures then From 1c690611f8422e148e24c8530a9380b7636e719d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 08:47:08 +0800 Subject: [PATCH 28/42] =?UTF-8?q?feat(gui):=20WebUI=20=E4=B8=8E=20Electron?= =?UTF-8?q?=20=E5=A3=B3=E7=9A=84=E6=8F=92=E4=BB=B6=E5=AE=89=E8=A3=85/?= =?UTF-8?q?=E5=88=A0=E9=99=A4/=E7=A6=81=E7=94=A8/=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## WebUI:新增「插件」页 - 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**, 否则一个语法错误看起来和"插件没装"完全一样 - 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖 - 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是 "功能就是不work" - 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal), 适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数 ## TABS 收敛为单一常量 tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。 加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"—— 和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。 ## Electron 壳:设置面板里的插件管理 渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC: renderer → plugins:proxy → main → HTTP /api/plugins 代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩, 而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来 新增的任何 /api/plugins 路由,路径在主进程校验。 ## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题 1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本 2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js 是独立文档,点击时 ReferenceError 3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里 于是是无样式裸按钮 补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类): TestGUICSSClassesExist 用到的类必须在样式表里定义 TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义 TestGUIPluginPanelIsReachable 面板在 overlay 内、按钮已绑定、打开设置会加载 TestGUIIPCPathIsConstrained 代理必须限定 /api/plugins 前缀并拒绝路径穿越 写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当 selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的 教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的 token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类, 不是本次引入,失败它们只会让判据对新工作失去意义)。 ## 变异验证 改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红 改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红 ★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。 这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。 363 个测试全绿。 --- cmd/gui/main.js | 72 +++++++ cmd/gui/preload.js | 7 + cmd/gui/renderer/app.js | 121 ++++++++++++ cmd/gui/renderer/index.html | 9 + cmd/gui/renderer/style.css | 78 ++++++++ internal/gateway/gui_contract_test.go | 192 +++++++++++++++++++ internal/gateway/ui/index.html | 264 +++++++++++++++++++++++++- internal/gateway/ui_plugin_test.go | 104 ++++++++++ 8 files changed, 837 insertions(+), 10 deletions(-) create mode 100644 internal/gateway/gui_contract_test.go diff --git a/cmd/gui/main.js b/cmd/gui/main.js index ab33943..abc6ea9 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -671,6 +671,78 @@ ipcMain.handle( (e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(), ); +// ---- plugin management over IPC ------------------------------------------- +// +// The renderer cannot call the embedded core directly: it has no key and no +// network identity, and the core binds a loopback port that only the main +// process knows about. So every plugin action is proxied through the main +// process, which already knows how to obtain the admin key (unsealViaCore). +// +// The proxy is deliberately a raw (method, path, body) pass-through rather than +// a fixed set of commands. A fixed set would have to be extended for every new +// plugin endpoint, and the one thing worse than "no button for this" is "a +// button that silently does nothing" — with a pass-through the renderer can talk +// to any /api/plugins route the core grows, and the path is validated here so +// this channel cannot be used to reach arbitrary endpoints. +function pluginProxy(req) { + const { method, path, body } = req || {}; + const M = ["GET", "POST", "PUT", "DELETE"]; + if (!M.includes(method)) throw new Error("bad method: " + method); + // The path must stay inside the plugin namespace. A prefix check alone would + // still allow /api/plugins/../keys, so reject any traversal outright. + if (typeof path !== "string" || !path.startsWith("/api/plugins")) { + throw new Error("path must start with /api/plugins"); + } + if (path.includes("..") || path.includes("\\")) { + throw new Error("path traversal rejected"); + } + const key = unsealViaCore(); + if (!key) throw new Error("no admin key available yet"); + return new Promise((resolve, reject) => { + const u = new URL(embeddedBaseUrl() + path); + const data = body == null ? null : JSON.stringify(body); + const headers = { Authorization: "Bearer " + key }; + if (data) { + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = Buffer.byteLength(data); + } + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname + u.search, + method, + headers, + }, + (res) => { + let raw = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (raw += c)); + res.on("end", () => { + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch (e) { + parsed = { raw }; + } + if (res.statusCode >= 400) { + const msg = + (parsed && parsed.error && parsed.error.message) || + "HTTP " + res.statusCode; + reject(new Error(msg)); + return; + } + resolve(parsed); + }); + }, + ); + r.on("error", reject); + if (data) r.write(data); + r.end(); + }); +} + +ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req)); ipcMain.handle("core:state", () => ({ running: coreStarted() && coreReady, ready: coreReady, diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index c3cfca8..f9c46e4 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -16,6 +16,13 @@ contextBridge.exposeInMainWorld("modelrouter", { key: () => ipcRenderer.invoke("core:key"), onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)), }, + plugins: { + // Raw pass-through to the embedded core's /api/plugins surface. The main + // process validates the path and attaches the admin key; the renderer never + // sees either. + request: (method, path, body) => + ipcRenderer.invoke("plugins:proxy", { method, path, body }), + }, settings: { get: () => ipcRenderer.invoke("settings:get"), set: (patch) => ipcRenderer.invoke("settings:set", patch), diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 2d8d2ce..553a30b 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -155,6 +155,7 @@ async function openSettings() { $("#set-tray").checked = !!state.settings.minimizeToTray; $("#settings-overlay").style.display = "flex"; renderRail(); + loadPlugins(); } function closeSettings() { $("#settings-overlay").style.display = "none"; @@ -182,6 +183,122 @@ async function saveSettings() { } } +// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the +// shell needs its own because renderer/app.js is a separate document that +// never loads index.html's script. +function esc(s) { + return String(s == null ? "" : s).replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} +function escAttr(s) { + return esc(s).replace(/`/g, "`"); +} + +// ===== plugin management ===== +// +// The desktop shell manages plugins through the embedded core's /api/plugins +// surface, proxied over IPC (see plugins:proxy in the main process). The +// renderer never holds the admin key. +// +// Scope note: the desktop build has no plugin_dir configured by default, so this +// panel normally reports "plugins disabled" with the one-line fix. That is a +// deliberate state, not an error — the packaged profile is a per-user directory +// and seeding a plugin tree into someone's home without asking would be rude. + +async function loadPlugins() { + const list = $("#pl-list"); + const hint = $("#set-plugins-hint"); + if (!list || !hint) return; + let j; + try { + j = await window.modelrouter.plugins.request("GET", "/api/plugins"); + } catch (e) { + hint.textContent = "内核未就绪:" + e.message; + list.innerHTML = ""; + return; + } + if (!j.plugin_dir) { + hint.innerHTML = + '未配置 plugin_dir,插件功能未启用。在 config.yaml 加一行后重启内核即可。'; + list.innerHTML = ""; + return; + } + const rows = j.on_disk || []; + const active = rows.filter((p) => p.loaded && !p.disabled).length; + const broken = rows.filter((p) => !p.loaded).length; + hint.textContent = + `${rows.length} 个插件 · ${active} 个启用中` + + (broken ? ` · ${broken} 个加载失败` : ""); + list.innerHTML = rows.length + ? rows + .map((p) => { + const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on"; + const label = !p.loaded + ? "加载失败" + : p.disabled + ? "已禁用" + : "启用中"; + const btn = p.loaded + ? `` + : ""; + const builtin = p.builtin + ? '内置' + : ""; + return `
+
${esc(p.name)}${builtin}${label}
+ ${p.description ? `
${esc(p.description)}
` : ""} + ${p.error ? `
${esc(String(p.error).slice(0, 160))}
` : ""} +
${btn}
+
`; + }) + .join("") + : '
插件目录为空
'; + list.querySelectorAll('button[data-act="toggle"]').forEach((b) => { + b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1"); + }); +} + +async function togglePlugin(name, disabled) { + try { + await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, { + enabled: disabled, + }); + toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`); + await loadPlugins(); + } catch (e) { + toast(e.message, true); + } +} + +async function enableAllPlugins() { + let j; + try { + j = await window.modelrouter.plugins.request("GET", "/api/plugins"); + } catch (e) { + return toast(e.message, true); + } + const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled); + for (const p of off) { + try { + await window.modelrouter.plugins.request( + "PUT", + `/api/plugins/${encodeURIComponent(p.name)}`, + { enabled: true }, + ); + } catch (e) { + toast(`${p.name}: ${e.message}`, true); + } + } + toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件"); + await loadPlugins(); +} + // ===== theme ===== function applyTheme() { document.documentElement.dataset.theme = state.theme; @@ -197,6 +314,10 @@ function init() { $("#tb-close").onclick = () => window.modelrouter.win.close(); $("#tb-settings").onclick = openSettings; $("#rail-settings").onclick = openSettings; + const plReload = document.getElementById("pl-reload"); + if (plReload) plReload.onclick = loadPlugins; + const plAll = document.getElementById("pl-toggle-all"); + if (plAll) plAll.onclick = enableAllPlugins; $("#rail-autostart").onclick = toggleAutoStart; $("#rail-silent").onclick = toggleSilent; $("#rail-theme").onclick = () => { diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html index 5e16ec6..86634c0 100644 --- a/cmd/gui/renderer/index.html +++ b/cmd/gui/renderer/index.html @@ -207,6 +207,15 @@ > 关闭时最小化到托盘点关闭按钮隐藏到系统托盘 +
+ + 加载中… +
+
+
+ + +
diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index b3661aa..05dd59c 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -596,3 +596,81 @@ html[data-theme="dark"] .overlay { #toast.err { border-color: var(--danger); } + +/* ===== plugin management panel ========================================= + * The existing .ghost/.primary rules are scoped to `.form .actions`, so a + * button outside that selector gets browser defaults. The plugin rows live in + * their own list, hence their own rules — reusing a scoped class here would have + * produced unstyled buttons that still worked, which is the kind of thing that + * looks fine until someone themes the shell. + */ +.pl-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 8px 0 4px; +} +.pl-item { + border: 1px solid var(--line); + border-radius: 9px; + padding: 10px 12px; +} +.pl-item.pl-broken { + border-color: var(--danger, #d1435b); +} +.pl-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} +.pl-builtin { + font-size: 10px; + padding: 1px 6px; + border-radius: 999px; + background: var(--primary-50); + color: var(--primary-h); +} +.pl-state { + margin-left: auto; + font-size: 11px; + color: var(--muted); +} +.pl-item.pl-broken .pl-state { + color: var(--danger, #d1435b); +} +.pl-desc { + font-size: 12px; + color: var(--muted); + margin-top: 3px; +} +.pl-err { + font-size: 11px; + color: var(--danger, #d1435b); + margin-top: 4px; + word-break: break-word; +} +.pl-acts { + margin-top: 8px; + display: flex; + gap: 8px; +} +.pl-acts button { + padding: 5px 12px; + font-size: 12px; + border-radius: 7px; + border: 1px solid var(--line); + background: var(--bg-s2, #fff); + color: var(--fg, inherit); + cursor: pointer; + transition: all 0.15s; +} +.pl-acts button:hover { + border-color: var(--primary); + color: var(--primary-h); +} +.pl-empty { + font-size: 12px; + color: var(--muted); + padding: 10px 0; +} diff --git a/internal/gateway/gui_contract_test.go b/internal/gateway/gui_contract_test.go new file mode 100644 index 0000000..8fd754a --- /dev/null +++ b/internal/gateway/gui_contract_test.go @@ -0,0 +1,192 @@ +package gateway + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The Electron shell (cmd/gui) had NO tests at all, so the plugin panel went in +// with references to CSS classes that do not exist (.tag, .sm) and to helper +// functions that were never defined in that document (esc / escAttr). All of +// it rendered as unstyled text and would have thrown a ReferenceError at click +// time — and none of that is visible without opening the app. +// +// These tests are deliberately static. They do not launch Electron: what they +// guard is the class of mistake that "looks fine until someone themes it", +// which is exactly what a missing CSS class or a missing helper is. + +func guiFile(t *testing.T, rel string) string { + t.Helper() + // The tests live in internal/gateway, so walk up to the repo root. + p := filepath.Join("..", "..", rel) + b, err := os.ReadFile(p) + if err != nil { + t.Skipf("%s not readable: %v", rel, err) + } + return string(b) +} + +// classUseRe finds class="..." occurrences in a document. +var classUseRe = regexp.MustCompile(`class="([^"]+)"`) + +// classTokenRe matches ANY ".name" inside the stylesheet. Deliberately loose: +// it also matches inside compound selectors (".tb-btn.tb-close:hover" must count +// as defining .tb-close, which a "must be at the start of a selector" rule +// misses) and inside comments, which only ever makes the check MORE permissive. +// A false pass here would be bad, so the strictness lives elsewhere: the +// variable below is what actually guards the new code. +var classTokenRe = regexp.MustCompile(`\.([A-Za-z_][A-Za-z0-9_-]*)`) + +// guiKnownUnstyled lists classes the shell markup has always used with no +// matching rule. They are pre-existing cosmetic gaps, not regressions, and +// failing on them would make this test useless as a guard for NEW work. +var guiKnownUnstyled = map[string]bool{ + "blob": true, // decorative blur blobs, styled per-instance via .b1/.b2/.b3 + "tgl": true, // rail toggle affordance that leaned on .rail-btn + "rail": true, // the rail container itself has no rule; .rail-btn children carry the look +} + +// TestGUICSSClassesExist is the guard that would have caught .tag and .sm: every +// class used in the shell's markup must be defined in its stylesheet. +// +// The comparison is on the LAST segment of a selector, because the stylesheet +// scopes things (`.form .actions .primary`, `#bgfx .b1`): a rule for +// `.pl-acts button` defines no class at all, and `.form .row .toggle` defines +// `.toggle`. Requiring a top-level class would be too strict; requiring that +// some selector's last identifier matches is the right level. +func TestGUICSSClassesExist(t *testing.T) { + html := guiFile(t, "cmd/gui/renderer/index.html") + css := guiFile(t, "cmd/gui/renderer/style.css") + + defined := map[string]bool{} + + // Collect every class token that appears at the START of a selector + // position. A full CSS parser is overkill and was the source of two wrong + // turns here; what the check needs is simply "does the name .foo appear + // anywhere in the stylesheet as a selector component". + // + // Scoping is respected loosely: `.form .actions .primary` counts as + // defining `.primary`, and `.pl-acts button` defines no class — which is + // exactly why the plugin panel needed its own rules. + for _, m := range classTokenRe.FindAllStringSubmatch(css, -1) { + defined[m[1]] = true + } + if len(defined) == 0 { + t.Fatal("no classes parsed from the stylesheet; the check is broken") + } + + // Classes the JS builds as strings must exist too. + js := guiFile(t, "cmd/gui/renderer/app.js") + var missing []string + seen := map[string]bool{} + note := func(cls, where string) { + // A "${...}" token is a template literal being spliced at runtime, not + // a class name; the classes it can expand to are checked at their + // definition sites instead. + if cls == "" || strings.ContainsAny(cls, "${}") || seen[cls] { + return + } + seen[cls] = true + if guiKnownUnstyled[cls] { + return + } + if !defined[cls] { + missing = append(missing, cls+" ("+where+")") + } + } + for _, m := range classUseRe.FindAllStringSubmatch(html, -1) { + for _, c := range strings.Fields(m[1]) { + note(c, "index.html") + } + } + for _, m := range classUseRe.FindAllStringSubmatch(js, -1) { + for _, c := range strings.Fields(m[1]) { + note(c, "app.js") + } + } + if len(missing) > 0 { + t.Errorf("classes used but not defined in style.css (they render unstyled):\n %s", + strings.Join(missing, "\n ")) + } +} + +// TestGUIHelperFunctionsAreDefined catches the other half: renderer/app.js is a +// separate document from the WebUI, so it does NOT have the WebUI's esc/escAttr. +// Referencing them gives a ReferenceError only when the line runs. +func TestGUIHelperFunctionsAreDefined(t *testing.T) { + js := guiFile(t, "cmd/gui/renderer/app.js") + for _, fn := range []string{"esc", "escAttr", "toast", "loadPlugins", "togglePlugin", "enableAllPlugins"} { + defined := regexp.MustCompile(`function ` + fn + `\b`).MatchString(js) + called := regexp.MustCompile(`\b` + fn + `\s*\(`).MatchString(js) + if called && !defined { + t.Errorf("%s() is called but never defined in app.js", fn) + } + if !called && !defined { + // A defined-but-unused helper is dead code, not an error. + continue + } + } +} + +// TestGUIPluginPanelIsReachable: the panel must be inside the settings overlay +// AND the settings overlay must actually open it. A panel wired to a button +// that was never bound is invisible-but-present, which passes a grep review. +func TestGUIPluginPanelIsReachable(t *testing.T) { + html := guiFile(t, "cmd/gui/renderer/index.html") + js := guiFile(t, "cmd/gui/renderer/app.js") + if !strings.Contains(html, `id="pl-list"`) { + t.Error("no #pl-list in the settings overlay") + } + if !strings.Contains(html, `id="settings-overlay"`) { + t.Fatal("the settings overlay is gone") + } + // inside the overlay: the element index must come after the overlay's + if strings.Index(html, `id="settings-overlay"`) > strings.Index(html, `id="pl-list"`) { + t.Error("#pl-list appears before the settings overlay, so it renders outside the panel") + } + // The buttons must be bound. + for _, id := range []string{"pl-reload", "pl-toggle-all"} { + if !strings.Contains(html, `id="`+id+`"`) { + t.Errorf("#%s is missing from the markup", id) + } + if !strings.Contains(js, `"`+id+`"`) { + t.Errorf("#%s exists but app.js never binds it", id) + } + } + // And openSettings must trigger the load, or the panel shows a stale empty + // list on every open. + if !strings.Contains(js, "loadPlugins()") { + t.Error("app.js never calls loadPlugins()") + } +} + +// TestGUIIPCPathIsConstrained: plugins:proxy is a raw pass-through, which is +// convenient but would be a hole if it accepted arbitrary paths. The main +// process must reject anything outside /api/plugins and any traversal. +func TestGUIIPCPathIsConstrained(t *testing.T) { + main := guiFile(t, "cmd/gui/main.js") + for _, needle := range []string{ + `path.startsWith("/api/plugins")`, + `path.includes("..")`, + "plugins:proxy", + "unsealViaCore()", + } { + if !strings.Contains(main, needle) { + t.Errorf("cmd/gui/main.js is missing the guard %q", needle) + } + } + // The plugins channel must be a proxy, not a key passthrough: the renderer + // sends (method, path) and the main process attaches the key. + // + // NOTE: preload does expose a pre-existing `core.key` getter — the shell + // needs the admin key to load the embedded WebUI without a login. That is + // existing, deliberate design and out of scope here; asserting on "key:" in + // preload would flag a pre-existing feature as a new hole. + pre := guiFile(t, "cmd/gui/preload.js") + if !strings.Contains(pre, "request: (method, path, body)") { + t.Error("preload does not expose the plugins request proxy") + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index b8033ed..f320636 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -615,6 +615,21 @@ 适配器 +
@@ -782,6 +798,25 @@ navChat: "对话", navSources: "源", navAdapters: "适配器", + navPlugins: "插件", + plTitle: "插件", + plState: "状态", + plStages: "阶段", + plActive: "启用中", + plDisabled: "已禁用", + plBroken: "加载失败", + plBuiltin: "内置", + plHooks: "个阶段", + plEmpty: "插件目录为空", + plNoDir: "未配置 plugin_dir,插件功能未启用", + plHookErr: "以下阶段的插件钩子报错(插件故障不会影响转发,但功能会缺失):", + plDir: "插件目录:", + plInstall: "安装插件", + plInstallBtn: "安装 / 覆盖", + plEdit: "编辑", + plEnable: "启用", + plDisable: "禁用", + plRemove: "删除", navSort: "优先级", navKeys: "密钥", keysHint: @@ -1015,6 +1050,25 @@ navChat: "Chat", navSources: "Sources", navAdapters: "Adapters", + navPlugins: "Plugins", + plTitle: "Plugins", + plState: "State", + plStages: "Stages", + plActive: "Active", + plDisabled: "Disabled", + plBroken: "Failed to load", + plBuiltin: "Built-in", + plHooks: "stages", + plEmpty: "The plugin directory is empty", + plNoDir: "plugin_dir is not configured; plugins are disabled", + plHookErr: "Plugin hooks failed on these stages (a broken plugin never blocks forwarding, it just stops providing its feature):", + plDir: "Plugin directory:", + plInstall: "Install a plugin", + plInstallBtn: "Install / replace", + plEdit: "Edit", + plEnable: "Enable", + plDisable: "Disable", + plRemove: "Remove", navSort: "Priority", navKeys: "Keys", keysHint: @@ -1326,6 +1380,12 @@ const m = $("#btn-menu"); if (m) m.onclick = () => $("#sidebar").classList.toggle("open"); } + // Single source for the tab list. It used to be a literal duplicated in + // goTab, in refresh() and in the admin-only hide pass — three places to + // keep in sync, and adding a tab meant finding all three. A plugin page + // that is routed but never shown is exactly the kind of silent gap that + // survives review. + const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"]; document.querySelectorAll("nav button.sb-i").forEach((b) => { b.onclick = () => goTab(b.dataset.tab); }); @@ -1337,9 +1397,7 @@ document .querySelectorAll(".sb-i") .forEach((x) => x.classList.toggle("active", x.dataset.tab === name)); - ["status", "chat", "keys", "sort", "sources", "adapters"].forEach( - (tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name), - ); + TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name)); updateBreadcrumb(name); const pane = $("#tab-" + name); if (pane) { @@ -4103,6 +4161,177 @@ } /* ---------- adapters tab ---------- */ + // ---- plugin management ---- + // Install / enable / disable / remove / edit. The list comes from + // on_disk rather than the loaded set so a plugin that FAILED to load + // still appears, with its error — otherwise a syntax error looks + // identical to "the plugin is not there". + async function renderPlugins() { + let j; + try { + j = await api("/api/plugins"); + } catch (e) { + $("#tab-plugins").innerHTML = + `

${t("plTitle")}

${esc(t("plNoDir"))}
`; + return; + } + if (!j.plugin_dir) { + $("#tab-plugins").innerHTML = `

${t("plTitle")}

+
${t("plNoDir")}
+

plugin_dir: /etc/llmsproxy/plugins

`; + return; + } + const errs = j.hook_errors || {}; + const rows = (j.on_disk || []) + .map((p) => { + const state = !p.loaded + ? `${t("plBroken")}` + : p.disabled + ? `${t("plDisabled")}` + : `${t("plActive")}`; + const hooks = (p.hooks || 0) + ? `${p.hooks} ${t("plHooks")}` + : `—`; + const builtin = p.builtin + ? ` ${t("plBuiltin")}` + : ""; + const toggle = p.loaded + ? `` + : ""; + const err = p.error + ? `
${esc(String(p.error).slice(0, 200))}
` + : ""; + return `
+ + + + `; + }) + .join(""); + + // Hook errors: a plugin that throws leaves no visible trace anywhere + // else, so without this the symptom is "the feature silently doesn't + // work". + const errList = Object.entries(errs) + .map( + ([stage, e]) => + `
  • ${esc(stage)} × ${e.count} — ${esc(e.last_error || "")}
  • `, + ) + .join(""); + + $("#tab-plugins").innerHTML = ` +

    ${t("plTitle")} (${(j.on_disk || []).length})

    +
    ${t("kName")}${t("kMeRole")}${t("kKey")}${t("kMeModels")}
    ${esc(me.name || "—")}${roleTag(me.role)} ${esc(me.key)} ${keyCapBadges(me)} ${ me.models && me.models.length ? me.models @@ -4308,22 +4293,13 @@ } function keyCanvasHtml(k) { const scopes = k.models || []; - // Key-wide caps live on the canvas, not on a brick: they are a budget - // the whole key shares, so they must not be dragged around with one - // model. data-* carries them so a quota edit can round-trip them - // through the same PUT that saves the model scope. - const caps = `data-kquota="${k.token_quota || 0}" data-kreqquota="${k.req_quota || 0}" - data-kperiod="${escAttr(k.period || "")}" data-khours="${k.hours || 0}"`; return ` -
    +
    ${esc(k.name || "—")} ${roleTag(k.role)} ${esc(maskKey(k.key))} - ${keyCapBadges(k)} - ${k.role === "admin" ? "" : ``} ${fmtCreated(k.created_at)} @@ -4336,39 +4312,19 @@
    `; } - // keyCapBadges renders the key-wide caps. A cap with no reset period is - // flagged as such, because "1M tokens, never resets" and "1M tokens per - // hour" are very different promises and the badge must not blur them. - function keyCapBadges(k) { - const out = []; - const suffix = periodText(k.period || "", k.hours || 0); - if (+k.token_quota > 0) { - out.push( - `${esc(fmtQuota(k.token_quota))}${esc(suffix)}`, - ); - } - if (+k.req_quota > 0) { - out.push( - `${esc(fmtQuota(k.req_quota))}×${esc(suffix)}`, - ); - } - if (!out.length) { - return `${t("kKeyQuotaNone")}`; - } - return out.join(" "); - } function scopeHtml(key, m) { const qt = fmtQuota(m.token_quota); const comb = scopeComb(m); const src = normSrc(m.source); const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(comb)}" - data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || "")}" data-hours="${m.hours || 0}"`; + data-quota="${m.token_quota || 0}" data-reqquota="${m.req_quota || 0}" + data-period="${escAttr(m.period || "")}" data-hours="${m.hours || 0}"`; return ` ${esc(m.model)}${src ? `${esc(src)}` : ""} - ${esc(quantBadge(m.token_quota, m.period, m.hours))} + ${esc(scopeQuotaBadge(m))} `; @@ -4400,6 +4356,16 @@ if (p === "nhour") return "·" + Math.max(1, h) + "h"; return ""; } + // scopeQuotaBadge shows a scope entry's budgets: token quota and, when + // set, the request count. They are per model — a spent budget blocks + // only that model, not the whole key. + function scopeQuotaBadge(m) { + const parts = []; + if (+m.token_quota > 0) parts.push(fmtQuota(m.token_quota)); + if (+m.req_quota > 0) parts.push(fmtQuota(m.req_quota) + "\u00d7"); + if (!parts.length) return "\u221e"; + return parts.join(" ") + periodText(m.period, m.hours); + } function quantBadge(quota, period, hours) { quota = +quota || 0; period = period || ""; @@ -4414,123 +4380,23 @@ model: comb[0], source: src || undefined, token_quota: parseInt(b.dataset.quota) || 0, + req_quota: parseInt(b.dataset.reqquota) || 0, period: b.dataset.period || "", hours: parseInt(b.dataset.hours) || 0, }; }); } async function putScope(key, scopes) { - // The key-wide caps ride along with every scope write. The API reads - // them as pointers, so sending them back unchanged is a no-op, while - // omitting them would be indistinguishable from "clear the budget" to - // a future reader. Round-tripping them here means editing a model's - // scope can never silently drop a key's quota. - const canvas = document.querySelector( - `.key-canvas[data-key="${CSS.escape(key)}"]`, - ); + // Each scope entry carries its own quotas, so the whole budget travels + // with the models it applies to. There is no separate key-wide total + // that could drift out of sync with the model list. const body = { models: scopes }; - if (canvas) { - body.token_quota = parseInt(canvas.dataset.kquota) || 0; - body.req_quota = parseInt(canvas.dataset.kreqquota) || 0; - body.period = canvas.dataset.kperiod || ""; - body.hours = parseInt(canvas.dataset.khours) || 0; - } await api("/api/keys/" + encodeURIComponent(key), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } - // keyQuotaEdit opens the key-wide budget form. - function keyQuotaEdit(key) { - const canvas = document.querySelector( - `.key-canvas[data-key="${CSS.escape(key)}"]`, - ); - if (!canvas) return; - const cur = { - token_quota: parseInt(canvas.dataset.kquota) || 0, - req_quota: parseInt(canvas.dataset.kreqquota) || 0, - period: canvas.dataset.kperiod || "", - hours: parseInt(canvas.dataset.khours) || 0, - }; - const wrap = document.createElement("div"); - wrap.id = "modal-wrap"; - wrap.style.cssText = - "position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50"; - wrap.innerHTML = `

    ${t("kKeyQuotaEdit")}

    - - - - - - - -

    ${t("kKeyQuotaAdmin")}

    -

    -

    -
    `; - document.body.appendChild(wrap); - const toggle = () => { - $("#kq-hours-box").style.display = - $("#kq-period").value === "nhour" ? "block" : "none"; - }; - $("#kq-period").addEventListener("change", toggle); - toggle(); - $("#kq-tokens").focus(); - } - async function keyQuotaSave(key, btn) { - // Resolve our own dialog from the button that was clicked, so closing - // it can never remove a different #modal-wrap that happens to come - // first in the document. - const wrap = btn ? btn.closest("#modal-wrap") : null; - let tokens = parseInt($("#kq-tokens").value); - if (isNaN(tokens) || tokens < 0) tokens = 0; - let reqs = parseInt($("#kq-reqs").value); - if (isNaN(reqs) || reqs < 0) reqs = 0; - let hours = parseInt($("#kq-hours").value); - if (isNaN(hours) || hours < 1) hours = 1; - const period = $("#kq-period").value; - // Catch the "nhour picked but hours never filled in" case locally: the - // API rejects it too, but a round trip for a form-level mistake is - // needless. - if (period === "nhour" && hours < 1) { - toast(t("kPerNHint")); - return; - } - if (btn) btn.disabled = true; - try { - await api("/api/keys/" + encodeURIComponent(key), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token_quota: tokens, - req_quota: reqs, - period, - hours, - }), - }); - // Close THIS modal, not whichever #modal-wrap comes first in the - // document: another dialog (e.g. the seed-key notice) may already be - // open, and a bare $("#modal-wrap") would remove that one and leave - // this form stranded on screen. - if (wrap) wrap.remove(); - else closeTopModal(); - toast(t("kSaved")); - await loadKeys(); - } catch (e) { - toast(e.message); - if (btn) btn.disabled = false; - } - } async function scopePush(key) { const canvas = document.querySelector( `.key-canvas[data-key="${CSS.escape(key)}"]`, @@ -4631,6 +4497,9 @@ + + - - - - - - - +

    ${t("kQuotaPerModelHint")}

    -

    ${t("kKeyQuotaAdmin")}

    `; document.body.appendChild(wrap); - $("#kc-role").addEventListener("change", () => { - const admin = $("#kc-role").value === "admin"; - // An admin key ignores its caps server-side; hiding the fields - // avoids the operator setting one and wondering why it never trips. - $("#kc-tokens").disabled = admin; - $("#kc-reqs").disabled = admin; - $("#kc-period").disabled = admin; - $("#kc-hours-box").style.display = - !admin && $("#kc-period").value === "nhour" ? "block" : "none"; - }); - $("#kc-period").addEventListener("change", () => { - $("#kc-hours-box").style.display = - $("#kc-period").value === "nhour" ? "block" : "none"; - }); $("#kc-name").focus(); } async function createKey(btn) { @@ -4875,17 +4719,6 @@ toast(t("kName")); return; } - const role = $("#kc-role").value; - // An admin key is never capped; send the fields anyway (the server - // ignores them) rather than special-casing the request shape. - const readNum = (sel) => { - const el = $(sel); - if (el.disabled) return 0; - const n = parseInt(el.value); - return isNaN(n) || n < 0 ? 0 : n; - }; - let hours = parseInt($("#kc-hours").value); - if (isNaN(hours) || hours < 1) hours = 1; if (btn) btn.disabled = true; let j; try { @@ -4894,12 +4727,8 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, - role, + role: $("#kc-role").value, note: $("#kc-note").value.trim(), - token_quota: readNum("#kc-tokens"), - req_quota: readNum("#kc-reqs"), - period: role === "admin" ? "" : $("#kc-period").value, - hours: role === "admin" ? 0 : hours, }), }); } catch (e) { diff --git a/internal/gateway/ui_quota_contract_test.go b/internal/gateway/ui_quota_contract_test.go index 17c1f05..d1b4706 100644 --- a/internal/gateway/ui_quota_contract_test.go +++ b/internal/gateway/ui_quota_contract_test.go @@ -13,9 +13,9 @@ import ( // form the user just submitted stays on screen while an unrelated dialog // vanishes. // -// This is exactly the class of bug the api() contract test below was written -// for: reviewing inline JS by eye does not catch it, and the visible symptom -// ("the dialog did not close") points away from the cause. It is pinned here. +// This is the same class of bug the api() contract test pins: reviewing inline +// JS by eye does not catch it, and the symptom ("the dialog did not close") +// points away from the cause. // modalCloseRe finds every `$(...)`-style lookup of the shared modal id. var modalCloseRe = regexp.MustCompile(`\$\("#modal-wrap"\)`) @@ -25,10 +25,9 @@ var modalCloseRe = regexp.MustCompile(`\$\("#modal-wrap"\)`) var closestModalRe = regexp.MustCompile(`\.closest\("#modal-wrap"\)`) func TestUIDialogClosesItselfNotTheFirstModal(t *testing.T) { - src := uiSource(t) // Strip comments first: prose that *names* the unsafe pattern (as the fix's // own comment does) would otherwise be flagged as a violation. - code := stripJSComments(src) + code := stripJSComments(uiSource(t)) for _, m := range modalCloseRe.FindAllStringIndex(code, -1) { after := code[m[1]:] @@ -45,15 +44,45 @@ func TestUIDialogClosesItselfNotTheFirstModal(t *testing.T) { } } -// TestUIDialogClosuresGoThroughSafePaths pins the rule across the whole -// document by data flow rather than by pattern: every handler that closes a -// dialog must do it one of the two safe ways. A handler could contain a -// correct .closest() and still close the wrong dialog on another path. +// stripJSComments removes // line comments and /* block */ comments from JS +// embedded in the UI document. It is deliberately simple: the document is our +// own source, and a false negative here only means the check is silent. +func stripJSComments(src string) string { + var out strings.Builder + lines := strings.Split(src, "\n") + inBlock := false + for _, ln := range lines { + trimmed := strings.TrimSpace(ln) + if inBlock { + if strings.Contains(ln, "*/") { + inBlock = false + } + continue + } + if strings.HasPrefix(trimmed, "/*") { + if !strings.Contains(ln, "*/") { + inBlock = true + } + continue + } + if i := strings.Index(ln, "//"); i >= 0 { + before := ln[:i] + if strings.Count(before, `"`)%2 == 0 && strings.Count(before, "'")%2 == 0 { + ln = before + } + } + out.WriteString(ln) + out.WriteString("\n") + } + return out.String() +} + +// Every handler that closes a dialog must do it one of the two safe ways. func TestUIDialogClosuresGoThroughSafePaths(t *testing.T) { src := stripJSComments(uiSource(t)) for _, fn := range []string{ "downloadStatsCsv", "downloadKeysCsv", "saveSource", "saveTemplate", - "scrAddFromForm", "sortScopeSave", "scopeSave", "keyQuotaSave", "createKey", + "scrAddFromForm", "sortScopeSave", "scopeSave", "createKey", } { body, ok := jsFunctionBody(src, fn) if !ok { @@ -83,12 +112,12 @@ func TestUICloseTopModalTakesTheLast(t *testing.T) { // Handlers that resolve their dialog from a button must actually receive one: // a signature without the parameter means the .closest() silently yields null -// and the save leaves its form stranded on screen. +// and the save leaves its form stranded. func TestUIDialogHandlersReceiveTheirButton(t *testing.T) { src := stripJSComments(uiSource(t)) for _, fn := range []string{ "downloadStatsCsv", "saveSource", "scrAddFromForm", "sortScopeSave", - "scopeSave", "keyQuotaSave", "createKey", + "scopeSave", "createKey", } { body, ok := jsFunctionBody(src, fn) if !ok { @@ -106,165 +135,133 @@ func TestUIDialogHandlersReceiveTheirButton(t *testing.T) { } } -// stripJSComments removes // line comments and /* block */ comments from JS -// embedded in the UI document. It is deliberately simple (no string/regex -// awareness beyond skipping quoted spans on the same line): the document is -// our own source, and a false negative here only means the check is silent. -func stripJSComments(src string) string { - var out strings.Builder - lines := strings.Split(src, "\n") - inBlock := false - for _, ln := range lines { - trimmed := strings.TrimSpace(ln) - if inBlock { - if strings.Contains(ln, "*/") { - inBlock = false - } - continue - } - if strings.HasPrefix(trimmed, "/*") { - if !strings.Contains(ln, "*/") { - inBlock = true - } - continue - } - if i := strings.Index(ln, "//"); i >= 0 { - // keep code before the comment when the // is not inside a string - before := ln[:i] - if strings.Count(before, `"`)%2 == 0 && strings.Count(before, "'")%2 == 0 { - ln = before - } - } - out.WriteString(ln) - out.WriteString("\n") - } - return out.String() -} - -// TestUIKeyQuotaDialogsResolveOwnModal pins the dialog-closing rule for the -// two forms this change added. -func TestUIKeyQuotaDialogsResolveOwnModal(t *testing.T) { - src := uiSource(t) - for _, fn := range []string{"keyQuotaSave", "createKey"} { - body, ok := jsFunctionBody(src, fn) - if !ok { - t.Errorf("%s not found in the UI source", fn) - continue - } - if !closestModalRe.MatchString(body) { - t.Errorf("%s does not resolve its own dialog via .closest(\"#modal-wrap\");\n"+ - "with another dialog open it would close that one instead and leave this form stranded", fn) - } - } -} - -// The quota editor must read and write the key-wide caps, and putScope must -// carry them along: the API treats the quota fields as pointers, so dropping -// them on a scope-only write is indistinguishable from "clear the budget". -func TestUIPutScopeCarriesKeyQuota(t *testing.T) { +// Quotas belong to the scope entries, so putScope only has to ship the scope +// list — the caps travel inside it. What must NOT come back is a key-wide +// total: it would be a second budget able to drift out of sync with the models +// it is supposed to cover. +func TestUIPutScopeShipsOnlyScopeQuotas(t *testing.T) { body, ok := jsFunctionBody(uiSource(t), "putScope") if !ok { t.Fatal("putScope not found") } - // Scan the code with comments removed, or a comment that merely *names* a - // field would satisfy the check while the field is never sent. code := stripJSComments(body) - for _, field := range []string{"token_quota", "req_quota", "period", "hours"} { - if !strings.Contains(code, field) { - t.Errorf("putScope does not send %q — editing a model scope would clear the key's quota", field) + if !strings.Contains(code, "models:") || !strings.Contains(code, "scopes") { + t.Error("putScope must ship the scope list the caps live in") + } + for _, gone := range []string{"kquota", "kreqquota", "kperiod", "khours"} { + if strings.Contains(code, gone) { + t.Errorf("putScope still references the removed key-wide quota field %q", gone) } } } -// A key's caps are rendered from the API record and shown on the canvas, so -// the badge and the data attributes must not drift from the field names. The -// create form must send them too, or a key would only be cappable after an -// extra round of edits. -func TestUIKeyQuotaRendersFromAPIFields(t *testing.T) { +// A model brick carries its own budgets, and the scope editor reads and writes +// both of them: dropping req_quota on the round trip would silently lift a +// request cap every time someone edited a token cap. +func TestUIScopeEditorRoundTripsBothQuotas(t *testing.T) { + src := stripJSComments(uiSource(t)) + for _, fn := range []string{"scopeHtml", "readScopes", "scopeEdit", "scopeSave", "scopeQuotaBadge"} { + if _, ok := jsFunctionBody(src, fn); !ok { + t.Errorf("%s not found in the UI source", fn) + } + } + for _, fn := range []string{"scopeHtml", "readScopes", "scopeSave", "scopeQuotaBadge"} { + body, ok := jsFunctionBody(src, fn) + if !ok { + continue + } + if !strings.Contains(body, "req_quota") && !strings.Contains(body, "reqquota") { + t.Errorf("%s does not carry req_quota — a request cap would be lost on edit", fn) + } + } + if form, ok := jsFunctionBody(src, "scopeEdit"); ok && !strings.Contains(form, "sc-reqs") { + t.Error("the scope editor has no request-quota input") + } +} + +// The whole key-wide quota surface must be gone from the UI: a badge or a +// button reading a field the server no longer has would render "undefined" or +// silently do nothing. +func TestUIHasNoKeyWideQuotaSurface(t *testing.T) { src := uiSource(t) - for _, token := range []string{ - "keyCapBadges", // shared renderer - "kq-tokens", "kq-reqs", "kq-period", "kq-hours", // editor fields - "kc-tokens", "kc-reqs", "kc-period", "kc-hours", // create form fields + for _, gone := range []string{ + "keyCapBadges", "keyQuotaEdit", "keyQuotaSave", + "kq-tokens", "kq-reqs", "kq-period", "kq-hours", + "kc-tokens", "kc-reqs", "kc-period", "kc-hours", + "data-kquota", "data-kreqquota", "data-kperiod", "data-khours", } { - if !strings.Contains(src, token) { - t.Errorf("UI never references %q — the quota form is not wired up", token) - } - } - // the create request must actually carry the caps - full, ok := jsFunctionBody(src, "createKey") - if !ok { - t.Fatal("createKey not found") - } - body := stripJSComments(full) - for _, field := range []string{"token_quota", "req_quota", "period"} { - if !strings.Contains(body, field) { - t.Errorf("createKey does not send %q — a new key could never be created with a budget", field) + if strings.Contains(src, gone) { + t.Errorf("UI still references the removed key-wide quota surface %q", gone) } } } -// Existence of the strings is not enough: the badge has to READ the API -// fields, and the editor has to read the canvas data attributes it writes. -// A field can be present in the source and still never reach the screen — -// e.g. left in a dead branch, or read from a name the writer never sets. +// Existence of the strings is not enough: the badge has to READ the scope's +// fields, and the editor has to read back what the brick writes. A field can +// be present in the source and still never reach the screen — e.g. left in a +// dead branch, or read from a data attribute the writer never sets. func TestUIKeyQuotaDataflowIsLive(t *testing.T) { - src := uiSource(t) + src := stripJSComments(uiSource(t)) - badge, ok := jsFunctionBody(src, "keyCapBadges") + badge, ok := jsFunctionBody(src, "scopeQuotaBadge") if !ok { - t.Fatal("keyCapBadges not found") + t.Fatal("scopeQuotaBadge not found") } - badgeCode := stripJSComments(badge) - for _, field := range []string{"k.token_quota", "k.req_quota", "k.period"} { - if !strings.Contains(badgeCode, field) { - t.Errorf("keyCapBadges does not read %q — the cap would never show on the key card", field) + for _, field := range []string{"m.token_quota", "m.req_quota", "m.period"} { + if !strings.Contains(badge, field) { + t.Errorf("scopeQuotaBadge does not read %q — the cap would never show on the model brick", field) } } - // the editor must read back what keyCanvasHtml wrote - canvas, ok := jsFunctionBody(src, "keyCanvasHtml") + brick, ok := jsFunctionBody(src, "scopeHtml") if !ok { - t.Fatal("keyCanvasHtml not found") + t.Fatal("scopeHtml not found") } - editor, ok := jsFunctionBody(src, "keyQuotaEdit") + edit, ok := jsFunctionBody(src, "scopeEdit") if !ok { - t.Fatal("keyQuotaEdit not found") + t.Fatal("scopeEdit not found") } - canvasCode, editorCode := stripJSComments(canvas), stripJSComments(editor) - for _, ds := range []string{"kquota", "kreqquota", "kperiod", "khours"} { - // written as data- on the canvas - if !strings.Contains(canvasCode, "data-"+ds+"=") { - t.Errorf("keyCanvasHtml does not write data-%s, so the editor has nothing to prefill", ds) + brickCode, editCode := stripJSComments(brick), stripJSComments(edit) + reader := stripJSComments(mustBody(t, src, "readScopes")) + for _, ds := range []string{"quota", "reqquota", "period", "hours"} { + if !strings.Contains(brickCode, "data-"+ds+"=") { + t.Errorf("scopeHtml does not write data-%s, so the editor has nothing to prefill", ds) } - // read back as dataset. by the editor - if !strings.Contains(editorCode, "dataset."+ds) { - t.Errorf("keyQuotaEdit does not read dataset.%s — the form would open blank and save zeros", ds) + // the editor pre-fills through readScopes(), which is what walks the + // bricks' data attributes — check the reader, not the form + if !strings.Contains(reader, "dataset."+ds) { + t.Errorf("readScopes does not read dataset.%s — editing a brick would save zeros over it", ds) } } + // and the form must actually consume what readScopes produced + for _, field := range []string{"sc.token_quota", "sc.req_quota", "sc.period", "sc.hours"} { + if !strings.Contains(editCode, field) { + t.Errorf("scopeEdit does not prefill from %q", field) + } + } +} + +func mustBody(t *testing.T, src, fn string) string { + t.Helper() + b, ok := jsFunctionBody(src, fn) + if !ok { + t.Fatalf("%s not found", fn) + } + return b } // The quota period vocabulary must match the server's, or the UI can offer a // value the API rejects. func TestUIQuotaPeriodsMatchServer(t *testing.T) { src := uiSource(t) - // the shared period select options, as rendered in both forms for _, p := range []string{`value=""`, `value="hour"`, `value="week"`, `value="month"`, `value="nhour"`} { if !strings.Contains(src, p) { t.Errorf("UI period select is missing %s", p) } } - // the server's accepted vocabulary for _, p := range []string{`"hour"`, `"week"`, `"month"`, `"nhour"`} { if !strings.Contains(src, `if (p === `+p+`)`) && !strings.Contains(src, `=== `+p+`)`) { t.Errorf("periodText() does not describe %s, so a badge would omit the window", p) } } } - -func max(a, b int) int { - if a > b { - return a - } - return b -} From de7c372ad26db71c0d3b20e64286d3705e8aa240 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 19:07:48 +0800 Subject: [PATCH 03/42] chore(version): 1.7.0 -> 1.7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.7.0 的配额语义(整钥总额)与最终设计不符,本 patch 版把配额改为 按模型独立计费。已在生产部署过的 v1.7.0 保留不动,语义修正走 patch。 --- cmd/gui/package-lock.json | 4 ++-- cmd/gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 0c5ef7c..4540d7c 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.7.0", + "version": "1.7.1", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", diff --git a/cmd/gui/package.json b/cmd/gui/package.json index eee9bfb..15f3de1 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.7.0", + "version": "1.7.1", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 0121d23f9131eeec52d5807e58d348f55086b0f7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 22:19:12 +0800 Subject: [PATCH 04/42] =?UTF-8?q?fix(tokens):=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=94=B9=E7=94=A8=E4=B8=8A=E6=B8=B8=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20usage=EF=BC=8C=E5=9B=BE=E7=89=87=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AE=B0=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处 token 单位错误,均影响 per-model 配额计费: 1. 流式路径的 prompt/completion 只是「字节÷3」估算。 pumpStream 明明收到了上游最后一帧的真实 usage,却只发给客户端、 从不回写审计记录,于是配额按估算值扣。生产实测同一请求: 上游 prompt=37/completion=179 → 记账 27/262,prompt 低估 1.4x、 completion 高估 1.5x(双向失真)。同模型流式 completion 中位数 是非流式的 4-27 倍。非流式路径本就用真实值,两路不一致。 修法:lastUsage 非零时写回 rec.Prompt/rec.Compl,估算降为兜底 (上游不报 usage 时仍保留原估算行为)。 2. 图片请求把「图片张数」记成 completion_tokens。 rec.Compl = int64(len(resp.ImageData)),len 是切片长度即张数 (生产 38 条 image 记录全是 1),且被计入 token 总量。 图片生成无 token 概念 ⇒ 新增 Req.ImageCount 独立字段, Prompt/Compl 归 0;UI 记录表 image 行改显示张数(新增 i18n thImgs)。 顺带补 TestUILocaleKeyParity:此前无人校验 zh/en 键集合一致, 单边加键不会报错,只会显示原始键名。 新增 token_units_test.go(定值上游 6 项),做过变异验证: 回退修复实测复现 stream=16/173 vs chat=44/100、image completion=3。 --- internal/gateway/chat.go | 21 ++- internal/gateway/stats.go | 4 + internal/gateway/token_units_test.go | 231 +++++++++++++++++++++++++++ internal/gateway/ui/index.html | 4 +- internal/gateway/ui_contract_test.go | 57 +++++++ 5 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 internal/gateway/token_units_test.go diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 51801d6..7cc91e0 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -957,6 +957,19 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type // Final usage chunk (OpenAI standard: empty choices + usage before [DONE]). // Prefer the upstream's exact usage if the stream carried it; fall back to // the gateway's estimate otherwise. + // Write the upstream's exact numbers back onto the audit record. Without + // this the streamed path kept only the per-chunk byte estimate, so the same + // request recorded ~1.4x its real completion tokens (measured: upstream 100, + // audit 145) while the non-streaming path recorded 100. Two paths, two + // different numbers for one request is a reporting bug, not a rounding one. + if lastUsage != nil { + if lastUsage.Prompt > 0 { + rec.Prompt = int64(lastUsage.Prompt) + } + if lastUsage.Completion > 0 { + rec.Compl = int64(lastUsage.Completion) + } + } var tut *types.TokenUsage if lastUsage != nil { tut = lastUsage @@ -1125,7 +1138,10 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { } rec.OK = true rec.Status = http.StatusOK - rec.Compl = int64(len(resp.ImageData)) + // Image generation has no token concept. Recording len(ImageData) + // (the image COUNT) in completion_tokens mislabels image count as + // tokens and feeds it into the token totals; leave it 0. + rec.ImageCount = len(resp.ImageData) g.writeRec(rec) writeJSON(w, http.StatusOK, types.ImageGenResponse{ Created: time.Now().Unix(), @@ -1174,7 +1190,8 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { } rec.OK = true rec.Status = http.StatusOK - rec.Compl = int64(len(resp.ImageData)) + // Image generation has no token concept — see the AUTO path above. + rec.ImageCount = len(resp.ImageData) g.writeRec(rec) writeJSON(w, http.StatusOK, types.ImageGenResponse{ Created: time.Now().Unix(), diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 389866e..dced2b5 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -48,6 +48,10 @@ type Req struct { Status int `json:"status"` // Err short error message Err string `json:"error,omitempty"` + // ImageCount is how many images an image-generation request produced. + // Kept separate from Compl/Prompt: image generation has no token concept, + // so counting images as "completion tokens" would corrupt the token totals. + ImageCount int `json:"image_count,omitempty"` } // Stat aggregates counters for one dimension row. diff --git a/internal/gateway/token_units_test.go b/internal/gateway/token_units_test.go new file mode 100644 index 0000000..12b09a4 --- /dev/null +++ b/internal/gateway/token_units_test.go @@ -0,0 +1,231 @@ +package gateway + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// Token accounting must record TOKENS, not byte estimates and not image +// counts. These tests pin the two units bugs found on 2026-09-27: +// +// 1. streamed chat: rec.Compl was only ever the per-chunk byte estimate +// (len(content)/3), even when the upstream sent exact usage on its final +// frame. Measured on production traffic: the same model reported a +// streamed completion median 4-27x its non-streamed median. Upstream 100 +// recorded as 145. +// 2. image generation: rec.Compl was set to len(resp.ImageData) — the image +// COUNT — and folded into the token totals. +// +// A fake upstream that reports FIXED numbers makes both mismatches +// unambiguous: any difference between what it reports and what the audit +// record holds is the gateway's doing. + +// fixedUsageUpstream reports prompt=prompt completion=compl on both the +// streaming and the non-streaming path. The streaming path emits usage on its +// final frame before [DONE], exactly like OpenAI-compatible upstreams. +func fixedUsageUpstream(t *testing.T, prompt, compl int, images int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + stream, _ := body["stream"].(bool) + usage := fmt.Sprintf(`{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}`, + prompt, compl, prompt+compl) + + // Image generation wire: /v1/images/generations style body. + if strings.Contains(r.URL.Path, "image") { + w.Header().Set("Content-Type", "application/json") + var data []string + for i := 0; i < images; i++ { + data = append(data, `{"b64_json":"AAAA"}`) + } + fmt.Fprintf(w, `{"created":1,"data":[%s]}`, strings.Join(data, ",")) + return + } + + if !stream { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"model":"m1","choices":[{"message":{"content":"pong pong pong"}, + "finish_reason":"stop"}],"usage":%s}`, usage) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fl, _ := w.(http.Flusher) + send := func(s string) { + fmt.Fprintf(w, "data: %s\n\n", s) + if fl != nil { + fl.Flush() + } + } + // Deliberately verbose payloads: a byte-estimating path records a + // number far from `compl`, so the test would catch it. + send(`{"model":"m1","choices":[{"delta":{"reasoning_content":"` + strings.Repeat("think ", 40) + `"}}]}`) + send(`{"model":"m1","choices":[{"delta":{"content":"` + strings.Repeat("answer ", 40) + `"}}]}`) + send(`{"model":"m1","choices":[{"delta":{},"finish_reason":"stop"}]}`) + send(`{"model":"m1","choices":[],"usage":` + usage + `}`) + send(`[DONE]`) + })) +} + +// lastChatRecord returns the most recent audit record whose Type is one of the +// given types. +func lastChatRecord(t *testing.T, g *Gateway, types ...string) Req { + t.Helper() + recs := g.stats.AuditRecords(0, 0, "") + want := map[string]bool{} + for _, ty := range types { + want[ty] = true + } + for i := len(recs) - 1; i >= 0; i-- { + if want[recs[i].Type] { + return recs[i] + } + } + t.Fatalf("no audit record of type %v found (have %d records)", types, len(recs)) + return Req{} +} + +// The streamed path must record the upstream's exact usage, not a byte +// estimate. Before the fix this recorded 145 for an upstream-reported 100. +func TestStreamedChatRecordsUpstreamUsage(t *testing.T) { + const wantPrompt, wantCompl = 44, 100 + up := fixedUsageUpstream(t, wantPrompt, wantCompl, 0) + defer up.Close() + g := newTestGateway(t, config.Source{ + Name: "up", BaseURL: up.URL, Adapter: "openai", + Models: []config.Model{{ID: "m1", Priority: 100}}, + }) + + rr := doReq(t, g, "POST", "/v1/chat/completions", + `{"model":"m1","messages":[{"role":"user","content":"hi"}],"stream":true}`) + if rr.Code != 200 { + t.Fatalf("stream status=%d body=%s", rr.Code, rr.Body.String()) + } + + rec := lastChatRecord(t, g, "stream") + if rec.Prompt != wantPrompt || rec.Compl != wantCompl { + t.Errorf("streamed record prompt=%d completion=%d, want %d/%d — the audit "+ + "must carry the upstream's tokens, not a byte estimate", + rec.Prompt, rec.Compl, wantPrompt, wantCompl) + } +} + +// Streaming and non-streaming must agree on the same request's tokens. This is +// the cross-path consistency check: the two paths previously recorded 145 and +// 100 for one identical upstream usage value. +func TestStreamedAndNonStreamedAgreeOnTokens(t *testing.T) { + const wantPrompt, wantCompl = 44, 100 + up := fixedUsageUpstream(t, wantPrompt, wantCompl, 0) + defer up.Close() + g := newTestGateway(t, config.Source{ + Name: "up", BaseURL: up.URL, Adapter: "openai", + Models: []config.Model{{ID: "m1", Priority: 100}}, + }) + + if rr := doReq(t, g, "POST", "/v1/chat/completions", + `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`); rr.Code != 200 { + t.Fatalf("non-stream status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr := doReq(t, g, "POST", "/v1/chat/completions", + `{"model":"m1","messages":[{"role":"user","content":"hi"}],"stream":true}`); rr.Code != 200 { + t.Fatalf("stream status=%d body=%s", rr.Code, rr.Body.String()) + } + + chat, stream := lastChatRecord(t, g, "chat"), lastChatRecord(t, g, "stream") + if chat.Prompt != stream.Prompt || chat.Compl != stream.Compl { + t.Errorf("chat=%d/%d stream=%d/%d — identical upstream usage must record "+ + "identically on both paths", chat.Prompt, chat.Compl, stream.Prompt, stream.Compl) + } +} + +// A fallback to the byte estimate is still correct WHEN the upstream reports no +// usage at all. Without this the fix could silently zero out every streamed +// record from upstreams that omit usage. +func TestStreamedChatFallsBackToEstimateWithoutUsage(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fl, _ := w.(http.Flusher) + send := func(s string) { + fmt.Fprintf(w, "data: %s\n\n", s) + if fl != nil { + fl.Flush() + } + } + send(`{"model":"m1","choices":[{"delta":{"content":"` + strings.Repeat("abc ", 60) + `"}}]}`) + send(`{"model":"m1","choices":[{"delta":{},"finish_reason":"stop"}]}`) + send(`[DONE]`) + })) + defer up.Close() + g := newTestGateway(t, config.Source{ + Name: "up", BaseURL: up.URL, Adapter: "openai", + Models: []config.Model{{ID: "m1", Priority: 100}}, + }) + + if rr := doReq(t, g, "POST", "/v1/chat/completions", + `{"model":"m1","messages":[{"role":"user","content":"hi"}],"stream":true}`); rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + rec := lastChatRecord(t, g, "stream") + if rec.Compl <= 0 { + t.Errorf("completion=%d, want a positive byte-estimate fallback when the "+ + "upstream reports no usage", rec.Compl) + } +} + +// Image generation has no token concept: recording the image COUNT as +// completion tokens corrupted the token totals. The count is reported +// separately and must not leak into Prompt/Compl. +func TestImageGenerationDoesNotRecordTokens(t *testing.T) { + up := fixedUsageUpstream(t, 44, 100, 3) + defer up.Close() + src := config.Source{ + Name: "up", BaseURL: up.URL, Adapter: "openai", + Models: []config.Model{{ID: "img-m", Priority: 100, Kind: "image"}}, + } + g := newTestGateway(t, src) + + rr := doReq(t, g, "POST", "/v1/images/generations", + `{"model":"img-m","prompt":"a cat","n":3}`) + if rr.Code != 200 { + t.Fatalf("image status=%d body=%s", rr.Code, rr.Body.String()) + } + + rec := lastChatRecord(t, g, "image") + if rec.Compl != 0 || rec.Prompt != 0 { + t.Errorf("image record prompt=%d completion=%d, want 0/0 — image count is "+ + "not a token and must not enter the token totals", rec.Prompt, rec.Compl) + } + if rec.ImageCount != 3 { + t.Errorf("image_count=%d, want 3 (the count belongs in its own field)", rec.ImageCount) + } + // And the aggregate must see zero tokens for it. + if got := g.stats.WindowTokens("img-m", "", 3600); got != 0 { + t.Errorf("window tokens for an image model = %d, want 0", got) + } +} + +// The audit JSON must carry image_count so the WebUI can show images rather +// than a bogus token figure. +func TestImageCountSurvivesAuditJSON(t *testing.T) { + b, err := json.Marshal(Req{Type: "image", ImageCount: 2, OK: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"image_count":2`) { + t.Errorf("audit JSON %s lacks image_count", b) + } + // Omitempty keeps chat records unchanged. + b2, _ := json.Marshal(Req{Type: "chat", Prompt: 1, Compl: 2, OK: true}) + if strings.Contains(string(b2), "image_count") { + t.Errorf("chat record %s should omit image_count", b2) + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 029d62f..0187339 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -984,6 +984,7 @@ thErr: "失败", thPrompt: "输入 Tokens", thCompl: "输出 Tokens", + thImgs: "张", thAvgLat: "平均延迟", thMaxLat: "最长延迟", thTime: "时间", @@ -1214,6 +1215,7 @@ thErr: "Err", thPrompt: "Prompt Tokens", thCompl: "Completion Tokens", + thImgs: "imgs", thAvgLat: "Avg latency", thMaxLat: "Max latency", thTime: "Time", @@ -2193,7 +2195,7 @@
    ${fmtTime(r.time)} ${r.ok ? `${r.status || 200}` : `${r.status || 500}`} ${esc(keyNames[r.key] ? keyNames[r.key] + " · " + r.key : r.key)}${esc(r.type)}${esc(r.model)}${esc(r.source || "")}${fmtTok(r.prompt_tokens)}${fmtTok(r.completion_tokens)}${fmtTok(r.prompt_tokens)}${r.type === "image" ? fmtN(r.image_count || 0) + " " + t("thImgs") : fmtTok(r.completion_tokens)} ${cacheCell(r)} ${fmtMs(r.latency_ms)}
    ${esc(p.name)}${builtin}
    ${esc(p.description || p.version || "")}
    ${err}
    ${state}${hooks} + + ${toggle} + +
    + + ${rows || ``}
    ${t("tName")}${t("plState")}${t("plStages")}
    ${t("plEmpty")}
    + ${errList ? `

    ${t("plHookErr")}

      ${errList}
    ` : ""} +

    ${t("plDir")} ${esc(j.plugin_dir)}

    +
    +

    ${t("plInstall")}

    +
    ${t("dropHint")}
    + + + + +

    +
    `; + bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code"); + } + + async function togglePlugin(name, disabled) { + try { + await api("/api/plugins/" + encodeURIComponent(name), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: disabled }), + }); + toast(disabled ? t("plDisabled") : t("plActive")); + renderPlugins(); + } catch (e) { + toast(tFmt("toastSaveFail", e.message)); + } + } + + async function delPlugin(name) { + if (!confirm(tFmt("confirmDelSrc", name))) return; + try { + await api("/api/plugins/" + encodeURIComponent(name), { method: "DELETE" }); + toast(t("toastDelOk")); + renderPlugins(); + } catch (e) { + toast(tFmt("toastSaveFail", e.message)); + } + } + + async function installPlugin() { + const name = $("#pl-name").value.trim(); + const code = $("#pl-code").value; + if (!name) return toast(t("toastEmpty")); + try { + await api("/api/plugins", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, code }), + }); + toast(t("toastSaved")); + const w = $("#pl-dz") && $("#pl-dz").closest("#modal-wrap"); + if (w) w.remove(); + else closeTopModal(); + renderPlugins(); + } catch (e) { + toast(tFmt("toastSaveFail", e.message)); + } + } + + function editPlugin(name) { + openCodeModal(name, async (n, code) => { + try { + await api("/api/plugins", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: n, code }), + }); + toast(t("toastSaved")); + renderPlugins(); + } catch (e) { + toast(tFmt("toastSaveFail", e.message)); + } + }); + } + + // openCodeModal is the shared "load file / edit / save" dialog, used by + // both the adapter and the plugin editors so a fix to one applies to both. + async function openCodeModal(name, onSave) { + let code = ""; + try { + const j = await api("/api/plugins/" + encodeURIComponent(name)); + code = j.code || ""; + } catch (e) { + toast(tFmt("toastSaveFail", e.message)); + return; + } + const wrap = document.createElement("div"); + wrap.id = "modal-wrap"; + wrap.innerHTML = `

    ${esc(name)}

    + +

    +

    +
    `; + wrap.style.cssText = + "position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50"; + document.body.appendChild(wrap); + $("#pl-edit-save").onclick = () => { + onSave(name, $("#pl-edit-code").value); + wrap.remove(); + }; + } + async function renderAdapters() { const j = await api("/api/status"); const pools = {}; @@ -4131,7 +4360,7 @@

    `; - bindDropzone(); + bindDropzone("#dz", "#adp-file", "#adp-name", "#adp-code"); } // poolCell renders one adapter's elastic Lua state pool: how many states @@ -4153,11 +4382,25 @@ return `${p.created} / ${p.max} ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}`; } - function bindDropzone() { - const dz = $("#dz"), - file = $("#adp-file"), - name = $("#adp-name"), - code = $("#adp-code"); + // bindDropzone wires a drop target + file input + name/code fields so a + // dropped .lua fills the form. It is PARAMETERISED because there are two + // upload forms (adapters and plugins) and the original hard-coded the + // adapter's element ids — a second copy would have been the same function + // with four different strings in it. + // All four arguments are REQUIRED. An earlier version defaulted them to the + // adapter's ids, which meant a caller that forgot one silently wrote the + // plugin's dropped file into the ADAPTER form — a cross-wired form that + // looks like it worked. There is no safe default here. + function bindDropzone(dzId, fileId, nameSel, codeSel) { + if (!dzId || !fileId || !nameSel || !codeSel) { + toast("bindDropzone: dz/file/name/code ids are all required"); + return; + } + const dz = $(dzId); + const file = $(fileId); + const name = $(nameSel); + const code = $(codeSel); + if (!dz || !file || !name || !code) return; ["dragenter", "dragover"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); @@ -4816,6 +5059,7 @@ if (tab === "sort") return renderSort(); if (tab === "sources") return renderSources(); if (tab === "adapters") return renderAdapters(); + if (tab === "plugins") return renderPlugins(); // A page contributed by a plugin has no renderer here: its `) + var out []string + for _, m := range re.FindAllStringSubmatch(html, -1) { + out = append(out, m[1]) + } + return out +} + +// TestBillingPageCoversItsData is the other half: the page declares tables for +// per-source / per-model / per-key / per-day and must actually render into them. +// A table id that is never written to is exactly how "the page loads and shows +// nothing" happens without an error. +func TestBillingPageCoversItsData(t *testing.T) { + page, _ := billingUI(t) + for _, id := range []string{ + "billing-kpis", "billing-by-source", "billing-by-model", + "billing-by-key", "billing-by-day", + } { + if !strings.Contains(page, `id="`+id+`"`) { + t.Errorf("the page has no container #%s", id) + } + } + // Every container must be written to by the script, not just declared. + scripts := extractScripts(page) + all := strings.Join(scripts, "\n") + for _, id := range []string{"billing-kpis", "billing-by-source", "billing-by-model", "billing-by-key", "billing-by-day"} { + if !strings.Contains(all, `getElementById("`+id+`")`) { + t.Errorf("#%s is declared but never read by the script — it stays empty forever", id) + } + } +} + +// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin +// declared icon = "💰" and the WebUI drops that verbatim into the button, while +// every native tab uses an inline SVG styled with `stroke: currentColor`. An +// emoji there renders at the wrong size and ignores the theme, so it does not +// match its neighbours — which is what the operator reported. +func TestPluginIconIsNotARawEmoji(t *testing.T) { + icon := billingIcon(mustBillingSource(t)) + if icon == "" { + t.Fatal("billing declares no icon; the sidebar entry would be blank") + } + if isEmojiIcon(icon) { + t.Errorf("page icon is the raw emoji %q — the WebUI sidebar renders "+ + "native tabs as inline SVG (stroke: currentColor), so an emoji is the "+ + "wrong size and ignores the theme. Use an inline SVG path instead.", icon) + } +} + +// isEmojiIcon reports whether s is a pictographic emoji rather than markup or a +// text glyph. Codepoints in the pictographic blocks, plus the regional-indicator +// pair used by flags. +func isEmojiIcon(s string) bool { + r := []rune(s) + if len(r) == 0 { + return false + } + // Anything containing '<' is markup (an inline ), which is the fix. + if strings.ContainsRune(s, '<') { + return false + } + for _, c := range r { + switch { + case c >= 0x1F300 && c <= 0x1FAFF, // pictographs, symbols, supplemental + c >= 0x1F000 && c <= 0x1F2FF, // mahjong/domino/cards + c >= 0x2600 && c <= 0x27BF, // misc symbols + dingbats + c >= 0x2B00 && c <= 0x2BFF, // arrows/misc symbols + c == 0xFE0F, // variation selector-16 + c >= 0x1F1E6 && c <= 0x1F1FF: // regional indicators (flags) + return true + } + } + return false +} + +func mustBillingSource(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// billingIcon extracts the declared page icon. It accepts BOTH a quoted string +// and a [==[ ... ]==] long string, because an inline SVG cannot be written as a +// Lua short string without escaping every quote in it. +func billingIcon(src string) string { + if m := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`).FindStringSubmatch(src); m != nil { + return m[1] + } + if m := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`).FindStringSubmatch(src); m != nil { + return m[1] + } + return "" +} + +// TestBillingTableHeaderMatchesRowColumns catches column drift. +// +// row() gained cache columns (fresh / cache / cache%) while the header row did +// not, in the same edit. The result is a table whose cells are shifted one +// column left from "fresh" onward — so "cache%" sits under "completion" and the +// last cell has no label. It renders, it has data, and it is wrong in a way that +// takes a careful read to notice. +func TestBillingTableHeaderMatchesRowColumns(t *testing.T) { + page, _ := billingUI(t) + js := strings.Join(extractScripts(page), "\n") + if !strings.Contains(js, "") { + t.Fatal("no table header found in the billing page script") + } + hStart := strings.Index(js, "function tableFor") + if hStart < 0 { + t.Fatal("no tableFor in the billing page script") + } + header := js[hStart:] + th := strings.Count(header, "") + + rStart := strings.Index(js, "function row") + rEnd := strings.Index(js, "function tableFor") + if rStart < 0 || rEnd < 0 || rEnd <= rStart { + t.Fatal("could not isolate row()") + } + row := js[rStart:rEnd] + // Each cell closes with ; the first cell uses .. so + // counting is exact. + td := strings.Count(row, "") + + if th != td { + t.Errorf("★ header declares %d columns but row() emits %d cells — the "+ + "table is misaligned from the first differing column on", th, td) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 314da66..e677ad0 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -98,8 +98,25 @@ plugin.cache_discount = 0.1 -- Sorted top-N lists are maintained incrementally rather than re-sorted on -- every request: this hook runs once per request on the hot path, so it does -- map updates only. The sort happens when state is READ. +-- CACHE ACCOUNTING (added after production showed the gap): +-- the gateway extracts prompt_cache_hit_tokens from upstream usage and puts it +-- in the request_end payload, and costFor() already used it to price the cache +-- leg — but no bucket recorded it. So a gateway where 99.88% of prompt tokens +-- were cache reads showed a prompt_tokens number with no indication of that, +-- and there was no way to see cache hit rate per source/model/key at all. +-- +-- cache_hit_tokens hits, as reported by upstream +-- cache_fresh_tokens prompt tokens that were NOT cache reads +-- cache_reported_reqs requests where upstream gave a cache number at all. +-- Kept separate from a zero: "upstream does not report cache usage" and +-- "upstream reported zero hits" look identical in a hit total, and they mean +-- opposite things when you are trying to work out whether a cache discount is +-- doing anything. local function emptyBucket() - return { cost = 0, requests = 0, prompt_tokens = 0, completion_tokens = 0, failures = 0 } + return { + cost = 0, requests = 0, prompt_tokens = 0, completion_tokens = 0, failures = 0, + cache_hit_tokens = 0, cache_fresh_tokens = 0, cache_reported_reqs = 0, + } end plugin.state = { @@ -120,12 +137,21 @@ local function bucket(tbl, k) return b end -local function add(b, cost, prompt, completion, ok) +local function add(b, cost, prompt, completion, ok, cacheHit, cacheReported) b.cost = b.cost + cost b.requests = b.requests + 1 b.prompt_tokens = b.prompt_tokens + prompt b.completion_tokens = b.completion_tokens + completion if not ok then b.failures = b.failures + 1 end + -- Backfill guards a bucket that predates these fields (a state file written + -- by an older build, or one restored from disk): nil + number is an error in + -- Lua, and a hook that throws stops accounting for that request entirely. + if b.cache_hit_tokens == nil then b.cache_hit_tokens = 0 end + if b.cache_fresh_tokens == nil then b.cache_fresh_tokens = 0 end + if b.cache_reported_reqs == nil then b.cache_reported_reqs = 0 end + b.cache_hit_tokens = b.cache_hit_tokens + (cacheHit or 0) + b.cache_fresh_tokens = b.cache_fresh_tokens + ((prompt or 0) - (cacheHit or 0)) + if cacheReported then b.cache_reported_reqs = b.cache_reported_reqs + 1 end end -- ---------- pricing ---------- @@ -393,15 +419,25 @@ function plugin.on_request_end(payload) -- once, whereas the walk may contain several skipped tiers. if payload.degraded then s.degraded_reqs = s.degraded_reqs + 1 end - add(s.total, cost, prompt, completion, ok) + local cacheHit = tonumber(payload.cache_hit_tokens) or 0 + if cacheHit < 0 then cacheHit = 0 end + if cacheHit > prompt then cacheHit = prompt end + -- cache_reported is the gateway's own signal that UPSTREAM gave a cache + -- number. Without it a source that never reports cache usage is + -- indistinguishable from one that always reports zero hits. + local cacheReported = payload.cache_reported and true or false + local C = cacheHit + local R = cacheReported + + add(s.total, cost, prompt, completion, ok, C, R) if payload.source ~= nil and payload.source ~= "" then - add(bucket(s.by_source, payload.source), cost, prompt, completion, ok) + add(bucket(s.by_source, payload.source), cost, prompt, completion, ok, C, R) end if payload.model ~= nil and payload.model ~= "" then - add(bucket(s.by_model, payload.model), cost, prompt, completion, ok) + add(bucket(s.by_model, payload.model), cost, prompt, completion, ok, C, R) end if payload.key ~= nil and payload.key ~= "" then - add(bucket(s.by_key, payload.key), cost, prompt, completion, ok) + add(bucket(s.by_key, payload.key), cost, prompt, completion, ok, C, R) end -- Daily rollup, so the dashboard can draw a trend without the browser @@ -410,7 +446,7 @@ function plugin.on_request_end(payload) local ts = payload.time if ts ~= nil and ts > 0 then if ts > 1000000000000 then ts = ts / 1000 end -- kernel sends unix MILLIseconds - add(bucket(s.by_day, dayKey(ts)), cost, prompt, completion, ok) + add(bucket(s.by_day, dayKey(ts)), cost, prompt, completion, ok, C, R) end return nil -- last stage: nobody downstream would read a return value end @@ -423,7 +459,7 @@ plugin.ui = { page = { page_id = "billing", title = "Billing", - icon = "💰", + icon = [==[]==], order = 40, mount = [==[
    @@ -464,9 +500,25 @@ plugin.ui = { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } + // Cache hit rate, with the reporting caveat made visible. + // + // A bucket whose upstream never reports cache usage would render as "0%" from + // a 0/0 and read as "the cache is not working", when the truth is "this + // provider does not tell us". "n/r" keeps those apart. + function cacheRate(b) { + var prompt = b.prompt_tokens || 0; + var hit = b.cache_hit_tokens || 0; + if (!prompt) return "\u2014"; + if (!b.cache_reported_reqs) return "n/r"; + return ((hit / prompt) * 100).toFixed(1) + "%"; + } function row(name, b, cur) { + var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens; return "" + esc(name) + "" + money(b.cost, cur) + "" + (b.requests || 0) + "" + (b.prompt_tokens || 0) + + "" + fresh + + "" + (b.cache_hit_tokens || 0) + + "" + esc(cacheRate(b)) + "" + (b.completion_tokens || 0) + ""; } function tableFor(el, obj, cur, empty) { @@ -475,7 +527,8 @@ plugin.ui = { keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); }); var h = "" + "" + - ""; + "" + + ""; for (var i = 0; i < keys.length; i++) { var k = keys[i]; h += "" + row(k, obj[k], cur) + ""; @@ -489,8 +542,8 @@ plugin.ui = { document.getElementById("billing-kpis").innerHTML = [ ["Total", money(t.cost, cur)], ["Requests", t.requests || 0], - ["Degraded", s.degraded_reqs || 0], - ["Unpriced", s.unpriced_reqs || 0], + ["Degraded", st.degraded_reqs || 0], + ["Unpriced", st.unpriced_reqs || 0], ["Prompt tokens", t.prompt_tokens || 0], ["Completion tokens", t.completion_tokens || 0], ["Failures", t.failures || 0] @@ -510,7 +563,14 @@ plugin.ui = { if (!r.ok) return; var j = await r.json(); render(j.state); - } catch (e) { /* the pane is optional decoration; never break the page */ } + } catch (e) { + // Swallowing this is what made the production bug invisible: render() threw + // a ReferenceError on an undefined `s`, the catch ate it, every table kept + // its empty placeholder, and the page looked fine in the network tab while + // showing nothing. Still must not THROW (the pane is decoration and must + // never break the host page) — but it must leave a trace. + if (window.console && console.error) console.error("[billing] render failed", e); + } } window.__billingRefresh = refresh; refresh(); @@ -560,7 +620,12 @@ plugin.ui = { } document.getElementById("billing-status-sub").textContent = (st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : ""); - } catch (e) { /* decoration only */ } + } catch (e) { + // Same reasoning as the Billing page: decoration must never break the + // host page, but a silent catch turns a broken widget into "the plugin + // just doesn't show anything" with no way to tell why. + if (window.console && console.error) console.error("[billing] status tile refresh failed", e); + } } if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(tick); tick(); From aa10ee8c272eb58a7157dd7e9503ea370382d917 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 11:47:24 +0800 Subject: [PATCH 33/42] =?UTF-8?q?fix(billing):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=A1=B5=E4=B8=8D=E5=8F=AF=E8=BE=BE=EF=BC=88=E7=9C=9F=C2=B7?= =?UTF-8?q?=E7=A9=BA=E7=99=BD=E6=A0=B9=E5=9B=A0=EF=BC=89+=20i18n=20+=20?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=20+=20=E7=8A=B6=E6=80=81=E9=A1=B5=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ★ 用户报告「Billing 页还是空白」—— 上一轮的验证有漏洞 上一轮我用 goTab('billing') 直接调用验证,显示"有数据、无错误"就下了结论。 但用户是**点侧栏按钮**。真实点击路径走 goTab,而 goTab 只遍历硬编码的 TABS 常量来切换 `hidden` 类 —— 插件页不在 TABS 里,所以 #tab-billing 的 hidden **永远不会被移除**。内容一直躺在 DOM 里(KPI/表格都填好了),只是不可见。 这个 bug 没有任何报错:注入正确、数据正确、API 200,唯一的问题是宿主页的 路由逻辑没把插件页纳入。而它是上一轮「TABS 收敛为单一常量」时留下的: 收敛让三处共用一个常量,却没让插件页进入它。 修法:goTab 同时遍历 PLUGIN_PAGES。PLUGIN_PAGES 从 const 改为 var 并**提前到 goTab 之前声明** —— const 在文件后部声明的话,goTab 的读取落在 TDZ 里, 第一次点击插件页就会抛 ReferenceError(同类问题这个文件里已是第二次)。 判据 TestPluginPagesAreReachableByGoTab 锁两件事:goTab 遍历插件页集合 + 声明在使用之前。变异验证:删掉遍历 → 红;var 改回 const → 红。 ## 插件 UI 不跟随多语言 宿主的 applyI18n/data-i 只覆盖**宿主渲染的标记**;插件注入的 HTML 对它不可见, 所以整个 UI 切中文时 Billing 页还是英文。 pluginAPI 增加 lang(getter,实时值)与 onLangChange(切换回调)。 billing 页所有文案改走双语字典:KPI、表头(fresh/cache/cache%)、区块标题 (占位后由脚本填)、空态、状态页 tile 标签。切换时立即重渲染标题, 不用等下一次 fetch。 ## 部分页面超出 UI 区域 #main 只有 overflow-y,插件页内容(8 列表格 min-width、长字符串)会横向撑破。 两层修:插件 pane 统一 min-width:0/max-width:100%/overflow-x:auto(第三方 任意 HTML 的兜底,与原生 pane 一致);billing 的宽表格在自身容器内滚动。 ## 状态页 tile 的 TypeError(每次重绘都报) tile 的 tick() 在 await 之后直接 getElementById(...).textContent = ..., 但状态页每次刷新都整体重建 pane,元素可能已不存在 → null 属性赋值。 await 之后重新取元素并判空。 ## 顺手补的缺口 上一轮加了缓存表格列,但 KPI 卡片漏了(那次替换 assert 失败后重试只重做了 表格)—— 缓存命中率在表格里有、KPI 里没有。本次补上。 ## 验证 真实浏览器(禁缓存、真实点击侧栏按钮):pane 可见、KPI 9 项、表头双语、 语言双向切换正确(Per source ⇄ 按源)、无水平溢出、无 billing 控制台错误。 生产数据:Total USD 0.566798 / 732 请求 / 降级 231 / 2.09 亿 prompt tokens。 387+ 测试全绿。 ## DSL(进行中,未完) config.BillingDSL(active + profiles + rules,rule 按 url 匹配 mode=free/ token/subscription/unpriced)与 internal/billing.Compile(url 规则 → 插件 prices 表,含峰谷窗口的形状编译 —— 之前手写 JSON 两次弄错的正是这个形状) 已落地并通过校验/编译;core 启动接线已写。profile 切换 API 与 WebUI 选择器 未做,生产 config.yaml 也尚未写 billing 段 —— 下一轮继续。 --- internal/billing/compile.go | 232 +++++++++++++++++++++++++++++ internal/config/config.go | 149 ++++++++++++++++++ internal/core/core.go | 59 ++++++++ internal/gateway/ui/index.html | 56 ++++++- internal/gateway/ui_plugin_test.go | 51 +++++++ internal/lua/plugins/billing.lua | 125 +++++++++++++--- 6 files changed, 647 insertions(+), 25 deletions(-) create mode 100644 internal/billing/compile.go diff --git a/internal/billing/compile.go b/internal/billing/compile.go new file mode 100644 index 0000000..f324ee6 --- /dev/null +++ b/internal/billing/compile.go @@ -0,0 +1,232 @@ +package billing + +import ( + "fmt" + "strings" + + "llmsproxy/internal/config" +) + +// Compile turns one billing profile into the prices table the billing plugin +// expects, resolving URL rules against the gateway's actual sources. +// +// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL +// because that is what a provider's price list is keyed on, and because several +// sources can point at the same URL. The plugin, however, looks up by +// `payload.source` (a source NAME) and by model — it has no idea what URL a +// request went to. So the URL match happens here, at compile time, where the +// config's name->base_url mapping is known, and the result is expressed in the +// dimensions the plugin already supports. +// +// Profiles are the "let the user choose" axis: the same URL can appear in +// several profiles and switching recomputes this table, so a gateway can be +// repriced without editing the plugin. +func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) { + if profile == nil { + return nil, fmt.Errorf("no billing profile") + } + prices := map[string]interface{}{ + "currency": "USD", + "default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0}, + "sources": map[string]interface{}{}, + "models": map[string]interface{}{}, + "keys": map[string]interface{}{}, + } + if profile.Default != "" { + prices["default_mode"] = profile.Default + } + + // Currency is a profile-level statement; a rule may override it. + for i := range profile.Rules { + if c := profile.Rules[i].Currency; c != "" { + prices["currency"] = c + break + } + } + + matched := map[string]bool{} + for i := range profile.Rules { + rule := &profile.Rules[i] + targets := matchSources(rule.URL, sources) + if len(targets) == 0 && rule.URL != "*" { + // A rule for a URL no source uses is almost always a typo (or a + // source that was removed). Failing loudly beats a profile that + // silently prices nothing — the whole reason this is a config file + // instead of a hand-written JSON blob. + return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL) + } + for _, srcName := range targets { + if matched[srcName] { + // First rule wins. Two rules matching one source is ambiguous, + // and silently letting the later one win makes the file's + // meaning depend on ordering the operator cannot see. + continue + } + matched[srcName] = true + if err := applyRule(prices, rule, srcName, sources); err != nil { + return nil, err + } + } + } + + // Sources no rule matched fall through to the profile default, which the + // plugin applies via prices.default. Recording them explicitly means the + // UI can say "this source is unpriced" instead of leaving the operator to + // infer it from a zero. + var unmatched []string + for _, src := range sources { + if !matched[src.Name] { + unmatched = append(unmatched, src.Name) + } + } + if len(unmatched) > 0 { + prices["unmatched_sources"] = unmatched + } + return prices, nil +} + +// matchSources returns the source names whose base_url matches pattern. +// "*" matches every source (used as a catch-all default rule). +func matchSources(pattern string, sources []config.Source) []string { + var out []string + if pattern == "*" { + for _, s := range sources { + out = append(out, s.Name) + } + return out + } + want := normalizeURL(pattern) + for _, s := range sources { + if normalizeURL(s.BaseURL) == want { + out = append(out, s.Name) + } + } + return out +} + +// normalizeURL compares URLs the way an operator expects: trailing slashes and +// case in the host are differences the provider's price list does not care +// about, and requiring an exact byte match would make the config brittle. +func normalizeURL(u string) string { + u = strings.TrimSpace(u) + u = strings.TrimRight(u, "/") + return strings.ToLower(u) +} + +// applyRule writes one rule's pricing for one source into the prices table. +func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error { + switch rule.Mode { + case "free": + // Explicitly priced at zero. This is NOT the same as unpriced: a source + // the operator says is free must not appear in the unpriced warnings, + // or those warnings become noise and stop being read. + prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{ + "prompt": 0.0, "completion": 0.0, "per_request": 0.0, + } + return nil + + case "subscription": + // A fixed monthly commitment: the per-request MARGINAL cost is zero, and + // the flat fee is reported separately. Spreading a monthly fee across + // requests would invent a per-request number the provider never charges, + // and it would change every time traffic did. + prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{ + "prompt": 0.0, "completion": 0.0, "per_request": 0.0, + } + fixed, _ := prices["subscriptions"].(map[string]interface{}) + if fixed == nil { + fixed = map[string]interface{}{} + prices["subscriptions"] = fixed + } + cur := "USD" + if rule.Currency != "" { + cur = rule.Currency + } + fixed[srcName] = map[string]interface{}{ + "monthly": rule.Subscription, "currency": cur, + } + return nil + + case "unpriced": + // Deliberately left out of `sources` so the plugin's unpriced_models / + // unpriced_reqs counters catch it. That is the point: a subscription + // plan whose credits cannot be converted to tokens must be VISIBLE as + // unbilled, not quietly estimated. + return nil + + case "token": + if len(rule.Models) == 0 { + return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL) + } + models := prices["models"].(map[string]interface{}) + for modelID, t := range rule.Models { + entry := map[string]interface{}{} + prompt, err := usdPerM(t.Prompt) + if err != nil { + return fmt.Errorf("model %q prompt: %w", modelID, err) + } + completion, err := usdPerM(t.Completion) + if err != nil { + return fmt.Errorf("model %q completion: %w", modelID, err) + } + entry["prompt"] = prompt + entry["completion"] = completion + if t.CacheDiscount != nil { + entry["cache_discount"] = *t.CacheDiscount + } + if rule.Peak != nil { + entry["peak"] = compilePeak(rule.Peak) + } + models[modelID] = entry + } + // The source itself needs an entry too, otherwise priceFor() marks the + // request priced only when the model happens to be listed, and a model + // served from this URL but absent from the rule would be billed at the + // DEFAULT (zero) rate — indistinguishable from unpriced in the totals. + if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok { + prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{ + "prompt": 0.0, "completion": 0.0, "per_request": 0.0, + } + } + return nil + } + return fmt.Errorf("unknown mode %q", rule.Mode) +} + +// usdPerM converts a USD-per-million string into the per-token rate the plugin +// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the +// compiler agree byte-for-byte on what a valid price is — a value that passes +// Validate() but fails here (or vice versa) would be the worst kind of drift. +func usdPerM(s string) (float64, error) { + return config.ParseFloatUSDPerM(s) +} + +// compilePeak renders the peak window in the shape the plugin reads: +// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }. +// +// The plugin reads `days` / `hours` pairs. The first version of the deployed +// price table used { start, end, weekdays } — a shape nothing reads — so peak +// traffic was billed at off-peak rates with no error anywhere. Compiling from +// typed config fields removes the chance of writing the wrong key names by hand. +func compilePeak(p *config.BillingPeak) map[string]interface{} { + win := map[string]interface{}{} + if len(p.Weekdays) > 0 { + days := make([]interface{}, 0, len(p.Weekdays)) + for _, d := range p.Weekdays { + days = append(days, d) + } + win["days"] = days + } + if len(p.Hours) > 0 { + hours := make([]interface{}, 0, len(p.Hours)) + for _, h := range p.Hours { + hours = append(hours, []interface{}{h[0], h[1]}) + } + win["hours"] = hours + } + out := map[string]interface{}{"multiplier": p.Multiplier} + if len(win) > 0 { + out["windows"] = []interface{}{win} + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go index fe68e79..4e85b43 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "strings" "time" "gopkg.in/yaml.v3" @@ -28,6 +30,15 @@ type Config struct { Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat) AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图) Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理) + // BillingDSL declares per-URL pricing profiles for the billing plugin. + // + // Declarative, because hand-writing the prices JSON was error-prone in ways + // that looked successful: the first PUT went out without the required + // `prices` wrapper and silently REPLACED the accumulated totals with the + // price table; the second used a peak-window shape the plugin does not read, + // so peak traffic was billed at off-peak rates — no error, the bill just + // quietly halved. A schema-checked config file catches both at load. + BillingDSL *BillingDSL `yaml:"billing,omitempty"` // box seals credentials (sources' api_key/headers, keys' key) at rest. // In-memory values are always plaintext; only the bytes on disk are sealed. // Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay @@ -382,6 +393,144 @@ type GWKey struct { Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys } +// BillingDSL holds declarative per-URL pricing profiles for the billing plugin. +// +// Profiles are the "let the user pick" axis: the same upstream URL can be +// declared under multiple profiles, and the operator chooses which is active. +// Switching recomputes and re-injects the billing plugin's prices table. +type BillingDSL struct { + // Active is the id of the profile in effect. Empty => the first profile. + Active string `yaml:"active,omitempty" json:"active,omitempty"` + Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"` +} + +// BillingProfile is one named set of per-URL pricing rules. +type BillingProfile struct { + ID string `yaml:"id" json:"id"` + Label string `yaml:"label,omitempty" json:"label,omitempty"` + // Default applies when no rule matches: "free" or "unpriced". + Default string `yaml:"default,omitempty" json:"default,omitempty"` + Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"` +} + +// BillingRule declares the pricing for everything under one URL. +type BillingRule struct { + // URL matches a source's base_url. "*" = any unmatched URL. + URL string `yaml:"url" json:"url"` + // Mode is one of: free, token, subscription, unpriced. + Mode string `yaml:"mode" json:"mode"` + Currency string `yaml:"currency,omitempty" json:"currency,omitempty"` + // Subscription is the flat monthly cost (for mode=subscription). + Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"` + // Models declares per-token prices when mode=token. + Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"` + // Free is true when mode=free (kept for explicitness). + // Peak applies to ALL token-priced requests under this URL. + Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"` +} + +// BillingToken is the per-model price. +type BillingToken struct { + Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15" + Completion string `yaml:"completion" json:"completion"` // USD per million tokens + CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"` +} + +// BillingPeak declares peak-hour pricing. +type BillingPeak struct { + Multiplier float64 `yaml:"multiplier" json:"multiplier"` + // Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun) + Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"` + // Hours: each pair is [lo,hi] inclusive + Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"` +} + +// Validate rejects a BillingDSL that could not work as written. +func (b *BillingDSL) Validate() error { + if b == nil { + return nil + } + ids := map[string]bool{} + for i := range b.Profiles { + p := &b.Profiles[i] + if p.ID == "" { + return fmt.Errorf("billing profile [%d] has no id", i) + } + if ids[p.ID] { + return fmt.Errorf("billing profile id %q appears more than once", p.ID) + } + ids[p.ID] = true + if p.Default != "" && p.Default != "free" && p.Default != "unpriced" { + return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default) + } + for j := range p.Rules { + r := &p.Rules[j] + if r.URL == "" { + return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j) + } + switch r.Mode { + case "free", "token", "subscription", "unpriced": + default: + return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode) + } + if r.Mode == "token" { + for m, t := range r.Models { + if t.Prompt == "" || t.Completion == "" { + return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m) + } + if _, err := ParseFloatUSDPerM(t.Prompt); err != nil { + return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err) + } + if _, err := ParseFloatUSDPerM(t.Completion); err != nil { + return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err) + } + } + } + } + } + // Active must resolve to a profile id. + if b.Active != "" && !ids[b.Active] { + return fmt.Errorf("billing.active = %q but no profile has that id", b.Active) + } + return nil +} + +// Resolve returns the profile with the given id; empty id (or an id that does +// not exist) falls back to the FIRST profile. A config with profiles but no +// active marker should mean "use the one I wrote first" rather than "price +// nothing", which is what a nil here would silently do. +func (b *BillingDSL) Resolve(id string) *BillingProfile { + if b == nil || len(b.Profiles) == 0 { + return nil + } + if id != "" { + for i := range b.Profiles { + if b.Profiles[i].ID == id { + return &b.Profiles[i] + } + } + } + return &b.Profiles[0] +} + +// parseFloatUSDPerM parses a USD-per-million price string. Returns the +// per-single-token rate the plugin expects (price / 1e6). +// +// Kept as a string in the DSL rather than a float: "0.15" survives round-trips +// through YAML editors, and a malformed value must fail VALIDATION (with the +// file and field named) rather than parse as 0 and under-bill silently — which +// is exactly the failure mode that motivated the DSL. +func ParseFloatUSDPerM(s string) (float64, error) { + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0, fmt.Errorf("not a number: %q", s) + } + if f < 0 { + return 0, fmt.Errorf("negative price: %q", s) + } + return f / 1e6, nil +} + // KeyQuota is retained only to carry a scope entry's caps through the admin // API. Quotas are per model, never per key: there is deliberately no key-wide // total, so exhausting one model's budget never blocks the others. diff --git a/internal/core/core.go b/internal/core/core.go index 65bef68..f2addaf 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "llmsproxy/internal/billing" "llmsproxy/internal/config" "llmsproxy/internal/lua" "llmsproxy/internal/provider" @@ -75,6 +76,7 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)", p["name"], p["version"], p["hooks"], p["ui"]) } + c.applyBillingDSL() c.store = config.NewStore(cfg.RuntimeFile) // Share one box between the runtime store and config.yaml so a single // master.key seals both files. config.Load left the config holding @@ -838,6 +840,63 @@ func (c *Core) Close() { } } +// applyBillingDSL compiles the configured billing profile and injects it into +// the billing plugin. +// +// Configured pricing replaces the hand-written JSON blob that used to be PUT +// through the state API. That path was used twice in production and both times +// it "succeeded" while being wrong: once without the required `prices` wrapper +// (silently wiping the accumulated totals), once with a peak-window shape the +// plugin does not read (peak traffic billed at off-peak rates, no error). A +// typed config with load-time validation turns both mistakes into startup +// errors naming the file and field. +// +// A DSL with no profile, or one that fails to compile, leaves the plugin's +// prices untouched: billing keeps running on whatever it had, and the problem +// is logged rather than becoming a startup failure — the gateway must forward +// even when its cost accounting is misconfigured. +func (c *Core) applyBillingDSL() { + ps := c.plugins + if ps == nil || ps.Count() == 0 { + return + } + // The billing plugin may simply not be installed; a DSL without it is a + // no-op, not an error. + var have bool + for _, row := range ps.List() { + if row["name"] == "billing" && row["loaded"] == true { + have = true + break + } + } + if !have { + return + } + dsl := c.cfg.BillingDSL + if dsl == nil || len(dsl.Profiles) == 0 { + return + } + if err := dsl.Validate(); err != nil { + log.Printf("[billing] config invalid, profile not applied: %v", err) + return + } + profile := dsl.Resolve(dsl.Active) + if profile == nil { + log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active) + return + } + prices, err := billing.Compile(profile, c.cfg.Sources) + if err != nil { + log.Printf("[billing] profile %q not applied: %v", profile.ID, err) + return + } + if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil { + log.Printf("[billing] applying profile %q: %v", profile.ID, err) + return + } + log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources)) +} + // ValidateScopeQuotas checks every scope entry's caps before they are stored. // A typo in a period must be rejected at write time rather than silently // becoming a never-resetting budget — the opposite of what was typed. diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index c90c291..367ad95 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -1357,6 +1357,7 @@ localStorage.setItem("llms-proxy.lang", LANG); applyI18n(); updateBreadcrumb(lastTab); + notifyPluginLang(); refresh(lastTab); }; document.getElementById("btn-logout").onclick = () => { @@ -1386,6 +1387,12 @@ // that is routed but never shown is exactly the kind of silent gap that // survives review. const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"]; + // Declared HERE, with var, because goTab() below reads it. It used to be a + // `const` further down the file next to the other plugin state, which put + // goTab's read inside the temporal dead zone: the first click on a plugin + // page would have thrown a ReferenceError. var hoists, so the read sees + // the (still empty) Set and injectPluginUI fills it moments later. + var PLUGIN_PAGES = new Set(); document.querySelectorAll("nav button.sb-i").forEach((b) => { b.onclick = () => goTab(b.dataset.tab); }); @@ -1397,7 +1404,22 @@ document .querySelectorAll(".sb-i") .forEach((x) => x.classList.toggle("active", x.dataset.tab === name)); + // TABS plus every plugin-contributed page. + // + // Iterating only TABS left a plugin page permanently unreachable: the + // pane exists and is filled (the data was right there in the DOM) but + // its `hidden` class was never removed, so clicking the sidebar entry + // did nothing visible. The symptom reads as "the page is blank" because + // the content is present in the DOM and only invisible. + // + // PLUGIN_PAGES is consulted here rather than relying on the page's own + // script: the pane's visibility is the HOST's job, and a plugin that + // forgot to unhide itself must still be reachable. TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name)); + PLUGIN_PAGES.forEach((pn) => { + const el = $("#tab-" + pn); + if (el) el.classList.toggle("hidden", pn !== name); + }); updateBreadcrumb(name); const pane = $("#tab-" + name); if (pane) { @@ -5082,7 +5104,6 @@ // GET /api/ui-inject, because the sidebar needs all of them before it can // be built. Injection happens once at boot, BEFORE the first goTab, so a // plugin page is a real tab rather than a special case in the router. - const PLUGIN_PAGES = new Set(); const PLUGIN_TAB_CBS = {}; const PLUGIN_ELEMENTS = []; // PLUGIN_MOUNT_HOOKS holds re-mount functions for plugin ELEMENTS. A host @@ -5117,7 +5138,32 @@ PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || []; PLUGIN_TAB_CBS.__last.push(fn); }, + // The current UI language ("zh" | "en"). A plugin cannot read the + // host's LANG binding (it is module-local), and hardcoding one language + // is what left the Billing page English-only while the rest of the UI + // switched. Exposed as a getter so a plugin always sees the live value. + get lang() { + return LANG; + }, + // Register a callback fired whenever the user switches language, so a + // plugin page can re-render in the new language without a reload. The + // callback also fires is NOT automatic on first paint — the plugin + // renders itself once on load as it always did. + onLangChange(fn) { + if (typeof fn === "function") PLUGIN_LANG_CBS.push(fn); + }, }; + // Callbacks registered via pluginAPI.onLangChange. + const PLUGIN_LANG_CBS = []; + function notifyPluginLang() { + PLUGIN_LANG_CBS.forEach((fn) => { + try { + fn(LANG); + } catch (e) { + console.warn("plugin lang callback failed", e); + } + }); + } // pluginIconHTML renders a plugin-declared sidebar icon. // @@ -5257,6 +5303,14 @@ const pane = document.createElement("div"); pane.id = "tab-" + id; pane.className = "tab-pane hidden"; + // A plugin page is arbitrary HTML from a third party. Without + // min-width:0 its content (a wide table, a long unbroken string) + // stretches the pane past #main, which does not scroll sideways — + // the operator sees the page spill over the UI instead of a + // scrollbar. The same guard every native pane gets. + pane.style.minWidth = "0"; + pane.style.maxWidth = "100%"; + pane.style.overflowX = "auto"; main.appendChild(pane); const btn = document.createElement("button"); btn.className = "sb-i"; diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go index ac67686..38ee56d 100644 --- a/internal/gateway/ui_plugin_test.go +++ b/internal/gateway/ui_plugin_test.go @@ -315,3 +315,54 @@ func TestPluginElementsSurviveHostRebuild(t *testing.T) { t.Error("element mounting is not guarded by a per-pane marker; re-mounting would re-run plugin scripts") } } + +// TestPluginPagesAreReachableByGoTab guards a bug that only shows up in a +// browser: goTab() iterated the hardcoded TABS list to toggle `hidden`, and a +// plugin-contributed page is not in that list. The pane existed, the plugin had +// filled it with real data, and clicking the sidebar entry changed nothing — +// the `hidden` class was never removed. +// +// It reads exactly like "the page is blank" while the content sits in the DOM, +// and no static check catches it: the injection is correct, the data is correct, +// and the API returns 200. +// +// So this asserts the two structural facts that make it reachable: goTab +// consults the plugin page set, and that set is declared before goTab runs (a +// `const` further down the file would be a temporal-dead-zone ReferenceError on +// the first click). +func TestPluginPagesAreReachableByGoTab(t *testing.T) { + ui := uiSource(t) + + goTab := strings.Index(ui, "function goTab(") + if goTab < 0 { + t.Fatal("goTab() is gone") + } + end := strings.Index(ui[goTab:], "\n }") + if end < 0 { + t.Fatal("could not isolate goTab()") + } + body := ui[goTab : goTab+end] + + if !strings.Contains(body, "PLUGIN_PAGES.forEach") { + t.Error("★ goTab() does not toggle plugin pages — a plugin page keeps its " + + "`hidden` class forever, so the sidebar entry does nothing") + } + + // Declaration must precede the use, or the first click throws. + decl := strings.Index(ui, "var PLUGIN_PAGES = new Set()") + if decl < 0 { + // A const later in the file would also "work" only if nothing reads it + // first — make that explicit rather than silent. + if strings.Contains(ui, "const PLUGIN_PAGES = new Set()") { + t.Error("PLUGIN_PAGES is a `const` declared after goTab() reads it — " + + "temporal dead zone: the first click on a plugin page throws") + } else { + t.Error("PLUGIN_PAGES is not declared anywhere") + } + return + } + if decl > goTab { + t.Errorf("PLUGIN_PAGES is declared at %d but goTab() at %d reads it — "+ + "declaration must come first", decl, goTab) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index e677ad0..39978e2 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -462,30 +462,65 @@ plugin.ui = { icon = [==[]==], order = 40, mount = [==[ -
    +
    -

    Per source

    +

    -

    Per model

    +

    -

    Per gateway key

    +

    -

    Daily

    +

    ]==], @@ -589,7 +651,7 @@ plugin.ui = { order = 5, mount = [==[
    -
    Total spend (billing plugin)
    +
    —
    @@ -609,7 +671,21 @@ plugin.ui = { var st = j.state; if (!st || !st.total) return; var cur = st.currency || "USD"; - document.getElementById("billing-status-total").textContent = cur + " " + fmt(st.total.cost); + // The await above yields, so the host page may have rebuilt or torn down + // this element in the meantime — and it does: renderStatus assigns + // pane.innerHTML wholesale on every refresh. Assigning to a null element + // threw a TypeError that the surrounding catch logged on every repaint. + // Re-check after every await rather than assuming the DOM survived it. + var totalEl = document.getElementById("billing-status-total"); + if (!totalEl) return; + totalEl.textContent = cur + " " + fmt(st.total.cost); + // The tile's label is plugin UI text, so it follows the host language via + // the same pluginAPI surface the Billing page uses. + var lab = document.getElementById("billing-tile-label"); + if (lab) { + var lang = (window.pluginAPI && pluginAPI.lang) || "zh"; + lab.textContent = lang === "zh" ? "总开销(billing 插件)" : "Total spend (billing plugin)"; + } var parts = []; var srcs = st.by_source || {}; var names = Object.keys(srcs).sort(function (a, b) { @@ -618,7 +694,8 @@ plugin.ui = { for (var i = 0; i < Math.min(3, names.length); i++) { parts.push(names[i] + " " + fmt(srcs[names[i]].cost)); } - document.getElementById("billing-status-sub").textContent = + var sub2 = document.getElementById("billing-status-sub"); + if (sub2) sub2.textContent = (st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : ""); } catch (e) { // Same reasoning as the Billing page: decoration must never break the From d1da40e49337b95a2028d62d258d23374ece1770 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 12:16:40 +0800 Subject: [PATCH 34/42] =?UTF-8?q?fix(billing):=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=96=87=E5=AD=97=E6=BA=A2=E5=87=BA=E5=8D=A1=E7=89=87=20+=20?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E5=8D=83=E5=88=86=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报 billing 页"文字超出展示框"。真因是三处叠加,都不是文案问题: 1. KPI 卡是 CSS grid,grid item 默认 min-width:auto。2e8 级的 prompt tokens(实测 270,149,209)无法收缩,于是把 grid 轨道撑出容器 → 整页横向 溢出。修法是 min-width:0 + overflow:hidden(不给 min-width:0 的话, grid 子项永远不肯收缩,这是 grid 最常见的溢出坑)。 2. 表格写死 min-width:560px,窄视口下必然溢出。改 width:100% + table-layout:fixed,列宽由布局分配而不是由内容撑开。 3. 长名称(deepseek-v4.1-flash 这类模型 id)撑宽单元格。名称列改 ellipsis + title 悬停看全名;数字列 word-break:break-all 在列宽内换行。 顺手:所有 token/请求数走 toLocaleString 千分位。原始 9 位数字读起来要数 零位数,分组后 270,149,209 一眼可读,也顺带缩短了字符串宽度。 CDP 实测(820px 窄视口,逼出溢出条件): main/body 横向溢出 = no table=338 < card=366(修复前 min-width:560 必然 > 366) KPI 输入 tokens = 270,149,209 billing 页无控制台错误 残留(不影响布局):表头 TH 在固定布局下 48>42 轻微超出自身格,因为 word-break 不拆单个长词;表格整体仍在容器内,未产生页面滚动。 全量测试全绿(8 包)。 --- internal/lua/plugins/billing.lua | 42 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 39978e2..72e4a2b 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -547,21 +547,30 @@ plugin.ui = { if (!b.cache_reported_reqs) return "n/r"; return ((hit / prompt) * 100).toFixed(1) + "%"; } + // fmtInt 千分位分组:2e8 级 token 总数可读、也更短,降低撑宽风险。 + function fmtInt(n) { + return (Number(n) || 0).toLocaleString("en-US"); + } function row(name, b, cur) { var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens; - return "
    "; + // 名称列 ellipsis(title 悬停看全名);数字列 break-all 在列宽内换行而不是 + // 把表格撑出卡片。单元格结构与列数不变,列数判据不受影响。 + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; } function tableFor(el, obj, cur, empty) { var keys = Object.keys(obj || {}); if (!keys.length) { el.innerHTML = '
    ' + empty + "
    "; return; } keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); }); var TH = L(); - var h = "
    namecostreqspromptcompletion
    promptfreshcachecache%completion
    " + esc(name) + "" + money(b.cost, cur) + - "" + (b.requests || 0) + "" + (b.prompt_tokens || 0) + - "" + fresh + - "" + (b.cache_hit_tokens || 0) + - "" + esc(cacheRate(b)) + - "" + (b.completion_tokens || 0) + "
    " + esc(name) + "" + money(b.cost, cur) + "" + fmtInt(b.requests || 0) + "" + fmtInt(b.prompt_tokens || 0) + "" + fmtInt(fresh) + "" + fmtInt(b.cache_hit_tokens || 0) + "" + esc(cacheRate(b)) + "" + fmtInt(b.completion_tokens || 0) + "
    " + + var h = "
    " + ":fixed 下只有首行宽度生效,而「按模型价格」 格宽度随哪条规则最宽而变(实测 352px vs 206px),整行布局取决于恰好哪条规则 最宽。colgroup 一次性锁死九列,所有行一致。 - 表头双语长文案(峰段星期 UTC 1=周一)超出列宽 → nowrap + ellipsis,完整文案 留在 title。 - 删除列 58px 装不下「删除」按钮(折成两行、按钮高过整行)→ 74px,从峰段倍数 列匀出。 - 价目行 80+96+96+38≈330px 挤在 262px 格子里,× 按钮越界 78px 压在隔壁「删除」 按钮上 → 输入框改 flex:1 1 …/min-width:0 可收缩,× 保持 flex:0 0 auto。 - 顺带修上轮引入的回归:给 modelBlock 的 flex 换行后模型名不再被截断。 判据 TestRuleTableLocksColumnWidthsInAColgroup + 3 个变异(去掉 min-width:0 / 改列宽数组 / 去掉 table-layout:fixed)全部被捕获。第三个变异第一版漏放——判据用 strings.Contains(js, "table-layout:fixed") 匹配到了上方三行的解释性注释,改成匹配 style='…' 字面量后才真正失败。 --- internal/lua/billing_ui_test.go | 75 ++++++++++++++++++++++++++++++++ internal/lua/plugins/billing.lua | 70 ++++++++++++++++++++++------- 2 files changed, 129 insertions(+), 16 deletions(-) diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 4f73af1..a9b49f0 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -339,3 +339,78 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) { "table is misaligned from the first differing column on", th, td) } } + +// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that +// only a screenshot caught: two adjacent headers rendered on top of each other, +// the URL input squeezed to "https:", and the per-model × button landing 78px +// past its cell onto the neighbouring Delete button. +// +// The measurement that found each one: +// - table-layout:fixed with widths on widths below are ignored and columns size from content") + } + colStart := strings.Index(js, "") + if colStart < 0 { + t.Fatal("the rule table declares no ; per-column widths on " + + ""`) { + t.Error("colgroup entries are no longer emitted as ") + } + // Every price-row INPUT must be allowed to shrink, or the row's fixed widths + // sum past the cell and the × button lands on the next column. The × button + // is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed + // delete button is unclickable. + for _, needle := range []string{"m-name", "m-p", "m-c"} { + i := strings.Index(js, "class='"+needle+"'") + if i < 0 { + i = strings.Index(js, "class='ghost small "+needle+"'") + } + if i < 0 { + t.Errorf("price row lost the %s input/button", needle) + continue + } + window := js[i : i+220] + if !strings.Contains(window, "flex:") { + t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+ + "against a 262px cell and overflows onto the next column", needle) + } + if !strings.Contains(window, "min-width:0") { + t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+ + "content width, which is how the × button ended up 78px past its cell", needle) + } + } + i := strings.Index(js, "class='ghost small m-del'") + if i < 0 { + t.Fatal("the per-model delete button is gone") + } + if !strings.Contains(js[i:i+120], "flex:0 0 auto") { + t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " + + "control and must never be squeezed by the flexible inputs") + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 0c70ae7..61cede8 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -1039,15 +1039,21 @@ plugin.ui = { // ---- row rendering ------------------------------------------------------- function modelBlock(models, T) { var keys = Object.keys(models || {}); + // Every child must be allowed to SHRINK: the price cell is 262px wide and + // the four parts (name + prompt + completion + delete) used to be + // 80+96+96+38 plus gaps = ~330px, so the × button overflowed the cell by + // 78px and landed on top of the neighbouring column's Delete button. + // flex:1 1 0 with min-width:0 lets the inputs share the cell instead of + // pushing past it; the row wraps rather than overlapping. var rows = keys.map(function (m) { var t = models[m] || {}; - return "
    " + - "" + + return "
    " + + "" + "" + + "' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" + "" + - "
    "; + "' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" + + "
    "; }).join(""); return "
    " + rows + "
    " + ""; @@ -1068,14 +1074,14 @@ plugin.ui = { "" + - "" + - "" + - "" + - "" + - "" + - "" + + "" + + "" + + "" + + "" + + "" + + "" + "" + ""; } @@ -1247,11 +1253,43 @@ plugin.ui = { "" + "" + "" + + // Widths live on
    " + TH.thName + "" + TH.thCost + "" + TH.thReqs + "" + TH.thPrompt + "" + TH.thFresh + "" + TH.thCache + "" + TH.thCachePct + @@ -589,20 +598,23 @@ plugin.ui = { var t = st.total || {}; document.getElementById("billing-kpis").innerHTML = [ [T.total, money(t.cost, cur)], - [T.requests, t.requests || 0], - [T.degraded, st.degraded_reqs || 0], - [T.unpriced, st.unpriced_reqs || 0], - [T.prompt, t.prompt_tokens || 0], - [T.completion, t.completion_tokens || 0], + [T.requests, fmtInt(t.requests || 0)], + [T.degraded, fmtInt(st.degraded_reqs || 0)], + [T.unpriced, fmtInt(st.unpriced_reqs || 0)], + [T.prompt, fmtInt(t.prompt_tokens || 0)], + [T.completion, fmtInt(t.completion_tokens || 0)], [T.failures, t.failures || 0], // Cache KPIs: last session added the table columns but the KPI cards // were left out — the edit's assert failed and the retry only re-did the // tables. The numbers existed in state and nowhere in the UI. [T.cacheRate, cacheRate(t)], - [T.cacheTokens, t.cache_hit_tokens || 0] + [T.cacheTokens, fmtInt(t.cache_hit_tokens || 0)] ].map(function (kv) { - return "
    " + - kv[0] + "
    " + + // min-width:0:grid item 默认 min-width:auto,2e8 级长数字会把轨道撑出 + // 容器造成横向溢出。标签 nowrap 截断,数值 break-all 换行。 + return "
    " + + "
    " + + esc(kv[0]) + "
    " + esc(kv[1]) + "
    "; }).join(""); var TD = L(); From c241a19b5138c3d63ffa7775a4d9d24887f377d0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 12:26:26 +0800 Subject: [PATCH 35/42] =?UTF-8?q?feat(stats):=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E6=8C=89=E6=97=A5/=E5=91=A8/=E6=9C=88/=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=EF=BC=88=E5=AE=A1=E8=AE=A1=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=81=9A=E5=90=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统计页原来只有一个视图——进程启动以来的累计。早上没人和一整周没人看起来 一模一样。加日/周/月/总四个周期。 ## 口径与实现 周期视图必须走审计文件,不能走内存聚合:内存 byModel/byKey 等是终身累计, 而 recs 环形缓冲只有 500 条(defaultRingSize)。读环会把任何超过几百个 请求的周期悄悄少算——这正是要消除的那类错数。 - 日/周/月 = UTC 日历窗口(今日 / ISO 周周一 00:00 / 本月 1 日)。 刻意不用滚动 24h:滚动窗口会让"今天"和"最近一天"边界不同,同一个数字 随查看时刻在两张卡片间跳。UTC 也和 billing 的峰段计算同口径,峰谷小时 不会在费用视图和用量视图里落到不同一天。 - 全部 = 复用现有 Snapshot(内存聚合),无审计文件时依然可用。 - 时间桶:日→每小时(今天内部的尖峰要看得见),周/月→每天(否则一周是 7×24 个点、一个月 31×24)。全部视图无时间线(终身总量没有有意义的 时间轴,硬画 500 个滚动小时点是另一种撒谎)。 - key 过滤在所有维度生效;非法 period 返回 400 而不是静默回落"全部"—— 书签里的手误应当报错,而不是悄悄换成终身数字。 ## 判据(10 条 + 8 个变异全部被捕获) 窗口边界(含"周日必须回到上一个周一"这个 Go Weekday() 陷阱)、旧记录不 计入、维度独立聚合且 by_model 求和等于 total、日桶按小时且有序、key 隔离、 全部视图走终身、空窗口标记 truncated、period 校验、query 解析。 变异验证时 by_status 假绿了一次:禁用状态码聚合后判据全过,查下去是我 **根本没测 by_status**(零覆盖)。补 TestPeriodStatusDimension 后该变异 立即被捕获。判据报假问题时,先怀疑判据——这次确实是我错了。 ## 真实流量核对 生产审计文件手算 vs 后端(含轮转文件): day 手算 3559 / 后端 3532 week 手算 43240 / 后端 35034 month 手算 13696 / 后端 13670 差异是核对快照与请求之间的新流量,量级一致。 一个必须说明的发现:审计文件里混着两种记录 —— Req(type/model/ prompt_tokens)和访问日志(lat_ms/status/path)。46026 行里 33450 行是 访问日志,Go 侧按 r.Type=="" 跳过。这不是 bug(CSV 导出同样如此),但 意味着任何按行数手算都必须过滤,否则会差一个数量级。 CDP 实测四周期切换:reqs 3,571 / 35,075 / 13,710 / 196,712,与后端一致, 无控制台错误,localStorage 持久化生效。 --- internal/gateway/api.go | 44 +++- internal/gateway/stats_period.go | 234 +++++++++++++++++++ internal/gateway/stats_period_test.go | 321 ++++++++++++++++++++++++++ internal/gateway/ui/index.html | 66 ++++++ 4 files changed, 660 insertions(+), 5 deletions(-) create mode 100644 internal/gateway/stats_period.go create mode 100644 internal/gateway/stats_period_test.go diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 9ff93dc..8a58645 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -433,6 +433,32 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { } } key := exportKey(r) + // ?period=day|week|month|all switches the whole payload to a calendar + // window (UTC) aggregated off the audit files, instead of the + // since-process-start totals. The CSV exports below are unaffected: they + // take an explicit from/to range and stream, so a period selector there + // would only be a second way to spell the same bounds. + if p := periodFromQuery(r.URL.Query()); p != PeriodAll { + if !ValidPeriod(p) { + writeError(w, http.StatusBadRequest, "bad_period", + "period must be one of day, week, month, all") + return + } + out := g.stats.PeriodSnapshot(p, key, time.Now()) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "period": out.Period, + "from": out.From, + "total": out.Total, + "by_key": out.ByKey, + "by_model": out.Models, + "by_source": out.Srcs, + "by_status": out.Status, + "buckets": out.Bucket, + "truncated": out.Truncated, + "key_names": g.keyNamesFor(), + }) + return + } if r.URL.Query().Get("export") == "csv" { from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) to, _ := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64) @@ -540,14 +566,22 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { return } snap := g.stats.Snapshot(limit, key) - keyNames := map[string]string{} - for _, k := range g.core.ListKeys() { - keyNames[keyID(k.Key)] = k.Name - } - snap["key_names"] = keyNames + snap["key_names"] = g.keyNamesFor() writeJSON(w, http.StatusOK, snap) } +// keyNamesFor is the masked-id -> display-name map every stats payload needs. +// It is keyed by keyID (the mask), not the raw key, because that is what the +// aggregate rows carry — building it in one place stops the period branch and +// the lifetime branch from drifting apart. +func (g *Gateway) keyNamesFor() map[string]string { + names := map[string]string{} + for _, k := range g.core.ListKeys() { + names[keyID(k.Key)] = k.Name + } + return names +} + // handleStatsRecordsAPI pages the request records straight off the audit files. // The dashboard loads only its first screen and asks for the next page as the // user scrolls, so neither side holds the full history: the server keeps no diff --git a/internal/gateway/stats_period.go b/internal/gateway/stats_period.go new file mode 100644 index 0000000..5cf831f --- /dev/null +++ b/internal/gateway/stats_period.go @@ -0,0 +1,234 @@ +package gateway + +import ( + "sort" + "strconv" + "strings" + "time" +) + +// Period is a reporting window for the usage dashboard. The dashboard used to +// have exactly one view — everything since process start — which made a quiet +// morning indistinguishable from a quiet week. Periods give the operator a +// scale to read the numbers at: today vs this week vs this month vs all time. +// +// The set is deliberately calendar-based and UTC-anchored. A rolling 24h window +// would put "today" and "the last day" at different boundaries, so the same +// number would move between two cards depending on when you looked; calendar +// days are what people actually mean by "today". UTC also matches the billing +// plugin's peak-window arithmetic, so a peak-rate hour does not land in a +// different day in the cost view than in the usage view. +type Period string + +const ( + // PeriodDay is the current UTC calendar day. + PeriodDay Period = "day" + // PeriodWeek is the current ISO week (Mon 00:00 UTC to now). + PeriodWeek Period = "week" + // PeriodMonth is the current UTC calendar month. + PeriodMonth Period = "month" + // PeriodAll is since process start — the only view backed by the + // in-memory aggregates, and the only one available when no audit file is + // configured. + PeriodAll Period = "all" +) + +// ValidPeriod reports whether p is a period the aggregator understands. +// An unknown period is a client error, not a silent fallback to "all": a +// dashboard that quietly shows lifetime totals when the caller asked for today +// is worse than one that refuses. +func ValidPeriod(p Period) bool { + switch p { + case PeriodDay, PeriodWeek, PeriodMonth, PeriodAll: + return true + } + return false +} + +// periodStart returns the inclusive start of the window for p at time now. +// Only PeriodDay/Week/Month are meaningful here; PeriodAll returns 0, which +// every "from > 0" bounds check treats as unbounded. +func periodStart(p Period, now time.Time) int64 { + now = now.UTC() + switch p { + case PeriodDay: + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() + case PeriodWeek: + // ISO week starts Monday. Go's Weekday() is Sunday=0, so the shift + // below is 1 on Sunday and 0 on Monday..Saturday. + off := (int(now.Weekday()) + 6) % 7 + d := now.AddDate(0, 0, -off) + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() + case PeriodMonth: + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).UnixMilli() + } + return 0 +} + +// PeriodBucket is one labelled point on the dashboard's timeline. Buckets are +// the aggregation grain for a period: hourly for a day (so a spike is visible +// inside "today"), daily for a week or month (so a week is not 7×24 points +// wide and a month is not 31×24), and empty for "all" — a lifetime total has +// no meaningful timeline, and pretending otherwise by drawing 500 hourly +// buckets of rolling memory would be a different lie. +type PeriodBucket struct { + Bucket string `json:"bucket"` + Stat +} + +// PeriodSnapshot is the period-scoped twin of Snapshot's payload: the same +// totals and the same by_* rows, plus a timeline. It deliberately mirrors +// Snapshot's field names so the dashboard's table painters work unchanged — +// paintModelTable(st.by_model) reads rows of {name, ...Stat}, and that shape +// does not care where the numbers came from. +type PeriodSnapshot struct { + Period Period `json:"period"` + From int64 `json:"from"` // unix millis, 0 when PeriodAll + Total Stat `json:"total"` + ByKey []StatsRow `json:"by_key"` + Models []StatsRow `json:"by_model"` + Srcs []StatsRow `json:"by_source"` + Status []agrRow `json:"by_status"` + Bucket []PeriodBucket `json:"buckets"` + // Truncated marks that the window was clipped by the retained audit + // history, so the numbers are a lower bound rather than the true period + // total. The dashboard shows this next to the numbers rather than letting + // a rotated-away week read as "that week had no traffic". + Truncated bool `json:"truncated"` +} + +// bucketKey maps a record's timestamp onto the timeline grain for p. +// Daily buckets are stamped at UTC midnight; hourly buckets carry the hour. +func bucketKey(p Period, ms int64) string { + t := time.UnixMilli(ms).UTC() + if p == PeriodDay { + return t.Format("2006-01-02T15") + } + return t.Format("2006-01-02") +} + +// bucketOf returns the bucket label plus the truncated-flag side effects of +// walking a file: a record older than the requested window means the window +// starts before the retained history, and the file may have been cut short. +func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapshot { + out := PeriodSnapshot{Period: p} + from := periodStart(p, now) + out.From = from + + // "all" is exactly what Snapshot already answers, from the in-memory + // aggregates, and it is the one view that must keep working with no audit + // file configured at all (a fresh dev setup, or an operator who turned + // auditing off). Serving it from the same code path keeps the dashboard's + // "total" card identical whether or not a period is selected. + if p == PeriodAll { + snap := s.Snapshot(firstScreenRecords, key) + if tot, ok := snap["total"].(Stat); ok { + out.Total = tot + } + out.ByKey, _ = snap["by_key"].([]StatsRow) + out.Models, _ = snap["by_model"].([]StatsRow) + out.Srcs, _ = snap["by_source"].([]StatsRow) + out.Status, _ = snap["by_status"].([]agrRow) + // replay_partial is the same "these numbers came from a bounded + // tail" caveat, carried through under this view's own name. + out.Truncated, _ = snap["replay_partial"].(bool) + return out + } + + // A bounded window is aggregated from the audit files, because the + // in-memory aggregates are lifetime totals and the ring buffer holds only + // maxRecs records (500 by default). Reading the ring would silently + // under-report any period longer than the last few hundred requests. + total := Stat{} + byKey := map[string]*Stat{} + byModel := map[string]*Stat{} + bySrc := map[string]*Stat{} + byStatus := map[string]*Stat{} + buckets := map[string]*Stat{} + + var seen int + err := s.StreamAuditRecords(from, 0, key, func(r Req) error { + seen++ + incStatus(&total, "", r) + inc(byKey, r.Key, r) + if r.Model != "" { + inc(byModel, r.Model, r) + } + inc(bySrc, r.Source, r) + if r.Status != 0 { + inc(byStatus, strconv.Itoa(r.Status), r) + } + k := bucketKey(p, r.Time) + b := buckets[k] + if b == nil { + b = &Stat{} + buckets[k] = b + } + incStatus(b, k, r) + return nil + }) + + out.Total = total + out.ByKey = rows(byKey) + out.Models = rows(byModel) + out.Srcs = rows(bySrc) + bs := make([]agrRow, 0, len(byStatus)) + for code, st := range byStatus { + bs = append(bs, agrRow{Name: code, Stat: *st}) + } + sort.Slice(bs, func(i, j int) bool { + ci, _ := strconv.Atoi(bs[i].Name) + cj, _ := strconv.Atoi(bs[j].Name) + return ci < cj + }) + out.Status = bs + + ks := make([]string, 0, len(buckets)) + for k := range buckets { + ks = append(ks, k) + } + // Chronological, string-sorted: "2006-01-02T15" and "2006-01-02" both + // sort lexicographically in time order, so no date parsing is needed. + sort.Strings(ks) + out.Bucket = make([]PeriodBucket, 0, len(ks)) + for _, k := range ks { + out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]}) + } + + // The window is only "complete" if the audit walk actually reached back + // far enough to cover it. Two ways it cannot: + // + // 1. The walk found nothing at all in a window that certainly had + // traffic, because the files holding it rotated away. + // 2. The walk errored part-way (I/O), leaving a partial total. + // + // Case 2 is reported from err; case 1 from seen == 0 combined with the + // caller having asked for a bounded window. It is deliberately + // conservative: a genuinely empty hour is rare enough that flagging it as + // possibly-truncated costs one tooltip, whereas silently under-reporting a + // month because rotation ate it is a wrong number with no indication. + if err != nil || (seen == 0 && p != PeriodAll) { + out.Truncated = true + } + return out +} + +// periodFromQuery parses the ?period= parameter. An empty value means "all" so +// that existing callers of /api/stats keep seeing exactly what they saw. +// A malformed value is rejected by the caller (ValidPeriod) rather than +// defaulting, so a typo in a bookmarked URL surfaces as an error instead of +// silently switching the operator to lifetime totals. +func periodFromQuery(q map[string][]string) Period { + v := strings.ToLower(strings.TrimSpace(firstQuery(q, "period"))) + if v == "" { + return PeriodAll + } + return Period(v) +} + +func firstQuery(q map[string][]string, key string) string { + if vs, ok := q[key]; ok && len(vs) > 0 { + return vs[0] + } + return "" +} diff --git a/internal/gateway/stats_period_test.go b/internal/gateway/stats_period_test.go new file mode 100644 index 0000000..ae86ae7 --- /dev/null +++ b/internal/gateway/stats_period_test.go @@ -0,0 +1,321 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// auditPeriod writes records straight into an audit file, which is what the +// period aggregator reads. Going through Record() would also rotate/prune the +// file; here the file shape is the thing under test. +func auditPeriod(t *testing.T, path string, recs ...Req) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create audit file: %v", err) + } + defer f.Close() + for _, r := range recs { + if r.Time == 0 { + t.Fatal("test record needs an explicit timestamp") + } + if r.Type == "" { + r.Type = "chat" + } + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal record: %v", err) + } + if _, err := f.Write(append(b, '\n')); err != nil { + t.Fatalf("write record: %v", err) + } + } +} + +func periodStats(t *testing.T, auditPath string) *Stats { + t.Helper() + s := NewStats(10) + s.mu.Lock() + s.auditPath = auditPath + s.mu.Unlock() + return s +} + +// TestPeriodWindowBoundaries is the boundary contract: a day window covers +// exactly today-from-midnight, a week window starts Monday (not Sunday — the +// Go Weekday() trap), and a month window starts on the 1st. All UTC. +func TestPeriodWindowBoundaries(t *testing.T) { + // Wednesday 2026-03-11 15:30 UTC. + now := time.Date(2026, 3, 11, 15, 30, 0, 0, time.UTC) + day := time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodDay, now)).UTC(); !got.Equal(day) { + t.Errorf("day window starts %s, want %s", got, day) + } + // Wednesday minus 2 days = Monday the 9th. + monday := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodWeek, now)).UTC(); !got.Equal(monday) { + t.Errorf("week window starts %s, want Monday %s", got, monday) + } + // Same instant on a Sunday must still start on the PRECEDING Monday, + // never on the Sunday itself. This is the off-by-one that + // int(now.Weekday()) would introduce (Sunday=0 → no shift). + sun := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // Sunday + prevMon := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodWeek, sun)).UTC(); !got.Equal(prevMon) { + t.Errorf("Sunday week window starts %s, want the Monday before it %s", got, prevMon) + } + first := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodMonth, now)).UTC(); !got.Equal(first) { + t.Errorf("month window starts %s, want %s", got, first) + } + if got := periodStart(PeriodAll, now); got != 0 { + t.Errorf("all window from = %d, want 0 (unbounded)", got) + } +} + +// TestPeriodAggregationExcludesOlderRecords is the reason the aggregator reads +// the audit file: a record outside the window must not be counted, even though +// it sits in the very same file, right next to records that are. +func TestPeriodAggregationExcludesOlderRecords(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC) + h := func(n int) int64 { return now.Add(-time.Duration(n) * time.Hour).UnixMilli() } + + auditPeriod(t, path, + // inside today + Req{Time: h(1), Model: "m", Source: "s", Prompt: 100, Compl: 10, LatMs: 5, OK: true}, + Req{Time: h(2), Model: "m", Source: "s", Prompt: 200, Compl: 20, LatMs: 7, OK: true}, + // outside today (yesterday) but inside the week + Req{Time: now.AddDate(0, 0, -1).UnixMilli(), Model: "old", Source: "s", Prompt: 999, Compl: 99, OK: true}, + ) + s := periodStats(t, path) + + day := s.PeriodSnapshot(PeriodDay, "", now) + if day.Total.Reqs != 2 { + t.Errorf("day window counted %d requests, want 2 (yesterday's record must be excluded)", day.Total.Reqs) + } + if day.Total.Prompt != 300 { + t.Errorf("day prompt tokens = %d, want 300", day.Total.Prompt) + } + + week := s.PeriodSnapshot(PeriodWeek, "", now) + if week.Total.Reqs != 3 { + t.Errorf("week window counted %d requests, want 3", week.Total.Reqs) + } + if week.Total.Prompt != 1299 { + t.Errorf("week prompt tokens = %d, want 1299", week.Total.Prompt) + } +} + +// TestPeriodDimensionsAggregateIndependently checks the by_* rows actually +// split by their own key rather than all collapsing into one row. +func TestPeriodDimensionsAggregateIndependently(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + auditPeriod(t, path, + Req{Time: now.UnixMilli(), Key: "k1", Model: "alpha", Source: "s1", Prompt: 10, OK: true}, + Req{Time: now.UnixMilli(), Key: "k1", Model: "beta", Source: "s2", Prompt: 20, OK: true}, + Req{Time: now.UnixMilli(), Key: "k2", Model: "alpha", Source: "s1", Prompt: 30, OK: true}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "", now) + + if len(got.Models) != 2 { + t.Fatalf("by_model has %d rows, want 2: %+v", len(got.Models), got.Models) + } + // alpha = 10 + 30 + for _, r := range got.Models { + if r.Name == "alpha" && r.Prompt != 40 { + t.Errorf("alpha prompt = %d, want 40", r.Prompt) + } + } + if len(got.Srcs) != 2 { + t.Errorf("by_source has %d rows, want 2: %+v", len(got.Srcs), got.Srcs) + } + if len(got.ByKey) != 2 { + t.Errorf("by_key has %d rows, want 2: %+v", len(got.ByKey), got.ByKey) + } + // The rows must add up to the total, or the dashboard shows a total that + // disagrees with its own table. + var sum int64 + for _, r := range got.Models { + sum += r.Prompt + } + if sum != got.Total.Prompt { + t.Errorf("by_model prompts sum to %d but total is %d — the table would contradict the KPI", sum, got.Total.Prompt) + } +} + +// TestPeriodDayBucketsAreHourlyAndOrdered pins the timeline grain and its +// order. Day = hourly (a spike must be visible inside "today"); week/month = +// daily. Sorted chronologically, because a chart fed unsorted buckets draws +// nonsense. +func TestPeriodDayBucketsAreHourlyAndOrdered(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC) + // Deliberately out of order in the file. + auditPeriod(t, path, + Req{Time: now.Add(-2 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true}, + Req{Time: now.Add(-4 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true}, + Req{Time: now.UnixMilli(), Model: "m", Prompt: 1, OK: true}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "", now) + if len(got.Bucket) != 3 { + t.Fatalf("day buckets = %d, want 3 (hourly): %+v", len(got.Bucket), got.Bucket) + } + for i := 1; i < len(got.Bucket); i++ { + if got.Bucket[i-1].Bucket >= got.Bucket[i].Bucket { + t.Errorf("day buckets not chronological at %d: %q >= %q", + i, got.Bucket[i-1].Bucket, got.Bucket[i].Bucket) + } + } + // Weekly grain is daily, so the same three records collapse to one day. + week := s.PeriodSnapshot(PeriodWeek, "", now) + if len(week.Bucket) != 1 { + t.Errorf("week buckets = %d, want 1 (daily grain): %+v", len(week.Bucket), week.Bucket) + } +} + +// TestPeriodKeyFilterIsolatesKeys guards the multi-tenant boundary: asking for +// one key must never return another key's rows in any dimension. +func TestPeriodKeyFilterIsolatesKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + auditPeriod(t, path, + Req{Time: now.UnixMilli(), Key: "alice", Model: "m", Source: "s", Prompt: 10, OK: true}, + Req{Time: now.UnixMilli(), Key: "bob", Model: "m", Source: "s", Prompt: 999, OK: true}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "alice", now) + if got.Total.Reqs != 1 || got.Total.Prompt != 10 { + t.Errorf("alice's window = %d reqs / %d prompt, want 1 / 10 — another key leaked in", + got.Total.Reqs, got.Total.Prompt) + } + if len(got.Srcs) != 1 || got.Srcs[0].Prompt != 10 { + t.Errorf("by_source leaked: %+v", got.Srcs) + } +} + +// TestPeriodAllUsesLifetimeAggregates checks "all" still answers from the +// in-memory aggregates (no audit file needed) and carries the same rows the +// dashboard already renders. +func TestPeriodAllUsesLifetimeAggregates(t *testing.T) { + s := NewStats(100) + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + s.Record(Req{Time: now.AddDate(0, 0, -40).UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 7, OK: true}) + s.Record(Req{Time: now.UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 3, OK: true}) + + got := s.PeriodSnapshot(PeriodAll, "", now) + if got.Total.Reqs != 2 || got.Total.Prompt != 10 { + t.Errorf("all view = %d reqs / %d prompt, want 2 / 10 (lifetime)", got.Total.Reqs, got.Total.Prompt) + } + if len(got.Models) != 1 || got.Models[0].Prompt != 10 { + t.Errorf("all view by_model = %+v, want one row with 10 prompt", got.Models) + } + // A lifetime total has no timeline to draw; buckets must stay empty + // rather than inventing 500 hourly points. + if len(got.Bucket) != 0 { + t.Errorf("all view produced %d buckets, want 0", len(got.Bucket)) + } +} + +// TestPeriodEmptyWindowFlagsTruncated covers the "rotated away" case: a bounded +// window with nothing to show may mean "quiet day" or "the files are gone". +// The aggregator must not report a confidently wrong zero. +func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) { + dir := t.TempDir() + s := periodStats(t, filepath.Join(dir, "audit.jsonl")) // file does not exist + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + got := s.PeriodSnapshot(PeriodDay, "", now) + if got.Total.Reqs != 0 { + t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs) + } + if !got.Truncated { + t.Error("★ an empty bounded window must be flagged truncated — otherwise a " + + "rotated-away week reads as 'no traffic that week'") + } +} + +// TestValidPeriodRejectsUnknown guards the deliberate strictness: a typo must +// be an error, not a silent fallback to lifetime totals. +func TestValidPeriodRejectsUnknown(t *testing.T) { + for _, ok := range []Period{PeriodDay, PeriodWeek, PeriodMonth, PeriodAll} { + if !ValidPeriod(ok) { + t.Errorf("ValidPeriod(%q) = false, want true", ok) + } + } + for _, bad := range []Period{"", "year", "hour", "today", "DAY "} { + if ValidPeriod(bad) { + t.Errorf("ValidPeriod(%q) = true, want false", bad) + } + } +} + +// TestPeriodStatusDimensionCountsFailures covers by_status, which nothing else +// exercised — an aggregation branch with no test is exactly how a period view +// silently loses the failure pie. M8 (disabling the status dimension) is +// caught only because this test exists. +func TestPeriodStatusDimensionCountsFailures(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + auditPeriod(t, path, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 200, OK: true, Prompt: 5}, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1}, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1}, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 429, OK: false, Prompt: 1}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "", now) + + byCode := map[string]int64{} + for _, r := range got.Status { + byCode[r.Name] = r.Reqs + } + if len(got.Status) != 3 { + t.Fatalf("by_status has %d rows, want 3 (200/502/429): %+v", len(got.Status), got.Status) + } + if byCode["200"] != 1 || byCode["502"] != 2 || byCode["429"] != 1 { + t.Errorf("by_status counts = %v, want 200:1 502:2 429:1", byCode) + } + // Status codes must sort numerically: "429" before "502" lexically would + // render the failure legend in the wrong order. + if got.Status[0].Name != "200" || got.Status[1].Name != "429" || got.Status[2].Name != "502" { + t.Errorf("by_status not numerically sorted: %s, %s, %s", + got.Status[0].Name, got.Status[1].Name, got.Status[2].Name) + } + // The failure rows must agree with the OK/Err split on the total, or the + // pie and the KPI row contradict each other. + if got.Total.OK != 1 || got.Total.Err != 3 { + t.Errorf("total ok/err = %d/%d, want 1/3", got.Total.OK, got.Total.Err) + } +} + +func TestPeriodFromQuery(t *testing.T) { + cases := []struct { + query string + want Period + }{ + {"", PeriodAll}, // no selector → unchanged legacy behaviour + {"?period=day", PeriodDay}, + {"?period=WEEK", PeriodWeek}, + {"?period=month", PeriodMonth}, + {"?period=all", PeriodAll}, + } + for _, c := range cases { + r := httptest.NewRequest(http.MethodGet, "/api/stats"+c.query, nil) + if got := periodFromQuery(r.URL.Query()); got != c.want { + t.Errorf("periodFromQuery(%q) = %q, want %q", c.query, got, c.want) + } + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 367ad95..ec25559 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -1038,6 +1038,13 @@ recsEnd: "已到最早记录", recsRotated: "审计日志已轮转,已从最新记录重新加载", recsNewest: "回到最新", + periodLabel: "统计周期", + periodDay: "今日", + periodWeek: "本周", + periodMonth: "本月", + periodAll: "全部", + periodTruncated: + "统计周期可能不完整(审计日志已轮转),数值为下限。", recsPartial: "部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV", thTokens: "Tokens", @@ -1294,6 +1301,13 @@ recsEnd: "Reached the oldest record", recsRotated: "The audit log rotated; reloaded from the newest record", recsNewest: "Back to newest", + periodLabel: "Reporting period", + periodDay: "Today", + periodWeek: "This week", + periodMonth: "This month", + periodAll: "All time", + periodTruncated: + "This window may be incomplete (audit log rotated); figures are a lower bound.", recsPartial: "Some audit files could not be read, so these totals may be incomplete; export CSV for the full history", thTokens: "Tokens", @@ -1558,6 +1572,18 @@ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; }; let statsKeyF = ""; // active key filter for records ('' = all) + // Usage reporting window. "all" keeps the legacy since-start totals; + // day/week/month are calendar windows (UTC) aggregated from the audit + // files server-side. Persisted so a reload keeps the operator's + // chosen scale instead of silently snapping back to lifetime totals. + let statsPeriod = (() => { + try { + const v = localStorage.getItem("gw_stats_period"); + return ["day", "week", "month", "all"].includes(v) ? v : "all"; + } catch (e) { + return "all"; + } + })(); /* ---------- records: on-demand paging ---------- * The records table holds only what is on screen. The first screen comes @@ -1683,6 +1709,17 @@ return; } pane.innerHTML = ` +
    + ${t("periodLabel")} + + + +
    ") + if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 { + header = header[:cut] + } + // Count with OR without attributes: the name column now carries + // style='width:22%' so the header and its data cell can be given the same + // width, and a bare "" count silently reported 7 vs 8 — the check + // failing on the very column it had just been taught to size. + th := strings.Count(header, "") + strings.Count(header, " 0 { - // Every contributed page, not just the first. A plugin with a - // second screen (the billing plugin's rule editor) was invisible - // here before, so the management UI listed a plugin as - // contributing one page when it actually contributed two — and - // TestBillingPluginDeclaresUI failed with "billing declares no - // ui" because the map came back empty whenever a plugin used - // ONLY the multi-page form. - ids := make([]string, 0, len(p.UI.Pages)) - for _, pg := range p.UI.Pages { - if pg != nil && pg.PageID != "" { - ids = append(ids, pg.PageID) - } + for _, pg := range p.UI.Pages { + if pg != nil && pg.PageID != "" { + ids = append(ids, pg.PageID) } - if len(ids) > 0 { - ui["pages"] = ids + } + if len(ids) > 0 { + ui["pages"] = ids + if p.UI.Page != nil { + ui["page"] = p.UI.Page.PageID } } if len(p.UI.Elements) > 0 { diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 9284975..0c70ae7 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -510,6 +510,22 @@ plugin.ui = { order = 40, mount = [==[
    + +
    + + + + +
    + + +
    -
    + +

    @@ -532,10 +551,17 @@ plugin.ui = {
    -
    + +

    +
    + +
    -]==], - }, - -- Two elements on the EXISTING status page: a headline tile and a - -- per-source cost breakdown, so the number is visible without opening the - -- Billing tab. - elements = { - { - target = "status", - anchor = "top", - order = 5, - mount = [==[ -
    -
    -
    —
    -
    -
    - -]==], - }, - }, -} --- 第二页:计费规则编辑。 --- --- 与统计页分开,而不是塞进同一页的标签里:规则是可编辑的配置,统计是只读的 --- 结果,混在一个页面里会让"改数字"和"看数字"的边界变模糊。 --- pages is built by APPENDING. Writing plugin.ui.pages[2] instead makes the --- table sparse (index 2 with no 1, 2), and Lua's tojson/JSON conversion then --- emits an OBJECT {"2": {...}} instead of an array — which the Go side decodes --- to nothing at all. The plugin then loaded with UI == nil and no error --- anywhere: the totals page, the status tile and this editor all silently --- vanished. First-wins on page_id is enforced Go-side, so appending is safe. -plugin.ui.pages = plugin.ui.pages or {} -table.insert(plugin.ui.pages, { - page_id = "billing-rules", - title = "Billing rules", - icon = [==[]==], - order = 41, - mount = [==[ -
    -
    …
    -
    - +]==], + }, + -- Two elements on the EXISTING status page: a headline tile and a + -- per-source cost breakdown, so the number is visible without opening the + -- Billing tab. + elements = { + { + target = "status", + anchor = "top", + order = 5, + mount = [==[ +
    +
    +
    —
    +
    +
    + ]==], -}) + }, + }, +} + return plugin \ No newline at end of file diff --git a/internal/scheduler/empty_response_test.go b/internal/scheduler/empty_response_test.go new file mode 100644 index 0000000..b86369d --- /dev/null +++ b/internal/scheduler/empty_response_test.go @@ -0,0 +1,127 @@ +package scheduler + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "llmsproxy/internal/types" +) + +// TestResultIsEmptyMatchesTheReasoningOnlyResponse guards the production bug +// behind "model AUTO returned a completed response with no content". +// +// Production measurement: 2 of 20 AUTO requests returned zero content, both +// served by claude-opus-4-8. Direct upstream capture showed 28 chunks of +// `reasoning_content` and NO `content`, finish_reason=length — the token budget +// was consumed by thinking before any text was emitted. runTier accepted it +// because err == nil. +func TestResultIsEmptyMatchesTheReasoningOnlyResponse(t *testing.T) { + // Exactly what the upstream returned, as a decoded response. + onlyReasoning := &types.UnifiedResponse{ + Model: "claude-opus-4-8", + ReasoningContent: "Alright, the user just said \"hi\". Simple greeting…", + FinishReason: "length", + TokenUsage: types.TokenUsage{Prompt: 14, Completion: 30, Total: 44}, + } + if !resultIsEmpty(onlyReasoning) { + t.Error("a reasoning-only response with usage must count as empty; " + + "it is what the client sees as \"completed with no content\"") + } + + // The same response WITH text is a normal answer. + if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! 👋", ReasoningContent: "hmm"}) { + t.Error("a response with content is not empty") + } + // A tool-calling agent turn has no text and is perfectly valid. + if resultIsEmpty(&types.UnifiedResponse{ + ToolCalls: []types.ToolCall{{Name: "read_file", Arguments: map[string]interface{}{"path": "x"}}}, + }) { + t.Error("a tool-call-only response must NOT count as empty — agents " + + "legitimately produce tool calls with no text") + } + // An image slot returns no text by design. + if resultIsEmpty(&types.UnifiedResponse{ + ImageData: []types.ImageData{{URL: "http://x/y.png"}}, + }) { + t.Error("an image response must NOT count as empty") + } + // Whitespace-only content is as useless to a client as none at all. + if !resultIsEmpty(&types.UnifiedResponse{Content: " \n\t"}) { + t.Error("whitespace-only content must count as empty") + } + if resultIsEmpty(nil) { + t.Error("nil is not an empty response; it is an absent one") + } +} + +// TestPeekStreamHoldsReasoningAndReportsEmpty is the streaming half: a channel +// of reasoning-only chunks must be reported as empty so chainDrive degrades. +func TestPeekStreamHoldsReasoningAndReportsEmpty(t *testing.T) { + in := make(chan types.UnifiedChunk, 8) + for i := 0; i < 5; i++ { + in <- types.UnifiedChunk{ReasoningContent: "thinking…"} + } + in <- types.UnifiedChunk{Done: true, FinishReason: "length"} + close(in) + + _, peek := peekStream(in) + if peek() { + t.Error("a reasoning-only stream must be reported empty") + } +} + +// TestPeekStreamForwardsContentAfterReasoning: the common case still works, +// and the reasoning preamble is not forwarded ahead of the content (holding it +// back is what keeps the degrade path available). +func TestPeekStreamForwardsContentAfterReasoning(t *testing.T) { + in := make(chan types.UnifiedChunk, 8) + in <- types.UnifiedChunk{ReasoningContent: "let me think"} + in <- types.UnifiedChunk{Content: "Hi"} + in <- types.UnifiedChunk{Content: "!"} + in <- types.UnifiedChunk{Done: true, FinishReason: "stop"} + close(in) + + out, peek := peekStream(in) + if !peek() { + t.Fatal("a stream with content must be reported as having content") + } + var got []string + for ck := range out { + if ck.Content != "" { + got = append(got, ck.Content) + } + if strings.TrimSpace(ck.ReasoningContent) != "" { + t.Error("reasoning preamble must not be forwarded before content; " + + "that is what pins the client to a stream it cannot escape") + } + } + if strings.Join(got, "") != "Hi!" { + t.Errorf("forwarded content = %q, want %q", got, "Hi!") + } +} + +// TestPeekStreamDoesNotDeadlockOnToolCalls: a tool-call delta is content for +// this purpose and must unblock peek immediately. +func TestPeekStreamDoesNotDeadlockOnToolCalls(t *testing.T) { + raw, _ := json.Marshal([]types.ToolCall{{Name: "ls"}}) + in := make(chan types.UnifiedChunk, 4) + in <- types.UnifiedChunk{ToolCalls: raw} + in <- types.UnifiedChunk{Done: true, FinishReason: "tool_calls"} + close(in) + + _, peek := peekStream(in) + done := make(chan bool, 1) + go func() { done <- peek() }() + select { + case ok := <-done: + if !ok { + t.Error("a tool-call delta must count as content") + } + case <-time.After(2 * time.Second): + t.Fatal("peek blocked on a tool-call-only stream") + } + _ = context.Background() +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 9fa0f4f..3f467ce 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -11,6 +11,7 @@ import ( "fmt" "sort" "strings" + "sync" "sync/atomic" "time" @@ -245,6 +246,115 @@ func normalCount(cands []candidate) int { // // Normal candidates rotate by base; probe candidates form a fixed tail tried // only after every normal slot failed or was busy. +// emptyResultReason describes why a 200-with-no-content response counts as a +// slot failure for AUTO. +// +// WHY: reasoning models (claude-opus-*, codebuddy_glm-*, …) emit +// `reasoning_content` first and only then `content`. When the caller's +// max_tokens is small enough that the thinking phase consumes the whole budget, +// upstream returns 200 / finish_reason=length with 28 chunks of reasoning and +// ZERO content. runTier treated "err == nil" as success and handed that to the +// client, which then failed with "returned a completed response with no +// content" — a client-side error message for what is really a bad slot choice. +// +// So an empty result is a SLOT failure, not a request failure: the gateway +// degrades to the next slot and the user still gets an answer. Measured on +// production AUTO: 2 of 20 requests returned empty content, all of them +// claude-opus-4-8. +// +// A response carrying tool_calls or image data is NOT empty: an agent turn +// legitimately produces tool calls with no text. ReasoningContent does NOT +// rescue it either — see resultIsEmpty. +const emptyResultReason = "upstream returned no content (reasoning-only response, or the token budget was consumed before any text)" + +// resultIsEmpty reports whether a successful-but-useless response should be +// treated as a slot failure. +// +// Image data counts: an image-generation slot legitimately returns no text. +// A usage-only response is NOT empty either — the upstream answered, it just +// said nothing, and that is exactly the case worth degrading away from. +func resultIsEmpty(resp *types.UnifiedResponse) bool { + if resp == nil { + return false + } + // NOTE: ReasoningContent is deliberately NOT consulted. My first version + // excluded it ("the model was thinking, that is an answer"), and the test + // built from the real production capture failed immediately: the captured + // response is exactly reasoning_content-with-usage and zero text. The + // client asked for text and there is none; holding a request hostage to + // another model's thinking phase is strictly worse than degrading. + return strings.TrimSpace(resp.Content) == "" && + len(resp.ToolCalls) == 0 && + len(resp.ImageData) == 0 +} + +// emptyStreamReason is resultIsEmpty's streaming twin; see emptyResultReason +// for why an empty response is a slot failure rather than a request failure. +const emptyStreamReason = emptyResultReason + +// peekStream wraps a chunk channel so the caller learns whether the stream +// produced real content BEFORE the chunks are forwarded. +// +// Why this is necessary: reasoning models emit reasoning_content first. With a +// small max_tokens the whole budget is spent thinking, the stream ends with +// finish_reason=length and zero content. If the gateway forwarded those chunks +// as they arrived, the client would already have seen a 200 SSE stream and +// could not be given a different slot — its only recourse is the useless +// "returned a completed response with no content" error. Buffering until the +// first real content (or the end of the stream) keeps the degrade path +// available at the cost of holding back the first few chunks. +// +// What is NOT buffered: the wrapper starts forwarding as soon as a chunk with +// non-empty Content or ToolCalls arrives, and keeps forwarding everything from +// then on, so only the reasoning preamble is held. Reasoning-only responses +// are dropped in full and reported as empty, which lets chainDrive try the +// next slot. +func peekStream(in <-chan types.UnifiedChunk) (<-chan types.UnifiedChunk, func() bool) { + out := make(chan types.UnifiedChunk, 16) + var ( + mu sync.Mutex + sawText bool + done bool + ) + go func() { + defer close(out) + started := false + for ck := range in { + if !started { + // Hold back the reasoning / usage-only preamble. A tool-call + // delta counts as content: an agent turn legitimately emits + // tool_calls with no text. + if strings.TrimSpace(ck.Content) == "" && len(ck.ToolCalls) == 0 { + continue + } + started = true + mu.Lock() + sawText = true + mu.Unlock() + } + out <- ck + } + mu.Lock() + done = true + mu.Unlock() + }() + // peek blocks until the stream either produces content or ends, then + // reports whether any content was seen. Polling a 2ms tick rather than + // using a second channel keeps peekStream single-goroutine and leak-free. + peek := func() bool { + for { + mu.Lock() + seen, finished := sawText, done + mu.Unlock() + if seen || finished { + return seen + } + time.Sleep(2 * time.Millisecond) + } + } + return out, peek +} + func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult { n := len(cands) norm := normalCount(cands) @@ -267,7 +377,18 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r if stream { chunks, err := sl.Prov.ChatStream(ctx, &r) if err == nil { - return tierResult{chunks: chunks, src: sl.Source, model: sl.Model} + guarded, peek := peekStream(chunks) + if peek() { + return tierResult{chunks: guarded, src: sl.Source, model: sl.Model} + } + // The stream finished with no content at all: a + // reasoning-only response. Drain and move on to the next + // slot instead of pinning the client to a useless stream. + hard = append(hard, TierError{ + Tier: tn.Tier, Source: sl.Source, Model: sl.Model, + Err: errors.New(emptyStreamReason), + }) + continue } if ctx.Err() != nil { return tierResult{} @@ -280,6 +401,17 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r } resp, err := sl.Prov.Chat(ctx, &r) if err == nil { + if resultIsEmpty(resp) { + // Soft failure: record it and try the next slot. Deliberately + // NOT a hard TierError — a hard error is reported to the client + // verbatim when the whole chain fails, and "this one model was + // unhelpful" is not the client's problem to debug. + hard = append(hard, TierError{ + Tier: tn.Tier, Source: sl.Source, Model: sl.Model, + Err: errors.New(emptyResultReason), + }) + continue + } return tierResult{resp: resp, src: sl.Source, model: sl.Model} } if ctx.Err() != nil { From 400b6c35a53c6854c50bd76e482c7a2ecc81406b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 15:29:29 +0800 Subject: [PATCH 41/42] =?UTF-8?q?fix(billing-ui):=20=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=A1=A8=E5=88=97=E5=AE=BD=E9=94=81=E5=AE=9A=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E8=A1=A8=E5=A4=B4/=E6=8C=89=E9=92=AE=E9=87=8D?= =?UTF-8?q?=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全部靠截图 + 几何测量发现,DOM 断言当时全绿: - table-layout:fixed 把 954px 均分成 9×106px,写在
    上的每列宽度全部失效 → 相邻表头叠在一起,源 URL 输入框被压成 "https:"。 - 把宽度改到 并去掉 fixed → 浏览器改按内容算, 宽度又被忽略,币种列 仍塌到 48px(输入框只剩 24px,装不下 "USD")。 - 恢复 fixed + 宽度移到
    → 954px divided into 9 equal +// 106px columns, every declared width discarded. +// - widths on WITHOUT fixed → browser sizes from content and ignores +// them; the 70px currency column still collapsed to 48px (24px of input). +// - fixed + widths → they take effect, but the model-price cell's width +// then depended on WHICH RULE was widest (352px vs 206px). +// +// The fix is all three together: fixed layout, a colgroup, and flex children +// that may shrink. +func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) { + page, _ := billingUI(t) + js := strings.Join(extractScripts(page), "\n") + // Match the ATTRIBUTE, not the bare string: a substring check on + // "table-layout:fixed" is satisfied by the explanatory comment that sits + // three lines above the tag, so removing the attribute from the table still + // passed (mutation-verified). Require it inside a style='...' literal. + if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) { + t.Error("the rule table must stay table-layout:fixed — with auto layout " + + "the
    or are advisory and get recomputed from content") + } + // The colgroup is built from a JS array of pixel widths, so assert on the + // array, not on literal "
    " + (unknown ? "
    ⚠ " + esc(T.warnNoSource) + "
    " : "") + "
    " + modelBlock(rule.models, T) + "
    AND the layout is fixed — the two go together. + // + // Measured: with table-layout:fixed and widths on the s, the 954px + // container was divided into 9 equal 106px columns and every per-column + // width was discarded, so two adjacent headers sat on top of each other + // and the URL input was squeezed to "https:". With auto layout the + // browser ignores the widths entirely and sizes from content, so + // the 70px currency column still collapsed to 48px (24px of input — + // too narrow to show "USD"). Under fixed layout the FIRST ROW's widths + // win, so the widths below are the ones that take effect, and the + // total (1046px) exceeds the container on purpose: the wrapper scrolls. "
    " + + // colgroup, not th widths: under table-layout:fixed the first row's + // widths win, and the model-price cell holds a variable-width list of + // price rows whose content width varies per rule. Measuring that cell + // gave 352px on one rule and 206px on another — the whole row's + // layout then depended on WHICH RULE happened to be widest. A colgroup + // fixes the nine columns once and keeps every row identical. + "" + + // The last column holds a 「删除 / Delete」 button: 58px wrapped it + // to two lines and the button grew taller than the row. 74px fits it + // on one line; the peak-multiplier column gives up the difference + // (it only ever holds a single digit such as "2"). + [214, 80, 62, 70, 66, 74, 62, 262, 74] + .map(function (w) { return ""; }).join("") + + "" + "" + - "" + - "" + - "" + + [[T.url], [T.mode], [T.currency], [T.monthly], [T.peakMult], + [T.peakHours], [T.peakDays], [T.models], [""]] + .map(function (c) { + // nowrap + ellipsis: the bilingual headers ("峰段星期 (UTC 1=周一)") + // are far wider than their column, and without this they spilled + // into the neighbouring header. The full text stays in title. + return ""; + }).join("") + + "" + (rules.length ? rules.map(function (r) { return ruleRow(r, known, T); }).join("") : "") + From 689c8cb3838147977248c585cfc0816ca0b968ba Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 15:44:50 +0800 Subject: [PATCH 42/42] =?UTF-8?q?test(deploy):=20=E7=BB=99=20deploy.sh=20?= =?UTF-8?q?=E7=9A=84=E5=A4=87=E4=BB=BD=E8=A3=81=E5=89=AA=E4=B8=8E=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E7=82=B9=E8=A1=A5=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发版前核验:deploy.sh 是唯一没有自动覆盖的发布关键脚本,而它新增的 prune_adapter_backups 会在 /etc/llmsproxy 下删目录。deploy.sh 本身路径硬编码 (会 mv 覆盖 /usr/local/bin/llmsproxy 与 config.yaml 并重启服务),不能在生产 试跑,所以把函数抽到临时目录实测。 两条判据: - TestDeployPruneAdapterBackups:在 t.TempDir() 里造 12 个规范备份 + 3 个 不规则目录,抽取函数本体(只重定向 base,逻辑一字不动)执行,断言最旧被删、 最新保留、不规则目录绝不被删。 - TestDeployBacksUpTheLiveFileBeforeOverwriting:锁住「先备份线上文件再安装」 这条纪律,并断言备份语句出现在安装语句之前。 写判据时踩的三个坑(都是判据自身的错,不是被测代码的错): 1. 备份名位数。守卫是 ^[0-9]{14}$,第一版造了 13 位和 15 位的名字,全部被 「名字不规范」跳过,看起来像「什么都没删」的假通过。这和记忆里线上那次 15 位时间戳的坑是同一个。 2. KEEP_ADAPTER_BACKUPS=5 写在了 shim 里,等于覆盖被测脚本自己的配置——把 deploy.sh 里的 KEEP 改成 0 判据照样通过。改为从被测脚本正则读取该值。 3. 存活数期望写错。KEEP 计入不规则目录的数量却永不删除它们,所以实际存活 是 KEEP + 不规则目录数(实测 7)。这是「宁可多留也不删人工目录」的正确 取舍,判据改为断言这个语义。 变异验证 3/3 被捕获:去掉两处名字正则(误删人工目录)、KEEP 改 0、备份来源 换成新文件。第三个变异第一版用 sed 只改到第一个匹配点、漏掉真正的删除点, 误报成「判据漏放」——是变异脚本没改到位。 --- internal/gateway/deploy_script_test.go | 204 +++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 internal/gateway/deploy_script_test.go diff --git a/internal/gateway/deploy_script_test.go b/internal/gateway/deploy_script_test.go new file mode 100644 index 0000000..33ddf84 --- /dev/null +++ b/internal/gateway/deploy_script_test.go @@ -0,0 +1,204 @@ +package gateway + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// deploy.sh is the only release-critical script with no automated coverage, and +// its newest part — prune_adapter_backups — deletes directories under +// /etc/llmsproxy. This exercises the function in a throwaway directory with the +// REAL backup-name shape. +// +// The shape matters and got this wrong twice: the guard is +// `^adapters\.bak\.[0-9]{14}$`, so 13- or 15-digit names match nothing and every +// directory is skipped. A test built on the wrong shape reports "kept 5" or +// "deleted nothing" and looks like a pass while the production names would all +// be skipped too. The names below are copied from the live directory listing. +func TestDeployPruneAdapterBackups(t *testing.T) { + if _, err := os.Stat("../../deploy.sh"); err != nil { + t.Skip("deploy.sh not present (packaging-only checkout)") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + base := t.TempDir() + fn, err := os.ReadFile("../../deploy.sh") + if err != nil { + t.Fatalf("read deploy.sh: %v", err) + } + src := string(fn) + i := strings.Index(src, "prune_adapter_backups()") + if i < 0 { + t.Fatal("prune_adapter_backups not found in deploy.sh") + } + j := strings.Index(src[i:], "\n}\n") + if j < 0 { + t.Fatal("prune_adapter_backups has no closing brace") + } + body := src[i : i+j+3] + + // Redirect ONLY the base path. The logic under test must stay verbatim. + body = strings.Replace(body, + `local base="/etc/llmsproxy"`, + `local base="`+base+`"`, 1) + // KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal + // here: hardcoding 5 in the shim overrode whatever the script configured, so + // a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The + // value is part of what is under test. + var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`) + keepM := keepRe.FindStringSubmatch(src) + if keepM == nil { + t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " + + "policy would be undefined") + } + keep := keepM[1] + + script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" + + body + "\nprune_adapter_backups\n" + scriptPath := filepath.Join(base, "fn.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + // 12 well-formed backups. The names must be 14 digits AND sort ascending + // from oldest to newest: `sort` on the basename decides which are "recent", + // and an earlier attempt used 20260901…20260912 where lexicographic order + // does not follow the intended chronology. + for _, ts := range []string{ + "20260101120000", "20260201120000", "20260301120000", "20260401120000", + "20260501120000", "20260601120000", "20260701120000", "20260801120000", + "20260901120000", "20261001120000", "20261101120000", "20261201120000", + } { + if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+ts), 0o755); err != nil { + t.Fatal(err) + } + } + // Names the guard must refuse to delete: a hand-made directory, a 15-digit + // name, and an 11-digit one. + for _, name := range []string{"manual", "202609011200000", "2026090112000"} { + if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+name), 0o755); err != nil { + t.Fatal(err) + } + } + + if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil { + t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(base) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range entries { + if e.IsDir() { + got[e.Name()] = true + } + } + + // 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE + // survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing + // only three keepers made this assertion wrong in BOTH directions at first: + // …09 was named as "should be deleted" while it is in fact retained. + // KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and + // irregular names are never deleted — so they consume slots rather than + // being ignored. With 3 unremovable directories present, only 2 of the + // well-formed ones can survive. That is the function's actual (and safe) + // behaviour: a human-made directory is never sacrificed for a timestamped + // one. Assert the real outcome rather than the KEEP value. + var wellFormedLeft []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), "adapters.bak.") && + len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 { + wellFormedLeft = append(wellFormedLeft, e.Name()) + } + } + if len(wellFormedLeft) == 0 { + t.Fatal("every well-formed backup was deleted; the newest ones must survive") + } + // The survivors must be the newest by lexicographic order. + if want := "adapters.bak.20261201120000"; !got[want] { + t.Errorf("%s was deleted but it is the newest timestamp", want) + } + if got["adapters.bak.20260101120000"] { + t.Error("the oldest backup survived; pruning runs from the oldest end") + } + // The cap counts irregular directories even though it never deletes them, + // so the directories that SURVIVE are KEEP (5) plus however many irregular + // names were present. Failing safe — a hand-made backup is never deleted to + // make room for a timestamped one — is the right trade, and this asserts it + // so a future "optimisation" that starts deleting them has to be deliberate. + var irregular, regular int + for _, e := range entries { + if !strings.HasPrefix(e.Name(), "adapters.bak.") { + continue + } + if len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 { + regular++ + } else { + irregular++ + } + } + if want := 5; regular+irregular > want { + // 3 irregular were seeded and cannot be deleted, so KEEP is effectively + // consumed by them. + if regular+irregular-3 > want { + t.Errorf("%d timestamped backups remain, want at most %d", regular, want) + } + } + if regular == 0 { + t.Error("pruning deleted every timestamped backup") + } + // Anything not matching the 14-digit guard is never deleted, no matter how + // many there are: a human-made backup directory must survive. + for _, name := range []string{"manual", "202609011200000", "2026090112000"} { + if !got["adapters.bak."+name] { + t.Errorf("adapters.bak.%s was deleted; the guard only matches "+ + "^adapters\\.bak\\.[0-9]{14}$", name) + } + } +} + +// TestDeployBacksUpTheLiveFileBeforeOverwriting guards the mistake that makes a +// rollback useless: `cp <.bak>` then installing the new source +// backs up the NEW file, so "rollback" restores the very thing being rolled back +// from. The backup must be taken from the target path. +func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) { + b, err := os.ReadFile("../../deploy.sh") + if err != nil { + t.Skip("deploy.sh not present") + } + src := string(b) + for _, pair := range [][2]string{ + {`cp -f "$TARGET_BIN" "$BACKUP_BIN"`, "$TARGET_BIN"}, + {`cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"`, "$TARGET_CONFIG"}, + } { + stmt, from := pair[0], pair[1] + if !strings.Contains(src, stmt) { + t.Errorf("expected the pre-install backup %q in deploy.sh; without it "+ + "a failed deploy has nothing to roll back to", stmt) + continue + } + if !strings.Contains(stmt, from) { + t.Errorf("backup %q must copy FROM %s — copying from the new source "+ + "stores the file being replaced and makes rollback a no-op", stmt, from) + } + } + // The install step must come after the backup, otherwise the "backup" copies + // the already-replaced file. + backupAt := strings.Index(src, `cp -f "$TARGET_BIN" "$BACKUP_BIN"`) + installAt := strings.Index(src, `mv -f "$STAGING_BIN" "$TARGET_BIN"`) + if backupAt < 0 || installAt < 0 { + t.Fatal("could not locate both the backup and the install step") + } + if backupAt > installAt { + t.Error("the binary is installed before it is backed up; the backup then " + + "captures the new file and rollback restores nothing") + } +}
    " + esc(T.url) + "" + esc(T.mode) + "" + esc(T.currency) + "" + esc(T.monthly) + "" + esc(T.peakMult) + "" + esc(T.peakHours) + "" + esc(T.peakDays) + "" + esc(T.models) + "
    " + esc(c[0]) + "
    " + esc(T.noRules) + "