mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
67
internal/core/channels_free_flag_test.go
Normal file
67
internal/core/channels_free_flag_test.go
Normal 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("收费通道的模型不得因为前缀规则被判成免费")
|
||||
}
|
||||
}
|
||||
@ -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 声明正确。
|
||||
-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。
|
||||
|
||||
Reference in New Issue
Block a user