Skip to main content

ai_profile/preset/
mod.rs

1//! Provider 预置清单 —— 本 crate 存在的首要理由。
2//!
3//! # 为什么这份数据值得抽出来
4//!
5//! 它是**变动最频繁、跨应用差异为零**的那部分。模型 id 月月换代,而五个应用各存一份
6//! 就意味着漏改是必然的:DeepSeek 2026-07-24 下线 `deepseek-chat` 别名后,某个应用
7//! 直到两个月后才发现「点开即报错」。
8//!
9//! # 🔴 加一家 provider 的规矩
10//!
11//! 1. `base_url` **照抄服务商文档原文** —— 含版本段、不含端点后缀。
12//!    后端 [`crate::endpoint`] 原样使用、不做任何推断,所以这里少写一段就是 404。
13//! 2. `model` 选**够用档**而非最强档 —— 把旗舰塞进默认值等于替用户做了一个
14//!    他没同意的花钱决定。最强档留在 `models` 里随时可选。
15//! 3. `models` 只放**核对过**的 id,并填 `verified_at`。没实际调通过就留 `None`。
16//! 4. `vendor_id` 已有厂商要复用同一个(硅基流动的 chat/image 共用 `siliconflow`)。
17//! 5. 跑 `cargo test`,守卫测试必须全绿。
18//!
19//! # 数组顺序即呈现顺序
20//!
21//! 下拉按 `group_key` 分组,**同一 kind 内同组必须连续排列** —— 不连续会切出重复的分组标题。
22//! 有 `preset_groups_are_contiguous` 守着。不同 kind 分开渲染,各自从「国内」起排。
23
24use serde::Serialize;
25
26use crate::kind::{Kind, Protocol};
27
28pub mod vendor;
29
30pub mod catalog;
31mod chat;
32mod docgen;
33#[cfg(feature = "image")]
34mod image;
35#[cfg(feature = "tts")]
36mod tts;
37#[cfg(feature = "video")]
38mod video;
39
40pub use catalog::PresetCatalog;
41pub use docgen::render_providers_markdown;
42pub use vendor::{vendors, vendors_all, vendors_in, Vendor};
43
44/// 一个模型候选项。
45///
46/// 下拉只是**建议清单** —— 调用方应当允许用户手填任意 model id,也可以调
47/// [`crate::model_filter`] 清洗端点返回的真实清单。所以这里的值过期不会卡死用户。
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "camelCase")]
50#[non_exhaustive]
51pub struct ModelOption {
52    /// 模型 id,原样传给服务商
53    pub value: &'static str,
54    /// 展示用标签;与 `value` 相同则调用方直接显示 `value`
55    pub label: &'static str,
56    /// 上下文窗口(输入 + 输出总量)的**静态兜底值**,端点不报时用。
57    ///
58    /// 🔴 只在**官方文档明确写了**时填,且宁可保守 —— 猜大了会让裁历史裁不够,
59    /// 表现为「明明裁过还是超限」;猜小了只是浪费一点窗口,代价小得多。
60    ///
61    /// `None` = 不知道。调用方应当让用户手填,而不是自己兜一个默认值。
62    pub context_window: Option<u32>,
63    /// 单次输出上限的静态兜底值。同上,只填文档明确的。
64    pub max_output: Option<u32>,
65}
66
67impl ModelOption {
68    /// 标签与 id 相同、且不带限额信息的快捷构造。
69    ///
70    /// 绝大多数预置用它 —— 限额优先从端点实时取,静态值只给
71    /// 「端点不报 + 官方文档明确」的那些模型补。
72    pub const fn plain(value: &'static str) -> Self {
73        Self {
74            value,
75            label: value,
76            context_window: None,
77            max_output: None,
78        }
79    }
80
81    /// 带静态限额兜底的构造。
82    ///
83    /// 用于 OpenAI 规范兼容端点 —— 它们的 `/models` 只返回
84    /// `{id, object, owned_by}`,一个限额字段都没有(LM Studio /
85    /// Ollama 实测如此),不给静态值的话这个能力在那些端点上等于不存在。
86    ///
87    /// ⚠️ 这些数字**会过时**。DeepSeek V3 时代是 128K/8K,V4 已经是 1M/384K ——
88    /// 半年翻了八倍。所以:端点报了就用端点的,静态值只是端点沉默时的下限保证。
89    ///
90    /// ```
91    /// # use ai_profile::preset::ModelOption;
92    /// // DeepSeek V4 系官方标称:1M 上下文、384K 输出(2026-09-22 核对)
93    /// let m = ModelOption::with_limits("deepseek-flash", 1_000_000, 384_000);
94    /// assert_eq!(m.context_window, Some(1_000_000));
95    /// ```
96    pub const fn with_limits(value: &'static str, context_window: u32, max_output: u32) -> Self {
97        Self {
98            value,
99            label: value,
100            context_window: Some(context_window),
101            max_output: Some(max_output),
102        }
103    }
104
105    /// 只知道上下文窗口、不知道输出上限时用。
106    ///
107    /// 这种情况很常见:多数服务商文档会写「支持 200K 上下文」,
108    /// 却不单独说明单次输出上限。**不知道就是不知道**,别拿窗口大小去猜输出上限。
109    pub const fn with_context(value: &'static str, context_window: u32) -> Self {
110        Self {
111            value,
112            label: value,
113            context_window: Some(context_window),
114            max_output: None,
115        }
116    }
117
118    /// 这条模型的静态兜底限额;两者都没有时返回 `None`。
119    ///
120    /// 🔴 返回的 [`TokenLimits`](crate::limits::TokenLimits) 带
121    /// `source: Preset` 标记 —— 调用方据此知道这是**估计值**、该允许用户修改,
122    /// 而不是端点保证的事实。
123    pub fn preset_limits(&self) -> Option<crate::limits::TokenLimits> {
124        if self.context_window.is_none() && self.max_output.is_none() {
125            return None;
126        }
127        Some(crate::limits::TokenLimits::from_preset(
128            self.context_window,
129            self.max_output,
130        ))
131    }
132}
133
134/// 服务商专有的额外配置字段(如豆包 TTS 的 `appid` / `cluster`)。
135///
136/// # 🔴 为什么放在数据里而不是写死在界面里
137///
138/// 这类字段**按服务商而非按 kind 变化** —— 同是 tts,硅基流动就不需要 `appid`。
139/// 写死在组件里意味着**每接一家新服务商就要改前端**;放进预置表,
140/// 加一家 provider 只改本 crate 的一个数组字面量,所有下游同时生效。
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[serde(rename_all = "camelCase")]
143#[non_exhaustive]
144pub struct ExtraField {
145    /// 字段 key,收集后存进调用方的 `extra` JSON
146    pub key: &'static str,
147    /// i18n key
148    pub label_key: &'static str,
149    /// 纯文本标签 —— 给没接 i18n 的调用方
150    pub label: &'static str,
151    /// 输入框占位符
152    pub placeholder: Option<&'static str>,
153    /// 为真时缺失会让验证直接失败([`crate::VerifyError::MissingExtraField`]),
154    /// 调用方应在**发起验证之前**就禁用按钮
155    pub required: bool,
156}
157
158/// 一条 provider 预置。
159///
160/// `#[non_exhaustive]`:加字段是 minor 而非 major。调用方不能用字面量构造它,
161/// 只能从 [`presets`] / [`preset_by_key`] 取。
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "camelCase")]
164#[non_exhaustive]
165pub struct ProviderPreset {
166    /// 模板 key = 下拉 value,也是**存量配置回填的依据**。
167    ///
168    /// 🔴 改名 = major 版本:用户已存的配置靠它找回对应模板,改了就全掉进「自定义」。
169    pub key: &'static str,
170
171    /// 🔴 跨 kind 聚合的依据。
172    ///
173    /// 硅基流动的 chat / image / video / tts 共用一个 `vendor_id`,服务商目录靠它
174    /// 把四条预置并成一张卡。同 `vendor_id` 的 `base_url` host 必须一致 ——
175    /// 有 `vendor_ids_consistent` 守着。
176    pub vendor_id: &'static str,
177
178    /// 这条预置属于哪种能力
179    pub kind: Kind,
180
181    /// 分组 key(i18n)。数组顺序即呈现顺序,**同组必须连续**
182    pub group_key: &'static str,
183    /// 分组纯文本名 —— 给没接 i18n 的调用方
184    pub group_label: &'static str,
185
186    /// provider 名的 i18n key
187    pub label_key: &'static str,
188    /// provider 纯文本名。同 `group_label` 的理由
189    pub label: &'static str,
190
191    /// 下拉项副文本的 i18n key
192    pub hint_key: Option<&'static str>,
193    /// 副文本纯文本
194    pub hint: Option<&'static str>,
195
196    /// 预填的 base_url。
197    ///
198    /// 🔴 含版本段、不含端点后缀。`None` = 走官方端点或让用户自填
199    /// (调用方据此隐藏/显示输入框)。
200    pub base_url: Option<&'static str>,
201
202    /// 默认 model;空串 = 让用户自己填(本地推理服务的 id 因人而异)
203    pub model: &'static str,
204    /// 建议模型清单
205    pub models: &'static [ModelOption],
206
207    /// 实际协议:走 `/v1/messages` 还是 `/v1/chat/completions`
208    pub protocol: Protocol,
209
210    /// 从已存配置的 base_url 反推模板 key 用的主机名片段。
211    ///
212    /// 空数组 = 该档靠 `protocol` 而非 host 反推(自定义端点类)。
213    pub match_hosts: &'static [&'static str],
214
215    /// 专有协议的额外配置字段
216    pub extra_fields: &'static [ExtraField],
217
218    /// 用这条预置新建配置时,自动写进调用方 `extra` 的固定键值(用户不用填、也不该改)。
219    ///
220    /// 用途是**显式指定协议**,让 crate 不必认识品牌:比如某 New API 视频中转站带
221    /// `("video_api", "newapi")`,`media::video::VideoProtocol::detect`(`video` feature)就按 New API 处理。
222    /// 与 [`Self::extra_fields`] 的区别:那是让用户**填**的,这是预置**定死**的。
223    /// 线格式是 `[[key, value], …]`。
224    pub default_extra: &'static [(&'static str, &'static str)],
225
226    /// API Key 申请页;`None` = 本地服务,不需要申请
227    pub apply_url: Option<&'static str>,
228
229    /// 🔴 是否需要用户**先在本机把服务跑起来**(Ollama / LM Studio / vLLM)。
230    ///
231    /// 服务商目录据此区分「云端服务,去申请 key」与「本地服务,先启动它」——
232    /// 不区分的话,用户会按云服务的思路去配,配好却连不上。
233    pub is_local: bool,
234
235    /// 最后一次实际调通的日期(`YYYY-MM-DD`)。
236    ///
237    /// `None` = 未核实,仅供参考。调用方可对久未核实的预置给一个淡色提示。
238    pub verified_at: Option<&'static str>,
239}
240
241impl ProviderPreset {
242    /// 用户没填地址时,这条预置实际该请求的端点。
243    ///
244    /// - 预置写了 `base_url` → 就是它
245    /// - 没写,但**协议的官方端点落在它声明的 `match_hosts` 里** → 协议官方端点
246    ///   (「Anthropic 官方」这类固定走官方地址、界面隐藏地址框的预置)
247    /// - 其余(自定义端点、各类中转档,`match_hosts` 为空)→ `None`,必须由用户填
248    ///
249    /// 🔴 「Anthropic 官方」的 `base_url` 刻意留空(调用方据此隐藏输入框),但请求总得有个地址。
250    /// 此前「获取模型」验证(`client::Verifier::verify`)只看 `base_url`,用户不填地址时直接报
251    /// 「缺 base_url」—— 官方档恰恰是最不该让用户填地址的那一档。
252    pub fn endpoint(&self) -> Option<&'static str> {
253        self.base_url.or_else(|| {
254            let official = self.protocol.default_base_url();
255            self.match_hosts
256                .iter()
257                .any(|h| official.contains(h))
258                .then_some(official)
259        })
260    }
261}
262
263/// 下游自建预置用的 const 构造器(crate 自己的预置照旧写字面量)。
264///
265/// `ProviderPreset` 是 `#[non_exhaustive]`,下游不能写字面量;这组 `const fn` 让下游照样能写**静态表**,
266/// 再交给 [`PresetCatalog::extend`] 合并进目录。见 [`catalog`] 模块文档。
267///
268/// 缺省值:`vendor_id` = key、分组「本地 / 自建」、OpenAI 兼容协议、无模型、无 i18n key(`label_key` 为空,
269/// 接了 i18n 的调用方遇到空 key 应直接显示 `label`)。
270impl ProviderPreset {
271    /// 最小构造:key、能力、名字、地址(`None` = 让用户自填)。
272    pub const fn new(
273        key: &'static str,
274        kind: Kind,
275        label: &'static str,
276        base_url: Option<&'static str>,
277    ) -> Self {
278        Self {
279            key,
280            vendor_id: key,
281            kind,
282            group_key: GROUP_LOCAL.0,
283            group_label: GROUP_LOCAL.1,
284            label_key: "",
285            label,
286            hint_key: None,
287            hint: None,
288            base_url,
289            model: "",
290            models: &[],
291            protocol: Protocol::OpenAiCompatible,
292            match_hosts: &[],
293            extra_fields: &[],
294            default_extra: &[],
295            apply_url: None,
296            is_local: false,
297            verified_at: None,
298        }
299    }
300    /// 服务商聚合 id(同一家多种能力共用;缺省 = key)
301    pub const fn with_vendor(mut self, vendor_id: &'static str) -> Self {
302        self.vendor_id = vendor_id;
303        self
304    }
305    /// 分组(传 `GROUP_CHINA` 等常量)
306    pub const fn with_group(mut self, group: (&'static str, &'static str)) -> Self {
307        self.group_key = group.0;
308        self.group_label = group.1;
309        self
310    }
311    /// 下拉第二行的要点
312    pub const fn with_hint(mut self, hint: &'static str) -> Self {
313        self.hint = Some(hint);
314        self
315    }
316    /// 默认模型 + 候选清单
317    pub const fn with_models(
318        mut self,
319        model: &'static str,
320        models: &'static [ModelOption],
321    ) -> Self {
322        self.model = model;
323        self.models = models;
324        self
325    }
326    /// 对话协议(缺省 OpenAI 兼容)
327    pub const fn with_protocol(mut self, protocol: Protocol) -> Self {
328        self.protocol = protocol;
329        self
330    }
331    /// 反推模板用的主机名片段
332    pub const fn with_match_hosts(mut self, hosts: &'static [&'static str]) -> Self {
333        self.match_hosts = hosts;
334        self
335    }
336    /// 让用户填的专有字段
337    pub const fn with_extra_fields(mut self, fields: &'static [ExtraField]) -> Self {
338        self.extra_fields = fields;
339        self
340    }
341    /// 预置定死的 extra 键值(如 `("video_api", "newapi")`)
342    pub const fn with_default_extra(mut self, kv: &'static [(&'static str, &'static str)]) -> Self {
343        self.default_extra = kv;
344        self
345    }
346    /// 密钥申请页
347    pub const fn with_apply_url(mut self, url: &'static str) -> Self {
348        self.apply_url = Some(url);
349        self
350    }
351    /// 本地推理服务(需用户先把服务跑起来)
352    pub const fn local(mut self) -> Self {
353        self.is_local = true;
354        self
355    }
356}
357
358/// 分组 key —— 声明顺序与预置数组里的出现顺序一致。
359///
360/// 排序按「用户找到它的概率」从高到低:
361/// - **Anthropic / 协议档**在最前:按端点约定分档的那几条,放一起免得用户找两次
362/// - **国内**次之:主要用户群在国内
363/// - **国际**再次
364/// - **本地/自建**最后:这几档要求用户先把推理服务跑起来,能用的人本就知道自己在找什么
365pub const GROUP_ANTHROPIC: (&str, &str) = ("providerGroup.anthropic", "Anthropic / 协议档");
366/// 国内服务商
367pub const GROUP_CHINA: (&str, &str) = ("providerGroup.china", "国内");
368/// 国际服务商
369pub const GROUP_INTERNATIONAL: (&str, &str) = ("providerGroup.international", "国际");
370/// 本地 / 自建推理服务
371pub const GROUP_LOCAL: (&str, &str) = ("providerGroup.local", "本地 / 自建");
372
373/// 兜底模板 key —— 反推不出任何 host 时落到这里。
374pub const CUSTOM_PRESET_KEY: &str = "openai_compatible_custom";
375
376/// 全部预置。数组顺序即呈现顺序。
377///
378/// 顺序:chat → image → video → tts(只含本 build 开启的 kind)。
379/// 只开 `chat` 时直接返回静态数组;开了多模态才在首次调用时拼接一次。
380pub fn presets() -> &'static [ProviderPreset] {
381    #[cfg(not(any(feature = "image", feature = "video", feature = "tts")))]
382    {
383        chat::CHAT_PRESETS
384    }
385    #[cfg(any(feature = "image", feature = "video", feature = "tts"))]
386    {
387        static ALL: std::sync::OnceLock<Vec<ProviderPreset>> = std::sync::OnceLock::new();
388        ALL.get_or_init(|| {
389            let mut v = chat::CHAT_PRESETS.to_vec();
390            #[cfg(feature = "image")]
391            v.extend_from_slice(image::IMAGE_PRESETS);
392            #[cfg(feature = "video")]
393            v.extend_from_slice(video::VIDEO_PRESETS);
394            #[cfg(feature = "tts")]
395            v.extend_from_slice(tts::TTS_PRESETS);
396            v
397        })
398    }
399}
400
401/// 按 kind 过滤 —— 调用方渲染下拉时用。
402pub fn presets_for(kind: Kind) -> impl Iterator<Item = &'static ProviderPreset> {
403    presets().iter().filter(move |p| p.kind == kind)
404}
405
406/// 按 key 查找。
407pub fn preset_by_key(key: &str) -> Option<&'static ProviderPreset> {
408    presets().iter().find(|p| p.key == key)
409}
410
411/// 从已存配置反推模板 key。
412///
413/// - `Anthropic` 协议 + 空 base_url 或官方 URL → `anthropic_official`
414/// - `Anthropic` 协议 + 自定义 base_url → `claude_code`
415/// - 其余按 `match_hosts` 命中主机名;都不中 → [`CUSTOM_PRESET_KEY`]
416///
417/// 🔴 按 host 匹配而非全串相等:预置的 base_url 带版本段,而存量配置可能存的是
418/// 不带版本段的旧写法,全串比较会让老配置统统掉进「自定义端点」。
419pub fn infer_preset_key(protocol: Protocol, base_url: Option<&str>) -> &'static str {
420    let url = base_url.unwrap_or("").trim().to_ascii_lowercase();
421
422    if protocol == Protocol::Anthropic {
423        // 按 host 判,不按全串:官方地址带不带 `/v1` 都是官方档。
424        // 此前全串比较 `https://api.anthropic.com`,而默认端点已改成带 `/v1` 的写法,
425        // 用户照抄官方文档填进来就会被误判成「Claude Code 中转」档。
426        if url.is_empty() || url.contains("://api.anthropic.com") {
427            return "anthropic_official";
428        }
429        return "claude_code";
430    }
431    if url.is_empty() {
432        return CUSTOM_PRESET_KEY;
433    }
434    // 🔴 只在对话预置里反推:开了多模态后,硅基流动 / 火山方舟等同 host 的
435    //    生图、视频预置也在 presets() 里,不过滤会把一条对话配置认成生图档
436    for p in presets_for(Kind::Chat) {
437        if p.protocol != Protocol::OpenAiCompatible {
438            continue;
439        }
440        if p.match_hosts.iter().any(|h| url.contains(h)) {
441            return p.key;
442        }
443    }
444    CUSTOM_PRESET_KEY
445}
446
447/// 一条已存配置的模型在预置里登记的静态限额(`source: Preset`)。
448///
449/// 先按 [`infer_preset_key`] 找到预置,再按 model id 精确匹配。
450/// 查不到返回 `None` —— 自定义端点、预置外的模型都是这种情况,**不猜**。
451///
452/// 这是分层回退里的第 2 层;调用方把用户设置 / 端点上报用
453/// [`TokenLimits::or`](crate::limits::TokenLimits::or) 叠在它上面。
454///
455/// ```
456/// # use ai_profile::{preset, Protocol};
457/// let l = preset::model_limits(
458///     Protocol::OpenAiCompatible,
459///     Some("https://api.deepseek.com/v1"),
460///     "deepseek-flash",
461/// ).unwrap();
462/// assert!(l.context_window.is_some());
463/// assert!(preset::model_limits(Protocol::OpenAiCompatible, None, "whatever").is_none());
464/// ```
465pub fn model_limits(
466    protocol: Protocol,
467    base_url: Option<&str>,
468    model: &str,
469) -> Option<crate::limits::TokenLimits> {
470    let model = model.trim();
471    preset_by_key(infer_preset_key(protocol, base_url))?
472        .models
473        .iter()
474        .find(|m| m.value == model)
475        .and_then(ModelOption::preset_limits)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    /// 官方档没写地址也要有端点;自定义档没写地址就是没有,必须让用户填。
483    #[test]
484    fn endpoint_falls_back_to_official_only_for_fixed_presets() {
485        let official = preset_by_key("anthropic_official").unwrap();
486        assert!(
487            official.base_url.is_none(),
488            "官方档的 base_url 要留空(界面据此隐藏地址框)"
489        );
490        assert_eq!(official.endpoint(), Some("https://api.anthropic.com/v1"));
491
492        for key in ["claude_code", "codex", CUSTOM_PRESET_KEY] {
493            let p = preset_by_key(key).unwrap();
494            assert_eq!(p.endpoint(), None, "{key} 是让用户自填地址的档,不能替他猜");
495        }
496        let deepseek = preset_by_key("deepseek").unwrap();
497        assert_eq!(deepseek.endpoint(), deepseek.base_url);
498    }
499
500    /// 守卫:所有「没写地址却声明了主机名」的预置,都必须能解析出端点 ——
501    /// 否则它既不让用户填(界面隐藏地址框),又没有地址可请求。
502    #[test]
503    fn fixed_presets_without_base_url_resolve_an_endpoint() {
504        for p in presets() {
505            if p.base_url.is_none() && !p.match_hosts.is_empty() {
506                assert!(
507                    p.endpoint().is_some(),
508                    "{} 没写地址也解析不出官方端点",
509                    p.key
510                );
511            }
512        }
513    }
514
515    /// 🔴 同一 kind 内同组必须连续 —— 不连续会让下拉切出两个同名分组标题。
516    ///
517    /// 按 kind 分别判:各 kind 分开渲染(对话下拉、生图下拉……),各自从「国内」起排。
518    #[test]
519    fn preset_groups_are_contiguous() {
520        let mut kinds: Vec<Kind> = Vec::new();
521        for p in presets() {
522            if !kinds.contains(&p.kind) {
523                kinds.push(p.kind);
524            }
525        }
526        for kind in kinds {
527            let mut seen: Vec<&str> = Vec::new();
528            let mut prev = "";
529            for p in presets_for(kind) {
530                if p.group_key == prev {
531                    continue;
532                }
533                assert!(
534                    !seen.contains(&p.group_key),
535                    "{:?} 的分组 {} 被拆成了不连续的多段",
536                    kind,
537                    p.group_key
538                );
539                seen.push(p.group_key);
540                prev = p.group_key;
541            }
542        }
543    }
544
545    /// 同一 kind 的预置必须连续 —— `presets_for` 过滤无所谓,但文档与服务商目录按数组顺序走。
546    #[test]
547    fn preset_kinds_are_contiguous() {
548        let mut seen: Vec<Kind> = Vec::new();
549        let mut prev: Option<Kind> = None;
550        for p in presets() {
551            if prev == Some(p.kind) {
552                continue;
553            }
554            assert!(
555                !seen.contains(&p.kind),
556                "{:?} 的预置被拆成了不连续的多段",
557                p.kind
558            );
559            seen.push(p.kind);
560            prev = Some(p.kind);
561        }
562    }
563
564    /// 🔴 对话配置不能被反推成同 host 的生图 / 视频档。
565    #[test]
566    fn infer_preset_key_only_returns_chat_presets() {
567        for url in [
568            "https://api.siliconflow.cn/v1",
569            "https://ark.cn-beijing.volces.com/api/v3",
570        ] {
571            let key = infer_preset_key(Protocol::OpenAiCompatible, Some(url));
572            assert_eq!(
573                preset_by_key(key).map(|p| p.kind),
574                Some(Kind::Chat),
575                "{url} → {key}"
576            );
577        }
578    }
579
580    /// 🔴 base_url 必须自带版本段(或显式为 None)——
581    /// 端点拼接不做推断,少一段就是下游 404。
582    #[test]
583    fn preset_base_urls_are_well_formed() {
584        for p in presets() {
585            let Some(url) = p.base_url else { continue };
586            assert!(!url.ends_with('/'), "{}: base_url 不应以 / 结尾", p.key);
587            assert!(
588                !url.contains("chat/completions") && !url.ends_with("messages"),
589                "{}: base_url 不该带端点后缀",
590                p.key
591            );
592            // 版本段可以在末尾(/v1、/v4),也可以在中间(Gemini 的 /v1beta/openai)
593            let has_version = crate::endpoint::ends_with_version_segment(url)
594                || url.contains("/v1beta/")
595                || url.contains("/v1/");
596            assert!(has_version, "{}: base_url 看不到版本段 → {}", p.key, url);
597        }
598    }
599
600    /// key 重复会让回填逻辑静默错乱(`find` 只取第一个)。
601    #[test]
602    fn preset_keys_are_unique() {
603        let mut seen: Vec<&str> = Vec::new();
604        for p in presets() {
605            assert!(!seen.contains(&p.key), "重复的 preset key: {}", p.key);
606            seen.push(p.key);
607        }
608    }
609
610    /// 本地服务不该有申请密钥的链接;云端服务(除自定义档)应当有。
611    #[test]
612    fn local_presets_have_no_apply_url() {
613        for p in presets() {
614            if p.is_local {
615                assert!(p.apply_url.is_none(), "{}: 本地服务不需要申请密钥", p.key);
616            }
617        }
618    }
619
620    /// 反推要能把存量配置认回原模板 —— 包括**不带版本段**的旧写法。
621    #[test]
622    fn infer_preset_key_matches_by_host() {
623        assert_eq!(
624            infer_preset_key(
625                Protocol::OpenAiCompatible,
626                Some("https://api.deepseek.com/v1")
627            ),
628            "deepseek"
629        );
630        // 🔴 旧配置不带版本段,靠 host 匹配才认得回来
631        assert_eq!(
632            infer_preset_key(Protocol::OpenAiCompatible, Some("https://api.deepseek.com")),
633            "deepseek"
634        );
635        assert_eq!(
636            infer_preset_key(Protocol::Anthropic, Some("https://api.anthropic.com")),
637            "anthropic_official"
638        );
639        assert_eq!(
640            infer_preset_key(Protocol::Anthropic, None),
641            "anthropic_official"
642        );
643        // 🔴 官方文档写法带 /v1,同样是官方档(此前被误判成中转)
644        assert_eq!(
645            infer_preset_key(Protocol::Anthropic, Some("https://api.anthropic.com/v1")),
646            "anthropic_official"
647        );
648        // 中转站 → Claude Code 档
649        assert_eq!(
650            infer_preset_key(Protocol::Anthropic, Some("https://cc.example.cn/v1")),
651            "claude_code"
652        );
653        // 谁都不中 → 兜底
654        assert_eq!(
655            infer_preset_key(
656                Protocol::OpenAiCompatible,
657                Some("https://unknown.example/v1")
658            ),
659            CUSTOM_PRESET_KEY
660        );
661    }
662
663    /// 兜底档必须存在,否则 `infer_preset_key` 会返回一个查不到的 key。
664    #[test]
665    fn custom_preset_exists() {
666        assert!(
667            preset_by_key(CUSTOM_PRESET_KEY).is_some(),
668            "兜底档 {CUSTOM_PRESET_KEY} 必须在预置表里"
669        );
670    }
671}