use serde::Serialize;
use crate::kind::{Kind, Protocol};
pub mod vendor;
pub mod catalog;
mod chat;
mod docgen;
#[cfg(feature = "image")]
mod image;
#[cfg(feature = "tts")]
mod tts;
#[cfg(feature = "video")]
mod video;
pub use catalog::PresetCatalog;
pub use docgen::render_providers_markdown;
pub use vendor::{vendors, vendors_all, vendors_in, Vendor};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ModelOption {
pub value: &'static str,
pub label: &'static str,
pub context_window: Option<u32>,
pub max_output: Option<u32>,
}
impl ModelOption {
pub const fn plain(value: &'static str) -> Self {
Self {
value,
label: value,
context_window: None,
max_output: None,
}
}
pub const fn with_limits(value: &'static str, context_window: u32, max_output: u32) -> Self {
Self {
value,
label: value,
context_window: Some(context_window),
max_output: Some(max_output),
}
}
pub const fn with_context(value: &'static str, context_window: u32) -> Self {
Self {
value,
label: value,
context_window: Some(context_window),
max_output: None,
}
}
pub fn preset_limits(&self) -> Option<crate::limits::TokenLimits> {
if self.context_window.is_none() && self.max_output.is_none() {
return None;
}
Some(crate::limits::TokenLimits::from_preset(
self.context_window,
self.max_output,
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ExtraField {
pub key: &'static str,
pub label_key: &'static str,
pub label: &'static str,
pub placeholder: Option<&'static str>,
pub required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ProviderPreset {
pub key: &'static str,
pub vendor_id: &'static str,
pub kind: Kind,
pub group_key: &'static str,
pub group_label: &'static str,
pub label_key: &'static str,
pub label: &'static str,
pub hint_key: Option<&'static str>,
pub hint: Option<&'static str>,
pub base_url: Option<&'static str>,
pub model: &'static str,
pub models: &'static [ModelOption],
pub protocol: Protocol,
pub match_hosts: &'static [&'static str],
pub extra_fields: &'static [ExtraField],
pub default_extra: &'static [(&'static str, &'static str)],
pub apply_url: Option<&'static str>,
pub is_local: bool,
pub verified_at: Option<&'static str>,
}
impl ProviderPreset {
pub const fn new(
key: &'static str,
kind: Kind,
label: &'static str,
base_url: Option<&'static str>,
) -> Self {
Self {
key,
vendor_id: key,
kind,
group_key: GROUP_LOCAL.0,
group_label: GROUP_LOCAL.1,
label_key: "",
label,
hint_key: None,
hint: None,
base_url,
model: "",
models: &[],
protocol: Protocol::OpenAiCompatible,
match_hosts: &[],
extra_fields: &[],
default_extra: &[],
apply_url: None,
is_local: false,
verified_at: None,
}
}
pub const fn with_vendor(mut self, vendor_id: &'static str) -> Self {
self.vendor_id = vendor_id;
self
}
pub const fn with_group(mut self, group: (&'static str, &'static str)) -> Self {
self.group_key = group.0;
self.group_label = group.1;
self
}
pub const fn with_hint(mut self, hint: &'static str) -> Self {
self.hint = Some(hint);
self
}
pub const fn with_models(
mut self,
model: &'static str,
models: &'static [ModelOption],
) -> Self {
self.model = model;
self.models = models;
self
}
pub const fn with_protocol(mut self, protocol: Protocol) -> Self {
self.protocol = protocol;
self
}
pub const fn with_match_hosts(mut self, hosts: &'static [&'static str]) -> Self {
self.match_hosts = hosts;
self
}
pub const fn with_extra_fields(mut self, fields: &'static [ExtraField]) -> Self {
self.extra_fields = fields;
self
}
pub const fn with_default_extra(mut self, kv: &'static [(&'static str, &'static str)]) -> Self {
self.default_extra = kv;
self
}
pub const fn with_apply_url(mut self, url: &'static str) -> Self {
self.apply_url = Some(url);
self
}
pub const fn local(mut self) -> Self {
self.is_local = true;
self
}
}
pub const GROUP_ANTHROPIC: (&str, &str) = ("providerGroup.anthropic", "Anthropic / 协议档");
pub const GROUP_CHINA: (&str, &str) = ("providerGroup.china", "国内");
pub const GROUP_INTERNATIONAL: (&str, &str) = ("providerGroup.international", "国际");
pub const GROUP_LOCAL: (&str, &str) = ("providerGroup.local", "本地 / 自建");
pub const CUSTOM_PRESET_KEY: &str = "openai_compatible_custom";
pub fn presets() -> &'static [ProviderPreset] {
#[cfg(not(any(feature = "image", feature = "video", feature = "tts")))]
{
chat::CHAT_PRESETS
}
#[cfg(any(feature = "image", feature = "video", feature = "tts"))]
{
static ALL: std::sync::OnceLock<Vec<ProviderPreset>> = std::sync::OnceLock::new();
ALL.get_or_init(|| {
let mut v = chat::CHAT_PRESETS.to_vec();
#[cfg(feature = "image")]
v.extend_from_slice(image::IMAGE_PRESETS);
#[cfg(feature = "video")]
v.extend_from_slice(video::VIDEO_PRESETS);
#[cfg(feature = "tts")]
v.extend_from_slice(tts::TTS_PRESETS);
v
})
}
}
pub fn presets_for(kind: Kind) -> impl Iterator<Item = &'static ProviderPreset> {
presets().iter().filter(move |p| p.kind == kind)
}
pub fn preset_by_key(key: &str) -> Option<&'static ProviderPreset> {
presets().iter().find(|p| p.key == key)
}
pub fn infer_preset_key(protocol: Protocol, base_url: Option<&str>) -> &'static str {
let url = base_url.unwrap_or("").trim().to_ascii_lowercase();
if protocol == Protocol::Anthropic {
if url.is_empty() || url.contains("://api.anthropic.com") {
return "anthropic_official";
}
return "claude_code";
}
if url.is_empty() {
return CUSTOM_PRESET_KEY;
}
for p in presets_for(Kind::Chat) {
if p.protocol != Protocol::OpenAiCompatible {
continue;
}
if p.match_hosts.iter().any(|h| url.contains(h)) {
return p.key;
}
}
CUSTOM_PRESET_KEY
}
pub fn model_limits(
protocol: Protocol,
base_url: Option<&str>,
model: &str,
) -> Option<crate::limits::TokenLimits> {
let model = model.trim();
preset_by_key(infer_preset_key(protocol, base_url))?
.models
.iter()
.find(|m| m.value == model)
.and_then(ModelOption::preset_limits)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preset_groups_are_contiguous() {
let mut kinds: Vec<Kind> = Vec::new();
for p in presets() {
if !kinds.contains(&p.kind) {
kinds.push(p.kind);
}
}
for kind in kinds {
let mut seen: Vec<&str> = Vec::new();
let mut prev = "";
for p in presets_for(kind) {
if p.group_key == prev {
continue;
}
assert!(
!seen.contains(&p.group_key),
"{:?} 的分组 {} 被拆成了不连续的多段",
kind,
p.group_key
);
seen.push(p.group_key);
prev = p.group_key;
}
}
}
#[test]
fn preset_kinds_are_contiguous() {
let mut seen: Vec<Kind> = Vec::new();
let mut prev: Option<Kind> = None;
for p in presets() {
if prev == Some(p.kind) {
continue;
}
assert!(
!seen.contains(&p.kind),
"{:?} 的预置被拆成了不连续的多段",
p.kind
);
seen.push(p.kind);
prev = Some(p.kind);
}
}
#[test]
fn infer_preset_key_only_returns_chat_presets() {
for url in [
"https://api.siliconflow.cn/v1",
"https://ark.cn-beijing.volces.com/api/v3",
] {
let key = infer_preset_key(Protocol::OpenAiCompatible, Some(url));
assert_eq!(
preset_by_key(key).map(|p| p.kind),
Some(Kind::Chat),
"{url} → {key}"
);
}
}
#[test]
fn preset_base_urls_are_well_formed() {
for p in presets() {
let Some(url) = p.base_url else { continue };
assert!(!url.ends_with('/'), "{}: base_url 不应以 / 结尾", p.key);
assert!(
!url.contains("chat/completions") && !url.ends_with("messages"),
"{}: base_url 不该带端点后缀",
p.key
);
let has_version = crate::endpoint::ends_with_version_segment(url)
|| url.contains("/v1beta/")
|| url.contains("/v1/");
assert!(has_version, "{}: base_url 看不到版本段 → {}", p.key, url);
}
}
#[test]
fn preset_keys_are_unique() {
let mut seen: Vec<&str> = Vec::new();
for p in presets() {
assert!(!seen.contains(&p.key), "重复的 preset key: {}", p.key);
seen.push(p.key);
}
}
#[test]
fn local_presets_have_no_apply_url() {
for p in presets() {
if p.is_local {
assert!(p.apply_url.is_none(), "{}: 本地服务不需要申请密钥", p.key);
}
}
}
#[test]
fn infer_preset_key_matches_by_host() {
assert_eq!(
infer_preset_key(
Protocol::OpenAiCompatible,
Some("https://api.deepseek.com/v1")
),
"deepseek"
);
assert_eq!(
infer_preset_key(Protocol::OpenAiCompatible, Some("https://api.deepseek.com")),
"deepseek"
);
assert_eq!(
infer_preset_key(Protocol::Anthropic, Some("https://api.anthropic.com")),
"anthropic_official"
);
assert_eq!(
infer_preset_key(Protocol::Anthropic, None),
"anthropic_official"
);
assert_eq!(
infer_preset_key(Protocol::Anthropic, Some("https://api.anthropic.com/v1")),
"anthropic_official"
);
assert_eq!(
infer_preset_key(Protocol::Anthropic, Some("https://cc.example.cn/v1")),
"claude_code"
);
assert_eq!(
infer_preset_key(
Protocol::OpenAiCompatible,
Some("https://unknown.example/v1")
),
CUSTOM_PRESET_KEY
);
}
#[test]
fn custom_preset_exists() {
assert!(
preset_by_key(CUSTOM_PRESET_KEY).is_some(),
"兜底档 {CUSTOM_PRESET_KEY} 必须在预置表里"
);
}
}