diff --git a/internal/core/channels_free_flag_test.go b/internal/core/channels_free_flag_test.go new file mode 100644 index 0000000..5cf6e21 --- /dev/null +++ b/internal/core/channels_free_flag_test.go @@ -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("收费通道的模型不得因为前缀规则被判成免费") + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index b4ef9d0..5703084 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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 声明正确。 -- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。