Compare commits

...

8 Commits

Author SHA1 Message Date
5ccf068db3 chore(gui): 版本号 2.0.48(与内核同版本) 2026-10-20 18:33:29 +08:00
b4c5f0f0fc refactor(billing): 计费语义下沉到 Lua(内核只解 YAML/传数据)+ 采纳 PR #2 语义
按 issue #1 的修复方向(维护者评论):内核不再持有任何计费语义,billing 逻辑
全部在 `plugins/billing.lua`;内核只负责解 config.yaml、校验 schema、把
profile + 源 base_url 作为**数据**交给插件,再把插件算出的价目通过 `prices`
侧车注入。计费账目仍持久化在插件状态文件(`plugin_dir/.billing.state.json`)。

## 变更
- **删除** `internal/billing`(compile.go 359 行 + 其 Go 测试);
- 新增 `core.compileBillingPrices`:把 profile/sources JSON 化后调用插件导出
  `compile_prices`,返回价目表 + 未匹配规则清单(供错误日志点名 URL);
- `applyBillingDSL` / 规则编辑器改为走同一个插件编译入口(编辑器 lenient,
  半成品规则可保存、可报告);
- `billing.lua` 新增 `compile_prices`:URL 归一匹配、首条命中优先、
  free/subscription/unpriced/token、USD/M→per-token、peak 窗口、
  long_context 边际价、reasoning 可选价、default 策略、未匹配清单。
  错误通过载荷 `error` 字段回传(插件导出第二返回值通道不可靠)。

## 与 PR #2(valuelesser)合并后的语义
- **token 规则不产生任何源条目**:未列价的模型落到 unpriced 统计(可见),
  不再"静默计 $0"。比 issue 正文里的 `{per_request:0}` 标记更收口;
- `default: free` 由**编译期**给未匹配源写 `{per_request:0}` 标记;
  `unpriced`/缺省不写。删除无人读取的 `default_mode` 死键;
- 保留 `unmatched_rules` 清单(只用于降级应用时点名过期 URL)。

## 判据(原 Go 单测平移到 Lua + PR #2 的用例)
- `internal/lua/billing_compile_test.go`:通配符不标记源、token 规则无源条目、
  任意源条目不得携带 token 价(根因直防)、free/subscription 保留有意零价、
  reasoning 缺席、long_context 三种形态、peak 形状、URL 归一+首条优先、
  lenient 未匹配清单、硬错误 5 例、default 两态;
- `internal/lua/billing_test.go`:编译输出驱动真实 priceFor 的 A/B 用例
  (旧形状 cost=0 / 新形状 0.75 且 unpriced=0)、default 编译期语义 e2e;
- `internal/gateway`:token 规则断言"无源条目"、模型价 per-token 量级;
- `internal/core`:过期规则 URL 降级应用(有效规则仍生效)。
2026-10-20 18:33:26 +08:00
442fa5e3f6 chore(gui): 版本号 2.0.47(与内核同版本) 2026-10-20 18:05:19 +08:00
0caa37ea6a fix(billing): 过期规则 URL 不再让整个 profile 失效(降级应用 + 点名 URL)
生产现网发现(issue #1 排查):billing.rules 里一条指向
https://opencode.ai/inference/v1 的规则,在该源 base_url 变成
.../inference/openai/v1 之后匹配不到任何源;严格编译因此让**整个** profile
不生效 —— 账单恒 0、unpriced_reqs 涨到 3.7 万,唯一痕迹是一行启动日志。

- 严格编译失败时降级为 lenient:应用能匹配的规则,其余按 profile default;
- 日志用 ERROR 点名**具体哪条 URL** 写错(新增 billing.UnmatchedRuleURLs),
  并说明未匹配流量的记账去向;
- 判据 TestBillingStaleRuleURLStillAppliesOtherRules:一条过期规则 + 一条有效
  规则 ⇒ 有效规则必须仍然生效。
2026-10-20 18:04:43 +08:00
33cb2bdb06 fix(billing): 源层零价不再覆盖模型价(issue #1)+ default_mode 生效 + 文档/判据补齐
GitCode issue #1:任何按 `mode: token` 且 URL 非通配符的规则定价的源,计费恒为 0,
且 `unpriced_reqs` 也不增长 —— 完全静默。

## 真因
- `compile.go` 给这类规则写源条目 `{prompt:0, completion:0, per_request:0}`,
  本意只是"标记该源已定价"(同源未列入价目表的模型不算 unpriced);
- `billing.lua` 的 priceFor() 源层在模型层之后、且 `sp.prompt ~= nil` 即覆盖;
- 两者叠加 ⇒ 模型价被清成 0,而 `out.priced` 仍为 true ⇒ 连未定价计数都不涨。

## 修法
1. `compile.go`:token 规则的源条目只写 `{per_request: 0}`(只是标记,不参与 token
   计价)。`free` / `subscription` 保留全零(那里 0 本身就是语义)。
2. `billing.lua`:
   - `priceFor()` 的 `out` 补 `local`(此前是 VM 级全局变量,跨请求残留);
   - 实现 `profiles[].default` 的 `free|unpriced` 语义:`free` = 刻意免费(不进
     unpriced),`unpriced`(默认)= 未定价债务。此前 compile.go 写 `default_mode`
     但**无人读**,两种取值运行时完全等价。
3. `core.go`:应用 profile 时把 `unmatched_sources` 打进日志(WARNING 列出没有
   规则命中的源)。该字段此前只写不读,是第二个静默少报点。
4. `docs/plugins.md`:token 价优先级改为与实际实现一致的
   `keys > sources > models > default`,并写明"标记已定价只能用 per_request:0"。

## 判据(都先红后绿)
- `internal/billing`:token 规则的源条目**不含** prompt/completion;free/subscription
  仍保留显式 0。
- `internal/lua`:同源同模型下旧形状 cost=0(缺陷本身)、新形状 cost=0.75 且
  unpriced_reqs=0;未列入价目表的模型仍算"已定价";default_mode 三态。
- `internal/gateway`:`billing_rules_api_test` 的单位断言原本读
  `cc["models"]`(恒 false,整块被跳过——从未执行过),改为 `prices["models"]`,
  并新增"源条目不得带 token 价"断言。

## 顺带修掉一个"永不执行"的判据
`discover_mutate_test.go` 的变异锚点写成 6 个空格,而 `discover.go` 已是 2 个空格
⇒ 该变异每次都 `anchor not found`,变异 campaign 长期红着。修正锚点后,
"嵌套 metadata 不再读取"这条变异真的被执行并被测试 KILL。
2026-10-20 17:46:52 +08:00
0ae7d2c8be test(retry): 钉住声明式重试判据与流式握手重试语义
- config:ShouldRetry/DelayDuration 表驱动(nil/次数=1/状态不匹配/body 不匹配/
  空子串/多信号/非法间隔)
- provider:命中策略就重试直到 200、body 不匹配**不许**重试(400 也常用于真错误)、
  流式只重试握手(首帧之后不重试,避免重复输出)
2026-10-20 10:45:56 +08:00
02c01582f1 chore(gui): 版本号 2.0.46(与内核同版本) 2026-10-20 10:38:07 +08:00
6454b094bc fix(codearts): 上游限流改为可声明重试(内核通用能力)+ 会话/连接两处真因
用户连续报「codeart 还是不可用 / empty stream」。把上游原始响应打出来逐条对齐,
三个症状同源,且都不是"模型坏了":

| 场景 | 上游实际返回 |
|---|---|
| 流式、短时间连续请求 | HTTP **200** + 只带 error 的帧 ⇒ 内核判 `empty stream` |
| 非流式、同一条件 | HTTP 400 `TM.00001041: 并发会话数已达上限(3个)` |
| 突发请求 | HTTP 429 `TPM limit … 500000 tokens per minute` |

实测:最后一个请求结束后约 **44~60 秒**释放;同一账号连续请求 3~4 次即占满。
参考实现(示例插件 llm-adapter.ts)的做法是**每 10s 轮询后重试 chat**(最多 30
分钟),所以用户那边"看起来没事"。

## 我们的改法(内核只做通用机制,厂商知识留在插件)
1. **内核新增声明式重试策略** `source.retry`(纯数据:状态码列表 + 响应体子串 +
   间隔 + 次数)。内核不认识任何厂商;命中即在同一网关请求内每 10s 重试一次。
   流式只在**握手阶段**重试(首帧尚未下发,不会重复输出),非流式整体重试。
2. **插件为 codearts 声明**:400/429 + 命中 `TM.00001041` / `TPM limit` /
   `并发会话数已达上限` → 10s × 最多 18 次(≈3 分钟)。
3. **适配器补两个会话细节**:`Connection: close`(keep-alive 空闲连接会被上游
   计入并发会话)与稳定 `Session-Id`(同一账号恒定,避免每次请求新建会话)。
4. 错误文案补可行动提示(并发会话上限 / TPM 限流,约 1 分钟释放)。

## 验证(生产)
- 连续 6 次**流式**:6/6 成功(撞上限时自动等 10s 再试,客户端只感觉慢):
  `+0s / +12s / +34s / +86s / +100s / +144s`
- 连续 5 次**非流式**:5/5 成功(`+0 / +11 / +20 / +28 / +38s`)
- 修复前:第 4 个请求起必然 TM.00001041 / empty stream。

> 注:`TM.00001005 无正常可用套餐` 属于账号权益问题(不重试、如实报出),
> 与限流要分开处理。
2026-10-20 10:38:00 +08:00
19 changed files with 1437 additions and 574 deletions

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "2.0.45",
"version": "2.0.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "2.0.45",
"version": "2.0.48",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "2.0.45",
"version": "2.0.48",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -503,15 +503,46 @@ end
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
### 7.0 分层:计费语义在插件里,内核只给数据
`config.yaml` 的 `billing:` 是**声明**,真正的语义(规则怎么变成价目表、
覆盖顺序、未定价判定、峰谷/长上下文)全部在 `plugins/billing.lua`:
| 层 | 职责 |
|---|---|
| 内核 | 解 YAML、校验 DSL schema(`BillingProfile`/`BillingRule`);把 **profile + 每个源的 `base_url`** 作为数据交给插件;把插件算出的价目注入 `prices` 侧车;把计费记录持久化到插件状态文件 |
| 插件 | `compile_prices(profile, sources)`:URL 归一匹配、首条命中优先、`free`/`subscription`/`unpriced`/`token` 语义、USD/M → per-token、`peak` 窗口、`long_context` 边际价、`reasoning` 可选价、未匹配规则/源清单;`priceFor`/`costFor` 与账目累计 |
两条理由(GitCode issue #1):
1. 语义只写一处。曾经的 `compile.go`(Go)与 `priceFor`(Lua)各有一套覆盖
语义,组合起来把模型价静默清零 —— 账单加总正常、只是少报。
2. 插件沙箱**没有文件系统**,所以“插件直接读 config.yaml”在设计上不可行;
内核把解码后的 profile 作为钩子/接口传入,就是这里的分层形态。
**计费记录持久化**:插件状态(含累计账目)写在 `plugin_dir/.billing.state.json`
(同目录下 `.<插件名>.state.json`),随每次账目变更异步落盘,重启后恢复;
`prices` 与 `state` 分开存,改价不会抹掉历史账目。
### 7.1 计费维度
| 维度 | 用途 | 优先级 |
| 维度 | 用途 | token 价覆盖顺序 |
|---|---|---|
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
| `models` | 模型的 per-token 价 | 高 |
| `default` | 兜底价(通常全 0) | 最低 |
| `models` | 模型的 per-token 价 | 中 |
| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 |
| `keys` | 单个网关 key 的覆盖价 | **最高** |
**token 价**优先级:`keys` > `models` > `default`。
**token 价**优先级:`keys` > `sources` > `models` > `default`。
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
`{prompt:0, completion:0}` 零价条目;`default: free` 给未匹配源写的是
`{per_request:0}`(只标记、不碰 token 价)。
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
**`cache_discount`** 见 §7.7。
@ -599,6 +630,15 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
它是**编译期**策略(以前被校验后写进无人读取的 `default_mode` 键,两种取值运行时
完全等价):
| `default` | 编译结果 | `unpriced_reqs` |
|---|---|---|
| `unpriced`(省略时) | 未匹配源不写条目 | +1 |
| `free` | 未匹配源写 `{per_request:0}` 标记(只标记、不碰 token 价) | 不增加 |
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
`total` 与各维度,只有金额是 0。

View File

@ -1,323 +0,0 @@
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) {
return CompileOpts(profile, sources, false)
}
// CompileOpts is Compile with an explicit policy for rules that match no
// source.
//
// The default is STRICT and that is correct for startup: a profile with a
// typo'd URL prices nothing, every request on it is recorded as unpriced, and
// the bill silently comes out wrong. Failing the load is the right response.
//
// The rules editor needs the opposite. An operator editing a half-finished
// profile — adding a rule before the source that will use it exists, or
// renaming a URL while typing — must be able to save and then see the warning,
// not be blocked by an error they can only resolve by guessing. Lenient mode
// keeps the offending rule in the config and drops it from the compiled table,
// so it is preserved as text and clearly reported, without pretending it
// prices anything.
func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (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.
if lenient {
continue // kept in the config text, reported as a warning
}
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
}
// reasoning is optional and stays ABSENT (not nil, not 0) when the
// rule does not set it, which is what makes the billing plugin skip
// the repricing branch entirely. Writing an explicit 0 here would
// mean "thinking is free" and silently under-bill every request
// from a provider that bills it like ordinary output.
if strings.TrimSpace(t.Reasoning) != "" {
rp, err := usdPerM(t.Reasoning)
if err != nil {
return fmt.Errorf("model %q reasoning: %w", modelID, err)
}
entry["reasoning_price"] = rp
}
if lc := t.LongContext; lc != nil {
if lc.Above <= 0 {
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
}
above, err := longContextRate(lc, prompt)
if err != nil {
return fmt.Errorf("model %q long_context: %w", modelID, err)
}
entry["long_context"] = map[string]interface{}{
"above": lc.Above,
// Marginal, not blended. The plugin charges the tokens past the
// threshold at this rate and the rest at the base rate, so the
// threshold composes with a cache discount (which applies to the
// cached portion) instead of forcing the plugin to guess how
// the two interact.
"prompt": above,
}
}
if rule.Peak != nil {
entry["peak"] = compilePeak(rule.Peak)
}
models[modelID] = entry
}
// A specific-URL token rule needs a source entry so a model served
// from THIS url but absent from `rule.Models` is still recognised as
// belonging to a priced provider.
//
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
// says "these model ids have these prices, wherever they come from" —
// and marking every source priced at {0,0} would make priceFor() set
// priced=true for every model the catalogue does NOT list. Paid
// sources (commandcode, alittokenplan, …) would then record $0 instead
// of showing up in unpriced_reqs, which is precisely the silent
// zero-billing failure this whole plugin is built to make visible.
// The model lookup p.models[payload.model] already marks a listed
// model priced on its own, so the wildcard loses nothing.
if rule.URL != "*" {
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
}
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
// threshold, given the model's base per-million prompt price.
//
// Two ways to express it are supported because providers publish both: an
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
// silent no-op, and an operator who wrote one meant something.
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
hasMult := lc.Multiplier > 0
switch {
case hasPrompt && hasMult:
p, err := usdPerM(lc.Prompt)
if err != nil {
return 0, err
}
// Both given: the absolute rate wins, because it is the one the provider
// actually quotes. Silently multiplying instead would produce a number
// the operator never wrote.
return p, nil
case hasPrompt:
return usdPerM(lc.Prompt)
case hasMult:
return basePerToken * lc.Multiplier, nil
default:
return 0, fmt.Errorf("needs either prompt or multiplier")
}
}

View File

@ -1,214 +0,0 @@
package billing
import (
"testing"
"llmsproxy/internal/config"
)
// The wildcard token rule must not blanket-mark every source as priced.
//
// Why this is a hazard and not a style question: priceFor() sets priced=true as
// soon as the request's SOURCE has an entry in the price table, and that flag
// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model
// catalogue — it prices specific model ids wherever they come from — but
// compiling it into a {0,0,0} entry on all 21 sources made every model the
// catalogue does not list look "priced at zero". Paid providers (commandcode,
// alittokenplan, …) then stopped appearing in the unpriced warnings, which is
// exactly the silent zero-billing failure the plugin exists to surface.
func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
sources := []config.Source{
{Name: "free1", BaseURL: "https://free1.example.com/v1"},
{Name: "paid1", BaseURL: "https://paid1.example.com/v1"},
{Name: "paid2", BaseURL: "https://paid2.example.com/v1"},
}
profile := &config.BillingProfile{
ID: "p",
Rules: []config.BillingRule{
{URL: "https://free1.example.com/v1", Mode: "free"},
{
URL: "*", Mode: "token",
Models: map[string]config.BillingToken{
"only-catalogued-model": {Prompt: "1.00", Completion: "2.00"},
},
},
},
}
prices, err := Compile(profile, sources)
if err != nil {
t.Fatalf("compile: %v", err)
}
srcs := prices["sources"].(map[string]interface{})
// The explicitly free source keeps its entry (free is a deliberate 0).
if _, ok := srcs["free1"]; !ok {
t.Error("the explicit free rule lost its source entry")
}
// Paid sources must NOT be marked, or their unlisted models bill $0.
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
"models will bill $0 instead of appearing as unpriced", name)
}
}
// The catalogue itself must still be present, or the rule prices nothing.
models := prices["models"].(map[string]interface{})
if _, ok := models["only-catalogued-model"]; !ok {
t.Error("the wildcard rule did not publish its model price")
}
}
// The inverse guard: a token rule for a SPECIFIC url still marks that source,
// because it genuinely declares pricing for the models served from it.
func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
sources := []config.Source{
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
{Name: "other", BaseURL: "https://other.example.com/v1"},
}
profile := &config.BillingProfile{
ID: "p",
Rules: []config.BillingRule{{
URL: "https://mine.example.com/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}},
}
prices, err := Compile(profile, sources)
if err != nil {
t.Fatalf("compile: %v", err)
}
srcs := prices["sources"].(map[string]interface{})
if _, ok := srcs["mine"]; !ok {
t.Error("a specific-url token rule must still mark its own source priced")
}
if _, ok := srcs["other"]; ok {
t.Error("an unrelated source was marked priced by a specific-url rule")
}
}
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
// thinking-token rate.
//
// A rule that says nothing about reasoning must produce a price entry with NO
// reasoning_price key, so the billing plugin skips the repricing branch and
// bills thinking at the ordinary output rate — which is what every provider
// actually charges. Writing an explicit 0 instead would read as "thinking is
// free" and silently under-bill every request from that model.
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
// Case 1: no reasoning declared → the key must not appear.
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}}}
prices, err := Compile(p1, sources)
if err != nil {
t.Fatal(err)
}
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if _, present := entry["reasoning_price"]; present {
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
entry["reasoning_price"])
}
// Case 2: declared → present, and converted to per-token like every other rate.
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
},
}}}
prices2, err := Compile(p2, sources)
if err != nil {
t.Fatal(err)
}
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
rp, ok := e2["reasoning_price"].(float64)
if !ok {
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
}
if rp != 6e-6 {
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
}
// A malformed rate must be rejected, not silently ignored: an operator who
// typed "6 USD" expects either that value or an error, never a rule that
// looks applied and bills nothing.
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
},
}}}
if _, err := Compile(p3, sources); err == nil {
t.Error("a non-numeric reasoning rate must fail the compile")
}
}
// TestLongContextStepCompiles pins the long-context step's two accepted forms
// and its rejection of a step that would silently do nothing.
//
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
// missing: a step with no rate is a rule that LOOKS like it handles long
// context and does not, which under-bills without any visible sign.
func TestLongContextStepCompiles(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
},
}}}
prices, err := Compile(p, sources)
if err != nil {
return nil, err
}
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
return e["long_context"].(map[string]interface{}), nil
}
// Absolute rate (OpenAI's published shape).
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
if err != nil {
t.Fatalf("absolute long_context rate rejected: %v", err)
}
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
}
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
if err != nil {
t.Fatalf("multiplier long_context rejected: %v", err)
}
if lc2["prompt"] != 2e-6 {
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
}
// Both given: the absolute rate wins, because that is the number the
// provider quotes. Multiplying instead would produce a figure the operator
// never wrote.
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
if err != nil {
t.Fatal(err)
}
if lc3["prompt"] != 5e-6 {
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
}
// Neither: a no-op step must fail, not compile into a rule that silently
// bills long context at the base rate.
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
t.Error("a step with neither prompt nor multiplier must be rejected")
}
// A zero threshold would make every request "long".
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
t.Error("above=0 must be rejected")
}
}

View File

@ -95,7 +95,14 @@ type Source struct {
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
// upstreams behind geo/IP blocks that only respond through a proxy.
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
// answer a business-level rejection with a status code the kernel would
// otherwise surface to the client — e.g. a per-account concurrency cap that
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
// the matcher is pure DATA (status list + response-body substrings), so no
// provider name ever appears in core.
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
@ -105,6 +112,63 @@ type Source struct {
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
}
// RetryPolicy describes when a failed upstream attempt may be retried BEFORE
// anything is returned to the client. All conditions are data:
//
// OnStatus - retry only when the HTTP status is one of these
// OnBodyContains - ... AND the response body contains one of these substrings
// (empty = status alone decides)
// Delay - wait between attempts ("10s" etc.); parsed on demand
// MaxAttempts - TOTAL attempts including the first (1 or 0 = no retry)
//
// Scope: the retry happens inside one gateway request, so a stream is only
// retried before its first chunk reaches the client (the kernel already holds
// the first chunk back), and a non-stream call is retried as a whole.
type RetryPolicy struct {
OnStatus []int `yaml:"on_status,omitempty" json:"on_status,omitempty"`
OnBodyContains []string `yaml:"on_body_contains,omitempty" json:"on_body_contains,omitempty"`
Delay string `yaml:"delay,omitempty" json:"delay,omitempty"`
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
}
// ShouldRetry reports whether one failed attempt matches this policy.
func (r *RetryPolicy) ShouldRetry(status int, body string) bool {
if r == nil || r.MaxAttempts <= 1 {
return false
}
statusOK := len(r.OnStatus) == 0
for _, s := range r.OnStatus {
if s == status {
statusOK = true
break
}
}
if !statusOK {
return false
}
if len(r.OnBodyContains) == 0 {
return true
}
for _, sub := range r.OnBodyContains {
if sub != "" && strings.Contains(body, sub) {
return true
}
}
return false
}
// DelayDuration is the configured pause between attempts (default 10s).
func (r *RetryPolicy) DelayDuration() time.Duration {
if r == nil || r.Delay == "" {
return 10 * time.Second
}
d, err := time.ParseDuration(r.Delay)
if err != nil || d < 0 {
return 10 * time.Second
}
return d
}
// Load reads and validates a config file. When the file does not exist yet a
// default config is generated at that path first (first-run bootstrap), so a
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.

View File

@ -0,0 +1,59 @@
package config
import (
"testing"
"time"
)
// ShouldRetry 是纯数据判据:状态码列表 + 响应体子串。它决定"这次失败值不值得
// 在网关内部等一会儿重试"。厂商知识绝不能进内核,所以判据必须能用一个表格测清楚。
func TestRetryPolicyShouldRetry(t *testing.T) {
cases := []struct {
name string
policy *RetryPolicy
status int
body string
want bool
}{
{"nil policy", nil, 500, "anything", false},
{"max_attempts=1 disables", &RetryPolicy{OnStatus: []int{400}, MaxAttempts: 1}, 400, "x", false},
{"status matches, no body filter",
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 429, "", true},
{"status mismatch",
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 400, "429", false},
{"status + body match",
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
400, `{"error_code":"TM.00001041"}`, true},
{"status matches but body does not",
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
400, `{"error_code":"TM.00001005"}`, false},
{"empty substring never matches",
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{""}, MaxAttempts: 3}, 400, "x", false},
{"any of several body signals",
&RetryPolicy{OnStatus: []int{400, 429}, OnBodyContains: []string{"TM.00001041", "TPM limit"}, MaxAttempts: 3},
429, `{"message":"TPM limit exceeded"}`, true},
}
for _, tc := range cases {
if got := tc.policy.ShouldRetry(tc.status, tc.body); got != tc.want {
t.Errorf("%s: ShouldRetry(%d, %q) = %v, want %v", tc.name, tc.status, tc.body, got, tc.want)
}
}
}
func TestRetryPolicyDelay(t *testing.T) {
cases := []struct {
name string
policy *RetryPolicy
want time.Duration
}{
{"nil", nil, 10 * time.Second},
{"explicit", &RetryPolicy{Delay: "250ms"}, 250 * time.Millisecond},
{"garbage falls back", &RetryPolicy{Delay: "not-a-duration"}, 10 * time.Second},
{"negative falls back", &RetryPolicy{Delay: "-5s"}, 10 * time.Second},
}
for _, tc := range cases {
if got := tc.policy.DelayDuration(); got != tc.want {
t.Errorf("%s: DelayDuration() = %v, want %v", tc.name, got, tc.want)
}
}
}

View File

@ -0,0 +1,60 @@
package core
import (
"path/filepath"
"testing"
"llmsproxy/internal/config"
)
// 一个过期的规则 URL 不得让**整个**计费 profile 失效。
//
// 真实事故(GitCode issue #1 排查中发现,生产现网):config.yaml 里
// `billing.rules[0].url` 是 `https://opencode.ai/inference/v1`,而该源的
// base_url 已是 `.../inference/openai/v1`。严格编译因此返回
// "rule url ... matches no configured source base_url",applyBillingDSL 直接
// return —— 于是**所有**计费规则都不生效:账单恒为 0、unpriced_reqs 涨到 3.7 万,
// 而唯一的痕迹是一行启动日志。这正是「静默少报」最严重的一种。
//
// 修法:严格编译失败时降级为 lenient(用能匹配的规则),并打 ERROR 说明后果。
// 一个 URL 写错不该把整个价目表一起葬送;但也不能装作成功。
func TestBillingStaleRuleURLStillAppliesOtherRules(t *testing.T) {
cfg := newTestConfig(t)
dir := t.TempDir()
cfg.PluginDir = filepath.Join(dir, "plugins")
cfg.Sources = []config.Source{
{Name: "localzen", BaseURL: "https://opencode.ai/inference/openai/v1", Adapter: "openai",
Models: []config.Model{{ID: "m"}}},
}
cfg.BillingDSL = &config.BillingDSL{
Active: "p",
Profiles: []config.BillingProfile{{
ID: "p",
Rules: []config.BillingRule{
// 过期 URL:没有任何源的 base_url 与它相等
{URL: "https://opencode.ai/inference/v1", Mode: "free"},
// 有效规则:必须仍然生效
{URL: "https://opencode.ai/inference/openai/v1", Mode: "free"},
},
}},
}
c := newTestCore(t, cfg)
c.applyBillingDSL()
raw := c.plugins.Prices("billing")
if raw == nil {
t.Fatal("billing has no price table (profile never applied)")
}
prices, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("price table shape: %#v", raw)
}
sources, ok := prices["sources"].(map[string]interface{})
if !ok {
t.Fatalf("prices has no sources: %#v", prices)
}
if _, ok := sources["localzen"]; !ok {
t.Fatalf("一条过期规则 URL 把整个 profile 弄失效了:有效的 free 规则也没有生效,"+
"结果是账单恒为 0 且没有告警。prices=%#v", prices)
}
}

View File

@ -0,0 +1,84 @@
package core
import (
"encoding/json"
"fmt"
"llmsproxy/internal/config"
)
// compileBillingPrices turns a billing profile into the prices table the billing
// plugin consumes — by ASKING THE PLUGIN TO DO IT.
//
// Why the kernel does not compile prices any more (GitCode issue #1, follow-up
// direction): "how a rule becomes a price table" is billing semantics, and it
// used to live in TWO places — `internal/billing/compile.go` produced source
// entries with zero token prices, while `billing.lua`'s priceFor() applied the
// source dimension after the model dimension. Each was defensible alone; together
// they silently zeroed every request (cost 0, unpriced_reqs frozen). Splitting
// the semantics across two languages is what made that possible.
//
// So the kernel keeps only what it must own — decoding config.yaml and validating
// the DSL — and hands the PROFILE plus the source base URLs to the plugin as
// plain DATA. The plugin compiles, and the kernel injects the result through the
// existing `prices` sidecar (SetState), which also keeps price-vs-ledger
// separation intact.
//
// A plugin sandbox has no filesystem access, so "the plugin reads the config
// file" is not achievable by design; passing the decoded profile is the hook /
// interface this architecture provides instead.
func (c *Core) compileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
if c.plugins == nil {
return nil, nil, fmt.Errorf("plugins not loaded")
}
if profile == nil {
return nil, nil, fmt.Errorf("no billing profile")
}
// Structs go through JSON so the Lua side receives plain data (maps/slices):
// pushGoValue is not a struct encoder, and a silent shape mismatch here would
// look exactly like "the plugin priced nothing".
rawProfile, err := json.Marshal(profile)
if err != nil {
return nil, nil, err
}
var prof map[string]interface{}
if err := json.Unmarshal(rawProfile, &prof); err != nil {
return nil, nil, err
}
sources := make([]map[string]interface{}, 0, len(c.cfg.Sources))
for _, s := range c.cfg.Sources {
sources = append(sources, map[string]interface{}{"name": s.Name, "base_url": s.BaseURL})
}
out, err := c.plugins.Call("billing", "compile_prices", map[string]interface{}{
"profile": prof, "sources": sources, "lenient": lenient,
})
if err != nil {
return nil, nil, err
}
m, ok := out.(map[string]interface{})
if !ok {
return nil, nil, fmt.Errorf("billing plugin returned an unexpected shape: %T", out)
}
if msg, hasErr := m["error"]; hasErr {
if e := fmt.Sprint(msg); e != "" && e != "<nil>" {
return nil, nil, fmt.Errorf("%s", e)
}
}
prices, ok := m["prices"].(map[string]interface{})
if !ok {
return nil, nil, fmt.Errorf("billing plugin returned no prices table")
}
var unmatched []string
if arr, ok := m["unmatched_rules"].([]interface{}); ok {
for _, v := range arr {
unmatched = append(unmatched, fmt.Sprint(v))
}
}
return prices, unmatched, nil
}
// CompileBillingPrices is compileBillingPrices for callers outside the package
// (the rules editor validates an edit before persisting it).
func (c *Core) CompileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
return c.compileBillingPrices(profile, lenient)
}

View File

@ -16,7 +16,6 @@ import (
"sync/atomic"
"time"
"llmsproxy/internal/billing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/provider"
@ -1055,16 +1054,49 @@ func (c *Core) applyBillingDSL() {
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
return
}
prices, err := billing.Compile(profile, c.cfg.Sources)
prices, staleRules, err := c.compileBillingPrices(profile, false)
if err != nil {
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
return
// ONE stale rule URL must not take the whole price table down with it.
//
// The strict compiler rejects a profile whose rule matches no source,
// which is right for catching typos — but the runtime consequence used
// to be a bare `return`: every rule stopped applying, the bill came out
// at 0 and unpriced_reqs grew forever, with a single startup log line as
// the only trace. Found in production while fixing GitCode issue #1
// (a rule still pointed at .../inference/v1 after the source moved to
// .../inference/openai/v1; 37k requests had been recorded as unpriced).
//
// So: apply the rules that DO match, and say exactly what was dropped.
// The profile still fails loudly — it just no longer fails silently for
// everything else.
lenient, stale, lerr := c.compileBillingPrices(profile, true)
if lerr != nil {
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
profile.ID, err, lerr)
return
}
def := profile.Default
if def == "" {
def = "unpriced"
}
log.Printf("[billing] ERROR profile %q has %d rule URL(s) matching no configured "+
"source: %v — applied the remaining %d rule(s); unmatched traffic falls through "+
"to default=%q and keeps counting in unpriced_reqs. FIX THE RULE URL: a stale URL "+
"used to zero out the entire price table.", profile.ID, len(stale), stale,
len(profile.Rules)-len(stale), def)
prices, staleRules = lenient, stale
}
_ = staleRules
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))
// Sources that no rule matched are encoded by the COMPILER (the plugin):
// `default: free` writes a {per_request:0} marker, `unpriced` leaves them out
// so the plugin's counters see them. The old `unmatched_sources` key was
// written by the compiler and read by nobody — deleted rather than kept as
// decoration (GitCode issue #1 / PR #2).
}
// ValidateScopeQuotas checks every scope entry's caps before they are stored.

View File

@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"llmsproxy/internal/billing"
"llmsproxy/internal/config"
)
@ -238,7 +237,15 @@ func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.Bill
// Compile before persisting: a profile that cannot be turned into prices
// must not reach the config file, or the next restart fails to load the
// very rules the editor just accepted.
prices, err := billing.CompileOpts(next.Resolve(next.Active), cfg.Sources, true)
//
// The compilation itself runs in the BILLING PLUGIN (see
// core.compileBillingPrices): the kernel only decodes config.yaml and passes
// the profile + source URLs as data, so the "what does this rule mean"
// semantics live in exactly one place. Editor saves are lenient: a
// half-finished rule (URL typed before its source exists) must be saveable
// and reported, not rejected with an error the operator can only resolve by
// guessing.
prices, _, err := g.core.CompileBillingPrices(next.Resolve(next.Active), true)
if err != nil {
cfg.BillingDSL = prev // no half-applied state
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())

View File

@ -224,31 +224,58 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
if !ok {
t.Fatalf("prices has no sources map: %#v", prices)
}
cc, ok := sources["commandcode"]
if !ok {
t.Fatalf("the rule was saved but never injected: prices has %v, "+
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
}
if cc == nil {
t.Error("commandcode price entry is nil")
// A token rule must NOT produce a source entry at all: priceFor() applies
// the source dimension after the model dimension and lets it override the
// token rate, so a marker here is what silently zeroed the bill (issue #1 /
// PR #2). Models absent from the rule surface as unpriced instead.
if entry, ok := sources["commandcode"]; ok {
t.Errorf("the token rule produced a source entry %#v — it overrides the "+
"model price (issue #1)", entry)
}
// The free rule must price localzen, and the two must not be conflated.
if _, ok := sources["localzen"]; !ok {
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
}
// The injected price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
// Invariant: only DELIBERATE zero-token entries (mode=free/subscription) may
// carry prompt/completion, and they must be exactly 0. In this fixture the
// free rule covers localzen, so any other source with token prices — or a
// non-zero one — would override model rates (issue #1).
for name, raw := range sources {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
if name == "localzen" { // the explicit free rule of this fixture
continue
}
for _, key := range []string{"prompt", "completion"} {
if _, present := entry[key]; present {
t.Errorf("source %q carries %s=%v: it overrides every model price "+
"on that source (issue #1)", name, key, entry[key])
}
}
}
// The injected MODEL price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
// Asserting the exact magnitude catches a unit error (per-million vs
// per-token), which would be off by a factor of a million and still look
// like "a number".
models, ok := cc.(map[string]interface{})["models"].(map[string]interface{})
if ok {
if m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}); ok {
p, _ := m["prompt"].(float64)
if p <= 0 || p > 1e-6 {
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
"a per-million value here would over-bill by 1e6x", p)
}
}
//
// ⚠️ This used to read `cc["models"]`, but `models` lives at
// prices["models"], not inside a source entry — so the type assertion was
// always false and the whole block was skipped. It had never run, which is
// how a per-million unit error could survive a test whose comment claims
// to catch exactly that.
models, ok := prices["models"].(map[string]interface{})
if !ok {
t.Fatalf("prices has no models map: %#v", prices)
}
m, ok := models["deepseek-v4.1-flash"].(map[string]interface{})
if !ok {
t.Fatalf("the model price was not injected: %v", keysOf(models))
}
if p, _ := m["prompt"].(float64); p <= 0 || p > 1e-6 {
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
"a per-million value here would over-bill by 1e6x", p)
}
}

View File

@ -126,7 +126,12 @@ func TestRealMutants(t *testing.T) {
},
{
name: "nested metadata no longer read",
from: "\tcopyFields(m.Metadata) // nested first",
// 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已
// 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次
// 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被
// 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里
// `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。
from: "\tcopyFields(m.Metadata) // nested first",
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
},
{

View File

@ -0,0 +1,402 @@
package lua
import (
"fmt"
"strings"
"testing"
)
// 规则 → 价格表的编译现在**在插件里**(GitCode issue #1 的建议方向:内核只提供
// 钩子/接口,计费语义下沉到 Lua)。这些判据从原 internal/billing 的 Go 单测
// 平移而来,并补上 issue #1 暴露的两个形状陷阱。
//
// 为什么必须保留这些判据:语义一旦分裂回两处,就会出现"源层零价覆盖模型价"
// 这类静默归零 —— 账单加总正常、只是少报,且 unpriced_reqs 也不涨。
func compilePricesForTest(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) map[string]interface{} {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": lenient,
})
if err != nil {
t.Fatalf("compile_prices: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("compile_prices returned %T", out)
}
if e, hasErr := m["error"]; hasErr && fmt.Sprint(e) != "" {
t.Fatalf("编译失败: %v", e)
}
if p, ok := m["prices"].(map[string]interface{}); ok {
return p
}
t.Fatalf("no prices in %#v", m)
return nil
}
// 编译失败通过载荷里的 error 字段回传(内核桥的第二个返回值通道在插件导出上
// 不可靠;{error=...} 是本仓库的通行约定)。返回错误文本供断言。
func compilePricesErr(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) string {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": lenient,
})
if err != nil {
return err.Error()
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("compile_prices returned %T", out)
}
if e, hasErr := m["error"]; hasErr {
if s := fmt.Sprint(e); s != "" && s != "<nil>" {
return s
}
}
t.Fatalf("预期编译失败,实得 %#v", m)
return ""
}
func srcList(base string) []map[string]interface{} {
return []map[string]interface{}{{"name": "mine", "base_url": base}}
}
// 通配符 token 规则**不能**给源写零价条目:源层覆盖模型层,会把该源所有未列
// 价模型悄悄算成 0(原 Go 测试 TestWildcardTokenRuleDoesNotMarkPaidSources)。
func TestBillingCompileWildcardTokenDoesNotMarkSources(t *testing.T) {
sources := []map[string]interface{}{
{"name": "paid1", "base_url": "https://paid1.example.com/v1"},
{"name": "paid2", "base_url": "https://paid2.example.com/v1"},
{"name": "free1", "base_url": "https://free.example.com/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://free.example.com/v1", "mode": "free"},
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
"only-catalogued-model": map[string]interface{}{"prompt": "1.0", "completion": "2.0"},
}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
srcs := prices["sources"].(map[string]interface{})
if _, ok := srcs["free1"]; !ok {
t.Error("显式 free 规则的源条目丢了(free 的 0 是语义)")
}
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("通配符 token 规则把 %q 标成已定价/0 价 —— 其未列价模型会算成 0 而不是进入 unpriced", name)
}
}
models := prices["models"].(map[string]interface{})
if _, ok := models["only-catalogued-model"]; !ok {
t.Error("通配符规则没有发布模型价")
}
}
// token 规则**不得**产生源条目(PR #2 的结论,比 issue 正文的 per_request 标记更收口)。
//
// 只写 {per_request:0} 虽然不会清零模型价,但同源上未列入 rule.Models 的模型仍被
// 视为"已定价"、静默计 0、unpriced 计数不涨 —— 仍是静默少报。统一收口后它们
// 落到 unpriced 统计里(可见);真正"边际成本为零"用 free/subscription。
func TestBillingCompileTokenRuleEmitsNoSourceEntry(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
srcs := prices["sources"].(map[string]interface{})
if entry, ok := srcs["mine"]; ok {
t.Errorf("token 规则产生了源条目 %#v:源层会覆盖模型价 / 静默计 0", entry)
}
// per-million → per-token:0.15 USD/M = 1.5e-7
m := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if m["prompt"].(float64) != 1.5e-7 {
t.Errorf("prompt = %v, want 1.5e-7(per-token)", m["prompt"])
}
if m["completion"].(float64) != 6e-7 {
t.Errorf("completion = %v, want 6e-7", m["completion"])
}
}
// 根因直防:token 规则的源条目不得携带 token 价。
// (free/subscription 是唯一合法的"有意零价",由下一个测试单独守住。)
func TestBillingCompileNoSourceEntryEverCarriesTokenPrices(t *testing.T) {
sources := []map[string]interface{}{
{"name": "a", "base_url": "https://a.example/v1"},
{"name": "b", "base_url": "https://b.example/v1"},
{"name": "c", "base_url": "https://c.example/v1"},
{"name": "d", "base_url": "https://d.example/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://a.example/v1", "mode": "token",
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": "1", "completion": "2"}}},
map[string]interface{}{"url": "https://b.example/v1", "mode": "unpriced"},
map[string]interface{}{"url": "*", "mode": "token",
"models": map[string]interface{}{"m2": map[string]interface{}{"prompt": "3", "completion": "4"}}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
for name, raw := range prices["sources"].(map[string]interface{}) {
entry, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("源 %q 条目不是表: %#v", name, raw)
}
if _, bad := entry["prompt"]; bad {
t.Errorf("源 %q 携带 prompt=%v:会覆盖该源所有模型价", name, entry["prompt"])
}
if _, bad := entry["completion"]; bad {
t.Errorf("源 %q 携带 completion=%v:会覆盖该源所有模型价", name, entry["completion"])
}
}
}
// free / subscription 的零价是**语义**,必须保留 prompt/completion=0。
func TestBillingCompileExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
for _, mode := range []string{"free", "subscription"} {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": mode, "subscription": 10,
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
for _, key := range []string{"prompt", "completion"} {
if v, present := entry[key]; !present || v != 0.0 {
t.Errorf("%s: entry[%s] = %v (present=%v), want 显式 0", mode, key, v, present)
}
}
}
}
// reasoning 未配置时必须**缺席**(不是 0)。写 0 等于"思考免费",会静默少报
// 所有把 reasoning 按输出计价的 provider。
func TestBillingCompileReasoningPriceOmittedUnlessSet(t *testing.T) {
base := map[string]interface{}{"prompt": "1.0", "completion": "2.0"}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"plain": base,
"reasoning": map[string]interface{}{"prompt": "1.0", "completion": "2.0", "reasoning": "3.0"},
},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
models := prices["models"].(map[string]interface{})
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
t.Error("未配置 reasoning 的模型不应出现 reasoning_price(0 会被当成免费)")
}
rp := models["reasoning"].(map[string]interface{})["reasoning_price"].(float64)
if rp != 3e-6 {
t.Errorf("reasoning_price = %v, want 3e-6", rp)
}
_ = base
}
// long_context:绝对值优先;只给倍数时按基础价的倍数;两者都缺要报错。
func TestBillingCompileLongContextRates(t *testing.T) {
cases := []struct {
name string
lc map[string]interface{}
want float64
wantErr bool
}{
{"absolute", map[string]interface{}{"above": 128000, "prompt": "0.30"}, 3e-7, false},
{"multiplier", map[string]interface{}{"above": 128000, "multiplier": 2}, 3e-7, false},
{"both-absolute-wins", map[string]interface{}{"above": 128000, "prompt": "0.30", "multiplier": 5}, 3e-7, false},
{"neither", map[string]interface{}{"above": 128000}, 0, true},
{"above-zero", map[string]interface{}{"above": 0, "multiplier": 2}, 0, true},
}
for _, tc := range cases {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.6",
"long_context": tc.lc},
},
}},
}
if tc.wantErr {
compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false)
continue
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
lc := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["long_context"].(map[string]interface{})
if lc["prompt"].(float64) != tc.want {
t.Errorf("%s: long_context.prompt = %v, want %v", tc.name, lc["prompt"], tc.want)
}
if lc["above"].(float64) != 128000 {
t.Errorf("%s: above = %v", tc.name, lc["above"])
}
}
}
// peak 的形状是插件唯一读取的 {multiplier, windows=[{days,hours}]}。
func TestBillingCompilePeakShape(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}},
"peak": map[string]interface{}{"multiplier": 2,
"weekdays": []interface{}{1, 2}, "hours": []interface{}{[]interface{}{1, 4}}},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
pk := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["peak"].(map[string]interface{})
if pk["multiplier"].(float64) != 2 {
t.Errorf("multiplier = %v", pk["multiplier"])
}
wins, ok := pk["windows"].([]interface{})
if !ok || len(wins) != 1 {
t.Fatalf("windows = %#v", pk["windows"])
}
win := wins[0].(map[string]interface{})
if len(win["days"].([]interface{})) != 2 || len(win["hours"].([]interface{})) != 1 {
t.Errorf("window = %#v", win)
}
}
// URL 归一:尾斜杠与大小写不应影响匹配;首条命中优先。
func TestBillingCompileURLMatchingAndFirstWins(t *testing.T) {
sources := []map[string]interface{}{
{"name": "mine", "base_url": "https://Mine.Example.com/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://mine.example.com/v1/", "mode": "free"},
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
if entry["prompt"].(float64) != 0 {
t.Errorf("首条规则(free)应优先,实得 %#v", entry)
}
if _, ok := prices["models"].(map[string]interface{})["m"]; ok {
t.Error("第二条规则不应再被应用(首条命中优先,避免顺序歧义)")
}
}
// 严格模式:无源匹配的 URL 是硬错误;lenient 模式跳过并回报具体 URL。
func TestBillingCompileUnmatchedRules(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "free"},
map[string]interface{}{"url": "https://stale.example.com/v1", "mode": "free"},
},
}
if msg := compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false); msg == "" {
t.Fatal("严格模式必须报错")
}
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": srcList("https://mine.example.com/v1"), "lenient": true,
})
if err != nil {
t.Fatalf("lenient: %v", err)
}
m := out.(map[string]interface{})
rules := m["unmatched_rules"].([]interface{})
if len(rules) != 1 || rules[0] != "https://stale.example.com/v1" {
t.Errorf("unmatched_rules = %#v", rules)
}
prices := m["prices"].(map[string]interface{})
if _, ok := prices["sources"].(map[string]interface{})["mine"]; !ok {
t.Error("lenient 模式下有效规则仍须生效")
}
}
// 硬错误:未知 mode、token 无模型、负价、非数字、profile 不是表。
func TestBillingCompileHardErrors(t *testing.T) {
base := func(rule map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"id": "p", "rules": []interface{}{rule}}
}
url := "https://mine.example.com/v1"
src := srcList(url)
cases := []struct {
name string
profile map[string]interface{}
wantSub string
}{
{"unknown mode", base(map[string]interface{}{"url": url, "mode": "wat"}), "unknown mode"},
{"token no models", base(map[string]interface{}{"url": url, "mode": "token"}), "requires at least one model"},
{"negative price", base(map[string]interface{}{"url": url, "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "-1", "completion": "1"}}}), "negative price"},
{"not a number", base(map[string]interface{}{"url": url, "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "abc", "completion": "1"}}}), "not a number"},
{"profile not table", nil, "no billing profile"},
}
for _, tc := range cases {
msg := compilePricesErr(t, tc.profile, src, false)
if !strings.Contains(msg, tc.wantSub) {
t.Errorf("%s: error = %q, want substring %q", tc.name, msg, tc.wantSub)
}
}
}
// profile.default 是"没有规则命中的源"的策略,以前被校验后写进无人读的
// default_mode 键,运行时行为与 unpriced 完全一样(PR #2 修复)。
func TestBillingCompileProfileDefault(t *testing.T) {
sources := []map[string]interface{}{
{"name": "listed", "base_url": "https://listed.example/v1"},
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
}
tokenRule := map[string]interface{}{
"url": "https://listed.example/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
}
// default: free ⇒ 未匹配源写 {per_request:0}(只标记、不碰 token 价)
profile := map[string]interface{}{
"id": "p", "default": "free",
"rules": []interface{}{tokenRule},
}
prices := compilePricesForTest(t, profile, sources, false)
srcs := prices["sources"].(map[string]interface{})
un, ok := srcs["unlisted"].(map[string]interface{})
if !ok {
t.Fatalf("default: free 没有标记未匹配源: %#v", srcs)
}
if _, bad := un["prompt"]; bad {
t.Errorf("default-free 标记携带 prompt=%v:会清零该源模型价", un["prompt"])
}
if _, ok := srcs["listed"]; ok {
t.Error("default: free 不应标记已被规则命中的源")
}
if _, ok := prices["models"].(map[string]interface{})["m"]; !ok {
t.Error("token 规则的模型价丢了")
}
// default: unpriced / 缺省 ⇒ 不写任何标记(让 unpriced 计数看得见)
for _, def := range []interface{}{"unpriced", nil} {
profile := map[string]interface{}{"id": "p", "rules": []interface{}{}}
if def != nil {
profile["default"] = def
}
prices := compilePricesForTest(t, profile, sources, false)
if n := len(prices["sources"].(map[string]interface{})); n != 0 {
t.Errorf("default=%v 标记了 %d 个源;它们应保持未定价以便计数", def, n)
}
if _, present := prices["default_mode"]; present {
t.Error("default_mode 是无人读取的死键,不应再写入")
}
}
}

View File

@ -1236,3 +1236,133 @@ func TestLongContextComposesWithCacheDiscount(t *testing.T) {
// total = 0.2325
approx(t, "step composes with cache discount", got, 0.2325)
}
// ---- 源层零价覆盖模型价(issue #1)---------------------------------------
//
// 真实缺陷(GitCode issue #1):`compile.go` 给每条**具体 URL** 的 token 规则
// 写一条 `{prompt:0, completion:0, per_request:0}` 的源条目,本意只是"标记该源
// 已定价";而插件 priceFor() 的源层是**无条件覆盖**模型层:`sp.prompt ~= nil`
// 就把模型价改成 0。两条各自正当的规则叠加 ⇒ 每个请求 cost:0,且因为
// out.priced=true,连 unpriced_reqs 都不涨 —— 完全静默。
//
// 这里把两种条目形状都钉住:旧形状必须**真的是错的**(A 行),新形状必须
// **保住模型价同时仍算"已定价"**(B 行)。只断言"键存在"是不够的 —— 那正是
// 现有测试把错误形状固化成契约的原因。
func TestBillingSourceZeroPriceMustNotZeroModelPrice(t *testing.T) {
const promptPrice = 1.5e-7 // 0.15 USD/M
const completionPrice = 6e-7 // 0.60 USD/M
const want = 0.75 // 1e6 * 1.5e-7 + 1e6 * 6e-7
newPrices := func(sourceEntry map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": promptPrice, "completion": completionPrice},
},
"sources": map[string]interface{}{"src": sourceEntry},
}
}
run := func(t *testing.T, sourceEntry map[string]interface{}) (cost, unpriced float64) {
t.Helper()
ps, _ := billingVM(t)
_ = ps.SetState("billing", map[string]interface{}{"prices": newPrices(sourceEntry)})
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "src", "key": "***p", "ok": true,
"prompt_tokens": 1000000.0, "completion_tokens": 1000000.0,
"time": 1750000000000,
})
st := stateOf(t, ps)
total := st["total"].(map[string]interface{})
return total["cost"].(float64), st["unpriced_reqs"].(float64)
}
// A 行:compile.go 修复前的形状 —— 这是缺陷本身,必须算成 0。
cost, unpriced := run(t, map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
})
approx(t, "旧形状(全零源条目) cost", cost, 0)
if unpriced != 0 {
t.Errorf("旧形状下 unpriced_reqs = %v,缺陷正是它也为 0(静默)", unpriced)
}
// B 行:修复后的形状 —— 只带 per_request,不得覆盖模型价。
cost, unpriced = run(t, map[string]interface{}{"per_request": 0.0})
approx(t, "新形状(仅 per_request) cost", cost, want)
if unpriced != 0 {
t.Errorf("新形状下 unpriced_reqs = %v,want 0(该源仍算已定价)", unpriced)
}
}
// 同一源上一个**未列入价目表**的模型:源条目(仅 per_request)让它保持
// "已定价",所以不会被计入 unpriced_reqs —— 这正是 compile 写源条目的原意。
func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
ps, _ := billingVM(t)
_ = ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
"models": map[string]interface{}{
"priced-model": map[string]interface{}{"prompt": 1.5e-7, "completion": 6e-7},
},
"sources": map[string]interface{}{
"src": map[string]interface{}{"per_request": 0.0},
},
},
})
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "unlisted-model", "source": "src", "key": "***p", "ok": true,
"prompt_tokens": 1000.0, "completion_tokens": 0.0, "time": 1750000000000,
})
st := stateOf(t, ps)
if got := st["unpriced_reqs"].(float64); got != 0 {
t.Errorf("unpriced_reqs = %v,want 0:源条目存在即为已定价源", got)
}
}
// profile 级 default 策略必须真的生效 —— 且它是**编译期**语义(PR #2):
// `default: free` 由 compile_prices 给未匹配的源写 {per_request:0} 标记;
// `unpriced`/缺省不写。旧实现把 default 写进没人读的 default_mode 键,
// 两种取值运行时完全等价。
func TestBillingProfileDefaultIsDecidedAtCompileTime(t *testing.T) {
sources := []map[string]interface{}{
{"name": "listed", "base_url": "https://listed.example/v1"},
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
}
tokenRule := map[string]interface{}{
"url": "https://listed.example/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
}
run := func(t *testing.T, profile map[string]interface{}) float64 {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": false,
})
if err != nil {
t.Fatalf("compile_prices: %v", err)
}
m := out.(map[string]interface{})
if e, bad := m["error"]; bad && fmt.Sprint(e) != "" {
t.Fatalf("compile: %v", e)
}
_ = ps.SetState("billing", map[string]interface{}{"prices": m["prices"]})
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "some-unlisted-model", "source": "unlisted", "key": "***p", "ok": true,
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
})
return stateOf(t, ps)["unpriced_reqs"].(float64)
}
free := map[string]interface{}{"id": "p", "default": "free", "rules": []interface{}{tokenRule}}
if got := run(t, free); got != 0 {
t.Errorf("default=free: unpriced_reqs = %v, want 0(刻意免费,不该计入债务)", got)
}
for _, def := range []interface{}{"unpriced", nil} {
profile := map[string]interface{}{"id": "p", "rules": []interface{}{tokenRule}}
if def != nil {
profile["default"] = def
}
if got := run(t, profile); got != 1 {
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
}
}
}

View File

@ -72,6 +72,228 @@ local DEFAULT_PRICES = {
}
plugin.prices = DEFAULT_PRICES
-- ===== 规则 → 价格表编译(Lua 侧,内核只给数据) ======================
--
-- 为什么编译在插件里(GitCode issue #1 的建议方向):
-- 内核只负责把 config.yaml 解成结构体(YAML 解析 + schema 校验),并把
-- profile 与源的 base_url 作为**数据**通过 plugin.compile_prices 传进插件;
-- "规则如何变成价目表"(free/subscription/unpriced/token、URL 归一匹配、
-- 首条命中优先、token 价的 per-million → per-token、peak 窗口、
-- long_context 边际价、reasoning 可选价、未匹配规则/源)全部在这里。
--
-- 这样内核不需要认识任何计费语义,也不会再出现"两处各写一半、组合起来
-- 静默归零"——issue #1 的真因正是 compile.go 与 priceFor() 各有一套覆盖语义。
--
-- 返回形态(供内核 SetState 注入 plugin.prices):
-- { prices = <价目表>, unmatched_rules = {...}, unmatched_sources = {...} }
-- 硬错误用 `return nil, "原因"`,内核据此拒绝保存/记录 ERROR。
local function normalizeURL(u)
u = tostring(u or ""):gsub("^%s+", ""):gsub("%s+$", "")
u = u:gsub("/+$", "")
return u:lower()
end
local function matchSources(pattern, sources)
local out = {}
if pattern == "*" then
for _, s in ipairs(sources or {}) do out[#out + 1] = s.name end
return out
end
local want = normalizeURL(pattern)
for _, s in ipairs(sources or {}) do
if normalizeURL(s.base_url) == want then out[#out + 1] = s.name end
end
return out
end
-- USD per million → USD per token. 与内核校验用的是同一口径(/1e6),
-- 数值范围与 Go 的 ParseFloatUSDPerM 一致(不允许负数/非数字)。
local function usdPerM(v)
local s = tostring(v == nil and "" or v)
s = s:gsub("^%s+", ""):gsub("%s+$", "")
local n = tonumber(s)
if n == nil then return nil, "not a number: \"" .. tostring(v) .. "\"" end
if n < 0 then return nil, "negative price: \"" .. tostring(v) .. "\"" end
return n / 1e6, nil
end
-- peak 窗口的**唯一**合法形状:{ multiplier, windows = { { days, hours } } }。
-- 早期部署过的 {start,end,weekdays} 没有任何读取方(peak 静默按非高峰计价)。
local function compilePeak(pk)
local win = {}
if type(pk.weekdays) == "table" and #pk.weekdays > 0 then
local days = {}
for i, d in ipairs(pk.weekdays) do days[i] = d end
win.days = days
end
if type(pk.hours) == "table" and #pk.hours > 0 then
local hours = {}
for i, h in ipairs(pk.hours) do hours[i] = { h[1], h[2] } end
win.hours = hours
end
local out = { multiplier = tonumber(pk.multiplier) or 1 }
if next(win) ~= nil then out.windows = { win } end
return out
end
-- long_context 的边际价:绝对值(USD/M 字符串)优先,其次倍数 × 基础价。
-- 两者都缺 ⇒ 报错(一个"没有价格的阶梯"是静默 no-op,写它的人一定有意图)。
local function longContextRate(lc, basePerToken)
local prompt = tostring(lc.prompt == nil and "" or lc.prompt)
prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "")
local mult = tonumber(lc.multiplier) or 0
if prompt ~= "" then
local v, err = usdPerM(prompt)
if not v then return nil, err end
return v, nil
end
if mult > 0 then return basePerToken * mult, nil end
return nil, "needs either prompt or multiplier"
end
local function applyRule(prices, rule, srcName)
local mode = rule.mode
if mode == "free" or mode == "subscription" then
-- 显式零价:这里 0 本身就是语义(不是"未定价")。
prices.sources[srcName] = { prompt = 0, completion = 0, per_request = 0 }
if mode == "subscription" then
if prices.subscriptions == nil then prices.subscriptions = {} end
local cur = tostring(rule.currency == nil and "" or rule.currency)
if cur == "" then cur = "USD" end
prices.subscriptions[srcName] = {
monthly = tonumber(rule.subscription) or 0, currency = cur,
}
end
return true
end
if mode == "unpriced" then
-- 刻意不写源条目:让 unpriced_reqs/unpriced_models 抓住它。
return true
end
if mode == "token" then
if type(rule.models) ~= "table" or next(rule.models) == nil then
return nil, string.format("rule for url %q: mode token requires at least one model",
tostring(rule.url))
end
for modelID, t in pairs(rule.models) do
local prompt, perr = usdPerM(t.prompt)
if not prompt then return nil, string.format("model %q prompt: %s", tostring(modelID), perr) end
local completion, cerr = usdPerM(t.completion)
if not completion then return nil, string.format("model %q completion: %s", tostring(modelID), cerr) end
local entry = { prompt = prompt, completion = completion }
if t.cache_discount ~= nil then entry.cache_discount = tonumber(t.cache_discount) end
-- reasoning 可选,且**缺席**而非 0:写 0 等于"思考免费",会静默少报
-- 每一个把 reasoning 按输出计价的 provider。
local reasoning = tostring(t.reasoning == nil and "" or t.reasoning)
reasoning = reasoning:gsub("^%s+", ""):gsub("%s+$", "")
if reasoning ~= "" then
local rp, rerr = usdPerM(reasoning)
if not rp then return nil, string.format("model %q reasoning: %s", tostring(modelID), rerr) end
entry.reasoning_price = rp
end
if type(t.long_context) == "table" then
local above = tonumber(t.long_context.above) or 0
if above <= 0 then
return nil, string.format("model %q long_context: above must be > 0", tostring(modelID))
end
local rate, lerr = longContextRate(t.long_context, prompt)
if not rate then
return nil, string.format("model %q long_context: %s", tostring(modelID), lerr)
end
entry.long_context = { above = above, prompt = rate }
end
if type(rule.peak) == "table" then entry.peak = compilePeak(rule.peak) end
prices.models[modelID] = entry
end
-- ★ token 规则**不写任何源条目**(模型维度只发布模型价)。
--
-- 此前写 `{per_request:0}` 或 `{prompt:0,completion:0}` 都是为了"标记该源
-- 已定价",但 priceFor() 让源层**无条件覆盖**模型层的 prompt/completion:
-- * `{prompt:0,completion:0}` ⇒ 规则刚发布的模型价被清零(issue #1 本体);
-- * 只写 `{per_request:0}` ⇒ 模型价没被清零,但同源上**未列入 rule.Models**
-- 的模型仍被评为"已定价"、静默计 0,unpriced 计数依旧不涨。
-- 第二条仍然是"静默少报",只是窄一点。所以统一收口:token 规则不产生源条目,
-- 未列价的模型落到 unpriced 统计里(可见)。真正"边际成本为零"用
-- mode=free / mode=subscription —— 它们写的是有意为之的零价标记。
return true
end
return nil, string.format("unknown mode %q", tostring(mode))
end
local function compilePrices(profile, sources, lenient)
if type(profile) ~= "table" then return nil, "no billing profile" end
sources = sources or {}
local prices = {
currency = "USD",
default = { prompt = 0, completion = 0, per_request = 0 },
sources = {}, models = {}, keys = {},
}
local rules = type(profile.rules) == "table" and profile.rules or {}
-- currency 是 profile 级声明:第一条带 currency 的规则生效。
for i = 1, #rules do
local c = rules[i].currency
if type(c) == "string" and c ~= "" then prices.currency = c break end
end
local matched = {}
local unmatched_rules = {}
for i = 1, #rules do
local rule = rules[i]
local url = tostring(rule.url == nil and "" or rule.url)
local targets = matchSources(url, sources)
if #targets == 0 and url ~= "*" then
if lenient then
unmatched_rules[#unmatched_rules + 1] = url
else
return nil, string.format("rule url %q matches no configured source base_url", url)
end
else
for _, srcName in ipairs(targets) do
-- 首条命中优先:两条规则匹配同一个源是歧义,后面的不能悄悄覆盖前面的。
if not matched[srcName] then
matched[srcName] = true
local ok, err = applyRule(prices, rule, srcName)
if not ok then return nil, err end
end
end
end
end
-- profile 的 default 策略:只影响**没有规则命中**的源。
--
-- `free` = 运营声明"其余源一律免费" ⇒ 必须被识别为已定价,否则它们会一直计入
-- unpriced,告警变成噪音;标记用 `{per_request:0}`(只标记、不碰 token 价,
-- 否则任何通配符价目表都会被它清零,等于同一个 bug 换个入口复现)。
-- `unpriced`(或缺省)= 不写条目,让插件的 unpriced 计数看得见它们。
--
-- 这个策略以前只被校验、却被写进一个没人读的 `default_mode` 键 ⇒
-- `default: free` 与 `default: unpriced` 运行时完全等价。
if profile.default == "free" then
for _, src in ipairs(sources) do
if not matched[src.name] and prices.sources[src.name] == nil then
prices.sources[src.name] = { per_request = 0 }
end
end
end
local unmatched_sources = {}
for _, src in ipairs(sources) do
if not matched[src.name] then unmatched_sources[#unmatched_sources + 1] = src.name end
end
return { prices = prices, unmatched_rules = unmatched_rules, unmatched_sources = unmatched_sources }
end
-- 内核入口:profile 与源的 base_url 都是**数据**,插件不认识 config.yaml。
plugin.compile_prices = function(args)
args = args or {}
local out, err = compilePrices(args.profile, args.sources, args.lenient and true or false)
-- 错误用**表里的 error 字段**回传,而不是第二个返回值:内核桥的
-- `Call` 只把第一个返回值解码成数据(第二个返回值的错误通道在插件导出上
-- 并不可靠),用 {error=...} 是本仓库其它插件导出一致的约定。
if not out then return { error = tostring(err) } end
return out
end
plugin.exports = plugin.exports or {}
plugin.exports[#plugin.exports + 1] = "compile_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.<m>.cache_discount) or globally by
@ -189,7 +411,11 @@ local function priceFor(payload)
-- 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 = {
-- ★ local 是必须的:此前写的是全局 `out = {`,而 priceFor 每个请求都会调用,
-- 于是这个表挂在 VM 级全局变量上跨请求残留/泄漏(GitCode issue #1 顺带发现)。
-- 功能上因为每次整体重建而侥幸正确,但它是共享可变状态,任何提前 return 或
-- 后续分支忘了重建都会让上一单的价格漏到下一单 —— 计费错误不会报错。
local out = {
prompt = d.prompt or 0, completion = d.completion or 0,
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
-- nil = bill reasoning at the completion rate, which is what every
@ -236,6 +462,7 @@ local function priceFor(payload)
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

View File

@ -1046,6 +1046,24 @@ local PLATFORMS = {
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
-- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名
adapter = "codearts",
-- ★ 上游的"不可用"多数是**账号级限流**,约 1 分钟自愈,不是模型坏了:
-- * 400 `TM.00001041: 并发会话数已达上限(3个)` —— 账户并发会话上限,
-- 实测最后一个请求结束后约 44~60 秒释放;
-- * 429 `TPM limit ... 500000 tokens per minute` —— 每分钟 token 配额。
-- 内核原本**只在 429 上做冷却、不重试**,于是这类"等一会儿就好"的拒绝
-- 直接冒到客户端(流式还表现为 `empty stream`,因为上游用 HTTP 200 +
-- error 帧表达拒绝)。
--
-- 这里声明**声明式重试策略**(内核只按数据匹配,不认识 CodeArts):
-- 命中即在网关内部每 10s 重试一次,最多 18 次(≈3 分钟),客户端只是
-- 多等几秒,不会看到错误。流式只在**握手阶段**重试(首帧尚未下发),
-- 不会重复输出。
retry_policy = {
on_status = { 400, 429 },
on_body_contains = { "TM.00001041", "TPM limit", "并发会话数已达上限" },
delay = "10s",
max_attempts = 18,
},
-- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名),
-- 不是裸 access token。见 syncAccountSource 的注释。
credential_shape = "full",
@ -4627,6 +4645,9 @@ local function syncProviderSource(provider)
if plat.endpoint then src.endpoint = plat.endpoint end
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
if plat.headers_extra then src.headers = plat.headers_extra end
-- 声明式上游重试策略(见平台上的 retry_policy 注释):内核只按 status +
-- body 子串匹配,不认识任何厂商。
if type(plat.retry_policy) == "table" then src.retry = plat.retry_policy end
-- ★ 把**设备身份**随源带给适配器:有些平台的头不是简单 Bearer,
-- 需要 uid / device_id / machine_id 派生(trae 的 X-Uid / X-Device-Id /
-- X-Machine-Id)。适配器沙箱里**读不到凭据**,只能靠 meta 传。
@ -10128,7 +10149,21 @@ end
-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。
-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。
function adapter.build_headers(meta)
local h = { ["Content-Type"] = "application/json" }
-- ★★ Connection: close 是**必须**的,不是优化。
--
-- CodeArts 后端按**账户**限制并发会话数(3 个),而它把"会话"绑定在
-- HTTP 连接上:内核的 Go transport 会保留空闲连接(默认每 host 2 条)
-- 用于 keep-alive,于是第 3~4 个请求就被判成"并发会话数已达上限(3个)"
-- (TM.00001041),用户看到的是 codeart 连续不可用。
--
-- 实证(2026-10-20):
-- * 走网关连续 5 次 → #1/#2/#3 成功,#4/#5 全部 TM.00001041;
-- * 同一凭据、每请求**新连接 + Connection: close** 连续 5 次 → 5/5 成功;
-- * 之后连续流式也一样:3 次成功后开始 400。
-- 即"会话"就是连接,关掉连接会话立刻释放。
--
-- 代价是每次多一次 TCP/TLS 握手(~百毫秒级),换"渠道真的能用",值得。
local h = { ["Content-Type"] = "application/json", ["Connection"] = "close" }
local raw = tostring(meta.api_key or "")
local cred = nil
if raw:sub(1, 1) == "{" then
@ -10146,6 +10181,19 @@ function adapter.build_headers(meta)
return h
end
for k, v in pairs(signed) do h[k] = v end
-- ★★ Session-Id 是**必须**的,不是可选项 —— 它决定"并发会话数"怎么算。
--
-- 实测(2026-10-20,对照参考实现 llm-adapter.ts:1168 `headers.set('Session-Id', …)`):
-- 不发送会话号时,后端把**每个请求**当成一个新会话;而会话在响应结束后还要
-- 存活一段时间才释放,于是连续 3 个请求就把账户的「并发会话数上限(3个)」占满,
-- 之后所有请求(含流式)稳定回 TM.00001041 / 200+空流(用户看到 empty stream)。
-- 发一个**稳定**的会话号(同一账号内恒定),后端复用同一会话 ⇒ 不再累加。
--
-- 会话号取 AK 的 sha256 前 32 hex:稳定、可复现、不引入需要持久化的状态。
local ak = tostring(cred.access_key_id or cred.access_key or "")
if ak ~= "" then
h["Session-Id"] = string.sub(sha256_hex(ak), 1, 32)
end
return h
end

View File

@ -1156,7 +1156,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
if err != nil {
return nil, err
}
raw, status, err := p.do(ctx, chatURL, body, hdrs)
raw, status, err := p.doWithRetry(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
@ -1227,8 +1227,48 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
}
rc := make(chan respOrErr, 1)
go func() {
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
rc <- respOrErr{resp, err}
// Retry only the HANDSHAKE: a policy-matched rejection (e.g. a
// per-account concurrency cap answered with a business 4xx) can clear
// within seconds, and nothing has been sent to the client yet — the
// kernel holds the first chunk back. Once a 200 arrives and streaming
// starts, no retry happens (that would duplicate output).
rp := p.cfg.Retry
attempts := 1
if rp != nil && rp.MaxAttempts > 1 {
attempts = rp.MaxAttempts
}
for i := 0; i < attempts; i++ {
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
if err != nil {
rc <- respOrErr{resp, err}
return
}
if resp.StatusCode == 200 {
rc <- respOrErr{resp, nil}
return
}
// A rejection can only be classified after the body is read: the
// policy matches on body substrings, and a source may use the same
// status (400) for unrelated errors that must NOT be retried.
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !rp.ShouldRetry(resp.StatusCode, string(raw)) {
// Not the retryable body: hand the rejection back verbatim so
// the caller reports the real upstream error.
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
rc <- respOrErr{resp2, nil}
return
}
if i == attempts-1 {
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
rc <- respOrErr{resp2, nil}
return
}
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
rc <- respOrErr{nil, serr}
return
}
}
}()
inner := make(chan types.UnifiedChunk, 64)
@ -1560,6 +1600,50 @@ func (p *Provider) do(ctx context.Context, url, body string, hdr http.Header) (s
return string(raw), resp.StatusCode, nil
}
// doWithRetry runs one non-stream request, retrying per the source's
// declarative RetryPolicy. The policy is pure data (status list + body
// substrings), so the kernel stays vendor-agnostic: a provider that answers a
// per-account concurrency cap with a business 4xx can clear by itself while the
// client just waits a few seconds instead of seeing an error.
func (p *Provider) doWithRetry(ctx context.Context, url, body string, hdr http.Header) (string, int, error) {
rp := p.cfg.Retry
attempts := 1
if rp != nil && rp.MaxAttempts > 1 {
attempts = rp.MaxAttempts
}
var raw string
var status int
for i := 0; i < attempts; i++ {
r, st, err := p.do(ctx, url, body, hdr)
if err != nil {
return "", 0, err
}
raw, status = r, st
if status == 200 || !rp.ShouldRetry(status, raw) || i == attempts-1 {
break
}
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
return "", 0, serr
}
}
return raw, status, nil
}
// sleepCtx sleeps for d unless the request context ends first.
func sleepCtx(ctx context.Context, d time.Duration) error {
if d <= 0 {
return ctx.Err()
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func (p *Provider) doRaw(ctx context.Context, url, body string, hdr http.Header) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
if err != nil {

View File

@ -0,0 +1,131 @@
package provider
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/types"
)
// 上游重试是**内核的通用机制**,判据纯数据(status + body 子串),
// 目的是让"账号级限流、等一会儿就好"这类拒绝不冒到客户端。
//
// 真实事故(2026-10-20):CodeArts 账户并发会话上限 3 个,连续第 3~4 个请求
// 回 400 TM.00001041,最后一个请求结束后约 44~60 秒才释放;流式更糟 ——
// 上游用 HTTP 200 + error 帧表达拒绝,内核只能报 `empty stream`。
// 参考实现的处理是每 10s 重试一次 chat 直到放行,本测试钉住同一语义。
func TestChatRetriesDeclaredUpstreamRejection(t *testing.T) {
var hits int32
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
if n <= 2 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
return
}
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
s := src("codearts-like", up.URL, "openai", "m1")
s.Retry = &config.RetryPolicy{
OnStatus: []int{400},
OnBodyContains: []string{"TM.00001041"},
Delay: "1ms",
MaxAttempts: 4,
}
p := newTestProvider(t, s)
resp, err := p.Chat(context.Background(), &types.ChatRequest{
Model: "m1",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
})
if err != nil {
t.Fatalf("命中重试策略后应成功,实得错误: %v", err)
}
if resp.Content != "hi" {
t.Fatalf("content = %q", resp.Content)
}
if got := atomic.LoadInt32(&hits); got != 3 {
t.Fatalf("应恰好尝试 3 次(2 次失败 + 1 次成功),实得 %d", got)
}
}
// 同一状态码但 body 不匹配 ⇒ **不许重试**(400 也常用于真正的请求错误,
// 无差别重试只会把错误延迟 3 分钟才暴露给用户)。
func TestChatDoesNotRetryUnmatchedBody(t *testing.T) {
var hits int32
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error_code":"TM.00001005","error_msg":"无正常可用套餐"}`)
}))
defer up.Close()
s := src("codearts-like", up.URL, "openai", "m1")
s.Retry = &config.RetryPolicy{
OnStatus: []int{400},
OnBodyContains: []string{"TM.00001041"},
Delay: "1ms",
MaxAttempts: 5,
}
p := newTestProvider(t, s)
_, err := p.Chat(context.Background(), &types.ChatRequest{
Model: "m1",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
})
if err == nil {
t.Fatal("不可重试的 body 必须失败")
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("body 不匹配时只能尝试 1 次,实得 %d", got)
}
}
// 流式:只重试**握手**。命中策略 → 重试直到 200 才开始派发;一旦开始流式
// 就不再重试(重复输出比报错更糟)。
func TestChatStreamRetriesHandshakeOnly(t *testing.T) {
var hits int32
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
if n == 1 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
return
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n")
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer up.Close()
s := src("codearts-like", up.URL, "openai", "m1")
s.Retry = &config.RetryPolicy{
OnStatus: []int{400},
OnBodyContains: []string{"TM.00001041"},
Delay: "1ms",
MaxAttempts: 4,
}
p := newTestProvider(t, s)
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
Model: "m1",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
})
if err != nil {
t.Fatalf("握手重试后应成功: %v", err)
}
var content string
for ck := range ch {
content += ck.Content
}
if content != "hi" {
t.Fatalf("content = %q", content)
}
if got := atomic.LoadInt32(&hits); got != 2 {
t.Fatalf("应握手重试 1 次(共 2 次请求),实得 %d", got)
}
}