use std::future::Future;
use std::time::Duration;
use tokio::time::Instant;
use async_trait::async_trait;
use newt_core::router::Tier;
use crate::backend::{ChatReply, ChatRequest, InferenceBackend};
use crate::retry::{with_backoff, RetryPolicy};
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
const STREAM_TOTAL_TIMEOUT_MULTIPLIER: u32 = 5;
#[derive(Debug)]
struct ObservedProviderError(String);
impl std::fmt::Display for ObservedProviderError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for ObservedProviderError {}
fn is_observed_provider_error(error: &anyhow::Error) -> bool {
newt_core::agentic::openai_sse::is_provider_error(error)
|| error
.chain()
.any(|cause| cause.is::<ObservedProviderError>())
}
fn local_client(read_timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.read_timeout(read_timeout)
.build()
.expect("build local inference client")
}
async fn bounded_request<T>(
deadline: Instant,
request: impl Future<Output = anyhow::Result<T>>,
) -> anyhow::Result<T> {
tokio::time::timeout_at(deadline, request)
.await
.map_err(|_| anyhow::anyhow!("local inference request failed: deadline elapsed"))?
}
#[cfg(test)]
tokio::task_local! {
static RESPONSE_BYTES_READ: std::sync::Arc<std::sync::atomic::AtomicUsize>;
}
async fn decode_http_reply(
mut response: reqwest::Response,
idle_timeout: Duration,
total_deadline: Instant,
backend: &str,
decode: impl FnOnce(&[u8]) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let status = response.status();
let mut bytes = Vec::new();
let read_error = loop {
let remaining = total_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break Some(anyhow::anyhow!(
"total read deadline elapsed (each chunk arrived within the \
{idle_timeout:?} idle window, but the stream never finished)"
));
}
match tokio::time::timeout(idle_timeout.min(remaining), response.chunk()).await {
Ok(Ok(Some(chunk))) => {
bytes.extend_from_slice(&chunk);
#[cfg(test)]
let _ = RESPONSE_BYTES_READ.try_with(|count| {
count.fetch_add(chunk.len(), std::sync::atomic::Ordering::SeqCst);
});
}
Ok(Ok(None)) => break None,
Ok(Err(error)) => break Some(anyhow::Error::from(error)),
Err(error) => break Some(anyhow::Error::from(error)),
}
};
if !status.is_success() {
let detail = String::from_utf8_lossy(&bytes);
if let Some(error) = read_error {
anyhow::bail!(
"{backend} returned {status}: {detail}; response body read failure: {error}"
);
}
anyhow::bail!("{backend} returned {status}: {detail}");
}
let decoded = decode(&bytes);
if let Some(read_error) = read_error {
if let Err(error) = decoded {
if is_observed_provider_error(&error) {
let message = format!(
"{backend} response: {error}; response body read failure: {read_error}"
);
return Err(error.context(message));
}
}
anyhow::bail!("{backend} request failed reading response: {read_error}");
}
decoded
}
fn decode_ollama_stream(bytes: &[u8]) -> anyhow::Result<serde_json::Value> {
let body = std::str::from_utf8(bytes)
.map_err(|error| anyhow::anyhow!("invalid Ollama stream UTF-8: {error}"))?;
let mut content = String::new();
let mut terminal = None;
for (index, line) in body.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let frame: serde_json::Value = serde_json::from_str(line).map_err(|error| {
anyhow::anyhow!("invalid Ollama stream record {}: {error}", index + 1)
})?;
anyhow::ensure!(
terminal.is_none(),
"Ollama stream continued after its terminal frame"
);
anyhow::ensure!(frame.is_object(), "Ollama stream frame is not an object");
if let Some(error) = frame.get("error").filter(|error| !error.is_null()) {
let detail = error
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| error.to_string());
return Err(ObservedProviderError(format!("Ollama response error: {detail}")).into());
}
let message = match frame.get("message") {
Some(serde_json::Value::Object(message)) => Some(message),
None | Some(serde_json::Value::Null) => None,
Some(_) => anyhow::bail!("Ollama stream message is not an object"),
};
match message.and_then(|message| message.get("content")) {
Some(serde_json::Value::String(part)) => content.push_str(part),
None | Some(serde_json::Value::Null) => {}
Some(_) => anyhow::bail!("Ollama stream content is not a string"),
}
match frame.get("done").and_then(serde_json::Value::as_bool) {
Some(true) => terminal = Some(frame),
Some(false) => {}
None => anyhow::bail!("Ollama stream frame has no boolean done marker"),
}
}
let mut terminal =
terminal.ok_or_else(|| anyhow::anyhow!("Ollama stream ended before done"))?;
let object = terminal
.as_object_mut()
.expect("terminal stream frame was checked as an object");
let message = object
.entry("message")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("Ollama stream message is not an object"))?;
message.insert("content".to_owned(), serde_json::Value::String(content));
Ok(terminal)
}
#[derive(Debug)]
pub struct LocalOllamaBackend {
endpoint: String,
model: String,
client: reqwest::Client,
managed_client: bool,
timeout: Duration,
api_key: Option<String>,
retry: RetryPolicy,
}
impl LocalOllamaBackend {
pub fn new(endpoint: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
model: model.into(),
client: local_client(DEFAULT_REQUEST_TIMEOUT),
managed_client: true,
timeout: DEFAULT_REQUEST_TIMEOUT,
api_key: None,
retry: RetryPolicy::from_env(),
}
}
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self.managed_client = false;
self
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn with_api_key(mut self, api_key: impl Into<Option<String>>) -> Self {
self.api_key = api_key.into().filter(|k| !k.is_empty());
self
}
pub fn from_config(cfg: &newt_core::BackendConfig) -> Self {
let model = cfg.effective_model().unwrap_or_default().to_string();
Self::new(cfg.endpoint.clone(), model).with_api_key(cfg.resolve_api_key())
}
fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.api_key {
Some(key) => rb.bearer_auth(key),
None => rb,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
if self.managed_client {
self.client = local_client(timeout);
}
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://REDACTED-HOST:11434".to_string(),
"http://REDACTED-HOST:11434".to_string(),
"http://REDACTED-HOST: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 deadline = Instant::now() + self.timeout;
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": true,
"options": req.max_tokens.map(|t| serde_json::json!({ "num_predict": t })),
});
let url = format!("{}/api/chat", self.endpoint.trim_end_matches('/'));
let resp = bounded_request(deadline, async {
self.authed(self.client.post(&url).json(&body))
.send()
.await
.map_err(|e| anyhow::anyhow!("Ollama request failed: {e}"))
})
.await?;
let total_deadline = Instant::now() + self.timeout * STREAM_TOTAL_TIMEOUT_MULTIPLIER;
let json = decode_http_reply(
resp,
self.timeout,
total_deadline,
"Ollama",
decode_ollama_stream,
)
.await?;
let (content, _reasoning) =
newt_core::split_reasoning(json["message"]["content"].as_str().unwrap_or(""));
let usage = {
let input = json["prompt_eval_count"].as_u64().map(|n| n as u32);
let output = json["eval_count"].as_u64().map(|n| n as u32);
input.zip(output).map(|(i, o)| newt_core::TokenUsage {
input_tokens: i,
output_tokens: o,
})
};
Ok(ChatReply {
content,
model_id: self.model.clone(),
usage,
})
}
}
#[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> {
with_backoff(&self.retry, || self.try_complete(&req)).await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelInfo {
pub id: String,
}
#[derive(Debug)]
pub struct LocalVllmBackend {
endpoint: String,
model: String,
client: reqwest::Client,
managed_client: bool,
timeout: Duration,
api_key: Option<String>,
retry: RetryPolicy,
}
impl LocalVllmBackend {
pub fn new(endpoint: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
model: model.into(),
client: local_client(DEFAULT_REQUEST_TIMEOUT),
managed_client: true,
timeout: DEFAULT_REQUEST_TIMEOUT,
api_key: None,
retry: RetryPolicy::from_env(),
}
}
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self.managed_client = false;
self
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn with_api_key(mut self, api_key: impl Into<Option<String>>) -> Self {
self.api_key = api_key.into().filter(|k| !k.is_empty());
self
}
pub fn from_config(cfg: &newt_core::BackendConfig) -> Self {
let model = cfg.effective_model().unwrap_or_default().to_string();
Self::new(cfg.endpoint.clone(), model).with_api_key(cfg.resolve_api_key())
}
fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.api_key {
Some(key) => rb.bearer_auth(key),
None => rb,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
if self.managed_client {
self.client = local_client(timeout);
}
self
}
async fn try_complete(&self, req: &ChatRequest) -> anyhow::Result<ChatReply> {
let deadline = Instant::now() + self.timeout;
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": true,
"stream_options": {"include_usage": true},
});
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 = bounded_request(deadline, async {
self.authed(self.client.post(&url).json(&body))
.send()
.await
.map_err(|e| anyhow::anyhow!("vLLM request failed: {e}"))
})
.await?;
let total_deadline = Instant::now() + self.timeout * STREAM_TOTAL_TIMEOUT_MULTIPLIER;
let json = decode_http_reply(
resp,
self.timeout,
total_deadline,
"vLLM",
newt_core::agentic::openai_sse::decode_response,
)
.await?;
let (content, _reasoning) = newt_core::split_reasoning(
json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or(""),
);
let model_id = json["model"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| self.model.clone());
let usage = {
let input = json["usage"]["prompt_tokens"].as_u64().map(|n| n as u32);
let output = json["usage"]["completion_tokens"]
.as_u64()
.map(|n| n as u32);
input.zip(output).map(|(i, o)| newt_core::TokenUsage {
input_tokens: i,
output_tokens: o,
})
};
Ok(ChatReply {
content,
model_id,
usage,
})
}
pub async fn list_models(&self) -> anyhow::Result<Vec<ModelInfo>> {
bounded_request(Instant::now() + self.timeout, self.try_list_models()).await
}
async fn try_list_models(&self) -> anyhow::Result<Vec<ModelInfo>> {
let url = format!("{}/v1/models", self.endpoint.trim_end_matches('/'));
let resp = self
.authed(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> {
with_backoff(&self.retry, || self.try_complete(&req)).await
}
}
#[cfg(test)]
#[path = "local_timing_tests.rs"]
mod timing_tests;