use serde::Deserialize;
#[derive(Debug, Clone)]
pub(super) struct Provider {
pub id: String,
pub base_url: String,
pub api_key: Option<String>,
pub input_price: Option<f64>,
pub output_price: Option<f64>,
pub provider_type: String,
pub models: Vec<ModelInfo>,
pub engine: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct ModelInfo {
pub name: String,
pub input_price: Option<f64>,
pub output_price: Option<f64>,
}
impl Provider {
pub(super) fn is_local(&self) -> bool {
self.api_key.is_none() && self.provider_type != "simulated"
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ModelEntry {
pub id: String,
#[serde(default)]
pub pricing: Option<ModelPricing>,
#[serde(rename = "type", default)]
pub model_type: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ModelPricing {
pub input: Option<serde_json::Value>,
pub output: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ModelsResponseWrapped {
pub data: Vec<ModelEntry>,
}
pub(super) const MODEL_FILTER_KEYWORDS: &[&str] =
&["embed", "rerank", "bge", "e5-", "clip", "vision", "whisper"];
pub(super) const EXCLUDED_MODEL_TYPES: &[&str] = &["embedding", "rerank", "moderation", "image"];
pub(super) fn is_inference_model(entry: &ModelEntry) -> bool {
if let Some(ref t) = entry.model_type {
let lower = t.to_lowercase();
if EXCLUDED_MODEL_TYPES.iter().any(|kw| lower == *kw) {
return false;
}
return true;
}
let lower = entry.id.to_lowercase();
!MODEL_FILTER_KEYWORDS.iter().any(|kw| lower.contains(kw))
}
pub(super) fn parse_models_response(bytes: &[u8]) -> Option<Vec<FetchedModel>> {
let entries: Vec<ModelEntry> =
if let Ok(wrapped) = serde_json::from_slice::<ModelsResponseWrapped>(bytes) {
wrapped.data
} else if let Ok(flat) = serde_json::from_slice::<Vec<ModelEntry>>(bytes) {
flat
} else {
return None;
};
let models = entries
.into_iter()
.filter(is_inference_model)
.map(|m| {
let (inp, out) = extract_pricing(m.pricing.as_ref());
(m.id, inp, out)
})
.collect();
Some(models)
}
pub(super) fn build_models_url(base_url: &str) -> String {
format!("{}/models", base_url.trim_end_matches('/'))
}
pub(super) async fn fetch_models(
base_url: &str,
api_key: Option<&str>,
) -> Option<Vec<FetchedModel>> {
let url = build_models_url(base_url);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.ok()?;
let mut req = client.get(&url);
if let Some(key) = api_key {
if !key.is_empty() {
req = req.bearer_auth(key);
}
}
let resp = req.send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let bytes = resp.bytes().await.ok()?;
parse_models_response(&bytes)
}
pub(super) fn extract_pricing(p: Option<&ModelPricing>) -> (Option<f64>, Option<f64>) {
let p = match p {
Some(p) => p,
None => return (None, None),
};
let parse = |v: &serde_json::Value| -> Option<f64> {
match v {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::String(s) => s.parse().ok(),
_ => None,
}
};
let inp = p.input.as_ref().and_then(parse);
let out = p.output.as_ref().and_then(parse);
(inp, out)
}
pub(super) type FetchedModel = (String, Option<f64>, Option<f64>);
pub(super) async fn detect_ollama() -> Option<Vec<FetchedModel>> {
fetch_models("http://localhost:11434/v1", None).await
}
pub(super) fn build_ollama_provider(models: &[FetchedModel]) -> Provider {
let model_infos = fetched_to_model_infos(models);
Provider {
id: "ollama_local".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
api_key: None,
input_price: Some(0.0),
output_price: Some(0.0),
provider_type: "openai".to_string(),
models: model_infos,
engine: None,
}
}
pub(super) fn build_simulated_provider() -> Provider {
Provider {
id: "simulated".to_string(),
base_url: String::new(),
api_key: None,
input_price: Some(0.0),
output_price: Some(0.0),
provider_type: "simulated".to_string(),
models: vec![ModelInfo {
name: "simulated-default".to_string(),
input_price: Some(0.0),
output_price: Some(0.0),
}],
engine: None,
}
}
#[derive(Debug, Clone)]
pub(super) struct DetectedTool {
pub name: &'static str,
pub version: String,
}
pub(super) fn detect_exec_tools() -> Vec<DetectedTool> {
let mut tools = Vec::new();
if let Ok(output) = std::process::Command::new("claude")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
{
if output.status.success() {
let ver = String::from_utf8_lossy(&output.stdout).trim().to_string();
tools.push(DetectedTool {
name: "claude",
version: ver,
});
}
}
if let Ok(output) = std::process::Command::new("python3")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
{
if output.status.success() {
let raw = String::from_utf8_lossy(&output.stdout);
let ver = raw.trim().replace("Python ", "");
tools.push(DetectedTool {
name: "python3",
version: ver,
});
}
}
if let Ok(output) = std::process::Command::new("docker")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
{
if output.status.success() {
let raw = String::from_utf8_lossy(&output.stdout);
let ver = raw
.trim()
.strip_prefix("Docker version ")
.and_then(|s| s.split(',').next())
.unwrap_or("unknown")
.to_string();
tools.push(DetectedTool {
name: "docker",
version: ver,
});
}
}
tools
}
pub(super) fn build_claude_exec_provider() -> Provider {
Provider {
id: "claude_cli".to_string(),
base_url: String::new(),
api_key: None,
input_price: Some(0.0),
output_price: Some(0.0),
provider_type: "claude".to_string(),
models: vec![ModelInfo {
name: "sonnet".to_string(),
input_price: Some(0.0),
output_price: Some(0.0),
}],
engine: None,
}
}
pub(super) fn build_exec_provider() -> Provider {
Provider {
id: "exec_local".to_string(),
base_url: String::new(),
api_key: None,
input_price: Some(0.0),
output_price: Some(0.0),
provider_type: "exec".to_string(),
models: vec![ModelInfo {
name: "custom".to_string(),
input_price: Some(0.0),
output_price: Some(0.0),
}],
engine: None,
}
}
pub(super) fn derive_provider_pricing(
is_ollama: bool,
fetched: &Option<Vec<FetchedModel>>,
) -> (Option<f64>, Option<f64>) {
if is_ollama {
(Some(0.0), Some(0.0))
} else if let Some(models) = fetched {
models
.iter()
.find(|(_, i, o)| i.is_some() || o.is_some())
.map(|(_, i, o)| (*i, *o))
.unwrap_or((None, None))
} else {
(None, None)
}
}
pub fn sanitize_provider_id(raw: &str) -> Option<String> {
let slug: String = raw
.to_lowercase()
.chars()
.map(|c| if c == ' ' || c == '-' { '_' } else { c })
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if slug.is_empty() {
None
} else if slug.starts_with(|c: char| c.is_ascii_digit()) {
Some(format!("p_{slug}"))
} else {
Some(slug)
}
}
pub(super) fn build_provider_env_key(provider_id: &str) -> String {
let sanitized = sanitize_provider_id(provider_id).unwrap_or_else(|| provider_id.to_string());
format!("{}_API_KEY", sanitized.to_uppercase())
}
pub(super) fn fetched_to_model_infos(models: &[FetchedModel]) -> Vec<ModelInfo> {
models
.iter()
.map(|(n, inp, out)| ModelInfo {
name: n.clone(),
input_price: *inp,
output_price: *out,
})
.collect()
}