1use 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#[derive(Debug, Clone, Copy)]
33#[non_exhaustive]
34pub struct ServiceConfig<'a> {
35 pub preset_key: Option<&'a str>,
37 pub protocol: Protocol,
39 pub base_url: &'a str,
41 pub api_key: &'a str,
43 pub model: &'a str,
45 pub extra: &'a [(&'a str, &'a str)],
47}
48
49impl<'a> ServiceConfig<'a> {
64 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 pub fn with_preset(mut self, key: &'a str) -> Self {
79 self.preset_key = Some(key);
80 self
81 }
82
83 pub fn with_api_key(mut self, key: &'a str) -> Self {
85 self.api_key = key;
86 self
87 }
88
89 pub fn with_model(mut self, model: &'a str) -> Self {
91 self.model = model;
92 self
93 }
94
95 pub fn with_extra(mut self, extra: &'a [(&'a str, &'a str)]) -> Self {
97 self.extra = extra;
98 self
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
108#[serde(rename_all = "camelCase")]
109#[non_exhaustive]
110pub struct VerifyOk {
111 pub latency_ms: u32,
113 pub models: Vec<String>,
115 pub dropped: usize,
124 pub dropped_models: Vec<String>,
129 pub model_in_list: bool,
131 pub limits: Option<crate::limits::TokenLimits>,
139 pub model_limits: Vec<(String, crate::limits::TokenLimits)>,
144}
145
146const VERIFY_TIMEOUT_SECS: u64 = 20;
148
149#[derive(Debug, Clone)]
170pub struct Verifier {
171 client: reqwest::Client,
172}
173
174impl Verifier {
175 pub fn new() -> Result<Self, VerifyError> {
177 Self::from_builder(reqwest::Client::builder())
178 }
179
180 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 .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 pub async fn verify(&self, cfg: ServiceConfig<'_>) -> Result<VerifyOk, VerifyError> {
227 check_required_fields(cfg.preset_key, cfg.extra)?;
229
230 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 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 _ 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 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 let model_limits = parse_model_limits(&body);
280 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
298static 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
313pub 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
341pub 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 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
359fn 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
388pub 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
405pub fn diagnose(status: u16, body: &str, requested_url: &str, base_url: &str) -> VerifyError {
409 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
421fn 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 Some(msg.chars().take(300).collect())
436}
437
438pub 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
469pub 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
492fn 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
506pub 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 #[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 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 #[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 assert_eq!(suggest_url("https://api.deepseek.com/v1"), None);
576 assert_eq!(suggest_url("https://open.bigmodel.cn/api/paas/v4"), None);
577 assert_eq!(
579 suggest_url("https://generativelanguage.googleapis.com/v1beta/openai"),
580 None
581 );
582 assert_eq!(suggest_url(""), None);
583 }
584
585 #[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 #[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 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 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 #[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 assert_eq!(diagnose_success(r#"{"data":[]}"#, "u", "b"), None);
646 assert_eq!(diagnose_success(r#"["a"]"#, "u", "b"), None);
647 }
648
649 #[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 assert!(matches!(
669 diagnose(403, "{}", "u", "b"),
670 VerifyError::AuthFailed { .. }
671 ));
672 assert!(matches!(
674 diagnose(500, "{}", "u", "b"),
675 VerifyError::Malformed { .. }
676 ));
677 }
678
679 #[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 #[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 #[test]
711 fn check_required_fields_catches_missing() {
712 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 #[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}