use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiProvider {
Deepseek,
DeepseekCN,
NvidiaNim,
Openai,
Openrouter,
Novita,
Fireworks,
Sglang,
Vllm,
Ollama,
}
impl ApiProvider {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"deepseek" | "deep-seek" => Some(Self::Deepseek),
"deepseek-cn" | "deepseek_china" | "deepseekcn" | "deepseek-china" => {
Some(Self::DeepseekCN)
}
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
"openai" | "open-ai" | "openai-compatible" => Some(Self::Openai),
"openrouter" | "open_router" => Some(Self::Openrouter),
"novita" => Some(Self::Novita),
"fireworks" | "fireworks-ai" => Some(Self::Fireworks),
"sglang" | "sg-lang" => Some(Self::Sglang),
"vllm" | "v-llm" => Some(Self::Vllm),
"ollama" | "ollama-local" => Some(Self::Ollama),
_ => None,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Deepseek => "deepseek",
Self::DeepseekCN => "deepseek-cn",
Self::NvidiaNim => "nvidia-nim",
Self::Openai => "openai",
Self::Openrouter => "openrouter",
Self::Novita => "novita",
Self::Fireworks => "fireworks",
Self::Sglang => "sglang",
Self::Vllm => "vllm",
Self::Ollama => "ollama",
}
}
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::Deepseek => "DeepSeek",
Self::DeepseekCN => "DeepSeek (中国)",
Self::NvidiaNim => "NVIDIA NIM",
Self::Openai => "OpenAI",
Self::Openrouter => "OpenRouter",
Self::Novita => "Novita AI",
Self::Fireworks => "Fireworks AI",
Self::Sglang => "SGLang",
Self::Vllm => "vLLM",
Self::Ollama => "Ollama",
}
}
#[must_use]
pub fn all() -> &'static [Self] {
&[
Self::Deepseek,
Self::DeepseekCN,
Self::NvidiaNim,
Self::Openai,
Self::Openrouter,
Self::Novita,
Self::Fireworks,
Self::Sglang,
Self::Vllm,
Self::Ollama,
]
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ProviderCapability {
pub provider: ApiProvider,
pub resolved_model: String,
pub context_window: u32,
pub max_output: u32,
pub thinking_supported: bool,
pub cache_telemetry_supported: bool,
pub request_payload_mode: RequestPayloadMode,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum RequestPayloadMode {
ChatCompletions,
}
#[must_use]
pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {
if matches!(provider, ApiProvider::Ollama) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: 8192,
max_output: 4096,
thinking_supported: false,
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
};
}
let model_lower = resolved_model.to_ascii_lowercase();
let is_v4_pro = model_lower.contains("v4-pro") || model_lower == "deepseek-v4pro";
let is_v4_flash = model_lower.contains("v4-flash")
|| model_lower == "deepseek-v4flash"
|| model_lower == "deepseek-v4";
let context_window = if is_v4_pro || is_v4_flash {
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
} else {
crate::models::context_window_for_model(resolved_model)
.unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
};
let max_output = if is_v4_pro || is_v4_flash {
384_000
} else {
4096
};
let thinking_supported = is_v4_pro || is_v4_flash;
let cache_telemetry_supported = matches!(
provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim
);
let request_payload_mode = RequestPayloadMode::ChatCompletions;
ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window,
max_output,
thinking_supported,
cache_telemetry_supported,
request_payload_mode,
}
}
#[must_use]
pub fn canonical_model_name(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"deepseek-v4pro" => Some("deepseek-v4-pro"),
"deepseek-v4flash" => Some("deepseek-v4-flash"),
_ => None,
}
}
#[must_use]
pub fn normalize_model_name(model: &str) -> Option<String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return None;
}
if let Some(canonical) = canonical_model_name(trimmed) {
return Some(canonical.to_string());
}
let normalized = trimmed.to_ascii_lowercase();
if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
return None;
}
if trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
{
return Some(trimmed.to_string());
}
None
}
#[cfg(test)]
mod provider_drift_tests {
use super::ApiProvider;
use zagens_config::ProviderKind;
#[test]
fn every_facade_provider_kind_parses_into_runtime_api_provider() {
for kind in ProviderKind::ALL {
let name = kind.as_str();
let api = ApiProvider::parse(name).unwrap_or_else(|| {
panic!("facade ProviderKind '{name}' has no runtime ApiProvider mapping")
});
assert_eq!(
api.as_str(),
name,
"canonical string mismatch for facade provider '{name}'"
);
}
}
#[test]
fn every_runtime_api_provider_maps_back_to_facade_kind() {
for api in ApiProvider::all() {
if *api == ApiProvider::DeepseekCN {
continue;
}
let name = api.as_str();
assert!(
ProviderKind::ALL.iter().any(|kind| kind.as_str() == name),
"runtime ApiProvider '{name}' has no facade ProviderKind counterpart"
);
}
}
#[test]
fn api_provider_parse_round_trips_canonical_strings() {
for api in ApiProvider::all() {
assert_eq!(ApiProvider::parse(api.as_str()), Some(*api));
}
}
}