1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "camelCase")]
50#[non_exhaustive]
51pub struct ModelOption {
52 pub value: &'static str,
54 pub label: &'static str,
56 pub context_window: Option<u32>,
63 pub max_output: Option<u32>,
65}
66
67impl ModelOption {
68 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[serde(rename_all = "camelCase")]
143#[non_exhaustive]
144pub struct ExtraField {
145 pub key: &'static str,
147 pub label_key: &'static str,
149 pub label: &'static str,
151 pub placeholder: Option<&'static str>,
153 pub required: bool,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "camelCase")]
164#[non_exhaustive]
165pub struct ProviderPreset {
166 pub key: &'static str,
170
171 pub vendor_id: &'static str,
177
178 pub kind: Kind,
180
181 pub group_key: &'static str,
183 pub group_label: &'static str,
185
186 pub label_key: &'static str,
188 pub label: &'static str,
190
191 pub hint_key: Option<&'static str>,
193 pub hint: Option<&'static str>,
195
196 pub base_url: Option<&'static str>,
201
202 pub model: &'static str,
204 pub models: &'static [ModelOption],
206
207 pub protocol: Protocol,
209
210 pub match_hosts: &'static [&'static str],
214
215 pub extra_fields: &'static [ExtraField],
217
218 pub default_extra: &'static [(&'static str, &'static str)],
225
226 pub apply_url: Option<&'static str>,
228
229 pub is_local: bool,
234
235 pub verified_at: Option<&'static str>,
239}
240
241impl ProviderPreset {
242 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
263impl ProviderPreset {
271 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 pub const fn with_vendor(mut self, vendor_id: &'static str) -> Self {
302 self.vendor_id = vendor_id;
303 self
304 }
305 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 pub const fn with_hint(mut self, hint: &'static str) -> Self {
313 self.hint = Some(hint);
314 self
315 }
316 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 pub const fn with_protocol(mut self, protocol: Protocol) -> Self {
328 self.protocol = protocol;
329 self
330 }
331 pub const fn with_match_hosts(mut self, hosts: &'static [&'static str]) -> Self {
333 self.match_hosts = hosts;
334 self
335 }
336 pub const fn with_extra_fields(mut self, fields: &'static [ExtraField]) -> Self {
338 self.extra_fields = fields;
339 self
340 }
341 pub const fn with_default_extra(mut self, kv: &'static [(&'static str, &'static str)]) -> Self {
343 self.default_extra = kv;
344 self
345 }
346 pub const fn with_apply_url(mut self, url: &'static str) -> Self {
348 self.apply_url = Some(url);
349 self
350 }
351 pub const fn local(mut self) -> Self {
353 self.is_local = true;
354 self
355 }
356}
357
358pub const GROUP_ANTHROPIC: (&str, &str) = ("providerGroup.anthropic", "Anthropic / 协议档");
366pub const GROUP_CHINA: (&str, &str) = ("providerGroup.china", "国内");
368pub const GROUP_INTERNATIONAL: (&str, &str) = ("providerGroup.international", "国际");
370pub const GROUP_LOCAL: (&str, &str) = ("providerGroup.local", "本地 / 自建");
372
373pub const CUSTOM_PRESET_KEY: &str = "openai_compatible_custom";
375
376pub 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
401pub fn presets_for(kind: Kind) -> impl Iterator<Item = &'static ProviderPreset> {
403 presets().iter().filter(move |p| p.kind == kind)
404}
405
406pub fn preset_by_key(key: &str) -> Option<&'static ProviderPreset> {
408 presets().iter().find(|p| p.key == key)
409}
410
411pub 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 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 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
447pub 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 assert_eq!(
645 infer_preset_key(Protocol::Anthropic, Some("https://api.anthropic.com/v1")),
646 "anthropic_official"
647 );
648 assert_eq!(
650 infer_preset_key(Protocol::Anthropic, Some("https://cc.example.cn/v1")),
651 "claude_code"
652 );
653 assert_eq!(
655 infer_preset_key(
656 Protocol::OpenAiCompatible,
657 Some("https://unknown.example/v1")
658 ),
659 CUSTOM_PRESET_KEY
660 );
661 }
662
663 #[test]
665 fn custom_preset_exists() {
666 assert!(
667 preset_by_key(CUSTOM_PRESET_KEY).is_some(),
668 "兜底档 {CUSTOM_PRESET_KEY} 必须在预置表里"
669 );
670 }
671}