@@ -1023,21 +1023,21 @@ def import_credential(self, raw):
10231023 with TestClient (app ) as client :
10241024 # 第一次:拉取成功,缓存写入
10251025 first = client .get ("/v1/models" , headers = auth ).json ()["data" ]
1026- assert {item ["id" ] for item in first } == {"glm-5.2" , "DeepSeek-V4-Flash " }
1026+ assert {item ["id" ] for item in first } == {"glm-5.2" , "deepseek-v4-flash " }
10271027 cached_keys = app .state .services .model_list_cache ["trae" ].keys ()
10281028 assert cached_keys == {"glm-5.2" , "deepseek-v4-flash" }
10291029
10301030 # 第二次:拉取失败 → 用缓存兜底,列表不缺模型
10311031 trae .fail = True
10321032 second = client .get ("/v1/models" , headers = auth ).json ()["data" ]
1033- assert {item ["id" ] for item in second } == {"glm-5.2" , "DeepSeek-V4-Flash " }
1033+ assert {item ["id" ] for item in second } == {"glm-5.2" , "deepseek-v4-flash " }
10341034 assert {item ["id" ] for item in second } and all (
10351035 item ["providers" ] == ["trae" ] for item in second )
10361036
10371037 # 恢复后重新拉取成功,缓存刷新
10381038 trae .fail = False
10391039 third = client .get ("/v1/models" , headers = auth ).json ()["data" ]
1040- assert {item ["id" ] for item in third } == {"glm-5.2" , "DeepSeek-V4-Flash " }
1040+ assert {item ["id" ] for item in third } == {"glm-5.2" , "deepseek-v4-flash " }
10411041
10421042
10431043def test_models_blocklist_filters_noise_and_old (tmp_path ):
@@ -1088,6 +1088,51 @@ def import_credential(self, raw):
10881088 assert "kimi-k2.6" not in ids2 and "glm-5.2" in ids2
10891089
10901090
1091+ def test_models_blocklist_matches_normalized_id_and_display_name (tmp_path ):
1092+ """黑名单按**归一后的对外写法**匹配:归一键、展示名都能命中。
1093+
1094+ 对外 id 改为归一键后,用户照列表里看到的名字写黑名单必须生效——
1095+ 按 zen 归一键 `longcat-2.5-preview`、展示名 `MiMo V2.5` 都能滤掉;
1096+ Qoder 的原代号 `kmodel_latest` 归一键是展示名派生的 `kimi-k3`,按
1097+ `kimi-k3` 也能滤。同时老口径(原代号 `custom_model_*`)仍生效。
1098+ """
1099+ settings = Settings (_env_file = None , APP_SECRET = SECRET , DATA_DIR = str (tmp_path ),
1100+ MODEL_BLOCKLIST = "longcat-2.5-preview,MiMo V2.5,kimi-k3,"
1101+ "custom_model_*" )
1102+
1103+ class Stub :
1104+ def __init__ (self , pid : str , models ):
1105+ self .id = pid
1106+ self ._models = models
1107+
1108+ async def list_models (self , _data ):
1109+ return list (self ._models )
1110+
1111+ def import_credential (self , raw ): # pragma: no cover - 未使用
1112+ return raw
1113+
1114+ from src .provider .base import Model
1115+
1116+ app = build_app (settings , providers = {
1117+ "zen" : Stub ("zen" , [Model (id = "longcat-2.5-preview-free" ,
1118+ name = "LongCat 2.5 Preview" ),
1119+ Model (id = "mimo-v2.5-free" , name = "MiMo V2.5" )]),
1120+ "qoder" : Stub ("qoder" , [Model (id = "kmodel_latest" , name = "Kimi-K3" ),
1121+ Model (id = "custom_model_claude" )]),
1122+ })
1123+ for provider_id in ("zen" , "qoder" ):
1124+ app .state .credentials .add (provider = provider_id , credential_data = {"t" : "x" })
1125+ key = app .state .api_keys .create ("root" )["api_key" ]
1126+ with TestClient (app ) as client :
1127+ ids = {item ["id" ] for item in client .get (
1128+ "/v1/models" , headers = {"Authorization" : f"Bearer { key } " }).json ()["data" ]}
1129+ # 归一键 / 展示名 / 原代号三种写法命中即滤
1130+ assert "longcat-2.5-preview" not in ids
1131+ assert "mimo-v2.5" not in ids
1132+ assert "kimi-k3" not in ids
1133+ assert ids == set ()
1134+
1135+
10911136def test_models_blocklist_hot_reload_applies_without_waiting_for_ttl (tmp_path ):
10921137 """MODEL_BLOCKLIST 热更后下一次 /v1/models 立即过滤,不受 TTL 缓存影响。
10931138
@@ -1306,20 +1351,21 @@ def import_credential(self, raw): # pragma: no cover - 未使用
13061351 with TestClient (app ) as client :
13071352 ids = {m ["id" ] for m in client .get (
13081353 "/v1/models" , headers = {"Authorization" : f"Bearer { key } " }).json ()["data" ]}
1309- # zen 单渠道:对外 id 仍是原 id,展示名是清洗后的可读名
1310- assert ids == {"longcat-2.5-preview-free" , "mimo-v2.5-free" ,
1354+ # 对外 id 一律是归一键:zen 的 free 尾缀被去掉;CB 重名条目因退回原代号
1355+ # (展示名不唯一)也保持原 id。
1356+ assert ids == {"longcat-2.5-preview" , "mimo-v2.5" ,
13111357 "hy4-preview" , "hy4-preview-x" }
13121358 # 重名未按名入键:不得留下指向其中一个的歧义名字别名。两个原 id 各自
13131359 # 映射回自己是正确的(用户按原 id 直连仍要能定位到本渠道)。
13141360 assert app .state .services .model_aliases ["codebuddy" ] == {
13151361 "hy4-preview" : "hy4-preview" , "hy4-preview-x" : "hy4-preview-x" }
13161362
13171363
1318- def test_models_name_merge_single_channel_keeps_raw_id (tmp_path ):
1319- """单渠道条目对外仍用上游原 id(Qoder `kmodel_latest`);展示名也能直连 。
1364+ def test_models_single_channel_outward_id_is_normalized (tmp_path ):
1365+ """单渠道条目的对外 id 也是归一键 (Qoder `kmodel_latest` → `kimi-k3`) 。
13201366
1321- 只有多条渠道真正并到一起时才改用归一键——单渠道保持原 id,避免无谓
1322- 改名;展示名同样登记为别名,用户按 `Kimi K3` / `kimi-k3` 也能落到该渠道 。
1367+ 原代号只留在别名表与 `by_provider.raw_id`,转发时换回;用户按原代号直连
1368+ 仍能命中 。
13231369 """
13241370 settings = Settings (_env_file = None , APP_SECRET = SECRET , DATA_DIR = str (tmp_path ))
13251371
@@ -1344,12 +1390,12 @@ def import_credential(self, raw): # pragma: no cover - 未使用
13441390 with TestClient (app ) as client :
13451391 data = client .get ("/v1/models" , headers = {
13461392 "Authorization" : f"Bearer { key } " }).json ()["data" ]
1347- assert [m ["id" ] for m in data ] == ["kmodel_latest" ]
1348- assert data [0 ]["name" ] == "Kimi K3" # 展示名统一清洗
1393+ assert [m ["id" ] for m in data ] == ["kimi-k3" ] # 对外 id 用归一键
1394+ assert data [0 ]["name" ] == "Kimi K3" # 展示名统一清洗
13491395 aliases = app .state .services .model_aliases
1350- assert aliases ["qoder" ]["kmodel_latest " ] == "kmodel_latest"
1351- assert aliases ["qoder" ]["kimi-k3 " ] == "kmodel_latest" # 归一后的名字键
1352- assert aliases ["qoder" ]["kimi k3" ] == "kmodel_latest" # 原样的展示名也能直连
1396+ assert aliases ["qoder" ]["kimi-k3 " ] == "kmodel_latest" # 对外 id → 原代号
1397+ assert aliases ["qoder" ]["kmodel_latest " ] == "kmodel_latest" # 原代号也能直连
1398+ assert aliases ["qoder" ]["kimi k3" ] == "kmodel_latest" # 原样的展示名也能直连
13531399
13541400
13551401def test_models_name_merge_cross_channel_via_normalized_key (tmp_path ):
@@ -1379,7 +1425,7 @@ def import_credential(self, raw): # pragma: no cover - 未使用
13791425 # 归一到同一键(`longcat-2.5-preview`)→ 并成一条,对外 id 用该键
13801426 "zen" : Stub ("zen" , [Model (id = "longcat-2.5-preview-free" , name = "" )]),
13811427 "trae" : Stub ("trae" , [Model (id = "longcat-2.5-preview" , name = "" )]),
1382- # kilo 的 `kilo-auto/free` 归一到 `kilo-auto`,单渠道仍用原 id 对外
1428+ # kilo 的 `kilo-auto/free` 归一到 `kilo-auto`,单渠道对外 id 用归一键
13831429 "kilo" : Stub ("kilo" , [Model (id = "kilo-auto/free" , name = "" )]),
13841430 })
13851431 for provider_id in ("zen" , "trae" , "kilo" ):
@@ -1389,7 +1435,7 @@ def import_credential(self, raw): # pragma: no cover - 未使用
13891435 data = {m ["id" ]: m for m in client .get (
13901436 "/v1/models" , headers = {"Authorization" : f"Bearer { key } " }).json ()["data" ]}
13911437
1392- assert set (data ) == {"longcat-2.5-preview" , "kilo-auto/free " }
1438+ assert set (data ) == {"longcat-2.5-preview" , "kilo-auto" }
13931439 merged = data ["longcat-2.5-preview" ]
13941440 assert merged ["providers" ] == ["trae" , "zen" ]
13951441 assert merged ["name" ] == "LongCat 2.5 Preview"
@@ -1400,7 +1446,7 @@ def import_credential(self, raw): # pragma: no cover - 未使用
14001446 aliases = app .state .services .model_aliases
14011447 assert aliases ["zen" ]["longcat-2.5-preview" ] == "longcat-2.5-preview-free"
14021448 assert aliases ["zen" ]["longcat-2.5-preview-free" ] == "longcat-2.5-preview-free"
1403- # kilo 单渠道:对外 id 是原 id,但归一后的键也能直连到同一渠道
1449+ # kilo 单渠道:对外 id 是归一键,原代号仍可直连
14041450 assert aliases ["kilo" ]["kilo-auto" ] == "kilo-auto/free"
14051451 assert aliases ["kilo" ]["kilo-auto/free" ] == "kilo-auto/free"
14061452
@@ -1437,7 +1483,7 @@ def import_credential(self, raw): # pragma: no cover - 未使用
14371483 with TestClient (app ) as client :
14381484 data = [m for m in client .get (
14391485 "/v1/models" , headers = {"Authorization" : f"Bearer { key } " }).json ()["data" ]]
1440- assert {m ["id" ] for m in data } == {"kilo-auto/free " , "auto" }
1486+ assert {m ["id" ] for m in data } == {"kilo-auto" , "auto" }
14411487 assert all (len (m ["providers" ]) == 1 for m in data ) # 各渠道单列
14421488
14431489
@@ -1493,24 +1539,41 @@ def import_credential(self, raw): # pragma: no cover - 未使用
14931539 assert aliases ["zen" ]["glm-5" ] == "glm-5"
14941540
14951541
1496- def test_models_outward_id_triple_collision_falls_back_to_group_key ():
1497- """三方撞车、全部候选被占 → 用归一键兜底(`_disambiguate` 的 `for...else`) 。
1542+ def test_models_outward_id_collision_falls_back_to_channel_suffix ():
1543+ """对外 id 撞车且候选被占时,依次退回「原代号归一键 → 加渠道名后缀」 。
14981544
1499- entry 的对外 id、主渠道原 id、归一形式全被先定稿的条目占掉时,退回
1500- 构造唯一的归一键,保证对外 id 有值。真实构造里归一键按组唯一、不会与
1501- 已有 id 重复,这里只验证「走到兜底分支」。
1545+ entry 的对外 id 与主渠道原代号归一键都被先定稿的条目占掉时,退回加渠道名
1546+ 后缀的候选,保证对外 id 唯一。
15021547 """
15031548 from src .api .models import _disambiguate
15041549
15051550 entries = [
15061551 {"id" : "x" , "key" : "kx" , "providers" : ["p1" ], "raw_ids" : {"p1" : "x" }},
15071552 {"id" : "y" , "key" : "ky" , "providers" : ["p2" ], "raw_ids" : {"p2" : "y" }},
15081553 {"id" : "k4" , "key" : "k4" , "providers" : ["p3" ], "raw_ids" : {"p3" : "k4" }},
1509- # id/原 id/归一形式全被占(x、y、k4):只能走 else 兜底
1510- {"id" : "x" , "key" : "k4" , "providers" : ["p1" ], "raw_ids" : {"p1" : "y" }},
1554+ # id(x)与主渠道原代号归一(y)都被占:退回 `y-p1`
1555+ {"id" : "x" , "key" : "k9" , "providers" : ["p1" ], "raw_ids" : {"p1" : "y" }},
1556+ ]
1557+ _disambiguate (entries )
1558+ assert [e ["id" ] for e in entries ] == ["x" , "y" , "k4" , "y-p1" ]
1559+
1560+
1561+ def test_models_outward_id_collision_falls_back_to_numeric_suffix ():
1562+ """候选全被占时追加数字后缀(`_unique_suffix` 兜底),保证对外 id 唯一。"""
1563+ from src .api .models import _disambiguate
1564+
1565+ entries = [
1566+ {"id" : "x" , "key" : "kx" , "providers" : ["p1" ], "raw_ids" : {"p1" : "x" }},
1567+ {"id" : "y" , "key" : "ky" , "providers" : ["p2" ], "raw_ids" : {"p2" : "y" }},
1568+ {"id" : "y-p1" , "key" : "kz" , "providers" : ["p3" ],
1569+ "raw_ids" : {"p3" : "y-p1" }},
1570+ {"id" : "x-2" , "key" : "kw" , "providers" : ["p4" ],
1571+ "raw_ids" : {"p4" : "x-2" }},
1572+ # 候选 x / y / y-p1 与后缀 x-2 全被占 → 用 x-3 兜底
1573+ {"id" : "x" , "key" : "k9" , "providers" : ["p1" ], "raw_ids" : {"p1" : "y" }},
15111574 ]
15121575 _disambiguate (entries )
1513- assert [e ["id" ] for e in entries ] == ["x" , "y" , "k4 " , "k4 " ]
1576+ assert [e ["id" ] for e in entries ] == ["x" , "y" , "y-p1 " , "x-2" , "x-3 " ]
15141577
15151578
15161579def test_models_list_orders_cb_then_trae_first (tmp_path ):
0 commit comments