use async_trait::async_trait;
use newt_core::router::Tier;
use crate::backend::{ChatReply, ChatRequest, InferenceBackend};
#[derive(Debug)]
pub struct LocalOllamaBackend {
endpoint: String,
model: String,
client: reqwest::Client,
}
impl LocalOllamaBackend {
pub fn new(endpoint: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
model: model.into(),
client: reqwest::Client::new(),
}
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("build client");
self
}
pub async fn discover(model: &str) -> anyhow::Result<Self> {
let env_host = std::env::var("OLLAMA_HOST").ok();
Self::discover_inner(model, env_host.as_deref(), &Self::default_endpoints()).await
}
pub async fn discover_with_candidates(
model: &str,
candidates: &[String],
) -> anyhow::Result<Self> {
let env_host = std::env::var("OLLAMA_HOST").ok();
Self::discover_inner(model, env_host.as_deref(), candidates).await
}
pub async fn discover_with_env(
model: &str,
env_host: Option<&str>,
candidates: &[String],
) -> anyhow::Result<Self> {
Self::discover_inner(model, env_host, candidates).await
}
async fn discover_inner(
model: &str,
env_host: Option<&str>,
candidates: &[String],
) -> anyhow::Result<Self> {
if let Some(host) = env_host {
tracing::info!(
endpoint = %host,
"Ollama endpoint chosen via OLLAMA_HOST (verbatim, not probed)"
);
return Ok(Self::new(host, model));
}
let probe_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(500))
.build()?;
for endpoint in candidates {
if Self::probe(&probe_client, endpoint).await {
tracing::info!(endpoint = %endpoint, "Ollama endpoint chosen by discovery probe");
return Ok(Self::new(endpoint, model));
}
}
anyhow::bail!(
"no reachable Ollama endpoint found (tried {} candidates)",
candidates.len()
)
}
pub async fn discover_strict(model: &str) -> anyhow::Result<Self> {
let env_host = std::env::var("OLLAMA_HOST").ok();
Self::discover_strict_with_env(model, env_host.as_deref(), &Self::default_endpoints()).await
}
pub async fn discover_strict_with_env(
model: &str,
env_host: Option<&str>,
candidates: &[String],
) -> anyhow::Result<Self> {
let all_candidates: Vec<&str> = env_host
.into_iter()
.chain(candidates.iter().map(String::as_str))
.collect();
let probe_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(500))
.build()?;
for endpoint in &all_candidates {
if Self::probe(&probe_client, endpoint).await {
tracing::info!(endpoint = %endpoint, "Ollama endpoint chosen (strict)");
return Ok(Self::new(*endpoint, model));
}
}
anyhow::bail!(
"discover_strict: no reachable Ollama endpoint (tried {} candidates)",
all_candidates.len()
)
}
pub fn default_endpoints() -> Vec<String> {
vec![
"http://ollama-proxy.inference.svc.cluster.local:11434".to_string(),
"http://ollama.home.lab:11434".to_string(),
"http://dgx-ollama.home.lab:11434".to_string(),
"http://gnuc-ollama.home.lab:11434".to_string(),
"http://127.0.0.1:11434".to_string(),
]
}
async fn probe(client: &reqwest::Client, endpoint: &str) -> bool {
let url = format!("{}/api/tags", endpoint.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
async fn try_complete(&self, req: &ChatRequest) -> anyhow::Result<ChatReply> {
let body = serde_json::json!({
"model": self.model,
"messages": req.messages.iter().map(|m| {
serde_json::json!({ "role": &m.role, "content": &m.content })
}).collect::<Vec<_>>(),
"stream": false,
"options": req.max_tokens.map(|t| serde_json::json!({ "num_predict": t })),
});
let url = format!("{}/api/chat", self.endpoint.trim_end_matches('/'));
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("Ollama request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Ollama returned {status}: {text}");
}
let json: serde_json::Value = resp.json().await?;
let content = json["message"]["content"]
.as_str()
.unwrap_or("")
.to_string();
Ok(ChatReply {
content,
model_id: self.model.clone(),
})
}
fn is_retryable(err: &anyhow::Error) -> bool {
let msg = err.to_string();
if msg.contains("request failed") {
return true;
}
if let Some(rest) = msg.strip_prefix("Ollama returned ") {
if let Some(code_str) = rest.split_whitespace().next() {
if let Ok(code) = code_str.parse::<u16>() {
return (500..600).contains(&code);
}
}
}
false
}
}
#[async_trait]
impl InferenceBackend for LocalOllamaBackend {
fn name(&self) -> &str {
"ollama-local"
}
fn model_id(&self) -> &str {
&self.model
}
fn supports_tier(&self, _tier: Tier) -> bool {
true
}
fn endpoint(&self) -> Option<&str> {
Some(&self.endpoint)
}
async fn complete(&self, req: ChatRequest) -> anyhow::Result<ChatReply> {
let retry_delays_ms: &[u64] = &[250, 500, 1000];
let mut last_err = anyhow::anyhow!("no attempts made");
for (attempt, delay_ms) in std::iter::once(0)
.chain(retry_delays_ms.iter().copied())
.enumerate()
{
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
match self.try_complete(&req).await {
Ok(reply) => return Ok(reply),
Err(e) => {
if !Self::is_retryable(&e) {
return Err(e);
}
tracing::warn!(attempt, error = %e, "retrying Ollama request");
last_err = e;
}
}
}
Err(last_err)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelInfo {
pub id: String,
}
#[derive(Debug)]
pub struct LocalVllmBackend {
endpoint: String,
model: String,
client: reqwest::Client,
}
impl LocalVllmBackend {
pub fn new(endpoint: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
model: model.into(),
client: reqwest::Client::new(),
}
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("build client");
self
}
async fn try_complete(&self, req: &ChatRequest) -> anyhow::Result<ChatReply> {
let mut body = serde_json::json!({
"model": self.model,
"messages": req.messages.iter().map(|m| {
serde_json::json!({ "role": &m.role, "content": &m.content })
}).collect::<Vec<_>>(),
"stream": false,
});
if let Some(max) = req.max_tokens {
body["max_tokens"] = serde_json::json!(max);
}
let url = format!(
"{}/v1/chat/completions",
self.endpoint.trim_end_matches('/')
);
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("vLLM request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("vLLM returned {status}: {text}");
}
let json: serde_json::Value = resp.json().await?;
let content = json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string();
let model_id = json["model"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| self.model.clone());
Ok(ChatReply { content, model_id })
}
fn is_retryable(err: &anyhow::Error) -> bool {
let msg = err.to_string();
if msg.contains("request failed") {
return true;
}
if let Some(rest) = msg.strip_prefix("vLLM returned ") {
if let Some(code_str) = rest.split_whitespace().next() {
if let Ok(code) = code_str.parse::<u16>() {
return (500..600).contains(&code);
}
}
}
false
}
pub async fn list_models(&self) -> anyhow::Result<Vec<ModelInfo>> {
let url = format!("{}/v1/models", self.endpoint.trim_end_matches('/'));
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| anyhow::anyhow!("vLLM list_models request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("vLLM list_models returned {status}: {text}");
}
let json: serde_json::Value = resp.json().await?;
let data = json["data"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("vLLM list_models response missing 'data' array"))?;
let models = data
.iter()
.filter_map(|entry| {
entry["id"]
.as_str()
.map(|id| ModelInfo { id: id.to_string() })
})
.collect();
Ok(models)
}
}
#[async_trait]
impl InferenceBackend for LocalVllmBackend {
fn name(&self) -> &str {
"vllm-local"
}
fn model_id(&self) -> &str {
&self.model
}
fn supports_tier(&self, _tier: Tier) -> bool {
true
}
fn endpoint(&self) -> Option<&str> {
Some(&self.endpoint)
}
async fn complete(&self, req: ChatRequest) -> anyhow::Result<ChatReply> {
let retry_delays_ms: &[u64] = &[250, 500, 1000];
let mut last_err = anyhow::anyhow!("no attempts made");
for (attempt, delay_ms) in std::iter::once(0)
.chain(retry_delays_ms.iter().copied())
.enumerate()
{
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
match self.try_complete(&req).await {
Ok(reply) => return Ok(reply),
Err(e) => {
if !Self::is_retryable(&e) {
return Err(e);
}
tracing::warn!(attempt, error = %e, "retrying vLLM request");
last_err = e;
}
}
}
Err(last_err)
}
}