端点与模型清单
两个纯函数模块,不需要 client feature,也不碰网络。
端点拼接
核心契约:base_url 原样使用
use ai_profile::endpoint::join_api_path;
assert_eq!(
join_api_path("https://api.deepseek.com/v1", "models"),
"https://api.deepseek.com/v1/models"
);写什么就是什么,版本段由用户自己填。 预置清单里各家都已带上完整版本段。
为什么不自动补 /v1
这里曾经是推断式的:「末段是 v<数字> 就不补、否则补 /v1、末尾 # 可以强制不补」。 推断看着聪明,但它的例外一直在变多:
| 情况 | 实际地址 |
|---|---|
| 多数服务商 | /v1 |
| 智谱 | /v4 |
| Gemini OpenAI 兼容层 | /v1beta/openai —— 版本段不在末尾 |
| 各类中转站 | /api/openai/v1、/proxy/anthropic … |
Gemini 那条只能靠末尾 # 开后门。「需要一个转义符才能表达的规则」本身就说明规则不对。
而推断错的代价是隐性的:用户照着服务商文档填了正确的 base_url, 库悄悄加了一段,他只看到 404 —— 而且第一反应是怀疑自己填错了, 根本不会想到是库擅自改了地址。
这条契约有守卫测试
endpoint_never_infers_version 盯着,防止有人日后"好心"把推断加回来。
仅有的两处容错
// 1. 误填了完整对话端点 —— 剥回 base
join_api_path("https://api.x.com/v1/chat/completions", "models")
// → "https://api.x.com/v1/models"
// 2. 旧契约遗留的末尾 # 标记(存量配置里可能有)
join_api_path("https://api.x.com/v1#", "models")
// → "https://api.x.com/v1/models"第 1 条对应一个很常见的用户行为:「同一个字段填了两种东西」—— 有人把文档里的完整请求地址整个粘进来。
join_chat_endpoint:只对对话端点成立的捷径
use ai_profile::endpoint::join_chat_endpoint;
// 用户粘了完整端点 → 原样使用
join_chat_endpoint("https://relay.example/v1/chat/completions", "chat/completions")
// → "https://relay.example/v1/chat/completions"path 传 "chat/completions"(OpenAI 兼容)或 "messages"(Anthropic)。
为什么它和 join_api_path 是两个函数
「获取模型」走的是 <base>/models。如果共用一套判断, 用户填了完整的 /chat/completions 会让它拿这个地址去 GET,必然失败。
表现是「能聊天却拉不到模型列表」—— 一个很难联想到根因的故障。
ends_with_version_segment
use ai_profile::endpoint::ends_with_version_segment;
assert!(ends_with_version_segment("https://api.deepseek.com/v1"));
assert!(ends_with_version_segment("https://open.bigmodel.cn/api/paas/v4"));
assert!(!ends_with_version_segment("https://api.deepseek.com"));
assert!(!ends_with_version_segment("https://v4.example.com")); // 这是主机名只认「v + 全数字」,所以主机名以 v4. 开头的不会被误判。
端点拼接不用它 —— 保留是为两件事:守住「预置 base_url 必须自带版本段」这条契约, 以及给前端「这个地址看着缺版本段」的提醒保持同一口径。
模型清单清洗
端点返回的 /models 往往混着向量、重排、语音、OCR 等非对话模型。 聚合平台尤其明显 —— 硅基流动一次能返回上百条,OpenRouter 实测 433 条。
use ai_profile::model_filter::{clean_fetched_models, is_chat_model_id};
let cleaned = clean_fetched_models(ids);
println!("{} 个可用,滤掉 {} 个", cleaned.models.len(), cleaned.dropped);dropped 用于「已滤掉 N 个向量 / 重排 / 语音等」这类提示文案 —— 让用户知道清单被处理过,而不是以为端点就这么几个。
排除法,不是白名单
判断依据是特征词排除(embed、rerank、whisper、tts、image、ocr…), 而不是「认识的模型才留」。
理由:厂商上新速度远快于特征词更新。白名单必然把新模型误藏, 而**"藏起来"对用户是不可见的** —— 他只会觉得"这个端点怎么没有那个模型", 不会想到是客户端滤掉了。排除法最多漏掉几个该滤的,代价小得多。
全被滤光时原样返回
// 🔴 如果过滤后一个不剩,返回去重后的原始清单,并把 dropped 记 0那说明这套特征词在这个端点上判错了。此时宁可把原始清单摆给用户看, 也不能给他一个空下拉 —— 增强而非依赖:任何一步出错都不该让用户卡在"选不了模型"。
CleanedModels
| 字段 | 说明 |
|---|---|
models | 可用于对话的清单(已去重) |
dropped | 被滤掉的条数 |
is_chat_model_id(id) 是单条判断,可以单独使用。