use async_trait::async_trait;
use futures::{stream::BoxStream, StreamExt};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::providers::http::build_http_client;
use crate::providers::stream_util::line_stream;
use crate::providers::{LlmError, Provider, ProviderConfig, Result};
use crate::types::{
ChatRequest, ChatResponse, Embedding, EmbeddingRequest, EmbeddingResponse, EmbeddingUsage,
FinishReason, Message, StreamChunk, Usage,
};
const DEFAULT_BASE_URL: &str = "http://localhost:11434";
pub struct OllamaProvider {
client: Client,
base_url: String,
}
impl OllamaProvider {
pub fn new(config: ProviderConfig) -> Self {
Self {
client: build_http_client(
config.timeout_secs.map(Duration::from_secs),
config.custom_headers.as_ref(),
),
base_url: config
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
}
}
}
#[derive(Serialize)]
struct OllamaRequest<'a> {
model: &'a str,
messages: &'a [OllamaMessage],
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<OllamaOptions>,
}
fn build_ollama_options(req: &ChatRequest) -> Option<OllamaOptions> {
if req.temperature.is_some() || req.max_tokens.is_some() || req.top_p.is_some() {
Some(OllamaOptions {
temperature: req.temperature,
num_predict: req.max_tokens,
top_p: req.top_p,
})
} else {
None
}
}
#[derive(Serialize)]
struct OllamaOptions {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>,
}
#[derive(Serialize, Deserialize)]
struct OllamaMessage {
role: String,
content: String,
}
impl From<&Message> for OllamaMessage {
fn from(msg: &Message) -> Self {
Self {
role: match msg.role {
crate::types::Role::System => "system".to_string(),
crate::types::Role::User => "user".to_string(),
crate::types::Role::Assistant => "assistant".to_string(),
crate::types::Role::Tool => "tool".to_string(),
},
content: msg.content.as_text(),
}
}
}
#[derive(Deserialize)]
struct OllamaResponse {
message: OllamaMessageResponse,
model: String,
#[serde(default)]
eval_count: u64,
#[serde(default)]
prompt_eval_count: u64,
}
#[derive(Deserialize)]
struct OllamaMessageResponse {
#[serde(default)]
content: String,
}
#[derive(Deserialize)]
struct OllamaStreamChunk {
message: Option<OllamaMessageResponse>,
done: bool,
#[serde(default)]
done_reason: Option<String>,
#[serde(default)]
eval_count: u64,
#[serde(default)]
prompt_eval_count: u64,
}
#[derive(Deserialize)]
struct OllamaErrorBody {
error: String,
}
#[derive(Serialize)]
struct OllamaEmbedRequest<'a> {
model: &'a str,
input: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
dimensions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
truncate: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
keep_alive: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct OllamaEmbedResponse {
#[serde(default)]
model: Option<String>,
embeddings: Vec<Vec<f32>>,
#[serde(default)]
prompt_eval_count: Option<u64>,
}
fn parse_ndjson_line(line: &str) -> Vec<Result<StreamChunk>> {
let line = line.trim();
if line.is_empty() {
return Vec::new();
}
let parsed = match serde_json::from_str::<OllamaStreamChunk>(line) {
Ok(p) => p,
Err(e) => {
return vec![Err(LlmError::Parse(format!(
"failed to parse Ollama stream chunk: {e}"
)))];
}
};
if parsed.done {
vec![Ok(StreamChunk {
done: true,
finish_reason: Some(
parsed
.done_reason
.map(FinishReason::from)
.unwrap_or(FinishReason::Stop),
),
usage: Some(Usage {
prompt_tokens: parsed.prompt_eval_count,
completion_tokens: parsed.eval_count,
total_tokens: parsed.prompt_eval_count.saturating_add(parsed.eval_count),
..Default::default()
}),
..Default::default()
})]
} else if let Some(msg) = parsed.message {
vec![Ok(StreamChunk {
delta: msg.content,
..Default::default()
})]
} else {
Vec::new()
}
}
#[async_trait]
impl Provider for OllamaProvider {
async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
let messages: Vec<OllamaMessage> = req.messages.iter().map(OllamaMessage::from).collect();
let body = OllamaRequest {
model: &req.model,
messages: &messages,
stream: false,
options: build_ollama_options(req),
};
tracing::debug!(
provider = "ollama",
model = &req.model,
"sending chat request"
);
let resp = self
.client
.post(format!("{}/api/chat", self.base_url))
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
let msg = serde_json::from_str::<OllamaErrorBody>(&text)
.map(|e| e.error)
.unwrap_or(text);
let err = LlmError::Api {
status: status.as_u16(),
message: msg,
};
tracing::error!(
provider = "ollama",
status = status.as_u16(),
error_kind = "api_error",
"API error"
);
return Err(err);
}
let parsed: OllamaResponse = resp
.json()
.await
.map_err(|e| LlmError::Parse(e.to_string()))?;
let result = ChatResponse {
content: parsed.message.content,
model: parsed.model,
usage: Some(Usage {
prompt_tokens: parsed.prompt_eval_count,
completion_tokens: parsed.eval_count,
total_tokens: parsed.prompt_eval_count.saturating_add(parsed.eval_count),
..Default::default()
}),
..Default::default()
};
tracing::debug!(
provider = "ollama",
model = &result.model,
"chat response received"
);
Ok(result)
}
async fn stream(&self, req: &ChatRequest) -> Result<BoxStream<'static, Result<StreamChunk>>> {
let messages: Vec<OllamaMessage> = req.messages.iter().map(OllamaMessage::from).collect();
let body = OllamaRequest {
model: &req.model,
messages: &messages,
stream: true,
options: build_ollama_options(req),
};
tracing::debug!(
provider = "ollama",
model = &req.model,
"sending stream request"
);
let resp = self
.client
.post(format!("{}/api/chat", self.base_url))
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
let msg = serde_json::from_str::<OllamaErrorBody>(&text)
.map(|e| e.error)
.unwrap_or(text);
let err = LlmError::Api {
status: status.as_u16(),
message: msg,
};
tracing::error!(
provider = "ollama",
status = status.as_u16(),
error_kind = "api_error",
"API error"
);
return Err(err);
}
let byte_stream = resp
.bytes_stream()
.map(|r| r.map_err(|e| LlmError::Stream(e.to_string())));
let stream = line_stream(byte_stream).flat_map(|line_result| {
let chunks = match line_result {
Ok(line) => parse_ndjson_line(&line),
Err(e) => vec![Err(e)],
};
futures::stream::iter(chunks)
});
Ok(stream.boxed())
}
async fn embed(&self, req: &EmbeddingRequest) -> Result<EmbeddingResponse> {
let truncate = req.extra.get("truncate").and_then(|v| v.as_bool());
let options = req.extra.get("options").cloned();
let keep_alive = req.extra.get("keep_alive").cloned();
let body = OllamaEmbedRequest {
model: &req.model,
input: &req.input,
dimensions: req.dimensions,
truncate,
options,
keep_alive,
};
tracing::debug!(
provider = "ollama",
model = %req.model,
input_count = req.input.len(),
"sending embedding request"
);
let resp = self
.client
.post(format!("{}/api/embed", self.base_url))
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
let msg = serde_json::from_str::<OllamaErrorBody>(&text)
.map(|e| e.error)
.unwrap_or(text);
let err = LlmError::Api {
status: status.as_u16(),
message: msg,
};
tracing::error!(
provider = "ollama",
status = status.as_u16(),
error_kind = "api_error",
"embedding API error"
);
return Err(err);
}
let parsed: OllamaEmbedResponse = resp
.json()
.await
.map_err(|e| LlmError::Parse(format!("Ollama embed parse: {e}")))?;
let data: Vec<Embedding> = parsed
.embeddings
.into_iter()
.enumerate()
.map(|(i, vec)| Embedding {
index: i,
embedding: vec,
})
.collect();
let usage = parsed.prompt_eval_count.map(|c| EmbeddingUsage {
prompt_tokens: c,
total_tokens: c,
});
let model = parsed.model.unwrap_or_else(|| req.model.clone());
let result = EmbeddingResponse { model, data, usage };
tracing::debug!(
provider = "ollama",
model = %result.model,
embedding_count = result.data.len(),
"embedding response received"
);
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn ollama_stream_malformed_json_returns_parse_error() {
let chunks = parse_ndjson_line("{not valid json}");
let err = chunks.into_iter().next().unwrap().unwrap_err();
assert!(matches!(err, LlmError::Parse(_)));
}
#[test]
fn ollama_stream_ignores_empty_lines() {
let chunks = parse_ndjson_line("");
assert!(chunks.is_empty());
}
#[test]
fn ollama_stream_valid_chunk_still_parses() {
let chunks = parse_ndjson_line(r#"{"message":{"content":"hello"},"done":false}"#);
let chunk = chunks.into_iter().next().unwrap().unwrap();
assert_eq!(chunk.delta, "hello");
assert!(!chunk.done);
}
use std::io::{Read, Write};
use std::net::TcpListener as StdTcpListener;
use std::sync::Barrier;
fn fake_ollama_server(
handler: impl Fn(&str, &str, &[u8]) -> (u16, String) + Send + 'static,
) -> String {
let listener = StdTcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let barrier = Arc::new(Barrier::new(2));
let b = Arc::clone(&barrier);
std::thread::spawn(move || {
b.wait();
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = vec![0u8; 16384];
let n = stream.read(&mut buf).unwrap_or(0);
let raw = String::from_utf8_lossy(&buf[..n]).to_string();
let first_line = raw.lines().next().unwrap_or("");
let mut parts = first_line.split_whitespace();
let method = parts.next().unwrap_or("GET");
let path = parts.next().unwrap_or("/");
let body_start = raw.find("\r\n\r\n").map(|i| i + 4).unwrap_or(raw.len());
let body_bytes = &buf[body_start..n];
let (status, body) = handler(method, path, body_bytes);
let resp = format!(
"HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
body.len(),
body
);
stream.write_all(resp.as_bytes()).ok();
stream.flush().ok();
let _ = stream.read(&mut [0u8; 1]);
}
});
barrier.wait();
url
}
#[test]
fn ollama_embed_posts_to_api_embed() {
let url = fake_ollama_server(|method, path, body| {
assert_eq!(method, "POST");
assert_eq!(path, "/api/embed");
let json: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(json["model"], "all-minilm");
assert_eq!(json["input"][0], "hello");
(200, r#"{"model":"all-minilm","embeddings":[[0.1]]}"#.into())
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("all-minilm", "hello");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(provider.embed(&req)).unwrap();
}
#[test]
fn ollama_embed_accepts_batch_and_preserves_order() {
let url = fake_ollama_server(|_m, _p, _b| {
(200, r#"{"model":"m","embeddings":[[0.1],[0.2]]}"#.into())
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::batch("m", ["a", "b"]);
let rt = tokio::runtime::Runtime::new().unwrap();
let resp = rt.block_on(provider.embed(&req)).unwrap();
assert_eq!(resp.data.len(), 2);
assert_eq!(resp.data[0].index, 0);
assert_eq!(resp.data[0].embedding, vec![0.1_f32]);
assert_eq!(resp.data[1].index, 1);
assert_eq!(resp.data[1].embedding, vec![0.2_f32]);
}
#[test]
fn ollama_embed_sends_dimensions() {
let url = fake_ollama_server(|_m, _p, body| {
let json: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(json["dimensions"], 384);
(200, r#"{"model":"m","embeddings":[[0.1]]}"#.into())
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("m", "hi").with_dimensions(384);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(provider.embed(&req)).unwrap();
}
#[test]
fn ollama_embed_forwards_truncate_options_keep_alive_from_extra() {
let url = fake_ollama_server(|_m, _p, body| {
let json: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(json["truncate"], false);
assert_eq!(json["options"]["temperature"], 0);
assert_eq!(json["keep_alive"], "10m");
(200, r#"{"model":"m","embeddings":[[0.1]]}"#.into())
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("m", "hi")
.with_extra("truncate", false)
.with_extra("options", serde_json::json!({"temperature": 0}))
.with_extra("keep_alive", "10m");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(provider.embed(&req)).unwrap();
}
#[test]
fn ollama_embed_parses_usage_from_prompt_eval_count() {
let url = fake_ollama_server(|_m, _p, _b| {
(
200,
r#"{"model":"m","embeddings":[[0.1,0.2]],"prompt_eval_count":8}"#.into(),
)
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("m", "hi");
let rt = tokio::runtime::Runtime::new().unwrap();
let resp = rt.block_on(provider.embed(&req)).unwrap();
let usage = resp.usage.expect("usage should be present");
assert_eq!(usage.prompt_tokens, 8);
assert_eq!(usage.total_tokens, 8);
}
#[test]
fn ollama_embed_model_falls_back_to_request_model() {
let url = fake_ollama_server(|_m, _p, _b| (200, r#"{"embeddings":[[0.1]]}"#.into()));
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("fallback-model", "hi");
let rt = tokio::runtime::Runtime::new().unwrap();
let resp = rt.block_on(provider.embed(&req)).unwrap();
assert_eq!(resp.model, "fallback-model");
}
#[test]
fn ollama_embed_maps_api_error() {
let url = fake_ollama_server(|_m, _p, _b| (404, r#"{"error":"model not found"}"#.into()));
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("nonexistent", "hi");
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt.block_on(provider.embed(&req)).unwrap_err();
assert!(
matches!(&err, LlmError::Api { status: 404, message } if message.contains("model not found"))
);
}
#[test]
fn lmrs_client_ollama_embed_strips_prefix() {
use crate::LmrsClient;
let url = fake_ollama_server(|_m, _p, body| {
let json: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(json["model"], "all-minilm");
(200, r#"{"model":"all-minilm","embeddings":[[0.1]]}"#.into())
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = Arc::new(OllamaProvider::new(config));
let rt = tokio::runtime::Runtime::new().unwrap();
let llm = LmrsClient::new();
rt.block_on(llm.set_custom("ollama", provider));
rt.block_on(llm.embed("ollama/all-minilm", "hello"))
.unwrap();
}
#[test]
fn ollama_embed_does_not_send_user() {
let url = fake_ollama_server(|_m, _p, body| {
let json: serde_json::Value = serde_json::from_slice(body).unwrap();
assert!(json.get("user").is_none(), "user should not be sent");
(200, r#"{"model":"m","embeddings":[[0.1]]}"#.into())
});
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("m", "hi").with_user("u");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(provider.embed(&req)).unwrap();
}
#[test]
fn ollama_embed_malformed_response_returns_parse() {
let url = fake_ollama_server(|_m, _p, _b| (200, r#"not json"#.into()));
let config = ProviderConfig::new("").with_base_url(&url);
let provider = OllamaProvider::new(config);
let req = EmbeddingRequest::new("m", "hi");
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt.block_on(provider.embed(&req)).unwrap_err();
assert!(matches!(err, LlmError::Parse(_)));
}
}