Files
ModelRouter/internal/core/channels_free_flag_test.go
JianFeeeee 2a2067284d fix(channels): isFree 与声明自相矛盾 —— 免费判定改为**按平台声明权威来源**
## 缺陷

cline 的 3 个模型来自远端 **free 数组**(`recommended-models` 的唯一权威 free 集合),
却声明在 `models` 里 ⇒ 生产实测**全部 isFree=False**,与旁边的注释
「免费模型(远端 free 数组实测)」直接矛盾。用户看不到哪个可以不花钱用。

## ★ 关键认识:两个参考用的是**不同机制**,不能混成一条链

| 平台 | 免费判定的权威 |
|---|---|
| cline | 远端 `free[]` → 命名约定 `cline-free/*` → 静态兜底表(**不查 cost**) |
| opencode | models.dev 的 `cost`(`/zen/v1/models` 连能力字段都没有,cost 是**唯一**来源) |

我第一版把两者写成一条「OR 链」(任一来源说免费即免费),结果与既有判据
`TestCapabilityOverridesFreeFlag` **正面冲突** —— 那条判据要求「能力表说收费时
必须为 false」。冲突暴露了真问题:**只有平台自己知道它的免费名单在哪**。

⇒ 改为平台声明 `free_source`:
- `"capability"`(opencode)—— cost 权威,本地声明只作能力表拉不到时的兜底;
- 缺省 —— 声明链权威(**OR 语义**:任一来源确认免费即免费)。
  OR 语义是刻意的:写成「后者覆盖前者」会让「远端说免费、能力表拉不到」
  静默退回收费,正是这类静默降级。

## 其他改动

- `parseRemoteModels` 现在把远端的 `free[]` 记成**权威免费集合**
  (此前只收了 id、丢掉了"它免费"这层信息),provider 走**显式参数**而非
  隐藏全局状态(隐藏状态是漂移源);
- cline 补 `free_prefix = "cline-free/"`(远端未响应时的兜底依据)与显式
  `models_free`(远端抖动时不把免费模型误标成收费)。

## 判据

`TestIsFreeUsesAuthorityChain`:① cline 的 `cline-free/*` 与
`stealth/space-bunny-alpha` 必须免费;② 命名约定在远端未响应时能独立判出免费;
③ **反向**:收费通道的同名模型不得因前缀规则被判免费(参考点名
「免费模型是独立 id」)。既有 `TestCapabilityOverridesFreeFlag` 同时保持绿 ——
两条语义靠平台声明共存,而不是二选一。

## 附带发现

全量回归曾出现 `TestConcurrentPluginAdapterUpdates` 挂 59 分钟:
单独跑 0.058s 通过 ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建缓存占满 100%**,
不是代码死锁。清理 6G 后全量 9 包全绿。
2026-10-14 20:00:58 +08:00

68 lines
2.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"testing"
)
// `isFree` 的判定链(照参考 cline-models.ts)。
//
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
//
// 参考的判定链(权威在前):
//
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
// ③ 声明的 models_free
// ④ 能力表的 cost(models.dev)
//
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
func TestIsFreeUsesAuthorityChain(t *testing.T) {
_, ps := poolCore(t)
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
if !got["cline-free/mimo-v2.6-flash"] {
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
}
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
if !got["stealth/space-bunny-alpha"] {
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
}
// 命名约定必须生效(远端未响应时唯一的兜底依据)
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "cline-free/some-new-model",
})
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
if pj["is_free"] != true {
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
}
// 反向:收费通道的同名模型**不得**被判免费
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
})
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
if pj2["is_free"] == true {
t.Error("收费通道的模型不得因为前缀规则被判成免费")
}
}