use std::sync::LazyLock;
use std::time::Duration;
use color_eyre::{eyre::bail, Result};
use reqwest::{Client, RequestBuilder, StatusCode};
use tokio::sync::mpsc;
use crate::agent::{ContentPart, Message, StreamOutcome, ToolDefinition};
use crate::anthropic::ClaudeClient;
use crate::config::AppConfig;
use crate::openai::OpenAiClient;
static HTTP: LazyLock<Client> = LazyLock::new(Client::new);
pub async fn installed_models(cfg: &AppConfig) -> Option<Vec<String>> {
if !cfg.provider.is_local() {
return None;
}
let base = cfg.resolve_base_url().ok()?;
let url = format!("{}/models", base.trim_end_matches('/'));
let response = HTTP
.get(url)
.timeout(Duration::from_secs(3))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let body: serde_json::Value = response.json().await.ok()?;
let models: Vec<String> = body
.get("data")?
.as_array()?
.iter()
.filter_map(|entry| entry.get("id")?.as_str().map(str::to_string))
.collect();
if models.is_empty() {
None
} else {
Some(models)
}
}
pub fn is_installed(models: &[String], model: &str) -> bool {
models.iter().any(|installed| {
installed == model
|| installed
.strip_suffix(":latest")
.is_some_and(|base| base == model)
})
}
pub async fn ollama_context_length(cfg: &AppConfig, model: &str) -> Option<usize> {
if cfg.provider != crate::config::Provider::Ollama {
return None;
}
let base = cfg.resolve_base_url().ok()?;
let root = base.trim_end_matches('/').strip_suffix("/v1")?;
let response = HTTP
.get(format!("{}/api/ps", root))
.timeout(Duration::from_secs(2))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let body: serde_json::Value = response.json().await.ok()?;
body.get("models")?
.as_array()?
.iter()
.find(|entry| {
entry
.get("name")
.and_then(|n| n.as_str())
.is_some_and(|name| is_installed(&[name.to_string()], model))
})?
.get("context_length")?
.as_u64()
.map(|n| n as usize)
}
pub fn http_client() -> Client {
HTTP.clone()
}
const MAX_ATTEMPTS: u32 = 3;
const MAX_RETRY_AFTER: Duration = Duration::from_secs(20);
fn is_transient(status: StatusCode) -> bool {
status == StatusCode::TOO_MANY_REQUESTS
|| status == StatusCode::REQUEST_TIMEOUT
|| status.is_server_error()
}
fn is_transient_transport(error: &reqwest::Error) -> bool {
error.is_timeout() || error.is_connect() || error.is_request()
}
fn backoff(attempt: u32) -> Duration {
Duration::from_millis(500 * 2u64.pow(attempt.saturating_sub(1)))
}
pub async fn send_with_retry<F>(build: F) -> Result<reqwest::Response>
where
F: Fn() -> RequestBuilder,
{
let mut attempt = 0;
loop {
attempt += 1;
let last = attempt >= MAX_ATTEMPTS;
match build().send().await {
Ok(response) if response.status().is_success() => return Ok(response),
Ok(response) => {
let status = response.status();
if !is_transient(status) || last {
let body = response.text().await.unwrap_or_default();
bail!("API error {}: {}", status, body);
}
let wait = retry_after(&response).unwrap_or_else(|| backoff(attempt));
crate::diag::warn(format!(
"provider returned {}, retrying in {:?} (attempt {} of {})",
status, wait, attempt, MAX_ATTEMPTS
));
tokio::time::sleep(wait).await;
}
Err(e) => {
if !is_transient_transport(&e) || last {
return Err(e.into());
}
let wait = backoff(attempt);
crate::diag::warn(format!(
"request failed ({}), retrying in {:?} (attempt {} of {})",
e, wait, attempt, MAX_ATTEMPTS
));
tokio::time::sleep(wait).await;
}
}
}
}
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
let seconds: u64 = response
.headers()
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(seconds).min(MAX_RETRY_AFTER))
}
pub enum LlmClient {
Anthropic(ClaudeClient),
OpenAiCompatible(OpenAiClient),
}
impl LlmClient {
pub fn from_config(config: &AppConfig) -> Result<Self> {
let api_key = config.get_api_key()?;
Ok(if config.provider.is_anthropic() {
Self::Anthropic(
ClaudeClient::new(api_key)
.with_model(config.default_model.clone())
.with_max_tokens(config.max_tokens),
)
} else {
Self::OpenAiCompatible(OpenAiClient::new(
api_key,
config.resolve_base_url()?,
config.default_model.clone(),
config.max_tokens,
))
})
}
pub async fn send_message(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
) -> Result<Vec<ContentPart>> {
match self {
Self::Anthropic(client) => client.send_message(history, tools, system).await,
Self::OpenAiCompatible(client) => client.send_message(history, tools, system).await,
}
}
pub async fn send_message_streaming(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
update_tx: &mpsc::UnboundedSender<String>,
) -> Result<StreamOutcome> {
match self {
Self::Anthropic(client) => {
client
.send_message_streaming(history, tools, system, update_tx)
.await
}
Self::OpenAiCompatible(client) => {
client
.send_message_streaming(history, tools, system, update_tx)
.await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Provider;
#[test]
fn an_exact_name_is_installed() {
let installed = vec!["qwen3:4b-instruct-2507-q4_K_M".to_string()];
assert!(is_installed(&installed, "qwen3:4b-instruct-2507-q4_K_M"));
}
#[test]
fn the_implicit_latest_tag_still_matches() {
let installed = vec!["llama3.2:latest".to_string()];
assert!(is_installed(&installed, "llama3.2"));
assert!(is_installed(&installed, "llama3.2:latest"));
}
#[test]
fn a_different_tag_is_a_different_model() {
let installed = vec!["llama3.1:8b".to_string(), "llama3.2".to_string()];
assert!(!is_installed(&installed, "llama3.1"));
assert!(!is_installed(&installed, "llama3.2:70b"));
assert!(!is_installed(&installed, "mistral"));
}
#[tokio::test]
async fn a_remote_provider_is_never_second_guessed() {
let cfg = AppConfig {
provider: Provider::Anthropic,
..AppConfig::default()
};
assert!(installed_models(&cfg).await.is_none());
}
#[tokio::test]
async fn a_server_that_is_not_running_answers_nothing_rather_than_nothing_installed() {
let cfg = AppConfig {
provider: Provider::Ollama,
base_url: Some("http://127.0.0.1:1/v1".to_string()),
..AppConfig::default()
};
assert!(installed_models(&cfg).await.is_none());
}
}
#[cfg(test)]
mod ollama_probe {
use super::*;
use crate::config::Provider;
#[tokio::test]
#[ignore]
async fn reads_the_window_the_server_loaded() {
let cfg = AppConfig {
provider: Provider::Ollama,
base_url: Some("http://127.0.0.1:11435/v1".to_string()),
..AppConfig::default()
};
let found = ollama_context_length(&cfg, "qwen3:4b-instruct-2507-q4_K_M").await;
assert_eq!(
found,
Some(32_768),
"detection must follow the server, not the 4096 default"
);
}
}