Skip to main content

ai_profile/client/
mod.rs

1//! 真实 HTTP 调用 —— `feature = "client"` 才编译。
2//!
3//! # 分层:纯函数与 IO 分开
4//!
5//! 本模块刻意把「判断逻辑」与「发请求」拆开:
6//!
7//! - [`diagnose`]、[`suggest_url`]、[`check_required_fields`] 是**纯函数**,可完整单测
8//! - [`verify`] 只负责发请求,拿到结果后交给上面那些函数判断
9//!
10//! 这样错误映射(本模块最容易出错的地方)不需要网络就能测。
11//!
12//! # 🔴 安全约定
13//!
14//! - **禁重定向**:reqwest 默认跟随最多 10 跳,跨 host 时只剥 `Authorization` 等
15//!   标准头,**不剥自定义头** —— Anthropic 的 `x-api-key` 会被原样发往跳转目标。
16//!   LLM 端点正常返回 200,不需要重定向。这同时堵死了二段跳 SSRF。
17//! - **密钥绝不进错误信息**:[`crate::VerifyError`] 的所有 `detail` 字段只放
18//!   端点返回的文本摘要,调用方可以安全地写日志。
19//! - 本模块**不持久化任何东西**,密钥用完即弃。
20
21use std::time::Duration;
22
23use crate::endpoint::{anthropic_base_url, ends_with_version_segment, join_api_path};
24use crate::error::VerifyError;
25use crate::kind::Protocol;
26use crate::model_filter::clean_fetched_models;
27use crate::preset::preset_by_key;
28
29/// 发起验证所需的配置 —— 全部来自调用方表单里**正在编辑**的值。
30///
31/// 刻意用借用而非 `String`:调用方通常直接从表单字段取,不该为验证一次而 clone。
32#[derive(Debug, Clone, Copy)]
33#[non_exhaustive]
34pub struct ServiceConfig<'a> {
35    /// 预置 key。给了就会校验该预置要求的 `extra_fields`,并在 base_url 为空时用它兜底
36    pub preset_key: Option<&'a str>,
37    /// 协议:决定鉴权头的形式
38    pub protocol: Protocol,
39    /// 端点地址;空串 = 用预置的 base_url
40    pub base_url: &'a str,
41    /// 明文密钥;空串 = 不带鉴权(本地服务常见)
42    pub api_key: &'a str,
43    /// 当前填的模型 id;空串 = 不校验模型是否存在
44    pub model: &'a str,
45    /// 专有字段的实际值,`(key, value)` 对
46    pub extra: &'a [(&'a str, &'a str)],
47}
48
49/// 🔴 本类型带 `#[non_exhaustive]`,**外部 crate 不能用字面量构造它** ——
50/// 必须走下面这组链式方法。
51///
52/// 这不是绕弯路:`non_exhaustive` 让本 crate 以后加字段只算 minor 版本,
53/// 代价就是调用方不能写 `ServiceConfig { .. }`。入参类型上用它,
54/// **必须同时提供完整的 builder**,否则下游根本没法用(实测踩过 E0639)。
55///
56/// ```no_run
57/// # use ai_profile::{client::ServiceConfig, Protocol};
58/// let cfg = ServiceConfig::new(Protocol::OpenAiCompatible, "https://api.deepseek.com/v1")
59///     .with_preset("deepseek")
60///     .with_api_key("sk-…")
61///     .with_model("deepseek-flash");
62/// ```
63impl<'a> ServiceConfig<'a> {
64    /// 最小构造:只给协议与端点。
65    pub fn new(protocol: Protocol, base_url: &'a str) -> Self {
66        Self {
67            preset_key: None,
68            protocol,
69            base_url,
70            api_key: "",
71            model: "",
72            extra: &[],
73        }
74    }
75
76    /// 指定预置 key —— 会校验该预置要求的 `extra_fields`,
77    /// 并在 `base_url` 为空时用预置的地址兜底。
78    pub fn with_preset(mut self, key: &'a str) -> Self {
79        self.preset_key = Some(key);
80        self
81    }
82
83    /// 明文密钥。空串 = 不带鉴权(本地服务常见)。
84    pub fn with_api_key(mut self, key: &'a str) -> Self {
85        self.api_key = key;
86        self
87    }
88
89    /// 当前填的模型 id;给了就会校验它在不在端点返回的清单里。
90    pub fn with_model(mut self, model: &'a str) -> Self {
91        self.model = model;
92        self
93    }
94
95    /// 服务商专有字段的实际值。
96    pub fn with_extra(mut self, extra: &'a [(&'a str, &'a str)]) -> Self {
97        self.extra = extra;
98        self
99    }
100}
101
102/// 验证成功的结果。
103///
104/// 带 `Serialize` 是为了能直接从 Tauri Command 返回 —— 与 [`crate::VerifyError`]
105/// 成对,调用方的成功/失败两条路径都不必再写一遍 DTO。
106/// 字段名转 camelCase,与 [`crate::ProviderPreset`] 等喂给界面的类型保持一致。
107#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
108#[serde(rename_all = "camelCase")]
109#[non_exhaustive]
110pub struct VerifyOk {
111    /// 往返耗时 —— UI 显示「正常 · 320ms」,中转站慢不慢一眼看出
112    pub latency_ms: u32,
113    /// 端点返回的可对话模型清单(已去重 + 清洗)
114    pub models: Vec<String>,
115    /// 清洗时滤掉的条数(向量 / 重排 / 语音 / OCR 等非对话模型)。
116    ///
117    /// 用途是「已滤掉 N 个向量 / 重排 / 语音等」这类提示文案 —— 让用户知道
118    /// 清单被处理过,而不是以为这个端点就这么几个模型。聚合平台上这个数字很大
119    /// (OpenRouter 实测 433 条里滤掉相当一部分),不说明会让人以为拉漏了。
120    ///
121    /// 全被滤光时本字段为 0 且 `models` 是未过滤的原始清单 —— 那说明特征词
122    /// 在这个端点上判错了,此时宁可把原始清单摆给用户看,也不给他一个空下拉。
123    pub dropped: usize,
124    /// 被滤掉的模型 id(端点顺序),`len() == dropped`。
125    ///
126    /// 只给「一个配置同时挂对话与生图 / 配音 / 向量模型」的调用方用 —— 把它们接在
127    /// `models` 后面,而不是丢掉。只做对话的调用方忽略它即可。
128    pub dropped_models: Vec<String>,
129    /// 当前填的 `model` 是否在清单里;`model` 为空或端点没返回清单时为 `true`
130    pub model_in_list: bool,
131    /// 🔴 **当前填的那个模型**的限额,由端点上报(`source: Endpoint`)。
132    ///
133    /// `None` 有两种情况,调用方处理方式相同 —— 回落到
134    /// [`ModelOption::preset_limits`](crate::preset::ModelOption::preset_limits):
135    ///
136    /// - 端点没报(OpenAI 规范里就没有 context 字段,是**常态**)
137    /// - 当前 `model` 不在端点返回的清单里
138    pub limits: Option<crate::limits::TokenLimits>,
139    /// 端点上报了限额的**全部**模型,`(id, 限额)`。
140    ///
141    /// 给「切换模型时立刻显示新窗口」这类场景用 —— 不必为换个模型再打一次端点。
142    /// 端点不报限额时是空表。
143    pub model_limits: Vec<(String, crate::limits::TokenLimits)>,
144}
145
146/// 交互式动作的超时 —— 用户正看着转圈,比对话请求短得多。
147const VERIFY_TIMEOUT_SECS: u64 = 20;
148
149/// 持有一个可复用的 HTTP 客户端。
150///
151/// # 为什么要有这个类型
152///
153/// `reqwest::Client` **内部持有连接池**,官方明确要求复用。每次验证都现建一个,
154/// 连接池就永远是空的 —— 每次都要重新做 TCP + TLS 握手,跨境端点尤其贵。
155/// 「全部验证」这种一次点四下的按钮会连做四次完整握手。
156///
157/// 建一次、存起来、反复用:
158///
159/// ```no_run
160/// # use ai_profile::client::{Verifier, ServiceConfig};
161/// # use ai_profile::Protocol;
162/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
163/// let verifier = Verifier::new()?;          // 应用启动时建一次
164/// let cfg = ServiceConfig::new(Protocol::OpenAiCompatible, "https://api.deepseek.com/v1");
165/// let ok = verifier.verify(cfg).await?;     // 之后反复用
166/// # Ok(())
167/// # }
168/// ```
169#[derive(Debug, Clone)]
170pub struct Verifier {
171    client: reqwest::Client,
172}
173
174impl Verifier {
175    /// 用默认配置建一个。
176    pub fn new() -> Result<Self, VerifyError> {
177        Self::from_builder(reqwest::Client::builder())
178    }
179
180    /// 从调用方自备的 builder 建 —— 代理、自定义证书、UA 都在这里配。
181    ///
182    /// 存在的理由:代理配置各家差异很大。sigil 用的是
183    /// `reqwest::Proxy::custom(闭包)` 做按 URL 动态路由,不是一个静态代理地址,
184    /// 任何「传一个代理 URL 字符串」的简化 API 都覆盖不了它。
185    ///
186    /// # 🔴 安全默认值由本函数强制施加,调用方覆盖不掉
187    ///
188    /// 超时与**禁重定向**是在你的配置**之后**加的(reqwest 的 builder 后写覆盖先写)。
189    /// 禁重定向不容商量:跨 host 跳转时 reqwest 只剥 `Authorization` 等标准头,
190    /// **不剥自定义头** —— Anthropic 的 `x-api-key` 会被原样发往跳转目标。
191    ///
192    /// 用本 crate 重导出的 [`crate::reqwest`] 来建 builder,版本必然匹配:
193    ///
194    /// ```no_run
195    /// # use ai_profile::client::Verifier;
196    /// # fn apply_proxy(b: ai_profile::reqwest::ClientBuilder) -> ai_profile::reqwest::ClientBuilder { b }
197    /// # fn f() -> Result<(), Box<dyn std::error::Error>> {
198    /// let v = Verifier::from_builder(apply_proxy(ai_profile::reqwest::Client::builder()))?;
199    /// # Ok(())
200    /// # }
201    /// ```
202    pub fn from_builder(builder: reqwest::ClientBuilder) -> Result<Self, VerifyError> {
203        let client = builder
204            .timeout(Duration::from_secs(VERIFY_TIMEOUT_SECS))
205            .connect_timeout(Duration::from_secs(10))
206            // 🔴 放在最后 = 调用方覆盖不掉。理由见上方文档
207            .redirect(reqwest::redirect::Policy::none())
208            .build()
209            .map_err(|e| VerifyError::Malformed {
210                detail: format!("构造 HTTP 客户端失败: {e}"),
211            })?;
212        Ok(Self { client })
213    }
214
215    /// 零成本验证:只打端点的模型列表接口,**不产生任何生成费用**。
216    ///
217    /// 四种 kind 都可调 —— 与 `dry_run`(真实调用,产生费用)分开正是因为
218    /// 四者的调用成本差几个数量级。
219    ///
220    /// 本方法只借用 `&self`,可以并发调用(`reqwest::Client` 自身是 `Send + Sync`
221    /// 且内部共享连接池),批量验证多家服务商时直接 `join_all` 即可。
222    ///
223    /// # 错误
224    ///
225    /// 每个 [`VerifyError`] 变体都对应一个调用方应当给出的动作,见该类型文档。
226    pub async fn verify(&self, cfg: ServiceConfig<'_>) -> Result<VerifyOk, VerifyError> {
227        // 1. 必填的专有字段 —— 不发请求就能判
228        check_required_fields(cfg.preset_key, cfg.extra)?;
229
230        // 2. 定端点
231        let effective_base = resolve_base_url(cfg.base_url, cfg.preset_key, cfg.protocol)?;
232        let base = effective_base.as_str();
233        let url = join_api_path(base, "models");
234
235        // 3. 发请求 —— 复用 self.client 的连接池
236        let key = cfg.api_key.trim();
237        let mut req = self.client.get(&url);
238        req = match cfg.protocol {
239            Protocol::Anthropic => {
240                let r = req.header("anthropic-version", "2023-06-01");
241                if key.is_empty() {
242                    r
243                } else {
244                    r.header("x-api-key", key)
245                }
246            }
247            // 本地服务(Ollama / vLLM)常常不校验密钥,空密钥也要允许发出去
248            _ if key.is_empty() => req,
249            _ => req.bearer_auth(key),
250        };
251
252        let started = std::time::Instant::now();
253        let resp = req.send().await.map_err(|_| VerifyError::Unreachable {
254            proxy_hint: needs_proxy_hint(&url),
255        })?;
256        let latency_ms = started.elapsed().as_millis().min(u128::from(u32::MAX)) as u32;
257
258        let status = resp.status().as_u16();
259        let body = resp.text().await.unwrap_or_default();
260
261        if !(200..300).contains(&status) {
262            return Err(diagnose(status, &body, &url, base));
263        }
264        if let Some(e) = diagnose_success(&body, &url, base) {
265            return Err(e);
266        }
267
268        // 4. 成功:清洗清单 + 校验当前 model 是否在里面
269        let ids = parse_model_ids(&body);
270        let cleaned = clean_fetched_models(ids);
271        let model = cfg.model.trim();
272        let model_in_list = model.is_empty()
273            || cleaned.models.is_empty()
274            || cleaned.models.iter().any(|m| m == model);
275
276        // 顺手把端点自己报的限额带回来 —— 请求已经发了,不多花一分钱。
277        // 多数 OpenAI 兼容端点不报(规范里就没这字段),那就是空表,
278        // 调用方回落到预置静态值。
279        let model_limits = parse_model_limits(&body);
280        // 当前填的这个模型的限额,调用方最常用的就是它
281        let limits = model_limits
282            .iter()
283            .find(|(id, _)| id == model)
284            .map(|(_, l)| *l);
285
286        Ok(VerifyOk {
287            latency_ms,
288            models: cleaned.models,
289            dropped: cleaned.dropped,
290            dropped_models: cleaned.dropped_models,
291            model_in_list,
292            limits,
293            model_limits,
294        })
295    }
296}
297
298/// 进程级共享的默认 [`Verifier`],供自由函数 [`verify`] 使用。
299///
300/// 存 `Result` 而不是在失败时 panic:建客户端失败(TLS 后端初始化不了)是环境问题,
301/// 应当作为错误返回给调用方,而不是把整个应用带崩。
302static DEFAULT_VERIFIER: std::sync::OnceLock<Result<Verifier, String>> = std::sync::OnceLock::new();
303
304fn default_verifier() -> Result<&'static Verifier, VerifyError> {
305    DEFAULT_VERIFIER
306        .get_or_init(|| Verifier::new().map_err(|e| e.to_string()))
307        .as_ref()
308        .map_err(|detail| VerifyError::Malformed {
309            detail: detail.clone(),
310        })
311}
312
313/// 校验预置要求的必填 `extra_fields` 是否都给了。
314///
315/// 🔴 调用方应当在**发起验证之前**就调它,用于禁用按钮 ——
316/// 禁用态优于「点了才报错」。
317pub fn check_required_fields(
318    preset_key: Option<&str>,
319    extra: &[(&str, &str)],
320) -> Result<(), VerifyError> {
321    let Some(p) = preset_key.and_then(preset_by_key) else {
322        return Ok(());
323    };
324    for f in p.extra_fields {
325        if !f.required {
326            continue;
327        }
328        let given = extra
329            .iter()
330            .find(|(k, _)| *k == f.key)
331            .is_some_and(|(_, v)| !v.trim().is_empty());
332        if !given {
333            return Err(VerifyError::MissingExtraField {
334                key: f.key.to_string(),
335            });
336        }
337    }
338    Ok(())
339}
340
341/// 404 时推断「正确的地址应该长什么样」—— 「一键修正」的数据来源。
342///
343/// 只在**确实看不到版本段**时给建议;已经有版本段却 404 说明是别的问题,
344/// 乱给建议会把用户引向另一个错误答案。
345pub fn suggest_url(base_url: &str) -> Option<String> {
346    let trimmed = base_url.trim().trim_end_matches('/');
347    if trimmed.is_empty() {
348        return None;
349    }
350    // 版本段可能在末尾(/v1、/v4),也可能在中间(Gemini 的 /v1beta/openai)。
351    // 补一个结尾 / 再找:否则 `…/v1beta` 作为最后一段时(前面刚去掉了结尾 /)会找不到
352    let probe = format!("{trimmed}/");
353    if ends_with_version_segment(trimmed) || probe.contains("/v1beta/") || probe.contains("/v1/") {
354        return None;
355    }
356    Some(format!("{trimmed}/v1"))
357}
358
359/// 定下「获取模型」要请求的 base:表单填的 → 预置的端点(含官方档的协议默认地址)→ 都没有则报缺字段。
360///
361/// Anthropic 协议再按 [`anthropic_base_url`] 补版本段,与对话同一口径 —— 否则会出现「获取通过、对话 404」。
362/// 诊断也用补过的 base:已经替用户补了 `/v1`,404 时再建议「补 /v1」就是误导。
363fn resolve_base_url(
364    form: &str,
365    preset_key: Option<&str>,
366    protocol: Protocol,
367) -> Result<String, VerifyError> {
368    let form = form.trim();
369    let base = if form.is_empty() {
370        preset_key
371            .and_then(preset_by_key)
372            .and_then(crate::preset::ProviderPreset::endpoint)
373            .unwrap_or("")
374    } else {
375        form
376    };
377    if base.is_empty() {
378        return Err(VerifyError::MissingExtraField {
379            key: "base_url".to_string(),
380        });
381    }
382    Ok(match protocol {
383        Protocol::Anthropic => anthropic_base_url(base),
384        _ => base.to_string(),
385    })
386}
387
388/// 2xx 响应的二次判定:状态码说成功,但响应体根本不是接口返回的东西。
389///
390/// 典型场景:base_url 指到了网站根目录(少了 `/v1` 之类的路径),网站把任何未知路径都回成首页 HTML、
391/// 状态码 200 —— 此前这会被当成「验证成功、模型清单为空」,用户看到绿色的对勾却什么也用不了。
392///
393/// 响应体不是合法 JSON 时按 [`VerifyError::NotFound`] 报(「接口不在这个地址」),
394/// 并照常给出 [`suggest_url`] 的「一键改用」建议;是 JSON 就返回 `None`(空清单 `{"data":[]}` 是合法的)。
395pub fn diagnose_success(body: &str, requested_url: &str, base_url: &str) -> Option<VerifyError> {
396    if serde_json::from_str::<serde_json::Value>(body).is_ok() {
397        return None;
398    }
399    Some(VerifyError::NotFound {
400        requested_url: requested_url.to_string(),
401        suggested_url: suggest_url(base_url),
402    })
403}
404
405/// HTTP 状态码 + 响应体 → 结构化错误。
406///
407/// 抽成纯函数是为了能脱离网络单测 —— 这是本模块最容易写错的地方。
408pub fn diagnose(status: u16, body: &str, requested_url: &str, base_url: &str) -> VerifyError {
409    // 端点通常把原因放在 error.message 里,比裸状态码有用得多
410    let detail = extract_error_message(body).unwrap_or_else(|| format!("HTTP {status}"));
411    match status {
412        401 | 403 => VerifyError::AuthFailed { detail },
413        404 => VerifyError::NotFound {
414            requested_url: requested_url.to_string(),
415            suggested_url: suggest_url(base_url),
416        },
417        _ => VerifyError::Malformed { detail },
418    }
419}
420
421/// 从端点的错误响应里抽出人话。各家格式不一,按常见顺序试。
422fn extract_error_message(body: &str) -> Option<String> {
423    let v: serde_json::Value = serde_json::from_str(body).ok()?;
424    let msg = v
425        .get("error")
426        .and_then(|e| e.get("message"))
427        .or_else(|| v.get("error").and_then(|e| e.as_str().map(|_| e)))
428        .and_then(|m| m.as_str())
429        .or_else(|| v.get("message").and_then(|m| m.as_str()))?;
430    let msg = msg.trim();
431    if msg.is_empty() {
432        return None;
433    }
434    // 截断:错误信息进 UI 也进日志,过长的原样回灌没意义
435    Some(msg.chars().take(300).collect())
436}
437
438/// 从 `/models` 响应里抽出「模型 id → 端点上报的限额」。
439///
440/// 与 [`parse_model_ids`] 并存而不是替换它:后者是公开 API,改签名是 major 变更,
441/// 而多数调用方只要一个 id 清单。
442///
443/// # 为什么值得单独抽一遍
444///
445/// crate 本来就在打这个端点。端点自己报的限额是**最可信**的来源 ——
446/// 它包含中转站的真实限制,而任何静态表都不可能知道这件事。
447/// 此前这些字段被 `parse_model_ids` 直接丢掉了,等于白打一次请求。
448///
449/// 只收录**报了限额**的模型:OpenAI 规范的裸响应(`{id, object, owned_by}`)
450/// 一条都不会进来,那是常态而非异常,调用方据此走静态兜底那一层。
451pub fn parse_model_limits(body: &str) -> Vec<(String, crate::limits::TokenLimits)> {
452    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
453        return Vec::new();
454    };
455    let arr = v
456        .get("data")
457        .and_then(|d| d.as_array())
458        .or_else(|| v.as_array());
459    let Some(arr) = arr else { return Vec::new() };
460    arr.iter()
461        .filter_map(|item| {
462            let id = item.get("id").and_then(|i| i.as_str())?;
463            let limits = crate::limits::parse_model_limits(item)?;
464            Some((id.to_string(), limits))
465        })
466        .collect()
467}
468
469/// 从 `/models` 响应里抽出模型 id 列表。
470///
471/// 主流端点是 `{ "data": [{ "id": "…" }] }`;少数自建网关直接返回裸数组
472/// `["gpt-4o", …]` 或 `[{"id": …}]`,一并容错。
473pub fn parse_model_ids(body: &str) -> Vec<String> {
474    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
475        return Vec::new();
476    };
477    let arr = v
478        .get("data")
479        .and_then(|d| d.as_array())
480        .or_else(|| v.as_array());
481    let Some(arr) = arr else { return Vec::new() };
482    arr.iter()
483        .filter_map(|item| {
484            item.get("id")
485                .and_then(|i| i.as_str())
486                .or_else(|| item.as_str())
487                .map(str::to_string)
488        })
489        .collect()
490}
491
492/// 这个 host 在国内通常需要代理 —— 用于给 `Unreachable` 带上提示。
493fn needs_proxy_hint(url: &str) -> bool {
494    const BLOCKED: &[&str] = &[
495        "api.openai.com",
496        "api.anthropic.com",
497        "generativelanguage.googleapis.com",
498        "openrouter.ai",
499        "api.groq.com",
500        "api.x.ai",
501    ];
502    let u = url.to_ascii_lowercase();
503    BLOCKED.iter().any(|h| u.contains(h))
504}
505
506/// 零成本验证 —— 用进程级共享的默认 [`Verifier`]。
507///
508/// 一次性、图省事的场景用它就够了,连接池仍然是复用的。
509///
510/// **需要代理就不能用它** —— 默认客户端没有任何代理配置。
511/// 桌面应用应当自己建一个 [`Verifier::from_builder`] 存进全局状态。
512///
513/// # 错误
514///
515/// 每个 [`VerifyError`] 变体都对应一个调用方应当给出的动作,见该类型文档。
516pub async fn verify(cfg: ServiceConfig<'_>) -> Result<VerifyOk, VerifyError> {
517    default_verifier()?.verify(cfg).await
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    /// 🔴 `VerifyOk` 的线格式是前端契约 —— 字段名变了下游界面会静默读到 undefined。
525    ///
526    /// 与 `VerifyError`(snake_case + code 标签)刻意不同:喂给界面渲染的数据走
527    /// camelCase,带判别标签的错误协议走 snake_case。两套并存是有意的,所以钉住。
528    #[test]
529    fn verify_ok_serializes_camel_case() {
530        let ok = VerifyOk {
531            latency_ms: 320,
532            models: vec!["deepseek-flash".into()],
533            dropped: 2,
534            dropped_models: vec!["bge-m3".into(), "tts-1".into()],
535            model_in_list: true,
536            limits: Some(crate::limits::TokenLimits::from_endpoint(
537                Some(128_000),
538                Some(8192),
539            )),
540            model_limits: Vec::new(),
541        };
542        let j = serde_json::to_string(&ok).unwrap();
543        assert!(j.contains(r#""latencyMs":320"#), "前端读 latencyMs:{j}");
544        assert!(
545            j.contains(r#""modelInList":true"#),
546            "前端读 modelInList:{j}"
547        );
548        // 🔴 限额与来源标记是前端契约:界面要据此显示「端点上报 128K」
549        //    还是「预估 128K,可修改」。字段名变了会静默读到 undefined。
550        assert!(j.contains(r#""contextWindow":128000"#), "{j}");
551        assert!(j.contains(r#""source":"endpoint""#), "来源必须能分辨:{j}");
552        assert!(
553            j.contains(r#""dropped":2"#),
554            "「已滤掉 N 个」的提示靠它:{j}"
555        );
556        assert!(
557            j.contains(r#""droppedModels":["bge-m3","tts-1"]"#),
558            "多模态调用方靠它把非对话模型接回清单:{j}"
559        );
560    }
561
562    /// 🔴 只在看不到版本段时给建议 —— 已有版本段却 404 是别的问题,
563    /// 乱给建议会把用户引向另一个错误答案。
564    #[test]
565    fn suggest_url_only_when_version_missing() {
566        assert_eq!(
567            suggest_url("https://api.deepseek.com").as_deref(),
568            Some("https://api.deepseek.com/v1")
569        );
570        assert_eq!(
571            suggest_url("https://api.deepseek.com/").as_deref(),
572            Some("https://api.deepseek.com/v1")
573        );
574        // 已有版本段 → 不建议
575        assert_eq!(suggest_url("https://api.deepseek.com/v1"), None);
576        assert_eq!(suggest_url("https://open.bigmodel.cn/api/paas/v4"), None);
577        // Gemini:版本段不在末尾,也不该建议
578        assert_eq!(
579            suggest_url("https://generativelanguage.googleapis.com/v1beta/openai"),
580            None
581        );
582        assert_eq!(suggest_url(""), None);
583    }
584
585    /// 🔴 版本段恰好是最后一段、且不是纯数字(`/v1beta`)时也不能建议。
586    ///
587    /// 此前先去掉末尾 `/` 再找 `/v1beta/`,`…/v1beta/` 变成 `…/v1beta` 就找不到了,
588    /// 会建议出 `…/v1beta/v1` 这种错地址。外部实现者照文档写 Python 版时发现的。
589    #[test]
590    fn suggest_url_sees_version_as_last_segment() {
591        assert_eq!(suggest_url("https://x.com/v1beta"), None);
592        assert_eq!(suggest_url("https://x.com/v1beta/"), None);
593        assert_eq!(suggest_url("https://x.com/v1/"), None);
594    }
595
596    /// 「Anthropic 官方」不填地址也要能验证 —— 它恰恰是界面隐藏地址框的那一档。
597    /// 此前这里只看预置的 base_url(刻意为空),直接报「缺 base_url」。
598    #[test]
599    fn official_preset_without_address_resolves_to_official_endpoint() {
600        assert_eq!(
601            resolve_base_url("", Some("anthropic_official"), Protocol::Anthropic).unwrap(),
602            "https://api.anthropic.com/v1"
603        );
604        // 表单填了就用表单的
605        assert_eq!(
606            resolve_base_url(
607                " https://relay.example.com ",
608                Some("anthropic_official"),
609                Protocol::Anthropic
610            )
611            .unwrap(),
612            "https://relay.example.com/v1"
613        );
614        // 自定义档没填地址:照旧报缺字段,不替用户猜
615        assert_eq!(
616            resolve_base_url("", Some("claude_code"), Protocol::Anthropic),
617            Err(VerifyError::MissingExtraField {
618                key: "base_url".into()
619            })
620        );
621        assert_eq!(
622            resolve_base_url("", None, Protocol::OpenAiCompatible),
623            Err(VerifyError::MissingExtraField {
624                key: "base_url".into()
625            })
626        );
627    }
628
629    /// 2xx 却回了网页:地址指到了网站根目录。此前判为「成功、清单为空」。
630    #[test]
631    fn success_status_with_html_body_is_not_found() {
632        let e = diagnose_success(
633            "<!doctype html><html><body>Welcome</body></html>",
634            "https://relay.example.com/models",
635            "https://relay.example.com",
636        );
637        assert_eq!(
638            e,
639            Some(VerifyError::NotFound {
640                requested_url: "https://relay.example.com/models".into(),
641                suggested_url: Some("https://relay.example.com/v1".into()),
642            })
643        );
644        // 合法 JSON(包括空清单)不拦
645        assert_eq!(diagnose_success(r#"{"data":[]}"#, "u", "b"), None);
646        assert_eq!(diagnose_success(r#"["a"]"#, "u", "b"), None);
647    }
648
649    /// 状态码要映射到能驱动 UI 动作的变体。
650    #[test]
651    fn diagnose_maps_status_to_actionable_errors() {
652        let e = diagnose(401, r#"{"error":{"message":"invalid key"}}"#, "u", "b");
653        assert!(matches!(e, VerifyError::AuthFailed { ref detail } if detail == "invalid key"));
654
655        let e = diagnose(404, "{}", "https://x.com/models", "https://x.com");
656        match e {
657            VerifyError::NotFound {
658                requested_url,
659                suggested_url,
660            } => {
661                assert_eq!(requested_url, "https://x.com/models");
662                assert_eq!(suggested_url.as_deref(), Some("https://x.com/v1"));
663            }
664            other => panic!("404 应映射为 NotFound,实际 {other:?}"),
665        }
666
667        // 403 与 401 同类
668        assert!(matches!(
669            diagnose(403, "{}", "u", "b"),
670            VerifyError::AuthFailed { .. }
671        ));
672        // 其余归兜底
673        assert!(matches!(
674            diagnose(500, "{}", "u", "b"),
675            VerifyError::Malformed { .. }
676        ));
677    }
678
679    /// 错误信息要从端点响应里抽人话,而不是只给「HTTP 500」。
680    #[test]
681    fn extracts_error_message_from_various_shapes() {
682        assert_eq!(
683            extract_error_message(r#"{"error":{"message":"no credit"}}"#).as_deref(),
684            Some("no credit")
685        );
686        assert_eq!(
687            extract_error_message(r#"{"message":"bad request"}"#).as_deref(),
688            Some("bad request")
689        );
690        assert_eq!(extract_error_message("not json"), None);
691        assert_eq!(extract_error_message(r#"{"error":{"message":"  "}}"#), None);
692    }
693
694    /// 三种响应形态都要能抽出模型 id。
695    #[test]
696    fn parses_model_ids_from_common_shapes() {
697        assert_eq!(
698            parse_model_ids(r#"{"data":[{"id":"gpt-4o"},{"id":"gpt-4o-mini"}]}"#),
699            vec!["gpt-4o", "gpt-4o-mini"]
700        );
701        assert_eq!(
702            parse_model_ids(r#"["llama3.1:8b","qwen3:8b"]"#),
703            vec!["llama3.1:8b", "qwen3:8b"]
704        );
705        assert_eq!(parse_model_ids(r#"[{"id":"a"}]"#), vec!["a"]);
706        assert!(parse_model_ids("garbage").is_empty());
707    }
708
709    /// 必填的专有字段缺失时,调用方能在发请求前就拦住。
710    #[test]
711    fn check_required_fields_catches_missing() {
712        // 当前预置都没有 required 的 extra_fields,给不存在的 key 也应放行
713        assert!(check_required_fields(Some("deepseek"), &[]).is_ok());
714        assert!(check_required_fields(None, &[]).is_ok());
715        assert!(check_required_fields(Some("不存在的预置"), &[]).is_ok());
716    }
717
718    /// 需要代理的 host 要带上提示,国内可直连的不带 —— 避免误导。
719    #[test]
720    fn proxy_hint_only_for_blocked_hosts() {
721        assert!(needs_proxy_hint("https://api.openai.com/v1/models"));
722        assert!(needs_proxy_hint(
723            "https://generativelanguage.googleapis.com/x"
724        ));
725        assert!(!needs_proxy_hint("https://api.deepseek.com/v1/models"));
726        assert!(!needs_proxy_hint("http://localhost:11434/v1/models"));
727    }
728}