use crate::config::Config;
use crate::error::{Error, Result};
use crate::model::Model;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Provider {
OpenAI,
Anthropic,
Gemini,
DeepSeek,
Mistral,
OpenRouter,
XAI,
Perplexity,
Ollama,
OllamaCloud,
GPUStack,
Hetzner,
TypeSafe,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProtocolName {
ChatCompletions,
Responses,
Anthropic,
Gemini,
Interactions,
Conversations,
RouterChatCompletions,
}
impl ProtocolName {
pub fn parse(name: &str) -> Result<ProtocolName> {
match name {
"chat_completions" => Ok(ProtocolName::ChatCompletions),
"responses" => Ok(ProtocolName::Responses),
"anthropic" => Ok(ProtocolName::Anthropic),
"gemini" => Ok(ProtocolName::Gemini),
"interactions" => Ok(ProtocolName::Interactions),
"conversations" => Ok(ProtocolName::Conversations),
"router_chat_completions" => Ok(ProtocolName::RouterChatCompletions),
other => Err(Error::Argument(format!("unknown protocol: {other}"))),
}
}
pub fn name(&self) -> &'static str {
match self {
ProtocolName::ChatCompletions => "chat_completions",
ProtocolName::Responses => "responses",
ProtocolName::Anthropic => "anthropic",
ProtocolName::Gemini => "gemini",
ProtocolName::Interactions => "interactions",
ProtocolName::Conversations => "conversations",
ProtocolName::RouterChatCompletions => "router_chat_completions",
}
}
}
pub const ALL: &[Provider] = &[
Provider::OpenAI,
Provider::Anthropic,
Provider::Gemini,
Provider::DeepSeek,
Provider::Mistral,
Provider::OpenRouter,
Provider::XAI,
Provider::Perplexity,
Provider::Ollama,
Provider::OllamaCloud,
Provider::GPUStack,
Provider::Hetzner,
Provider::TypeSafe,
];
const OPENAI_SEARCH_MODELS: &[&str] = &[
"gpt-4o-mini-search-preview",
"gpt-4o-mini-search-preview-2025-03-11",
"gpt-4o-search-preview",
"gpt-4o-search-preview-2025-03-11",
"gpt-5-search-api",
"gpt-5-search-api-2025-10-14",
];
pub fn display_name(slug: &str) -> &str {
Provider::resolve(slug).map(|p| p.display()).unwrap_or(slug)
}
impl Provider {
pub fn resolve(slug: &str) -> Option<Provider> {
ALL.iter().copied().find(|p| p.slug() == slug)
}
pub fn resolve_or_err(slug: &str) -> Result<Provider> {
Provider::resolve(slug).ok_or_else(|| {
let known: Vec<&str> = ALL.iter().map(|p| p.slug()).collect();
Error::Api(
format!(
"Unknown provider: {slug:?}. Available providers: {}",
known.join(", ")
),
None,
)
})
}
pub fn slug(&self) -> &'static str {
match self {
Provider::OpenAI => "openai",
Provider::Anthropic => "anthropic",
Provider::Gemini => "gemini",
Provider::DeepSeek => "deepseek",
Provider::Mistral => "mistral",
Provider::OpenRouter => "openrouter",
Provider::XAI => "xai",
Provider::Perplexity => "perplexity",
Provider::Ollama => "ollama",
Provider::OllamaCloud => "ollama_cloud",
Provider::GPUStack => "gpustack",
Provider::Hetzner => "hetzner",
Provider::TypeSafe => "typesafe",
}
}
pub fn display(&self) -> &'static str {
match self {
Provider::OpenAI => "OpenAI",
Provider::Anthropic => "Anthropic",
Provider::Gemini => "Gemini",
Provider::DeepSeek => "DeepSeek",
Provider::Mistral => "Mistral",
Provider::OpenRouter => "OpenRouter",
Provider::XAI => "XAI",
Provider::Perplexity => "Perplexity",
Provider::Ollama => "Ollama",
Provider::OllamaCloud => "OllamaCloud",
Provider::GPUStack => "GPUStack",
Provider::Hetzner => "Hetzner",
Provider::TypeSafe => "TypeSafe",
}
}
pub fn is_original_image_detail(&self) -> bool {
*self == Provider::OpenAI
}
pub fn account_identity(&self, config: &Config) -> Option<String> {
let key = |option: &str| config.get(option).unwrap_or_default().to_string();
let base = self.api_base(config).unwrap_or_default();
let parts = match self {
Provider::OpenAI => vec![
base,
key("openai_api_key"),
key("openai_organization_id"),
key("openai_project_id"),
],
Provider::Anthropic => vec![base, key("anthropic_api_key")],
Provider::Gemini => vec![base, key("gemini_api_key")],
Provider::OpenRouter => vec![base, key("openrouter_api_key")],
Provider::XAI => vec![base, key("xai_api_key")],
Provider::DeepSeek => vec![base, key("deepseek_api_key")],
_ => return None,
};
use sha2::{Digest, Sha256};
let digest = Sha256::digest(parts.join("\0").as_bytes());
Some(digest.iter().map(|b| format!("{b:02x}")).collect())
}
pub fn cache_write_input_multiplier(&self, _model_id: &str, ttl: &str) -> Option<f64> {
match (self, ttl) {
(Provider::Anthropic, "1h") => Some(2.0),
_ => None,
}
}
pub fn is_local(&self) -> bool {
matches!(self, Provider::Ollama | Provider::GPUStack)
}
pub fn assume_models_exist(&self) -> bool {
self.is_local() || matches!(self, Provider::OllamaCloud | Provider::Hetzner)
}
pub fn configuration_requirements(&self) -> &'static [&'static str] {
match self {
Provider::OpenAI => &["openai_api_key"],
Provider::Anthropic => &["anthropic_api_key"],
Provider::Gemini => &["gemini_api_key"],
Provider::DeepSeek => &["deepseek_api_key"],
Provider::Mistral => &["mistral_api_key"],
Provider::OpenRouter => &["openrouter_api_key"],
Provider::XAI => &["xai_api_key"],
Provider::Perplexity => &["perplexity_api_key"],
Provider::Ollama => &["ollama_api_base"],
Provider::OllamaCloud => &["ollama_cloud_api_key"],
Provider::GPUStack => &["gpustack_api_base"],
Provider::Hetzner => &["hetzner_api_key"],
Provider::TypeSafe => &["typesafe_api_key"],
}
}
pub fn is_configured(&self, config: &Config) -> bool {
self.configuration_requirements()
.iter()
.all(|r| config.get(r).is_some())
}
pub fn ensure_configured(&self, config: &Config) -> Result<()> {
if self.is_configured(config) {
return Ok(());
}
let lines: Vec<String> = self
.configuration_requirements()
.iter()
.filter(|r| config.get(r).is_none())
.map(|r| {
format!(
" config.set(\"{r}\", std::env::var(\"{}\")?);",
r.to_uppercase()
)
})
.collect();
Err(Error::Configuration(format!(
"{} provider is not configured. Add this to your initialization:\n\nrust_llm::configure(|config| {{\n{}\n}});",
self.display(),
lines.join("\n")
)))
}
pub fn api_base(&self, config: &Config) -> Result<String> {
let (key, default) = match self {
Provider::OpenAI => ("openai_api_base", Some("https://api.openai.com/v1")),
Provider::Anthropic => ("anthropic_api_base", Some("https://api.anthropic.com")),
Provider::Gemini => (
"gemini_api_base",
Some("https://generativelanguage.googleapis.com/v1beta"),
),
Provider::DeepSeek => ("deepseek_api_base", Some("https://api.deepseek.com")),
Provider::Mistral => ("mistral_api_base", Some("https://api.mistral.ai/v1")),
Provider::OpenRouter => ("openrouter_api_base", Some("https://openrouter.ai/api/v1")),
Provider::XAI => ("xai_api_base", Some("https://api.x.ai/v1")),
Provider::Perplexity => ("perplexity_api_base", Some("https://api.perplexity.ai")),
Provider::Ollama => ("ollama_api_base", None),
Provider::OllamaCloud => ("ollama_cloud_api_base", Some("https://ollama.com/v1")),
Provider::GPUStack => ("gpustack_api_base", None),
Provider::Hetzner => (
"hetzner_api_base",
Some("https://inference.hetzner.com/api/v1"),
),
Provider::TypeSafe => ("typesafe_api_base", Some("https://api.typesafe.ai")),
};
config
.get(key)
.or(default)
.map(str::to_string)
.ok_or_else(|| Error::Configuration(format!("{key} is not set")))
}
pub fn agent_url(&self, config: &Config) -> Result<String> {
let base = self.api_base(config)?;
let base = base.strip_suffix('/').unwrap_or(&base);
Ok(format!(
"{}/v1/agent",
base.strip_suffix("/v1").unwrap_or(base)
))
}
pub fn headers(&self, config: &Config) -> Vec<(String, String)> {
let bearer = |key: &str| {
config
.get(key)
.map(|v| vec![("Authorization".to_string(), format!("Bearer {v}"))])
.unwrap_or_default()
};
match self {
Provider::OpenAI => {
let mut h = bearer("openai_api_key");
if let Some(org) = config.get("openai_organization_id") {
h.push(("OpenAI-Organization".into(), org.into()));
}
if let Some(project) = config.get("openai_project_id") {
h.push(("OpenAI-Project".into(), project.into()));
}
h
}
Provider::Anthropic => vec![
(
"x-api-key".into(),
config.get("anthropic_api_key").unwrap_or_default().into(),
),
("anthropic-version".into(), "2023-06-01".into()),
],
Provider::Gemini => {
vec![(
"x-goog-api-key".into(),
config.get("gemini_api_key").unwrap_or_default().into(),
)]
}
Provider::DeepSeek => bearer("deepseek_api_key"),
Provider::Mistral => bearer("mistral_api_key"),
Provider::OpenRouter => {
let mut h = bearer("openrouter_api_key");
h.push((
"HTTP-Referer".into(),
config
.get("openrouter_app_url")
.unwrap_or("https://github.com/cole-robertson/rust_llm")
.into(),
));
h.push((
"X-OpenRouter-Title".into(),
config
.get("openrouter_app_name")
.unwrap_or("RustLLM")
.into(),
));
h
}
Provider::XAI => bearer("xai_api_key"),
Provider::Perplexity => bearer("perplexity_api_key"),
Provider::Ollama => bearer("ollama_api_key"),
Provider::OllamaCloud => bearer("ollama_cloud_api_key"),
Provider::GPUStack => bearer("gpustack_api_key"),
Provider::Hetzner => bearer("hetzner_api_key"),
Provider::TypeSafe => bearer("typesafe_api_key"),
}
}
pub fn default_protocol(&self) -> ProtocolName {
match self {
Provider::OpenAI | Provider::XAI => ProtocolName::Responses,
Provider::Anthropic => ProtocolName::Anthropic,
Provider::Gemini => ProtocolName::Gemini,
Provider::Perplexity => ProtocolName::Responses,
_ => ProtocolName::ChatCompletions,
}
}
pub fn supports_protocol(&self, protocol: ProtocolName) -> bool {
match self {
Provider::OpenAI
| Provider::XAI
| Provider::DeepSeek
| Provider::OpenRouter
| Provider::GPUStack => {
matches!(
protocol,
ProtocolName::ChatCompletions | ProtocolName::Responses
)
}
Provider::Anthropic => protocol == ProtocolName::Anthropic,
Provider::Gemini => {
matches!(protocol, ProtocolName::Gemini | ProtocolName::Interactions)
}
Provider::Mistral => matches!(
protocol,
ProtocolName::ChatCompletions | ProtocolName::Conversations
),
Provider::Perplexity => {
matches!(
protocol,
ProtocolName::ChatCompletions
| ProtocolName::Responses
| ProtocolName::RouterChatCompletions
)
}
Provider::TypeSafe => false,
_ => protocol == ProtocolName::ChatCompletions,
}
}
pub fn resolve_protocol(
&self,
explicit: Option<ProtocolName>,
model: &Model,
config: &Config,
) -> Result<ProtocolName> {
let configured = match config.get(&format!("{}_protocol", self.slug())) {
Some(name) => Some(ProtocolName::parse(name).map_err(|_| self.not_a_protocol(name))?),
None => None,
};
let protocol = match explicit.or(configured) {
Some(p) => p,
None => match self {
Provider::OpenAI
if OPENAI_SEARCH_MODELS.contains(&model.id.as_str())
|| model.id.contains("audio")
|| model.id.contains("realtime") =>
{
ProtocolName::ChatCompletions
}
_ => self.default_protocol(),
},
};
if *self == Provider::TypeSafe {
return Err(Error::Api(
format!("{} doesn't support chat", self.display()),
None,
));
}
if !self.supports_protocol(protocol) {
return Err(self.not_a_protocol(protocol.name()));
}
Ok(protocol)
}
pub fn protocol_names(&self) -> &'static [&'static str] {
match self {
Provider::OpenAI => &["responses", "chat_completions", "embeddings", "files"],
Provider::Anthropic => &["anthropic", "files"],
Provider::Gemini => &["gemini", "interactions", "live_transcription", "files"],
Provider::DeepSeek => &["chat_completions", "responses", "files"],
Provider::Mistral => &["chat_completions", "conversations", "files"],
Provider::OpenRouter => &["chat_completions", "responses", "files"],
Provider::XAI => &["responses", "chat_completions", "files"],
Provider::Perplexity => &[
"chat_completions",
"router_chat_completions",
"files",
"agent_responses",
],
Provider::GPUStack => &["chat_completions", "responses"],
Provider::Ollama | Provider::OllamaCloud | Provider::Hetzner => &["chat_completions"],
Provider::TypeSafe => &["system_one"],
}
}
fn not_a_protocol(&self, name: &str) -> Error {
Error::Api(
format!(
"{name} is not a protocol of {}. Available: {}",
self.display(),
self.protocol_names().join(", ")
),
None,
)
}
pub fn configuration_options(&self) -> &'static [&'static str] {
match self {
Provider::OpenAI => &[
"openai_api_key",
"openai_api_base",
"openai_organization_id",
"openai_project_id",
"openai_use_system_role",
],
Provider::Anthropic => &["anthropic_api_key", "anthropic_api_base"],
Provider::Gemini => &["gemini_api_key", "gemini_api_base"],
Provider::DeepSeek => &["deepseek_api_key", "deepseek_api_base"],
Provider::Mistral => &["mistral_api_key", "mistral_api_base"],
Provider::OpenRouter => &[
"openrouter_api_key",
"openrouter_api_base",
"openrouter_app_url",
"openrouter_app_name",
],
Provider::XAI => &["xai_api_key", "xai_api_base"],
Provider::Perplexity => &["perplexity_api_key", "perplexity_api_base"],
Provider::Ollama => &["ollama_api_base", "ollama_api_key"],
Provider::OllamaCloud => &["ollama_cloud_api_key", "ollama_cloud_api_base"],
Provider::GPUStack => &["gpustack_api_base", "gpustack_api_key"],
Provider::Hetzner => &["hetzner_api_key", "hetzner_api_base"],
Provider::TypeSafe => &["typesafe_api_key", "typesafe_api_base"],
}
}
pub fn is_remote(&self) -> bool {
!self.is_local()
}
pub fn retry_delay(&self, headers: &[(String, String)]) -> Option<f64> {
if *self != Provider::OpenAI {
return None;
}
["x-ratelimit-reset-requests", "x-ratelimit-reset-tokens"]
.iter()
.filter_map(|name| headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)))
.filter_map(|(_, v)| parse_reset_duration(v))
.reduce(f64::max)
}
pub fn parse_error(&self, body: &str) -> Option<String> {
match self {
Provider::Perplexity => self
.strip_html_error(body)
.or_else(|| crate::error::parse_error_message(body)),
Provider::OpenRouter => openrouter_parse_error(body),
_ => crate::error::parse_error_message(body),
}
}
pub(crate) fn router_url(&self, config: &Config, operation: &str) -> Result<String> {
let base = self.api_base(config)?;
let base = base.strip_suffix('/').unwrap_or(&base);
let base = base.strip_suffix("/router/v1").unwrap_or(base);
Ok(format!("{base}/router/v1/{operation}"))
}
pub(crate) fn strip_html_error(&self, body: &str) -> Option<String> {
if *self != Provider::Perplexity || !body.contains("<html>") || !body.contains("<title>") {
return None;
}
let start = body.find("<title>")? + 7;
let end = body[start..].find("</title>")? + start;
let title = &body[start..end];
if title.is_empty() || title.contains('\n') {
return None;
}
let digits = title.len() - title.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let rest = &title[digits..];
let trimmed = rest.trim_start();
Some(
if digits > 0 && trimmed.len() < rest.len() {
trimmed
} else {
title
}
.to_string(),
)
}
}
fn parse_reset_duration(value: &str) -> Option<f64> {
static PART: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"(\d+(?:\.\d+)?)(ms|h|m|s)").unwrap()); let parts: Vec<(&str, &str)> = PART
.captures_iter(value)
.map(|c| {
let (_, [amount, unit]) = c.extract();
(amount, unit)
})
.collect();
if parts.is_empty()
|| parts
.iter()
.map(|(a, u)| format!("{a}{u}"))
.collect::<String>()
!= value
{
return None;
}
let seconds = |unit: &str| match unit {
"h" => 3600.0,
"m" => 60.0,
"s" => 1.0,
_ => 0.001,
};
Some(
parts
.iter()
.map(|(a, u)| a.parse::<f64>().unwrap_or(0.0) * seconds(u))
.sum(),
)
}
fn openrouter_parse_error(body: &str) -> Option<String> {
use serde_json::Value;
if body.is_empty() {
return None;
}
let try_parse =
|s: &str| serde_json::from_str::<Value>(s).unwrap_or_else(|_| Value::String(s.to_string()));
fn error_message(value: Option<&Value>) -> Option<String> {
match value? {
Value::Object(o) => o.get("message").and_then(Value::as_str).map(str::to_string),
Value::Array(parts) => {
let messages: Vec<String> = parts
.iter()
.filter_map(|p| error_message(Some(p)))
.collect();
(!messages.is_empty()).then(|| messages.join(". "))
}
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
let part_message = |part: &Value| -> Option<String> {
let Value::Object(part) = part else {
return error_message(Some(part));
};
let error = part.get("error");
let message = error_message(error);
let Some(Value::Object(metadata)) = error.and_then(|e| e.get("metadata")) else {
return message;
};
let raw = match metadata.get("raw") {
Some(Value::String(s)) => try_parse(s),
Some(other) => other.clone(),
None => Value::Null,
};
let Value::Object(raw) = raw else {
return message;
};
match error_message(raw.get("error")) {
Some(raw_message) => Some(
message
.into_iter()
.chain([raw_message])
.collect::<Vec<_>>()
.join(" - "),
),
None => message,
}
};
match try_parse(body) {
Value::Object(o) => part_message(&Value::Object(o)),
Value::Array(parts) => {
let messages: Vec<String> = parts
.iter()
.filter_map(part_message)
.filter(|m| !m.is_empty())
.collect();
(!messages.is_empty()).then(|| messages.join(". "))
}
Value::String(s) => Some(s),
other => Some(other.to_string()),
}
}
pub fn local_providers() -> Vec<Provider> {
ALL.iter().copied().filter(Provider::is_local).collect()
}
pub fn remote_providers() -> Vec<Provider> {
ALL.iter().copied().filter(Provider::is_remote).collect()
}
pub fn configured_providers(config: &Config) -> Vec<Provider> {
ALL.iter()
.copied()
.filter(|p| p.is_configured(config))
.collect()
}
pub fn configured_remote_providers(config: &Config) -> Vec<Provider> {
ALL.iter()
.copied()
.filter(|p| p.is_remote() && p.is_configured(config))
.collect()
}