use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use serde::{Deserialize, Serialize};
use web_time::{SystemTime, UNIX_EPOCH};
use crate::error::{LiterLlmError, Result};
pub(crate) fn unix_timestamp_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn validate_sampling_param_range(
body: &serde_json::Value,
field: &str,
provider: &str,
min: f64,
max: f64,
) -> Result<()> {
let Some(value) = body.get(field).and_then(serde_json::Value::as_f64) else {
return Ok(());
};
if value < min || value > max {
return Err(LiterLlmError::BadRequest {
message: format!(
"{field}={value} is outside {provider}'s supported range [{min}, {max}]; lower \
the requested value or omit `{field}` to use the provider default"
),
status: 400,
});
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum StreamFormat {
#[default]
Sse,
AwsEventStream,
Ndjson,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ProviderCapabilities {
pub vision: bool,
pub reasoning: bool,
pub structured_output: bool,
pub function_calling: bool,
pub audio_in: bool,
pub audio_out: bool,
pub video_in: bool,
}
static DEFAULT_CAPABILITIES: ProviderCapabilities = ProviderCapabilities {
vision: false,
reasoning: false,
structured_output: false,
function_calling: false,
audio_in: false,
audio_out: false,
video_in: false,
};
pub fn capabilities(provider_name: &str) -> ProviderCapabilities {
let Ok(reg) = REGISTRY.as_ref() else {
return DEFAULT_CAPABILITIES;
};
for entry in ®.providers {
if entry.config.name == provider_name {
return entry.capabilities;
}
}
DEFAULT_CAPABILITIES
}
const PROVIDERS_JSON: &str = include_str!("../../schemas/providers.json");
static REGISTRY: LazyLock<std::result::Result<ProviderRegistry, String>> = LazyLock::new(|| {
serde_json::from_str::<ProviderRegistryRaw>(PROVIDERS_JSON)
.map(ProviderRegistry::from_raw)
.map_err(|e| {
tracing::error!(error = %e, "embedded schemas/providers.json failed to parse");
e.to_string()
})
});
fn registry() -> Result<&'static ProviderRegistry> {
REGISTRY.as_ref().map_err(|e| LiterLlmError::ServerError {
message: format!("embedded schemas/providers.json is invalid: {e}"),
status: 500,
})
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RegionalEndpointConfig {
region: String,
openai_base_url: String,
anthropic_base_url: String,
docs_root: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Endpoint {
Chat,
Embedding,
Image,
Audio,
Moderation,
Rerank,
Search,
Ocr,
}
impl Endpoint {
fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Embedding => "embedding",
Self::Image => "image",
Self::Audio => "audio",
Self::Moderation => "moderation",
Self::Rerank => "rerank",
Self::Search => "search",
Self::Ocr => "ocr",
}
}
}
#[derive(Debug, Deserialize)]
struct ProviderEntry {
#[serde(flatten)]
config: ProviderConfig,
#[serde(default)]
aliases: Vec<String>,
#[serde(default)]
endpoint_urls: HashMap<String, String>,
#[serde(default)]
no_route_endpoints: Vec<Endpoint>,
#[serde(default)]
capabilities: ProviderCapabilities,
#[allow(dead_code)]
#[serde(default)]
regional_endpoints: Vec<RegionalEndpointConfig>,
#[allow(dead_code)]
#[serde(default, rename = "streaming_format")]
stream_format: StreamFormat,
}
#[derive(Debug, Deserialize)]
struct ProviderRegistryRaw {
providers: Vec<ProviderEntry>,
#[serde(default, deserialize_with = "deserialize_hashset")]
complex_providers: HashSet<String>,
}
#[derive(Debug)]
struct ProviderRegistry {
providers: Vec<ProviderEntry>,
configs: Vec<ProviderConfig>,
complex_providers: HashSet<String>,
}
impl ProviderRegistry {
fn from_raw(raw: ProviderRegistryRaw) -> Self {
let configs = raw.providers.iter().map(|e| e.config.clone()).collect();
Self {
configs,
providers: raw.providers,
complex_providers: raw.complex_providers,
}
}
}
fn deserialize_hashset<'de, D>(deserializer: D) -> std::result::Result<HashSet<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let vec = Vec::<String>::deserialize(deserializer)?;
Ok(vec.into_iter().collect())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
pub display_name: Option<String>,
pub base_url: Option<String>,
pub auth: Option<AuthConfig>,
pub endpoints: Option<Vec<String>>,
pub model_prefixes: Option<Vec<String>>,
pub param_mappings: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AuthType {
Bearer,
#[serde(alias = "header", alias = "x-api-key")]
ApiKey,
None,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
#[serde(rename = "type")]
pub auth_type: AuthType,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub env_var: Option<String>,
}
fn status_for_error_kind(code: Option<&str>, kind: Option<&str>) -> u16 {
match code {
Some("rate_limit_exceeded" | "insufficient_quota") => return 429,
Some("invalid_api_key") => return 401,
Some("model_not_found") => return 404,
Some("server_error") => return 500,
_ => {}
}
match kind {
Some("rate_limit_error") => 429,
Some("authentication_error") => 401,
Some("billing_error") => 402,
Some("permission_error") => 403,
Some("not_found_error") => 404,
Some("request_too_large") => 413,
Some("server_error" | "api_error") => 500,
Some("timeout_error") => 504,
Some("overloaded_error") => 529,
_ => 400,
}
}
fn u16_field(value: &serde_json::Value) -> Option<u16> {
let number = match value {
serde_json::Value::String(s) => s.trim().parse::<u64>().ok()?,
other => other.as_u64()?,
};
u16::try_from(number).ok()
}
pub(crate) fn status_code_field(event: &serde_json::Value) -> Option<u16> {
event.pointer("/error/status_code").and_then(u16_field).or_else(|| {
event
.get("status_code")
.and_then(u16_field)
.filter(|s| (400..=599).contains(s))
})
}
pub(crate) fn google_error_status(event: &serde_json::Value) -> Option<u16> {
let from_code = event
.pointer("/error/code")
.and_then(u16_field)
.filter(|s| (400..=599).contains(s));
from_code.or_else(
|| match event.pointer("/error/status").and_then(serde_json::Value::as_str)? {
"INVALID_ARGUMENT" | "FAILED_PRECONDITION" | "OUT_OF_RANGE" => Some(400),
"UNAUTHENTICATED" => Some(401),
"PERMISSION_DENIED" => Some(403),
"NOT_FOUND" => Some(404),
"ABORTED" | "ALREADY_EXISTS" => Some(409),
"RESOURCE_EXHAUSTED" => Some(429),
"CANCELLED" => Some(499),
"UNKNOWN" | "INTERNAL" | "DATA_LOSS" => Some(500),
"UNIMPLEMENTED" => Some(501),
"UNAVAILABLE" => Some(503),
"DEADLINE_EXCEEDED" => Some(504),
_ => None,
},
)
}
fn retry_after_field(event: &serde_json::Value) -> Option<std::time::Duration> {
let seconds = |value: &serde_json::Value| -> Option<std::time::Duration> {
let secs = match value {
serde_json::Value::String(s) => s.trim().parse::<f64>().ok()?,
other => other.as_f64()?,
};
std::time::Duration::try_from_secs_f64(secs).ok()
};
[event.pointer("/error/retry_after"), event.get("retry_after")]
.into_iter()
.flatten()
.find_map(seconds)
}
pub(crate) fn stream_error_event(event: &serde_json::Value, status: Option<u16>) -> Option<LiterLlmError> {
let error = event.get("error")?;
let text = |key: &str| error.get(key).and_then(serde_json::Value::as_str);
let message = text("message").unwrap_or("provider returned an error mid-stream");
let status = status.unwrap_or_else(|| status_for_error_kind(text("code"), text("type")));
if !(400..=599).contains(&status) {
return Some(LiterLlmError::BadRequest {
message: message.to_string(),
status,
});
}
let body = serde_json::json!({ "error": { "message": message, "code": text("code") } });
Some(LiterLlmError::from_status(
status,
&body.to_string(),
retry_after_field(event),
))
}
pub(crate) trait Provider: Send + Sync {
fn validate(&self) -> Result<()> {
Ok(())
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn env_var(&self) -> Option<&str> {
None
}
fn name(&self) -> &str;
fn unrouted_endpoints(&self) -> &[Endpoint] {
&[]
}
fn base_url(&self) -> &str;
fn at_base_url(&self, base_url: &str) -> Option<Box<dyn Provider>> {
let _ = base_url;
None
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)>;
fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
&[]
}
fn dynamic_headers(&self, _body: &serde_json::Value) -> Vec<(String, String)> {
vec![]
}
fn matches_model(&self, model: &str) -> bool;
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
if let Some(rest) = model.strip_prefix(self.name())
&& let Some(stripped) = rest.strip_prefix('/')
{
return stripped;
}
model
}
fn chat_completions_path(&self) -> &str {
"/chat/completions"
}
fn embeddings_path(&self) -> &str {
"/embeddings"
}
fn models_path(&self) -> &str {
"/models"
}
fn next_models_page_url(&self, _url: &str, _page: &serde_json::Value) -> Option<String> {
None
}
fn image_generations_path(&self) -> &str {
"/images/generations"
}
fn audio_speech_path(&self) -> &str {
"/audio/speech"
}
fn audio_transcriptions_path(&self) -> &str {
"/audio/transcriptions"
}
fn moderations_path(&self) -> &str {
"/moderations"
}
fn rerank_path(&self) -> &str {
"/rerank"
}
fn files_path(&self) -> &str {
"/files"
}
fn batches_path(&self) -> &str {
"/batches"
}
fn responses_path(&self) -> &str {
"/responses"
}
fn search_path(&self) -> &str {
"/search"
}
fn ocr_path(&self) -> &str {
"/ocr"
}
#[allow(dead_code)]
fn supports_streaming(&self) -> bool {
true
}
fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
let _ = body;
Ok(())
}
fn transform_embedding_request(&self, body: &mut serde_json::Value) -> Result<()> {
self.transform_request(body)
}
fn transform_embedding_response(&self, body: &mut serde_json::Value, request_model: &str) -> Result<()> {
let _ = request_model;
self.transform_response(body)
}
fn transform_response(&self, _body: &mut serde_json::Value) -> Result<()> {
Ok(())
}
fn transform_error(&self, error: crate::error::LiterLlmError) -> crate::error::LiterLlmError {
error
}
fn transform_rerank_response(&self, _body: &mut serde_json::Value) -> Result<()> {
Ok(())
}
fn transform_rerank_request(&self, _body: &mut serde_json::Value) -> Result<()> {
Ok(())
}
fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
join_url(self.base_url(), endpoint_path)
}
fn parse_stream_event(&self, event_data: &str) -> Result<Option<crate::types::ChatCompletionChunk>> {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(event_data)
&& let Some(err) = stream_error_event(&value, status_code_field(&value))
{
return Err(err);
}
serde_json::from_str::<crate::types::ChatCompletionChunk>(event_data)
.map(Some)
.map_err(|e| LiterLlmError::Streaming {
message: format!("failed to parse SSE data: {e}"),
})
}
fn stream_format(&self) -> StreamFormat {
StreamFormat::Sse
}
fn stream_has_final_event(&self) -> bool {
false
}
fn stream_tool_call_indexes_restart(&self) -> bool {
false
}
fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
self.build_url(endpoint_path, model)
}
fn signing_headers(&self, method: &str, url: &str, body: &[u8]) -> Result<Vec<(String, String)>> {
let _ = (method, url, body);
Ok(vec![])
}
fn signing_headers_with_credential(
&self,
method: &str,
url: &str,
body: &[u8],
credential: Option<&crate::auth::Credential>,
) -> Result<Vec<(String, String)>> {
let _ = credential;
self.signing_headers(method, url, body)
}
}
pub(crate) fn carry_extra_body(from: &mut serde_json::Value, to: &mut serde_json::Value) {
if let Some(extra_body) = from.get_mut("extra_body").map(serde_json::Value::take) {
to["extra_body"] = extra_body;
}
}
pub(crate) mod anthropic;
pub(crate) mod azure;
pub(crate) mod bedrock;
pub(crate) mod cohere;
pub mod custom;
pub(crate) mod github_copilot;
pub(crate) mod google_ai;
pub(crate) mod mistral;
pub(crate) mod ollama;
pub mod outbound_policy;
pub(crate) mod rebased;
pub(crate) mod rerank_shape;
pub(crate) mod vertex;
pub(crate) mod voyage;
#[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
pub use outbound_policy::configure_outbound_client_builder;
#[cfg(any(feature = "native-http", feature = "wasm-http"))]
pub(crate) use outbound_policy::outbound_forbidden_from_reqwest;
pub use outbound_policy::{
OutboundPolicy, current_policy, set_outbound_policy, validate_outbound_url, validate_outbound_url_sync,
};
#[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
pub(crate) use outbound_policy::{authenticated_outbound_client, configure_credential_free_outbound_client_builder};
pub(crate) struct OpenAiProvider;
impl Provider for OpenAiProvider {
fn at_base_url(&self, base_url: &str) -> Option<Box<dyn Provider>> {
Some(Box::new(OpenAiCompatibleProvider {
name: "custom".into(),
base_url: base_url.to_owned(),
env_var: None,
model_prefixes: vec![],
hint_provider: Some("openai".into()),
}))
}
fn name(&self) -> &str {
"openai"
}
fn base_url(&self) -> &str {
"https://api.openai.com/v1"
}
fn env_var(&self) -> Option<&str> {
Some("OPENAI_API_KEY")
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
}
fn matches_model(&self, model: &str) -> bool {
model.starts_with("gpt-")
|| model.starts_with("o1-")
|| model.starts_with("o3-")
|| model.starts_with("o4-")
|| model == "o1"
|| model == "o3"
|| model == "o4"
|| model.starts_with("dall-e-")
|| model.starts_with("whisper-")
|| model.starts_with("tts-")
|| model.starts_with("text-embedding-")
|| model.starts_with("chatgpt-")
|| model.starts_with("openai/")
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
model.strip_prefix("openai/").unwrap_or(model)
}
fn transform_response(&self, body: &mut serde_json::Value) -> Result<()> {
transform_openai_audio_response(body)
}
}
pub(crate) fn transform_openai_audio_response(body: &mut serde_json::Value) -> Result<()> {
use serde_json::json;
let choices = match body.get_mut("choices").and_then(|c| c.as_array_mut()) {
Some(c) => c,
None => return Ok(()),
};
for choice in choices {
let audio = match choice.pointer("/message/audio") {
Some(a) if !a.is_null() => a.clone(),
_ => continue,
};
let data = audio.get("data").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let format = audio.get("format").and_then(|v| v.as_str()).unwrap_or("wav").to_owned();
let transcript = audio
.get("transcript")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let mut parts: Vec<serde_json::Value> = vec![];
if !transcript.is_empty() {
parts.push(json!({"type": "text", "text": transcript}));
}
if !data.is_empty() {
parts.push(json!({
"type": "output_audio",
"audio": {"data": data, "format": format}
}));
}
if !parts.is_empty()
&& let Some(message) = choice.get_mut("message")
{
message["content"] = json!(parts);
}
}
Ok(())
}
pub(crate) struct OpenAiCompatibleProvider {
pub name: String,
pub base_url: String,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub env_var: Option<&'static str>,
pub model_prefixes: Vec<String>,
pub hint_provider: Option<String>,
}
impl Provider for OpenAiCompatibleProvider {
fn name(&self) -> &str {
&self.name
}
fn base_url(&self) -> &str {
&self.base_url
}
fn env_var(&self) -> Option<&str> {
self.env_var
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
}
fn matches_model(&self, model: &str) -> bool {
self.model_prefixes
.iter()
.any(|prefix| model.starts_with(prefix.as_str()))
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
if let Some((prefix, rest)) = model.split_once('/')
&& !rest.is_empty()
&& (prefix == self.name || self.hint_provider.as_deref() == Some(prefix))
{
return rest;
}
model
}
}
pub(crate) fn hint_provider_name(model_hint: &str) -> Option<String> {
let name = model_hint.split_once('/').map_or(model_hint, |(prefix, _)| prefix);
(!name.is_empty()).then(|| name.to_owned())
}
pub(crate) struct ConfigDrivenProvider {
config: &'static ProviderConfig,
aliases: &'static [String],
endpoint_urls: Option<&'static HashMap<String, String>>,
unrouted_endpoints: &'static [Endpoint],
}
impl ConfigDrivenProvider {
#[cfg(test)]
#[must_use]
pub(crate) fn new(config: &'static ProviderConfig) -> Self {
Self {
config,
aliases: &[],
endpoint_urls: None,
unrouted_endpoints: &[],
}
}
fn from_entry(entry: &'static ProviderEntry) -> Self {
Self {
config: &entry.config,
aliases: &entry.aliases,
endpoint_urls: Some(&entry.endpoint_urls),
unrouted_endpoints: &entry.no_route_endpoints,
}
}
}
impl Provider for ConfigDrivenProvider {
fn name(&self) -> &str {
&self.config.name
}
fn unrouted_endpoints(&self) -> &[Endpoint] {
self.unrouted_endpoints
}
fn base_url(&self) -> &str {
self.config.base_url.as_deref().unwrap_or("")
}
fn env_var(&self) -> Option<&str> {
self.config.auth.as_ref().and_then(|a| a.env_var.as_deref())
}
fn rerank_path(&self) -> &str {
rerank_shape::rerank_path_of(&self.config.name)
}
fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
if endpoint_path == self.rerank_path()
&& let Some(url) = self.endpoint_urls.and_then(|urls| urls.get("rerank"))
{
return url.clone();
}
format!("{}{}", self.base_url(), endpoint_path)
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
match model.split_once('/') {
Some((prefix, rest)) if prefix == self.config.name || self.aliases.iter().any(|alias| alias == prefix) => {
rest
}
_ => model,
}
}
fn transform_rerank_response(&self, body: &mut serde_json::Value) -> Result<()> {
rerank_shape::reshape_rerank_reply(&self.config.name, body);
Ok(())
}
fn transform_rerank_request(&self, body: &mut serde_json::Value) -> Result<()> {
rerank_shape::reshape_rerank_request(&self.config.name, body);
Ok(())
}
fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
if let Some(mappings) = &self.config.param_mappings
&& let Some(obj) = body.as_object_mut()
{
for (from, to) in mappings {
if let Some(val) = obj.remove(from.as_str()) {
obj.insert(to.clone(), val);
}
}
}
Ok(())
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
let auth_type = self
.config
.auth
.as_ref()
.map(|a| &a.auth_type)
.unwrap_or(&AuthType::Bearer);
match auth_type {
AuthType::None => None,
AuthType::ApiKey => Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key))),
AuthType::Bearer | AuthType::Unknown => {
Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
}
}
}
fn matches_model(&self, model: &str) -> bool {
if let Some(prefixes) = &self.config.model_prefixes {
prefixes.iter().any(|p| model.starts_with(p.as_str()))
} else {
false
}
}
}
fn registry_entry_for_prefix<'r>(reg: &'r ProviderRegistry, prefix: &str) -> Option<&'r ProviderEntry> {
reg.providers
.iter()
.find(|e| e.config.name == prefix)
.or_else(|| reg.providers.iter().find(|e| e.aliases.iter().any(|a| a == prefix)))
}
pub(crate) fn has_provider_prefix(model: &str) -> bool {
let Some((prefix, _)) = model.split_once('/') else {
return false;
};
prefix == "google_ai"
|| REGISTRY
.as_ref()
.is_ok_and(|reg| registry_entry_for_prefix(reg, prefix).is_some())
|| custom::is_custom_provider_prefix(prefix, model)
}
fn has_route(reg: &ProviderRegistry, entry: &ProviderEntry) -> bool {
entry.config.base_url.is_some() && !reg.complex_providers.contains(&entry.config.name)
}
pub(crate) fn route_model(model: &str) -> Result<Option<Box<dyn Provider>>> {
route_detected(model, detect_provider(model))
}
fn route_builtin_model(model: &str) -> Result<Option<Box<dyn Provider>>> {
route_detected(model, detect_builtin_provider(model))
}
fn route_detected(model: &str, detected: Option<Box<dyn Provider>>) -> Result<Option<Box<dyn Provider>>> {
if let Some(provider) = detected {
return Ok(Some(provider));
}
if let Ok(reg) = REGISTRY.as_ref()
&& let Some(error) = unrouted_provider_error(reg, model)
{
return Err(error);
}
Ok(None)
}
pub(crate) fn route_hint(hint: &str) -> Result<Option<Box<dyn Provider>>> {
route_hint_with(hint, route_model)
}
pub(crate) fn route_builtin_hint(hint: &str) -> Result<Option<Box<dyn Provider>>> {
route_hint_with(hint, route_builtin_model)
}
type ModelRoute = fn(&str) -> Result<Option<Box<dyn Provider>>>;
fn route_hint_with(hint: &str, route: ModelRoute) -> Result<Option<Box<dyn Provider>>> {
if let Some(provider) = route(hint)? {
return Ok(Some(provider));
}
if hint.contains('/') {
return Ok(None);
}
route(&format!("{hint}/"))
}
pub(crate) fn on_base_url(provider: Box<dyn Provider>, base_url: &str) -> Box<dyn Provider> {
match provider.at_base_url(base_url) {
Some(rebased) => rebased,
None => Box::new(rebased::BaseUrlOverride::new(provider, base_url)),
}
}
pub(crate) fn join_url(base_url: &str, endpoint_path: &str) -> String {
let Ok(mut base) = url::Url::parse(base_url) else {
return format!("{base_url}{endpoint_path}");
};
let path = format!(
"{}/{}",
base.path().trim_end_matches('/'),
endpoint_path.trim_start_matches('/')
);
base.set_path(&path);
base.into()
}
fn unrouted_provider_error(reg: &ProviderRegistry, model: &str) -> Option<LiterLlmError> {
let (prefix, _) = model.split_once('/')?;
let entry = registry_entry_for_prefix(reg, prefix)?;
if has_route(reg, entry) {
return None;
}
Some(LiterLlmError::BadRequest {
message: format!(
"provider `{}` has no built-in route; set base_url to its endpoint",
entry.config.name
),
status: 400,
})
}
pub(crate) fn refuse_unrouted_endpoint(provider: &dyn Provider, endpoint: Endpoint) -> Result<()> {
if !provider.unrouted_endpoints().contains(&endpoint) {
return Ok(());
}
Err(LiterLlmError::BadRequest {
message: format!(
"provider `{}` has no built-in route for {}; set base_url to its endpoint",
provider.name(),
endpoint.as_str()
),
status: 400,
})
}
pub(crate) fn detect_provider(model: &str) -> Option<Box<dyn Provider>> {
custom::detect_custom_provider(model).or_else(|| detect_builtin_provider(model))
}
pub(crate) fn detect_builtin_provider(model: &str) -> Option<Box<dyn Provider>> {
let openai = OpenAiProvider;
if openai.matches_model(model) {
return Some(Box::new(openai));
}
let anthropic = anthropic::AnthropicProvider::default();
if anthropic.matches_model(model) {
return Some(Box::new(anthropic));
}
if model.starts_with("azure/") {
return Some(Box::new(azure::AzureProvider::new()));
}
if model.starts_with("gemini/") || model.starts_with("google_ai/") {
return Some(Box::new(google_ai::GoogleAiProvider::default()));
}
if model.starts_with("vertex_ai/") {
return Some(Box::new(vertex::VertexAiProvider::from_env()));
}
if model.starts_with("bedrock/") {
return Some(Box::new(bedrock::BedrockProvider::from_env()));
}
if cohere::CohereProvider.matches_model(model) {
return Some(Box::new(cohere::CohereProvider));
}
if model.starts_with("mistral-")
|| model.starts_with("codestral-")
|| model.starts_with("pixtral-")
|| model.starts_with("mistral/")
{
return Some(Box::new(mistral::MistralProvider));
}
if ollama::OllamaProvider::owns(model) {
return Some(Box::new(ollama::OllamaProvider::default()));
}
if model.starts_with("voyage/") {
return Some(Box::new(voyage::VoyageProvider));
}
if model.starts_with("github_copilot/") {
return Some(Box::new(github_copilot::GithubCopilotProvider::from_env()));
}
let reg = match REGISTRY.as_ref() {
Ok(r) => r,
Err(_) => return None,
};
if let Some((prefix, _)) = model.split_once('/')
&& let Some(entry) = registry_entry_for_prefix(reg, prefix)
&& has_route(reg, entry)
{
return Some(Box::new(ConfigDrivenProvider::from_entry(entry)));
}
for entry in ®.providers {
if reg.complex_providers.contains(&entry.config.name) {
continue;
}
if let Some(prefixes) = &entry.config.model_prefixes {
let matches = prefixes
.iter()
.any(|p| model.starts_with(p.as_str()) && !p.ends_with('/'));
if matches && entry.config.base_url.is_some() {
return Some(Box::new(ConfigDrivenProvider::from_entry(entry)));
}
}
}
None
}
pub fn all_providers() -> Result<&'static [ProviderConfig]> {
Ok(®istry()?.configs)
}
pub fn complex_provider_names() -> Result<&'static HashSet<String>> {
Ok(®istry()?.complex_providers)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct RenamingProvider;
impl Provider for RenamingProvider {
fn name(&self) -> &str {
"renaming"
}
fn base_url(&self) -> &str {
"https://example.invalid"
}
fn auth_header<'a>(&'a self, _api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
None
}
fn matches_model(&self, _model: &str) -> bool {
false
}
fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
body["renamed"] = json!(true);
Ok(())
}
}
#[test]
fn transform_embedding_response_keeps_the_reply_model_by_default() {
let mut body = json!({"object": "list", "data": [], "model": ""});
RenamingProvider
.transform_embedding_response(&mut body, "request-model")
.expect("the default hook should not fail");
assert_eq!(body["model"], "");
}
#[test]
fn transform_embedding_request_runs_transform_request_by_default() {
let mut body = json!({"model": "m", "input": "x"});
RenamingProvider
.transform_embedding_request(&mut body)
.expect("the default hook should not fail");
assert_eq!(body["renamed"], true);
}
#[test]
fn validate_sampling_param_range_rejects_out_of_range_value() {
let body = json!({"temperature": 1.8});
let err = validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0)
.expect_err("1.8 is outside [0.0, 1.0]");
assert_eq!(err.status_code(), 400);
assert_eq!(
err.to_string(),
"bad request: temperature=1.8 is outside Anthropic's supported range [0, 1]; lower \
the requested value or omit `temperature` to use the provider default"
);
}
#[test]
fn validate_sampling_param_range_accepts_in_range_value() {
let body = json!({"temperature": 0.5});
assert!(validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0).is_ok());
}
#[test]
fn validate_sampling_param_range_ignores_absent_field() {
let body = json!({"model": "claude-3-5-sonnet"});
assert!(validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0).is_ok());
}
#[test]
fn static_key_providers_declare_their_env_var() {
let cases: Vec<(&str, Box<dyn Provider>)> = vec![
("OPENAI_API_KEY", Box::new(OpenAiProvider)),
(
"ANTHROPIC_API_KEY",
Box::new(super::anthropic::AnthropicProvider::new()),
),
("COHERE_API_KEY", Box::new(super::cohere::CohereProvider)),
("MISTRAL_API_KEY", Box::new(super::mistral::MistralProvider)),
(
"GEMINI_API_KEY",
Box::new(super::google_ai::GoogleAiProvider::default()),
),
(
"AZURE_OPENAI_API_KEY",
Box::new(super::azure::AzureProvider::with_base_url("https://x.openai.azure.com")),
),
];
for (expected, provider) in cases {
assert_eq!(
provider.env_var(),
Some(expected),
"{} must declare {expected}",
provider.name()
);
}
}
#[test]
fn minimax_regional_endpoints_are_registered() {
let registry = registry().expect("registry should load");
let provider = registry
.providers
.iter()
.find(|entry| entry.config.name == "minimax")
.expect("MiniMax provider should be registered");
assert!(provider.capabilities.reasoning);
assert_eq!(
provider.regional_endpoints,
vec![
RegionalEndpointConfig {
region: "global_en".into(),
openai_base_url: "https://api.minimax.io/v1".into(),
anthropic_base_url: "https://api.minimax.io/anthropic".into(),
docs_root: "https://platform.minimax.io/docs".into(),
},
RegionalEndpointConfig {
region: "cn_zh".into(),
openai_base_url: "https://api.minimaxi.com/v1".into(),
anthropic_base_url: "https://api.minimaxi.com/anthropic".into(),
docs_root: "https://platform.minimaxi.com/docs".into(),
},
]
);
}
#[test]
fn openai_tool_result_image_part_serializes_to_exact_content_array() {
use crate::types::{ContentPart, Message, ToolMessage, UserContent};
let message = Message::Tool(ToolMessage {
content: UserContent::Parts(vec![
ContentPart::text("Here is a screenshot:"),
ContentPart::image_data_url("data:image/png;base64,abc123"),
]),
tool_call_id: "call_shot".into(),
name: None,
});
let mut body = json!({"model": "gpt-4o", "messages": [message]});
OpenAiProvider
.transform_request(&mut body)
.expect("transform_request should not fail");
assert_eq!(
body["messages"][0],
json!({
"role": "tool",
"content": [
{"type": "text", "text": "Here is a screenshot:"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}
],
"tool_call_id": "call_shot"
})
);
}
#[test]
fn transform_response_audio_message_hoisted() {
let mut body = json!({
"id": "chatcmpl-audio-123",
"object": "chat.completion",
"created": 1700000000u64,
"model": "gpt-4o-audio-preview",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"audio": {
"id": "audio-abc",
"data": "aGVsbG8=",
"transcript": "hello",
"format": "wav",
"expires_at": 9999999999u64
}
},
"finish_reason": "stop"
}]
});
transform_openai_audio_response(&mut body).expect("transform must succeed");
let content = body
.pointer("/choices/0/message/content")
.expect("content must be present");
assert!(content.is_array(), "content must be a parts array, got: {content}");
let parts = content.as_array().expect("array");
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["type"], "text");
assert_eq!(parts[0]["text"], "hello");
assert_eq!(parts[1]["type"], "output_audio");
assert_eq!(parts[1]["audio"]["data"], "aGVsbG8=");
assert_eq!(parts[1]["audio"]["format"], "wav");
}
#[test]
fn transform_response_audio_no_op_when_no_audio_field() {
let mut body = json!({
"choices": [{
"message": {
"role": "assistant",
"content": "plain text"
}
}]
});
let original = body.clone();
transform_openai_audio_response(&mut body).expect("transform must succeed");
assert_eq!(body, original, "body must be unchanged when no audio field is present");
}
#[test]
fn parse_stream_event_tolerates_trailing_metadata_event_without_id() {
let payload = r#"{"choices":[],"x-opencode-type":"inference-cost","cost":"0.00001400","normalizedUsage":{"inputTokens":84,"outputTokens":8}}"#;
let chunk = OpenAiProvider
.parse_stream_event(payload)
.expect("trailing metadata event must not error")
.expect("event should decode to a chunk, not be skipped");
assert!(chunk.id.is_empty());
assert!(chunk.choices.is_empty());
}
#[test]
fn parse_stream_event_surfaces_mid_stream_error_object() {
let payload = r#"{"error":{"message":"tool call validation failed","type":"invalid_request_error","code":"tool_use_failed","status_code":400}}"#;
let err = OpenAiProvider
.parse_stream_event(payload)
.expect_err("a mid-stream error object must not decode as a chunk");
assert_eq!(err.status_code(), 400);
assert!(
err.to_string().contains("tool call validation failed"),
"the provider's own message must reach the caller, got: {err}"
);
}
#[test]
fn parse_stream_event_defaults_status_when_error_object_omits_it() {
let payload = r#"{"error":{"message":"upstream capacity exceeded"}}"#;
let err = OpenAiProvider
.parse_stream_event(payload)
.expect_err("an error object without a status must still be an error");
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("upstream capacity exceeded"));
}
fn mid_stream_error(payload: &str) -> LiterLlmError {
OpenAiProvider
.parse_stream_event(payload)
.expect_err("a mid-stream error object must not decode as a chunk")
}
#[test]
fn parse_stream_event_reads_retry_after_from_error_event() {
use std::time::Duration;
let cases = [
(
r#"{"error":{"message":"slow down","type":"rate_limit_error","retry_after":7}}"#,
Some(Duration::from_secs(7)),
),
(
r#"{"error":{"message":"slow down","type":"rate_limit_error"},"retry_after":3}"#,
Some(Duration::from_secs(3)),
),
(
r#"{"error":{"message":"slow down","type":"rate_limit_error","retry_after":1.5}}"#,
Some(Duration::from_millis(1500)),
),
(
r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":7},"retry_after":3}"#,
Some(Duration::from_secs(7)),
),
(r#"{"error":{"message":"x","type":"rate_limit_error"}}"#, None),
(
r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":-4}}"#,
None,
),
(
r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":"soon"}}"#,
None,
),
(
r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":"30"}}"#,
Some(Duration::from_secs(30)),
),
(
r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":-4},"retry_after":5}"#,
Some(Duration::from_secs(5)),
),
(
r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":"soon"},"retry_after":"6"}"#,
Some(Duration::from_secs(6)),
),
];
for (payload, want) in cases {
let err = mid_stream_error(payload);
assert!(
matches!(err, LiterLlmError::RateLimited { .. }),
"{payload}: got {err:?}"
);
assert_eq!(err.retry_after(), want, "{payload}");
}
}
#[test]
fn parse_stream_event_retry_after_edge_inputs() {
use std::time::Duration;
let cases = [
(r#""retry_after":null"#, Some(Duration::from_secs(9))),
(r#""retry_after":"soon""#, Some(Duration::from_secs(9))),
(r#""retry_after":-1"#, Some(Duration::from_secs(9))),
(r#""retry_after":"30""#, Some(Duration::from_secs(30))),
(r#""retry_after":"2.5""#, Some(Duration::from_millis(2500))),
];
for (inner, want) in cases {
let payload = format!(r#"{{"error":{{"message":"x","type":"rate_limit_error",{inner}}},"retry_after":9}}"#);
let err = mid_stream_error(&payload);
assert_eq!(err.retry_after(), want, "{payload}");
}
let err = mid_stream_error(r#"{"error":{"message":"x","type":"rate_limit_error","retry_after":1e12}}"#);
assert_eq!(err.retry_after(), Some(Duration::from_secs(1_000_000_000_000)));
}
#[test]
fn parse_stream_event_top_level_status_code_edge_inputs() {
let cases = [
(
r#"{"error":{"message":"x","type":"rate_limit_error"},"status_code":200}"#,
429,
),
(r#"{"error":{"message":"x"},"status_code":503}"#, 503),
(r#"{"error":{"message":"x","status_code":"429"}}"#, 429),
(r#"{"error":{"message":"x","status_code":" 429 "}}"#, 429),
(r#"{"error":{"message":"x","status_code":401},"status_code":503}"#, 401),
];
for (payload, status) in cases {
let err = mid_stream_error(payload);
assert_eq!(err.status_code(), status, "{payload}: {err:?}");
}
}
#[test]
fn parse_stream_event_anthropic_error_types_keep_their_status() {
for (kind, status) in [
("billing_error", 402),
("request_too_large", 413),
("timeout_error", 504),
] {
let err = mid_stream_error(&format!(r#"{{"error":{{"message":"m","type":"{kind}"}}}}"#));
assert_eq!(err.status_code(), status, "{kind}: {err:?}");
}
let err = mid_stream_error(r#"{"error":{"message":"m","type":"timeout_error"}}"#);
assert!(matches!(err, LiterLlmError::ServiceUnavailable { .. }), "got: {err:?}");
}
#[test]
fn parse_stream_event_reads_retry_after_on_overloaded_event() {
let err = mid_stream_error(r#"{"error":{"message":"busy","type":"overloaded_error","retry_after":2}}"#);
assert!(matches!(err, LiterLlmError::ServiceUnavailable { .. }), "got: {err:?}");
assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
}
#[test]
fn parse_stream_event_classifies_rate_limit_error_without_status() {
let err = mid_stream_error(
r#"{"error":{"message":"slow down","type":"rate_limit_error","code":"rate_limit_exceeded"}}"#,
);
assert!(matches!(err, LiterLlmError::RateLimited { .. }), "got: {err:?}");
assert_eq!(err.status_code(), 429);
assert!(err.is_transient());
assert!(err.to_string().contains("slow down"));
}
#[test]
fn parse_stream_event_classifies_rate_limit_error_with_status() {
let err = mid_stream_error(
r#"{"error":{"message":"slow down","type":"rate_limit_error","code":"rate_limit_exceeded","status_code":429}}"#,
);
assert!(matches!(err, LiterLlmError::RateLimited { .. }), "got: {err:?}");
assert_eq!(err.status_code(), 429);
}
#[test]
fn parse_stream_event_classifies_context_length_error_like_http() {
let err = mid_stream_error(
r#"{"error":{"message":"too long","type":"invalid_request_error","code":"context_length_exceeded"}}"#,
);
assert!(
matches!(err, LiterLlmError::ContextWindowExceeded { .. }),
"got: {err:?}"
);
}
#[test]
fn parse_stream_event_classifies_authentication_error_from_status() {
let err = mid_stream_error(r#"{"error":{"message":"bad key","code":"invalid_api_key","status_code":401}}"#);
assert!(
matches!(err, LiterLlmError::Authentication { status: 401, .. }),
"got: {err:?}"
);
}
#[test]
fn parse_stream_event_classifies_the_edges_of_the_error_range() {
let err = mid_stream_error(r#"{"error":{"message":"edge","status_code":599}}"#);
assert!(
matches!(err, LiterLlmError::ServerError { status: 599, .. }),
"got: {err:?}"
);
let err =
mid_stream_error(r#"{"error":{"message":"edge","code":"context_length_exceeded","status_code":400}}"#);
assert!(
matches!(err, LiterLlmError::ContextWindowExceeded { .. }),
"got: {err:?}"
);
}
#[test]
fn parse_stream_event_keeps_raw_payload_status_outside_error_range() {
for status in [0_u16, 99, 200, 399, 600] {
let payload = format!(r#"{{"error":{{"message":"odd","status_code":{status}}}}}"#);
let err = mid_stream_error(&payload);
assert!(
matches!(&err, LiterLlmError::BadRequest { status: got, .. } if *got == status),
"status {status}, got: {err:?}"
);
assert!(err.to_string().contains("odd"), "status {status}, got: {err}");
}
}
#[test]
fn parse_stream_event_ignores_payload_status_above_u16() {
let err = mid_stream_error(r#"{"error":{"message":"huge","status_code":65965}}"#);
assert!(
matches!(err, LiterLlmError::BadRequest { status: 400, .. }),
"got: {err:?}"
);
}
#[test]
fn parse_stream_event_classifies_rate_limit_code_alone() {
let err = mid_stream_error(r#"{"error":{"message":"slow down","code":"rate_limit_exceeded"}}"#);
assert!(matches!(err, LiterLlmError::RateLimited { .. }), "got: {err:?}");
assert_eq!(err.status_code(), 429);
}
#[test]
fn parse_stream_event_classifies_rate_limit_type_alone() {
let err = mid_stream_error(r#"{"error":{"message":"slow down","type":"rate_limit_error"}}"#);
assert!(matches!(err, LiterLlmError::RateLimited { .. }), "got: {err:?}");
assert_eq!(err.status_code(), 429);
}
#[test]
fn parse_stream_event_prefers_error_code_over_type() {
let cases = [
(r#""code":"rate_limit_exceeded","type":"invalid_request_error""#, 429),
(r#""code":"rate_limit_exceeded","type":"authentication_error""#, 429),
];
for (kind, status) in cases {
let err = mid_stream_error(&format!(r#"{{"error":{{"message":"x",{kind}}}}}"#));
assert!(matches!(err, LiterLlmError::RateLimited { .. }), "{kind}: got {err:?}");
assert_eq!(err.status_code(), status, "{kind}");
}
}
#[test]
fn parse_stream_event_classifies_error_code_and_type_without_status() {
let cases = [
(r#""code":"insufficient_quota""#, 429),
(r#""code":"invalid_api_key""#, 401),
(r#""code":"model_not_found""#, 404),
(r#""code":"server_error""#, 500),
(r#""type":"authentication_error""#, 401),
(r#""type":"permission_error""#, 403),
(r#""type":"not_found_error""#, 404),
(r#""type":"server_error""#, 500),
(r#""type":"api_error""#, 500),
(r#""type":"overloaded_error""#, 529),
];
for (fields, status) in cases {
let err = mid_stream_error(&format!(r#"{{"error":{{"message":"boom",{fields}}}}}"#));
let http_body = format!(r#"{{"error":{{"message":"boom",{fields}}}}}"#);
let http = LiterLlmError::from_status(status, &http_body, None);
assert_eq!(
std::mem::discriminant(&err),
std::mem::discriminant(&http),
"{fields}: stream gave {err:?}, HTTP {status} gives {http:?}"
);
assert_eq!(err.status_code(), http.status_code(), "{fields}");
}
}
#[test]
fn parse_stream_event_classifies_insufficient_quota_as_quota_exceeded() {
let err = mid_stream_error(r#"{"error":{"message":"out of credit","code":"insufficient_quota"}}"#);
assert!(
matches!(err, LiterLlmError::ProviderQuotaExceeded { .. }),
"got: {err:?}"
);
assert!(!err.is_transient());
}
#[test]
fn parse_stream_event_reads_unknown_error_kind_as_bad_request() {
let err = mid_stream_error(r#"{"error":{"message":"odd","code":"something_else","type":"other"}}"#);
assert!(
matches!(err, LiterLlmError::BadRequest { status: 400, .. }),
"got: {err:?}"
);
}
#[test]
fn parse_stream_event_keeps_fallback_message_when_error_has_none() {
let err = mid_stream_error(r#"{"error":{"code":"rate_limit_exceeded"}}"#);
assert!(
err.to_string().contains("provider returned an error mid-stream"),
"got: {err}"
);
}
#[test]
fn parse_stream_event_tolerates_metadata_event_without_choices() {
let payload =
r#"{"object":"chat.completion.chunk","usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}"#;
let chunk = OpenAiProvider
.parse_stream_event(payload)
.expect("a metadata event without `choices` must not error")
.expect("event should decode to a chunk, not be skipped");
assert!(chunk.choices.is_empty());
}
#[test]
fn parse_stream_event_parses_normal_chunk_with_id() {
let payload = r#"{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}"#;
let chunk = OpenAiProvider
.parse_stream_event(payload)
.expect("valid chunk must parse")
.expect("valid chunk must yield Some");
assert_eq!(chunk.id, "chatcmpl-1");
assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("hi"));
}
}
#[cfg(test)]
mod route_model_tests {
use super::*;
const UNROUTED: &[&str] = &[
"a2a",
"amazon_nova",
"anthropic_text",
"assemblyai",
"auto_router",
"aws_polly",
"azure_ai",
"azure_text",
"baseten",
"bedrock_mantle",
"black_forest_labs",
"chatgpt",
"cloudflare",
"cursor",
"databricks",
"dataforseo",
"datarobot",
"deepgram",
"elevenlabs",
"fal_ai",
"gdc",
"gigachat",
"heroku",
"manus",
"nvidia_riva",
"oci",
"petals",
"ragflow",
"reducto",
"runwayml",
"sagemaker",
"sagemaker_chat",
"sap",
"searxng",
"serper",
"snowflake",
"soniox",
"stability",
"tavily",
"triton",
"watsonx",
"watsonx_text",
];
const GATEWAYS: &[&str] = &[
"custom",
"custom_openai",
"hosted_vllm",
"infinity",
"litellm_proxy",
"openai_like",
];
fn refusal(model: &str) -> String {
match route_model(model) {
Err(LiterLlmError::BadRequest { message, status }) => {
assert_eq!(status, 400, "{model}: refusal must be a 400");
message
}
Err(other) => panic!("{model}: expected BadRequest, got {other:?}"),
Ok(found) => panic!(
"{model}: expected a refusal, got provider {:?}",
found.map(|p| p.name().to_owned())
),
}
}
#[test]
fn provider_without_route_is_refused_with_its_name_and_base_url() {
for name in UNROUTED.iter().chain(GATEWAYS) {
let message = refusal(&format!("{name}/some-model"));
assert!(message.contains(&format!("`{name}`")), "{name}: message {message:?}");
assert!(message.contains("base_url"), "{name}: message {message:?}");
}
}
#[test]
fn nested_provider_namespace_is_refused_by_its_first_segment() {
let message = refusal("azure_ai/agents/some-agent");
assert!(message.contains("`azure_ai`"), "{message:?}");
}
#[test]
fn no_registry_provider_falls_through_to_the_default() {
let reg = REGISTRY.as_ref().expect("registry should load");
let mut checked = 0;
for entry in ®.providers {
let names = std::iter::once(entry.config.name.as_str()).chain(entry.aliases.iter().map(String::as_str));
for name in names {
let model = format!("{name}/some-model");
assert!(
!matches!(route_model(&model), Ok(None)),
"{model}: falls through to the default provider"
);
checked += 1;
}
}
assert!(checked >= reg.providers.len(), "sweep covered {checked} names");
}
#[test]
fn providers_with_a_route_keep_routing() {
for (model, expected) in [
("gpt-4o", "openai"),
("claude-3-5-sonnet-20241022", "anthropic"),
("anthropic/claude-3-5-sonnet-20241022", "anthropic"),
("ollama/llama3", "ollama"),
("ollama_chat/llama3", "ollama"),
("groq/llama3-70b", "groq"),
("deepseek/deepseek-chat", "deepseek"),
("mistral/mistral-large-latest", "mistral"),
] {
let provider = route_model(model)
.unwrap_or_else(|e| panic!("{model}: {e}"))
.unwrap_or_else(|| panic!("{model}: no provider"));
assert_eq!(provider.name(), expected, "{model}");
}
}
fn synthetic_registry(providers: serde_json::Value, complex: &[&str]) -> ProviderRegistry {
let raw: ProviderRegistryRaw =
serde_json::from_value(serde_json::json!({"providers": providers, "complex_providers": complex}))
.expect("synthetic registry should parse");
ProviderRegistry::from_raw(raw)
}
#[test]
fn registry_entry_without_base_url_is_refused_even_when_not_complex() {
let reg = synthetic_registry(serde_json::json!([{"name": "plain", "aliases": ["plain_alias"]}]), &[]);
for model in ["plain/m", "plain_alias/m"] {
match unrouted_provider_error(®, model) {
Some(LiterLlmError::BadRequest { message, status }) => {
assert_eq!(status, 400, "{model}");
assert!(message.contains("`plain`") && message.contains("base_url"), "{message}");
}
other => panic!("{model}: expected a refusal, got {other:?}"),
}
}
}
fn provider_for(providers: serde_json::Value, name: &str) -> ConfigDrivenProvider {
let reg: &'static ProviderRegistry = Box::leak(Box::new(synthetic_registry(providers, &[])));
let entry = reg
.providers
.iter()
.find(|e| e.config.name == name)
.expect("entry should exist");
ConfigDrivenProvider::from_entry(entry)
}
#[test]
fn endpoint_listed_in_no_route_endpoints_is_refused_and_the_others_are_not() {
let mixed = provider_for(
serde_json::json!([{
"name": "mixed",
"base_url": "https://mixed.example/v1",
"no_route_endpoints": ["search", "audio"]
}]),
"mixed",
);
for endpoint in [Endpoint::Search, Endpoint::Audio] {
match refuse_unrouted_endpoint(&mixed, endpoint) {
Err(LiterLlmError::BadRequest { message, status }) => {
assert_eq!(status, 400);
assert_eq!(
message,
format!(
"provider `mixed` has no built-in route for {}; set base_url to its endpoint",
endpoint.as_str()
)
);
}
other => panic!("{endpoint:?}: expected a refusal, got {other:?}"),
}
}
for endpoint in [Endpoint::Chat, Endpoint::Image, Endpoint::Embedding] {
assert!(refuse_unrouted_endpoint(&mixed, endpoint).is_ok(), "{endpoint:?}");
}
let other = provider_for(
serde_json::json!([{"name": "other", "base_url": "https://other.example/v1"}]),
"other",
);
assert!(refuse_unrouted_endpoint(&other, Endpoint::Search).is_ok());
}
fn builtin(name: &str) -> Box<dyn Provider> {
let model = if name == "openai" {
"gpt-4o".to_owned()
} else {
format!("{name}/m")
};
detect_builtin_provider(&model).expect("shipped provider should route")
}
#[test]
fn shipped_registry_marks_each_provider_endpoint_pair_without_a_route() {
for (provider, endpoint) in [
("brave", Endpoint::Search),
("perplexity", Endpoint::Search),
("exa_ai", Endpoint::Search),
("firecrawl", Endpoint::Search),
("linkup", Endpoint::Search),
("parallel_ai", Endpoint::Search),
("google_pse", Endpoint::Search),
("duckduckgo", Endpoint::Search),
("edenai", Endpoint::Audio),
("edenai", Endpoint::Image),
] {
assert!(
refuse_unrouted_endpoint(builtin(provider).as_ref(), endpoint).is_err(),
"{provider} {endpoint:?}"
);
}
for (provider, endpoint) in [
("brave", Endpoint::Chat),
("perplexity", Endpoint::Chat),
("edenai", Endpoint::Chat),
("edenai", Endpoint::Embedding),
("modelscope", Endpoint::Image),
("openai", Endpoint::Search),
] {
assert!(
refuse_unrouted_endpoint(builtin(provider).as_ref(), endpoint).is_ok(),
"{provider} {endpoint:?}"
);
}
}
#[test]
fn registry_entry_with_base_url_that_is_not_complex_has_a_route() {
let reg = synthetic_registry(
serde_json::json!([{"name": "plain", "base_url": "https://plain.example/v1"}]),
&[],
);
assert!(unrouted_provider_error(®, "plain/m").is_none());
}
#[test]
fn complex_registry_entry_with_base_url_is_refused() {
let reg = synthetic_registry(
serde_json::json!([{"name": "fancy", "base_url": "https://fancy.example/v1"}]),
&["fancy"],
);
assert!(unrouted_provider_error(®, "fancy/m").is_some());
}
#[test]
fn prefix_that_names_no_registry_entry_is_not_refused() {
let reg = synthetic_registry(serde_json::json!([{"name": "plain"}]), &[]);
assert!(unrouted_provider_error(®, "other/m").is_none());
assert!(unrouted_provider_error(®, "no-slash").is_none());
}
#[test]
fn bare_hint_is_refused_like_its_prefix() {
for hint in ["snowflake", "watsonx", "hosted_vllm"] {
match route_hint(hint) {
Err(LiterLlmError::BadRequest { message, .. }) => {
assert!(message.contains(&format!("`{hint}`")), "{message}");
}
Err(error) => panic!("{hint}: expected a bad request, got {error:?}"),
Ok(routed) => panic!(
"{hint}: expected a refusal, got {:?}",
routed.map(|provider| provider.name().to_owned())
),
}
}
}
#[test]
fn bare_hint_with_a_route_or_no_registry_entry_is_accepted() {
for hint in ["anthropic", "groq", "ollama", "gpt-4o", "some-local-model"] {
assert!(route_hint(hint).is_ok(), "{hint}");
}
}
#[test]
fn bare_hint_and_slash_hint_select_the_same_provider() {
for (bare, slash) in [
("cohere", "cohere/command-r-plus"),
("mistral", "mistral/mistral-large-latest"),
("voyage", "voyage/rerank-2"),
("anthropic", "anthropic/claude-sonnet-4"),
("gemini", "gemini/gemini-2.0-flash"),
("vertex_ai", "vertex_ai/gemini-2.0-flash"),
("ollama", "ollama/llama3"),
("groq", "groq/llama3-8b"),
] {
let bare_name = route_hint(bare).expect("no refusal").map(|p| p.name().to_owned());
let slash_name = route_hint(slash).expect("no refusal").map(|p| p.name().to_owned());
assert!(bare_name.is_some(), "{bare} selects a provider");
assert_eq!(bare_name, slash_name, "{bare} and {slash}");
}
}
#[test]
fn openai_at_a_base_url_is_the_generic_provider() {
let provider = route_hint("gpt-5").expect("no refusal").expect("gpt-5 names OpenAI");
let rebased = on_base_url(provider, "http://localhost:8080/v1");
assert_eq!(rebased.name(), "custom");
assert_eq!(rebased.strip_model_prefix("openai/gpt-5"), "gpt-5");
assert_eq!(rebased.strip_model_prefix("custom/my-model"), "my-model");
assert_eq!(rebased.strip_model_prefix("anthropic/claude-x"), "anthropic/claude-x");
}
#[test]
fn hint_that_names_no_provider_selects_none() {
for hint in ["llama3.1:8b", "some-local-model", "not-a-provider/model"] {
assert!(route_hint(hint).expect("no refusal").is_none(), "{hint}");
}
}
#[test]
fn on_base_url_changes_only_the_host() {
let provider = route_hint("cohere").expect("no refusal").expect("cohere is built in");
let rebased = on_base_url(provider, "http://gateway.test/v1/");
assert_eq!(rebased.name(), "cohere");
assert_eq!(rebased.chat_completions_path(), "/chat");
assert_eq!(rebased.build_url("/chat", "m"), "http://gateway.test/v1/chat");
}
#[test]
fn a_rebased_provider_keeps_its_model_list_paging() {
let paging = Box::new(super::vertex::VertexAiProvider::with_base_url("http://vertex.test"));
let rebased = rebased::BaseUrlOverride::new(paging, "http://gateway.test");
let page = serde_json::json!({"nextPageToken": "next"});
assert_eq!(
rebased
.next_models_page_url("http://gateway.test/models", &page)
.as_deref(),
Some("http://gateway.test/models?pageToken=next")
);
}
#[test]
fn provider_without_auth_sends_the_key_as_bearer_at_a_base_url() {
let local = route_hint("lmstudio")
.expect("no refusal")
.expect("lmstudio is built in");
assert!(
local.auth_header("k").is_none(),
"lmstudio sends no key at its own host"
);
let rebased = on_base_url(local, "http://gateway.test/v1");
let (name, value) = rebased.auth_header("k").expect("a key at a base_url is sent");
assert_eq!((name.as_ref(), value.as_ref()), ("Authorization", "Bearer k"));
}
#[test]
fn provider_with_auth_keeps_its_header_at_a_base_url() {
let provider = route_hint("cohere").expect("no refusal").expect("cohere is built in");
let rebased = on_base_url(provider, "http://gateway.test/v1");
let (name, value) = rebased.auth_header("k").expect("cohere sends a key");
assert_eq!((name.as_ref(), value.as_ref()), ("Authorization", "Bearer k"));
}
#[test]
fn model_that_names_no_registry_provider_is_left_to_the_caller() {
assert!(route_model("some-random-model").expect("no refusal").is_none());
assert!(route_model("not-a-provider/model").expect("no refusal").is_none());
}
}