use std::time::Duration;
use super::catalog::{self, Reference};
use super::openai_wire::{self, WebFlavor};
use super::{CompletionRequest, CompletionResponse, ModelInfo, PriceSource, Provider};
use crate::error::{Error, Result};
use crate::pricing::PriceTable;
pub use crate::net::REQUEST_TIMEOUT;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Auth {
Bearer,
None,
}
pub(crate) const PRESETS: &[(&str, &str, Auth)] = &[
("cerebras", "https://api.cerebras.ai/v1", Auth::Bearer),
("deepseek", "https://api.deepseek.com/v1", Auth::Bearer),
(
"fireworks",
"https://api.fireworks.ai/inference/v1",
Auth::Bearer,
),
(
"gemini",
"https://generativelanguage.googleapis.com/v1beta/openai",
Auth::Bearer,
),
("groq", "https://api.groq.com/openai/v1", Auth::Bearer),
("minimax", "https://api.minimax.io/v1", Auth::Bearer),
("mistral", "https://api.mistral.ai/v1", Auth::Bearer),
("moonshot", "https://api.moonshot.ai/v1", Auth::Bearer),
("perplexity", "https://api.perplexity.ai", Auth::Bearer),
(
"qwen",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
Auth::Bearer,
),
("together", "https://api.together.xyz/v1", Auth::Bearer),
("xai", "https://api.x.ai/v1", Auth::Bearer),
(
"zhipu",
"https://open.bigmodel.cn/api/paas/v4",
Auth::Bearer,
),
("jan", "http://localhost:1337/v1", Auth::None),
("koboldcpp", "http://localhost:5001/v1", Auth::None),
("llamacpp", "http://localhost:8080/v1", Auth::None),
("lmstudio", "http://localhost:1234/v1", Auth::None),
("localai", "http://localhost:8080/v1", Auth::None),
("ollama", "http://localhost:11434/v1", Auth::None),
("sglang", "http://localhost:30000/v1", Auth::None),
("vllm", "http://localhost:8000/v1", Auth::None),
];
pub(crate) fn preset_names() -> Vec<&'static str> {
PRESETS.iter().map(|(n, _, _)| *n).collect()
}
pub(crate) fn preset_list() -> String {
preset_names().join(", ")
}
#[derive(Debug, Clone)]
pub struct Compatible {
client: reqwest::Client,
api_key: String,
model: String,
base: String,
name: String,
auth: Auth,
reference: Option<Reference>,
cached: std::sync::Arc<std::sync::OnceLock<Vec<ModelInfo>>>,
}
impl Compatible {
pub fn new(
base: impl Into<String>,
auth: Auth,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self {
client: crate::net::http_client(),
api_key: api_key.into(),
model: model.into(),
base: base.into().trim_end_matches('/').to_string(),
name: "compatible".into(),
auth,
reference: None,
cached: std::sync::Arc::new(std::sync::OnceLock::new()),
}
}
pub fn preset(
name: &str,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Result<Self> {
let Some((label, base, auth)) = PRESETS.iter().find(|(n, _, _)| *n == name) else {
return Err(Error::Config(format!(
"unknown provider preset {name:?}; the presets are: {}",
preset_list()
)));
};
Ok(Self::new(*base, *auth, api_key, model).with_name(*label))
}
fn known(name: &'static str, api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::preset(name, api_key, model)
.expect("every named constructor names a row in PRESETS; F2 asserts it")
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = crate::net::http_client_with_timeout(timeout);
self
}
#[must_use]
pub fn with_reference_prices(mut self, reference: Reference) -> Self {
self.reference = Some(reference);
self
}
pub fn base(&self) -> &str {
&self.base
}
pub fn auth(&self) -> &Auth {
&self.auth
}
fn chat_url(&self) -> String {
format!("{}/chat/completions", self.base)
}
pub async fn price_table(&self) -> Result<PriceTable> {
let models = self.models().await?;
let mut table = PriceTable::new(crate::net::today_utc());
for m in models {
let Some(price) = m.price else { continue };
table = table.with(&m.id, price);
if !m.price_tiers.is_empty() {
table = table.with_tiers(&m.id, m.price_tiers);
}
}
Ok(table)
}
#[cfg(test)]
pub(crate) fn at(base: impl Into<String>, timeout: Duration) -> Self {
Self::new(base, Auth::Bearer, "test-key", "test-model").with_timeout(timeout)
}
async fn stream(
&self,
request: CompletionRequest,
on_token: &(dyn Fn(&str) + Send + Sync),
) -> Result<CompletionResponse> {
#[cfg(feature = "media")]
super::ensure_media_accepted(self.name(), self.accepts_images(), &request)?;
openai_wire::ensure_web_supported(self.name(), WebFlavor::OpenAi, &request)?;
let sent = std::time::Instant::now();
let mut req = self.client.post(self.chat_url());
req = match self.auth {
Auth::Bearer => req.bearer_auth(&self.api_key),
Auth::None => req,
};
let resp = req
.json(&openai_wire::body(&self.model, &request, WebFlavor::OpenAi))
.send()
.await?;
openai_wire::parse_stream_with(
super::ensure_success(resp).await?,
sent,
self.name(),
on_token,
)
.await
}
}
impl Provider for Compatible {
fn name(&self) -> &str {
&self.name
}
fn endpoint(&self) -> Option<&str> {
Some(&self.base)
}
fn endpoints(&self) -> Vec<&str> {
let mut out = vec![self.base.as_str()];
if let Some(reference) = &self.reference {
out.push(reference.url());
}
out
}
#[cfg(feature = "media")]
fn accepts_images(&self) -> bool {
true
}
async fn models(&self) -> Result<Vec<ModelInfo>> {
if let Some(hit) = self.cached.get() {
return Ok(hit.clone());
}
let url = format!("{}/models", self.base);
let mut models = catalog::fetch(&self.client, &url, &PriceSource::Vendor).await?;
if let Some(reference) = &self.reference {
let prices = reference.models().await?;
catalog::fill_missing_prices(&mut models, &prices);
}
let _ = self.cached.set(models.clone());
Ok(models)
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
self.stream(request, &|_| {}).await
}
async fn complete_streaming(
&self,
request: CompletionRequest,
on_token: &(dyn Fn(&str) + Send + Sync),
) -> Result<CompletionResponse> {
self.stream(request, on_token).await
}
}
impl Compatible {
pub fn cerebras(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("cerebras", api_key, model)
}
pub fn deepseek(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("deepseek", api_key, model)
}
pub fn fireworks(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("fireworks", api_key, model)
}
pub fn gemini(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("gemini", api_key, model)
}
pub fn groq(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("groq", api_key, model)
}
pub fn minimax(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("minimax", api_key, model)
}
pub fn mistral(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("mistral", api_key, model)
}
pub fn moonshot(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("moonshot", api_key, model)
}
pub fn perplexity(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("perplexity", api_key, model)
}
pub fn qwen(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("qwen", api_key, model)
}
pub fn together(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("together", api_key, model)
}
pub fn xai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("xai", api_key, model)
}
pub fn zhipu(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::known("zhipu", api_key, model)
}
pub fn jan(model: impl Into<String>) -> Self {
Self::known("jan", "", model)
}
pub fn koboldcpp(model: impl Into<String>) -> Self {
Self::known("koboldcpp", "", model)
}
pub fn llamacpp(model: impl Into<String>) -> Self {
Self::known("llamacpp", "", model)
}
pub fn lmstudio(model: impl Into<String>) -> Self {
Self::known("lmstudio", "", model)
}
pub fn localai(model: impl Into<String>) -> Self {
Self::known("localai", "", model)
}
pub fn ollama(model: impl Into<String>) -> Self {
Self::known("ollama", "", model)
}
pub fn sglang(model: impl Into<String>) -> Self {
Self::known("sglang", "", model)
}
pub fn vllm(model: impl Into<String>) -> Self {
Self::known("vllm", "", model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::failures::{json_response, serve, serve_recording, stream_response};
fn request() -> CompletionRequest {
#[allow(clippy::needless_update)]
CompletionRequest {
system: "s".into(),
user: "u".into(),
..Default::default()
}
}
const ONE_SECOND: Duration = Duration::from_secs(1);
#[tokio::test]
async fn f1_a_streamed_completion_arrives_through_the_real_http_and_sse_path() {
let (url, seen) = serve_recording(stream_response(
"data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\ndata: [DONE]\n\n",
));
let out = Compatible::at(&url, ONE_SECOND)
.complete(request())
.await
.unwrap();
assert_eq!(out.text.as_deref(), Some("done"));
let head = seen.lock().unwrap()[0].to_ascii_lowercase();
assert!(
head.contains("authorization: bearer test-key"),
"a bearer provider must send its key: {head}"
);
assert!(
head.starts_with("post /v1/chat/completions "),
"the path is the base with /chat/completions appended: {head}"
);
}
#[tokio::test]
async fn f1_an_auth_none_provider_sends_no_authorization_header_at_all() {
let (url, seen) = serve_recording(stream_response(
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n",
));
Compatible::new(&url, Auth::None, "", "m")
.with_timeout(ONE_SECOND)
.complete(request())
.await
.unwrap();
let head = seen.lock().unwrap()[0].clone();
assert!(
!head.to_ascii_lowercase().contains("authorization:"),
"Auth::None must send no credential header: {head}"
);
}
#[tokio::test]
async fn f1_a_base_that_already_carries_a_path_keeps_every_segment_of_it() {
let (url, seen) = serve_recording(stream_response(
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n",
));
let nested = format!("{url}/openai/v1");
Compatible::new(&nested, Auth::Bearer, "k", "m")
.with_timeout(ONE_SECOND)
.complete(request())
.await
.unwrap();
let head = seen.lock().unwrap()[0].clone();
assert!(
head.starts_with("POST /v1/openai/v1/chat/completions "),
"every segment of the base survives: {head}"
);
}
#[tokio::test]
async fn a_failure_is_classified_exactly_as_the_three_original_providers_classify_it() {
use crate::error::ProviderErrorKind as Kind;
let url = serve("HTTP/1.1 429 Too Many Requests\r\nContent-Length: 2\r\n\r\n{}".into());
let err = Compatible::at(&url, ONE_SECOND)
.complete(request())
.await
.unwrap_err();
let Error::Provider { kind, status, .. } = err else {
panic!("expected a provider error");
};
assert_eq!(kind, Kind::RateLimited);
assert_eq!(status, Some(429));
}
fn table_faults(rows: &[(&str, &str, Auth)]) -> Vec<String> {
let mut faults = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for (name, base, _) in rows {
if seen.contains(name) {
faults.push(format!("duplicate name {name}"));
}
seen.push(name);
let absolute = base.starts_with("http://") || base.starts_with("https://");
let host = base.split("://").nth(1).and_then(|r| r.split('/').next());
if !absolute || host.is_none_or(str::is_empty) {
faults.push(format!("{name} has no absolute base: {base}"));
}
if base.ends_with('/') {
faults.push(format!("{name} has a trailing slash: {base}"));
}
}
faults
}
#[test]
fn f2_every_preset_names_a_distinct_vendor_with_a_well_formed_base() {
assert_eq!(table_faults(PRESETS), Vec::<String>::new());
assert_eq!(PRESETS.len(), 21, "13 hosted vendors and 8 local runtimes");
}
#[test]
fn f2_the_table_check_reports_a_broken_row_rather_than_always_passing() {
let broken: &[(&str, &str, Auth)] = &[
("good", "https://api.example/v1", Auth::Bearer),
("relative", "/v1", Auth::Bearer),
("good", "https://api.example/v2", Auth::Bearer),
];
let faults = table_faults(broken);
assert_eq!(
faults,
vec![
"relative has no absolute base: /v1".to_string(),
"duplicate name good".to_string(),
]
);
}
#[test]
fn f2_every_named_constructor_agrees_with_its_table_row() {
let hosted: Vec<(&str, Compatible)> = vec![
("cerebras", Compatible::cerebras("k", "m")),
("deepseek", Compatible::deepseek("k", "m")),
("fireworks", Compatible::fireworks("k", "m")),
("gemini", Compatible::gemini("k", "m")),
("groq", Compatible::groq("k", "m")),
("minimax", Compatible::minimax("k", "m")),
("mistral", Compatible::mistral("k", "m")),
("moonshot", Compatible::moonshot("k", "m")),
("perplexity", Compatible::perplexity("k", "m")),
("qwen", Compatible::qwen("k", "m")),
("together", Compatible::together("k", "m")),
("xai", Compatible::xai("k", "m")),
("zhipu", Compatible::zhipu("k", "m")),
("jan", Compatible::jan("m")),
("koboldcpp", Compatible::koboldcpp("m")),
("llamacpp", Compatible::llamacpp("m")),
("lmstudio", Compatible::lmstudio("m")),
("localai", Compatible::localai("m")),
("ollama", Compatible::ollama("m")),
("sglang", Compatible::sglang("m")),
("vllm", Compatible::vllm("m")),
];
assert_eq!(hosted.len(), PRESETS.len(), "one constructor per row");
for (name, built) in &hosted {
let (_, base, auth) = PRESETS
.iter()
.find(|(n, _, _)| n == name)
.unwrap_or_else(|| panic!("{name} names no row"));
assert_eq!(built.base(), *base, "{name} base");
assert_eq!(built.auth(), auth, "{name} auth");
assert_eq!(built.name(), *name, "{name} is its own trace label");
}
}
#[test]
fn f2_a_local_runtime_takes_no_key_and_a_hosted_one_takes_a_bearer() {
for (name, _, auth) in PRESETS {
let local = name.parse::<Local>().is_ok();
assert_eq!(
*auth,
if local { Auth::None } else { Auth::Bearer },
"{name}"
);
}
}
struct Local;
impl std::str::FromStr for Local {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
match s {
"jan" | "koboldcpp" | "llamacpp" | "lmstudio" | "localai" | "ollama" | "sglang"
| "vllm" => Ok(Local),
_ => Err(()),
}
}
}
#[test]
fn f2_an_unknown_preset_is_refused_naming_the_ones_that_exist() {
let err = Compatible::preset("grok", "k", "m").unwrap_err();
let Error::Config(message) = &err else {
panic!("expected a configuration error, got {err:?}");
};
assert!(message.contains("grok"), "{message}");
assert!(
message.contains("groq"),
"the list must be shown: {message}"
);
assert!(message.contains("ollama"), "{message}");
assert!(Compatible::preset("groq", "k", "m").is_ok());
}
const VENDOR_BODY: &str = r#"{"data":[
{"id":"llama3.2"},
{"id":"deepseek-chat"}
]}"#;
const REFERENCE_BODY: &str = r#"{"data":[
{"id":"deepseek/deepseek-chat",
"pricing":{"prompt":"0.0000002574","completion":"0.0000010287"}}
]}"#;
#[tokio::test]
async fn f3_a_connected_provider_returns_a_non_empty_catalogue() {
let url = serve(json_response(VENDOR_BODY));
let models = Compatible::at(&url, ONE_SECOND).models().await.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "llama3.2");
assert_eq!(models[0].price, None);
assert_eq!(models[0].price_source, None);
}
#[tokio::test]
async fn f7_the_reference_lookup_is_off_until_it_is_asked_for() {
let vendor = serve(json_response(VENDOR_BODY));
let (reference_url, reference_seen) = serve_recording(json_response(REFERENCE_BODY));
let plain = Compatible::at(&vendor, ONE_SECOND);
assert_eq!(plain.endpoints().len(), 1, "one endpoint until asked");
plain.models().await.unwrap();
assert!(
reference_seen.lock().unwrap().is_empty(),
"the reference host must not be dialled by a provider that did not opt in"
);
let opted = Compatible::at(&vendor, ONE_SECOND)
.with_reference_prices(Reference::at(&reference_url).with_timeout(ONE_SECOND));
assert_eq!(opted.endpoints().len(), 2);
assert_eq!(opted.endpoints()[1], reference_url);
opted.models().await.unwrap();
assert_eq!(reference_seen.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn f6_a_reference_price_fills_a_gap_and_says_it_is_a_reference_price() {
let vendor = serve(json_response(VENDOR_BODY));
let reference = serve(json_response(REFERENCE_BODY));
let models = Compatible::at(&vendor, ONE_SECOND)
.with_reference_prices(Reference::at(&reference).with_timeout(ONE_SECOND))
.models()
.await
.unwrap();
let ds = models.iter().find(|m| m.id == "deepseek-chat").unwrap();
assert_eq!(ds.price.unwrap().input, 257_400);
assert!(matches!(
ds.price_source.as_ref().unwrap(),
PriceSource::Reference(_)
));
let llama = models.iter().find(|m| m.id == "llama3.2").unwrap();
assert_eq!(llama.price, None);
assert_eq!(llama.price_source, None);
for m in &models {
assert_eq!(m.price.is_some(), m.price_source.is_some(), "{}", m.id);
}
}
#[tokio::test]
async fn a_price_table_carries_the_moment_it_was_read_rather_than_a_typed_date() {
let vendor = serve(json_response(VENDOR_BODY));
let reference = serve(json_response(REFERENCE_BODY));
let table = Compatible::at(&vendor, ONE_SECOND)
.with_reference_prices(Reference::at(&reference).with_timeout(ONE_SECOND))
.price_table()
.await
.unwrap();
assert_eq!(table.price("deepseek-chat").unwrap().input, 257_400);
assert_eq!(table.price("llama3.2"), None);
let as_of = table.as_of();
assert_eq!(as_of.len(), 10, "YYYY-MM-DD: {as_of}");
assert_eq!(as_of.matches('-').count(), 2, "{as_of}");
assert!(as_of.starts_with("20"), "{as_of}");
}
}