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 包全绿。
This commit is contained in:
JianFeeeee
2026-10-14 19:59:41 +08:00
parent 742e2b5ed3
commit 2a2067284d
2 changed files with 162 additions and 6 deletions

View File

@ -0,0 +1,67 @@
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("收费通道的模型不得因为前缀规则被判成免费")
}
}

View File

@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
@ -312,10 +312,19 @@ local PLATFORMS = {
-- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。
-- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。
adapter = "channels-cline",
-- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费)
-- 命名约定:`cline-free/*` 是**免费通道**的独立 id(照参考
-- cline-models.ts 的 isFree 兜底规则)。远端 free 数组未响应时靠它判免费。
free_prefix = "cline-free/",
-- 这 3 个来自远端 free 数组(内嵌目录里没有),**它们是免费的**
-- (此前误放在 models 里 ⇒ 被标成收费,与注释自相矛盾)。
models = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
-- 显式声明免费集合:这是**兜底**(远端 free 数组是权威,但可能拉不到)。
-- 三者都在这里 ⇒ 即使远端抖动也不会把免费模型误标成收费。
models_free = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
-- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。
-- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed)
credit_unit = "dollars",
@ -679,6 +688,9 @@ local PLATFORMS = {
adapter = "openai",
-- models.dev 的键名(能力元数据的来源;照参考 opencode-capability)
capability_key = "opencode",
-- 该平台没有自己的免费名单,models.dev 的 cost **就是**唯一权威
-- (照参考 opencode-capability.ts)。故本地 models_free 只作兜底。
free_source = "capability",
-- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义
-- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。
models = {
@ -3014,9 +3026,15 @@ local function effectiveModels(provider)
return kept
end
-- remoteFreeSets:provider → { [modelId]=true },来自远端**权威** free 数组。
-- 照参考:`recommended-models` 的 free 数组是唯一权威的免费集合,
-- `/models` 的 id 列表里根本没有 cline-free/*。
-- 只收 id 不记"它免费"会丢掉这层信息 ⇒ 免费模型被标成收费。
remoteFreeSets = remoteFreeSets or {}
-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。
-- 每个分支的字段路径都照参考实现,含注释点名的坑。
local function parseRemoteModels(kind, body)
local function parseRemoteModels(kind, body, provider)
local ok, rec = pcall(json.decode, body or "")
if not ok or type(rec) ~= "table" then return {} end
local out = {}
@ -3071,6 +3089,19 @@ local function parseRemoteModels(kind, body)
-- free 数组优先在前(它含 cline-free/* 与 stealth/*)
pushFrom(rec.free)
pushFrom(rec)
-- ★ 同时把 `free` 数组记成**权威免费集合**(照参考 cline-models.ts:
-- `recommended-models` 的 free 数组是唯一权威来源)。
-- 只收 id 不记"它免费"会丢掉这层信息 —— 表现为免费模型被标成收费,
-- 用户不知道哪个可以不花钱用。
if type(rec.free) == "table" then
local fr = {}
for _, m in ipairs(rec.free) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then fr[id] = true end
end
remoteFreeSets = remoteFreeSets or {}
remoteFreeSets[provider] = fr
end
end
return out
@ -3114,7 +3145,7 @@ local function refreshRemoteModels(provider)
if err or not resp or resp.status ~= 200 then
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
else
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body)) do
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
if not seen[id] then seen[id] = true merged[#merged+1] = id end
end
end
@ -6400,6 +6431,15 @@ function plugin.model_list(req)
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
local free = {}
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
-- 远端**权威**免费集合(照参考 cline-models.ts 的 isFree 判定链):
-- ① 远端 free 数组命中的 id
-- ② 命名约定 `cline-free/*`(远端未响应时的兜底 —— 参考明写这条)
-- ③ 内嵌 models_free / 远端 cost=0 的能力表(下面按顺序覆盖)
-- ⚠️ 参考点名:免费模型是**独立 id**(`cline-free/deepseek-v4.1-flash`),
-- 与收费的 `deepseek/deepseek-v4.1-flash` 是两个不同的 id。故这里判的是
-- **id 本身**,不剥离前缀。
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local FREE_PREFIX = plat.free_prefix -- 命名约定(如 "cline-free/")
local out = {}
for i, m in ipairs(effectiveModels(plat.id)) do
if not hidden[m] then
@ -6408,8 +6448,31 @@ function plugin.model_list(req)
-- 不是我们硬编码的快照 —— 快照会随上游调整免费策略而过期。
-- 缓存未就绪时退回声明的 models_free,绝不触发网络(那是 5MB 的下载)。
local cap = capabilityOf(plat.id, m)
local is_free = free[m] and true or false
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false end
local is_free = false
-- ⚠️ **两个参考用的是不同机制**,不能混成一条链:
-- * **有自己免费名单的平台**(cline):远端 `free[]` 是唯一权威,
-- 再加命名约定与静态兜底表 —— 它们**不查 models.dev 的 cost**。
-- * **只有能力表可查的平台**(opencode:`/zen/v1/models` 连能力字段
-- 都没有):models.dev 的 `cost` **就是**唯一权威。
-- 故由平台声明 `free_source`,而不是我在这里猜一个通用规则
-- (猜的结果是两个参考都照顾不到)。
if plat.free_source == "capability" then
-- cost 权威:能力表说收费就必须是收费(本地声明只是能力表拉不到时的兜底)
if cap and cap.isFree ~= nil then
is_free = cap.isFree and true or false
else
is_free = free[m] and true or false
end
else
-- 声明链权威(OR 语义:任一来源确认免费即免费)。
-- 写成"后者覆盖前者"会让「远端说免费、能力表拉不到」静默退回收费。
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and FREE_PREFIX and m:sub(1, #FREE_PREFIX) == FREE_PREFIX then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
local entry = { id = m, name = (cap and cap.name) or m,
disabled = disabled[m] and true or false,
isFree = is_free }
@ -9487,6 +9550,32 @@ function plugin.__inject_refresh_platform_probe(args)
return { json = { ok = true } }
end
-- plugin.__is_free_probe:只给测试用 —— 对单个模型问"是否免费"。
-- 走**真实的**判定链(不自己重算),否则验的是探针而不是被测对象。
function plugin.__is_free_probe(args)
args = args or {}
local plat = platformById(tostring(args.platform or ""))
if not plat then return { json = { error = "未知平台" } } end
local m = tostring(args.model_id or "")
local free = {}
for _, x in ipairs(plat.models_free or {}) do free[x] = true end
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local cap = capabilityOf(plat.id, m)
local is_free = false
if plat.free_source == "capability" then
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false
else is_free = free[m] and true or false end
else
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
return { json = { is_free = is_free, model_id = m } }
end
-- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在
-- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。
-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。