mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## ① trae:模型选择器里那几个 `custom_model_*` 是**槽位不是模型** 用户报「trae 的模型不正确 / 渠道不可用」。逐个实测(生产): `custom_model_kimi` / `custom_model_claude` / `custom_model_1M` 返回 **空 content**(`custom_model_gpt-5` 也答非所问),而真实模型 (glm-5.2 → Z.ai GLM、kimi-k3 → Moonshot、minimax-m3 → MiniMax、 Doubao-Seed-2.1-Pro → 字节)全部正常 —— 所以渠道是通的,问题在**表**。 根因:`custom_model_*` 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威 判据是 `is_custom_model: true`。插件自己的 `trae_batch` 解析器早已按这条 **准入**剔除,但**兜底表**把它们当普通模型列了出来;而远端目录在部分账号 下恒空(企业租户账号实测:22 个 function 全回 `error_code 2001 Config detail for function: X not found`),用户只能看到 兜底表 ⇒ 选中槽位后上游回空 ⇒ 「模型坏了」。 修法(**纯插件**,内核零改动): - 新增 `models_unavailable` 一档:声明「本协议下不可调用」的条目, **既不进 UI 选择器,也不进上游源**(与 `models_hidden` 区别:后者只是 不进 UI,内部子 agent 仍可显式调用); - trae 的 11 个 `custom_model_*` 移入该档;把 4 个内部子 agent 留在 `models_hidden`(语义不变); - 生产验证:trae 源/选择器从 28 → **17**,逐个实测 17 个全部返回非空内容。 ## ② 远端目录「空」要能区分原因 `refresh_models` 此前只会报「远端返回空目录」,把两种完全不同的故障混为一谈: 网络/HTTP 故障 vs 服务端 200 但账号侧没有可下发配置。 现在后者如实报:「远端 200 但无模型配置(账号侧未下发 function config, 企业/租户账号需管理员开启,实测 error_code 2001)」——运维据此知道该找 租户管理员,而不是反复重试网络。 另加 `__trae_catalog_probe`(诊断探针):原样回传目录端点的 status/body 前 N 字节/解析条数,并在 2001 现场定位时立了大功(这类"上游 200 却空目录"没有 原始响应只能瞎猜)。 ## ③ opencode / localzen:免费模型按**实测**收敛 用户报「localzen 也不能用了」。逐项实测(生产): - `space-bunny-free` → **410** `Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.`(仍在本地源里 ⇒ 选中必失败) - `jev-1.13-free` → 400 `Model does not support this protocol.`(非 OpenAI chat 协议,本端点永远调不通) - `beam-free` → 200 但 content 空、无 finish_reason - 其余 7 个(mimo / nemotron-super-vl / nemotron-lightning / longcat / deepseek-v4.1 / glm-5.3-flash / minimax-m3.1-flash)全部正常返回内容 修法: - `DEAD_GONE_PATTERNS` 补 opencode 的下架文案 (`Model … has been deprecated`);配阴/阳性判据(参数/字段被弃用**不算**); - opencode 平台把 jev-1.13-free / beam-free 列入 `models_unavailable`, 并从兜底表与 models_free 里移除; - 生产 `localzen` 源(手写源,插件不管)按实测刷新为 7 个可用免费模型, 逐个验证返回内容。 ## 纪律 渠道逻辑全部留在插件里(channels.lua / 插件自装适配器);内核这轮**没有** 新增任何厂商语义 —— 上一轮加进内核的只有通用的 `adapter.hidden` 机制。
218 lines
8.1 KiB
Go
218 lines
8.1 KiB
Go
package core
|
||
|
||
import (
|
||
"testing"
|
||
)
|
||
|
||
// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。
|
||
//
|
||
// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住:
|
||
//
|
||
// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容),
|
||
// 只在它"独占一句话成分"时才算阳性;
|
||
// - rate limit / quota / unauthor / forbidden 一律**不是**下架;
|
||
// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案
|
||
// `model expired-v3 does not exist`;
|
||
// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。
|
||
func TestDeadModelGoneJudgement(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
cases := []struct {
|
||
msg string
|
||
want bool
|
||
why string
|
||
}{
|
||
// ── 阳性:真的是下架 ──
|
||
{"model not found", true, "经典文案"},
|
||
{"unknown model: foo", true, "unknown model"},
|
||
{"no such model", true, "no such model"},
|
||
{"The model does not exist", true, "does not exist"},
|
||
{"error: model_not_found", true, "独占一句话(行尾)"},
|
||
{"model_not_found.", true, "后面是句末标点"},
|
||
{"model is not registered", true, "CodeArts/InferHub 下架模型的真实文案"},
|
||
{"InferHub.002002009.404: The model is not registered, please request other model", true,
|
||
"CodeArts 完整 404 文案"},
|
||
// ── 阴性:看着像但**不是**下架 ──
|
||
{"error_class=model_not_found, retry allowed", false,
|
||
"字段名后面还有内容(初版误判下架,实测教训)"},
|
||
{"the model_not_found metric was not exported", false,
|
||
"作为指标名出现(实测教训)"},
|
||
{"rate limit exceeded for model foo", false, "限流换时段就能用"},
|
||
{"quota exceeded", false, "额度问题"},
|
||
{"insufficient balance", false, "余额问题"},
|
||
{"401 unauthorized", false, "认证问题"},
|
||
{"invalid api key", false, "认证问题"},
|
||
{"token expired", false, "token 过期(带主语)"},
|
||
// ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE,
|
||
// 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。
|
||
{"model expired-v3 does not exist", true,
|
||
"expired 无主语 → 不算认证失败;does not exist 才是真判据"},
|
||
// ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面):
|
||
// 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。
|
||
// 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是
|
||
// NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表),
|
||
// 一旦命中排除词就直接 false、不再看后面的不存在语义。
|
||
// 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。
|
||
{"the model lease expired", false, "租约/认证问题,不是下架"},
|
||
{"the model lease expired and does not exist", false,
|
||
"参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"},
|
||
// available/supported 不算(换套餐就能恢复)
|
||
{"model not available in your region", false,
|
||
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
||
{"", false, "空串"},
|
||
// ── opencode zen 的**下架**文案(2026-10-20 生产实测 410)──
|
||
// 上游原话:Model space-bunny-free has been deprecated. Use
|
||
// minimax-m3.1-flash-free instead. 此前不识别 ⇒ 该模型一直留在源里,
|
||
// 用户选中必 410("渠道不可用"的观感来源之一)。
|
||
{"Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.",
|
||
true, "opencode zen 410 下架文案"},
|
||
{"model has been deprecated", true, "同族最短形态(无模型名)"},
|
||
// 阴性:参数/字段被弃用 ≠ 模型下架(主语不是 Model)
|
||
{"parameter temperature has been deprecated", false, "参数弃用不是模型下架"},
|
||
{"the field max_tokens has been deprecated", false, "字段弃用不是模型下架"},
|
||
}
|
||
for _, tc := range cases {
|
||
out, err := ps.Call("channels", "__is_model_gone_probe",
|
||
map[string]interface{}{"message": tc.msg})
|
||
if err != nil {
|
||
t.Fatalf("探针: %v", err)
|
||
}
|
||
w, _ := out.(map[string]interface{})
|
||
j, _ := w["json"].(map[string]interface{})
|
||
got, _ := j["gone"].(bool)
|
||
if got != tc.want {
|
||
t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。
|
||
func TestDeadModelRecordAndPrune(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
before := modelIDsFor(t, ps, "zcode")
|
||
if len(before) == 0 {
|
||
t.Fatal("zcode 应有兜底模型")
|
||
}
|
||
victim := before[0]
|
||
|
||
// 记录一条阳性证据 → 该模型必须从列表消失
|
||
if _, err := ps.Call("channels", "__record_dead_probe",
|
||
map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil {
|
||
t.Fatalf("记录探针: %v", err)
|
||
}
|
||
after := modelIDsFor(t, ps, "zcode")
|
||
for _, id := range after {
|
||
if id == victim {
|
||
t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim)
|
||
}
|
||
}
|
||
if len(after) != len(before)-1 {
|
||
t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after))
|
||
}
|
||
|
||
// model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用)
|
||
m, err := ps.Call("channels", "model_clear_dead",
|
||
map[string]interface{}{"platform": "zcode", "model_id": victim})
|
||
if err != nil {
|
||
t.Fatalf("model_clear_dead: %v", err)
|
||
}
|
||
w, _ := m.(map[string]interface{})
|
||
j, _ := w["json"].(map[string]interface{})
|
||
if j == nil || j["ok"] != true {
|
||
t.Fatalf("model_clear_dead 失败: %#v", m)
|
||
}
|
||
restored := modelIDsFor(t, ps, "zcode")
|
||
found := false
|
||
for _, id := range restored {
|
||
if id == victim {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored)
|
||
}
|
||
}
|
||
|
||
// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。
|
||
// 判据用冷却状态本身,不依赖真实网络。
|
||
func TestRemoteCatalogGateCooldown(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 初始不冷却
|
||
g := func() (bool, bool) {
|
||
out, err := ps.Call("channels", "__catalog_gate_probe",
|
||
map[string]interface{}{"platform": "zcode"})
|
||
if err != nil {
|
||
t.Fatalf("探针: %v", err)
|
||
}
|
||
w, _ := out.(map[string]interface{})
|
||
j, _ := w["json"].(map[string]interface{})
|
||
began, _ := j["began"].(bool)
|
||
cooling, _ := j["cooling"].(bool)
|
||
return began, cooling
|
||
}
|
||
if _, cooling := g(); cooling {
|
||
t.Error("初始不该处于冷却")
|
||
}
|
||
// 模拟一次失败 → 进冷却
|
||
if _, err := ps.Call("channels", "__catalog_gate_fail_probe",
|
||
map[string]interface{}{"platform": "zcode"}); err != nil {
|
||
t.Fatalf("失败探针: %v", err)
|
||
}
|
||
began, cooling := g()
|
||
if !cooling {
|
||
t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)")
|
||
}
|
||
if began {
|
||
t.Error("冷却期内不该再发起拉取")
|
||
}
|
||
}
|
||
|
||
// modelIDsFor 读某平台当前对外的模型 id 列表。
|
||
func modelIDsFor(t *testing.T, ps interface {
|
||
Call(string, string, map[string]interface{}) (interface{}, error)
|
||
}, plat string) []string {
|
||
t.Helper()
|
||
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat})
|
||
if err != nil {
|
||
t.Fatalf("model_list(%s): %v", plat, err)
|
||
}
|
||
w, _ := m.(map[string]interface{})
|
||
j, _ := w["json"].(map[string]interface{})
|
||
var out []string
|
||
if arr, ok := j["models"].([]interface{}); ok {
|
||
for _, it := range arr {
|
||
if mm, ok := it.(map[string]interface{}); ok {
|
||
if id, ok := mm["id"].(string); ok {
|
||
out = append(out, id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|