use crate::error::{Error, Result};
use crate::providers::{ListModels, ProviderApi, ProviderClient};
use crate::types::{
ContentPart, DEFAULT_OLLAMA_MODEL, Event, Message, Model, ModelInfo, Provider, Response,
ResponseRequest, Role, ToolCall, ToolSpec,
};
use futures_util::StreamExt;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct OllamaTagsResponse {
#[serde(default)]
models: Vec<OllamaTag>,
}
#[derive(Debug, Deserialize)]
struct OllamaTag {
name: String,
#[serde(default)]
modified_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
message: OllamaMessage,
}
#[derive(Debug, Deserialize)]
struct OllamaChatStreamChunk {
#[serde(default)]
message: Option<OllamaMessage>,
#[serde(default)]
done: bool,
}
#[derive(Debug, Deserialize)]
struct OllamaMessage {
#[allow(dead_code)]
role: String,
#[serde(default)]
content: String,
#[serde(default)]
tool_calls: Vec<OllamaWireToolCall>,
}
#[derive(Debug, Deserialize)]
struct OllamaWireToolCall {
function: OllamaWireToolFunction,
}
#[derive(Debug, Deserialize)]
struct OllamaWireToolFunction {
#[serde(default)]
index: Option<u64>,
name: String,
#[serde(default)]
arguments: serde_json::Value,
}
fn normalize_tool_arguments(v: serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::String(s) => {
serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::String(s))
}
other => other,
}
}
fn tool_result_content_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[derive(Debug, Clone, Copy)]
pub struct OllamaProvider;
#[derive(Debug, Clone)]
pub struct OllamaApi {
base_url: String,
}
impl OllamaApi {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
}
impl OllamaProvider {
fn tags_url(base_url: &str) -> String {
format!("{}/api/tags", base_url.trim_end_matches('/'))
}
fn chat_url(base_url: &str) -> String {
format!("{}/api/chat", base_url.trim_end_matches('/'))
}
fn tools_from_specs(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
tools
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"function": {
"name": t.name,
"description": t.description.as_deref().unwrap_or(""),
"parameters": t.parameters,
}
})
})
.collect()
}
fn wire_tool_calls_to_tool_calls(wire: &[OllamaWireToolCall]) -> Vec<ToolCall> {
wire.iter()
.enumerate()
.map(|(i, tc)| ToolCall {
id: Some(format!("ollama-{}", tc.function.index.unwrap_or(i as u64))),
name: tc.function.name.clone(),
arguments: normalize_tool_arguments(tc.function.arguments.clone()),
})
.collect()
}
fn merge_tool_calls_unique(acc: &mut Vec<ToolCall>, incoming: Vec<ToolCall>) {
for tc in incoming {
let dup = acc
.iter()
.any(|e| e.name == tc.name && e.arguments == tc.arguments);
if !dup {
acc.push(tc);
}
}
}
fn messages_for_chat(messages: &[Message]) -> Vec<serde_json::Value> {
let mut out = Vec::new();
for m in messages {
match m.role {
Role::System => {
let text: String = m
.content
.iter()
.filter_map(|p| match p {
ContentPart::Text(t) => Some(t.as_str()),
ContentPart::Thinking { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
if text.is_empty() {
continue;
}
out.push(serde_json::json!({ "role": "system", "content": text }));
}
Role::User => {
let mut text = String::new();
let mut images: Vec<String> = Vec::new();
for part in &m.content {
match part {
ContentPart::Text(t) => text.push_str(t),
ContentPart::Thinking { text: th, .. } => text.push_str(th),
ContentPart::ImageBase64 { data, .. } => images.push(data.clone()),
ContentPart::ImageUrl { .. }
| ContentPart::Citation { .. }
| ContentPart::ToolCall { .. }
| ContentPart::ToolResult { .. } => {}
}
}
if text.is_empty() && images.is_empty() {
continue;
}
let mut msg = serde_json::json!({
"role": "user",
"content": text,
});
if !images.is_empty() {
msg["images"] = serde_json::json!(images);
}
out.push(msg);
}
Role::Assistant => {
let mut text = String::new();
let mut tool_calls_json = Vec::new();
let mut tc_idx = 0u64;
for part in &m.content {
match part {
ContentPart::Text(t) => text.push_str(t),
ContentPart::Thinking { text: th, .. } => text.push_str(th),
ContentPart::ToolCall {
id: _,
name,
arguments,
} => {
tool_calls_json.push(serde_json::json!({
"type": "function",
"function": {
"index": tc_idx,
"name": name,
"arguments": arguments,
}
}));
tc_idx += 1;
}
ContentPart::ImageUrl { .. }
| ContentPart::ImageBase64 { .. }
| ContentPart::Citation { .. }
| ContentPart::ToolResult { .. } => {}
}
}
if text.is_empty() && tool_calls_json.is_empty() {
continue;
}
let mut msg = serde_json::json!({ "role": "assistant" });
if !text.is_empty() {
msg["content"] = serde_json::json!(text);
}
if !tool_calls_json.is_empty() {
msg["tool_calls"] = serde_json::json!(tool_calls_json);
}
out.push(msg);
}
Role::Tool => {
for part in &m.content {
let ContentPart::ToolResult {
id,
function_name,
content,
} = part
else {
continue;
};
let tool_name = function_name
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(id.as_str());
out.push(serde_json::json!({
"role": "tool",
"tool_name": tool_name,
"content": tool_result_content_string(content),
}));
}
}
}
}
out
}
async fn resolve_model(http: &reqwest::Client, base_url: &str) -> String {
match ListModels::list_models(&OllamaProvider, http, "", base_url).await {
Ok(models) => models
.first()
.map(|m| m.id.clone())
.unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string()),
Err(_) => DEFAULT_OLLAMA_MODEL.to_string(),
}
}
}
#[async_trait::async_trait(?Send)]
impl ProviderApi for OllamaApi {
fn provider(&self) -> Provider {
Provider::Ollama
}
async fn send(&self, http: &reqwest::Client, req: ResponseRequest) -> Result<Response> {
OllamaProvider.send(http, "", &self.base_url, req).await
}
async fn stream(
&self,
http: &reqwest::Client,
req: ResponseRequest,
on_event: &mut dyn FnMut(Event),
) -> Result<Response> {
OllamaProvider
.stream(http, "", &self.base_url, req, on_event)
.await
}
async fn list_models(&self, http: &reqwest::Client) -> Result<Vec<ModelInfo>> {
OllamaProvider.list_models(http, "", &self.base_url).await
}
}
impl ListModels for OllamaProvider {
fn list_models(
&self,
http: &reqwest::Client,
_api_key: &str,
base_url: &str,
) -> impl std::future::Future<Output = Result<Vec<ModelInfo>>> + Send {
let url = Self::tags_url(base_url);
let http = http.clone();
async move {
let resp = http.get(url).send().await?;
let status = resp.status();
let text = resp.text().await?;
if !status.is_success() {
return Err(Error::Api {
provider: Provider::Ollama,
status: status.as_u16(),
body: text,
});
}
let parsed: OllamaTagsResponse = serde_json::from_str(&text)?;
Ok(parsed
.models
.into_iter()
.map(|m| ModelInfo {
id: m.name,
display_name: None,
provider: Provider::Ollama,
created_at: m.modified_at,
max_input_tokens: None,
max_output_tokens: None,
})
.collect())
}
}
}
impl ProviderClient for OllamaProvider {
async fn send(
&self,
http: &reqwest::Client,
_api_key: &str,
base_url: &str,
req: ResponseRequest,
) -> Result<Response> {
let model = match req.model {
Some(m) => m.0,
None => Self::resolve_model(http, base_url).await,
};
let url = Self::chat_url(base_url);
let messages = Self::messages_for_chat(&req.messages);
let mut body = serde_json::json!({
"model": model,
"messages": messages,
"stream": false,
});
if !req.tools.is_empty() {
body["tools"] = serde_json::json!(Self::tools_from_specs(&req.tools));
}
if let Some(max) = req.max_output_tokens {
body["options"] = serde_json::json!({ "num_predict": max });
}
let resp = http.post(url).json(&body).send().await?;
let status = resp.status();
let text = resp.text().await?;
if !status.is_success() {
return Err(Error::Api {
provider: Provider::Ollama,
status: status.as_u16(),
body: text,
});
}
let parsed: OllamaChatResponse = serde_json::from_str(&text)?;
let tool_calls = Self::wire_tool_calls_to_tool_calls(&parsed.message.tool_calls);
Ok(Response {
model: Model::new(body["model"].as_str().unwrap_or(DEFAULT_OLLAMA_MODEL)),
message: Message::text(Role::Assistant, parsed.message.content),
tool_calls,
metadata: serde_json::Value::Null,
#[cfg(feature = "raw-json")]
raw_json: serde_json::from_str::<serde_json::Value>(&text).ok(),
})
}
async fn stream<F>(
&self,
http: &reqwest::Client,
_api_key: &str,
base_url: &str,
req: ResponseRequest,
on_event: &mut F,
) -> Result<Response>
where
F: FnMut(Event) + ?Sized,
{
let model = match req.model {
Some(m) => m.0,
None => Self::resolve_model(http, base_url).await,
};
let url = Self::chat_url(base_url);
let messages = Self::messages_for_chat(&req.messages);
let mut body = serde_json::json!({
"model": model,
"messages": messages,
"stream": true,
});
if !req.tools.is_empty() {
body["tools"] = serde_json::json!(Self::tools_from_specs(&req.tools));
}
if let Some(max) = req.max_output_tokens {
body["options"] = serde_json::json!({ "num_predict": max });
}
let resp = http.post(url).json(&body).send().await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await?;
return Err(Error::Api {
provider: Provider::Ollama,
status: status.as_u16(),
body: text,
});
}
let mut out = String::new();
let mut stream_tool_calls: Vec<ToolCall> = Vec::new();
let mut buf = String::new();
let mut bytes = resp.bytes_stream();
while let Some(chunk) = bytes.next().await {
let chunk = chunk?;
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(idx) = buf.find('\n') {
let line = buf[..idx].trim().to_string();
buf.drain(..idx + 1);
if line.is_empty() {
continue;
}
let parsed: OllamaChatStreamChunk = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(msg) = parsed.message {
if !msg.content.is_empty() {
out.push_str(&msg.content);
on_event(Event::TextDelta(msg.content));
}
if !msg.tool_calls.is_empty() {
let chunk_calls = Self::wire_tool_calls_to_tool_calls(&msg.tool_calls);
let n_before = stream_tool_calls.len();
Self::merge_tool_calls_unique(&mut stream_tool_calls, chunk_calls);
for tc in stream_tool_calls.iter().skip(n_before) {
on_event(Event::ToolCall(tc.clone()));
}
}
}
if parsed.done {
break;
}
}
}
let resp = Response {
model: Model::new(body["model"].as_str().unwrap_or(DEFAULT_OLLAMA_MODEL)),
message: Message::text(Role::Assistant, out),
tool_calls: stream_tool_calls,
metadata: serde_json::Value::Null,
#[cfg(feature = "raw-json")]
raw_json: None,
};
on_event(Event::Completed(resp.clone()));
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_tags_response_into_model_info() {
let json = r#"
{
"models": [
{ "name": "llama3.1", "modified_at": "2026-01-01T00:00:00Z" }
]
}
"#;
let parsed: OllamaTagsResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.models.len(), 1);
assert_eq!(parsed.models[0].name, "llama3.1");
}
#[test]
fn parses_stream_chunk_delta() {
let json = r#"{ "message": { "role": "assistant", "content": "hi" }, "done": false }"#;
let parsed: OllamaChatStreamChunk = serde_json::from_str(json).unwrap();
assert!(!parsed.done);
assert_eq!(parsed.message.unwrap().content, "hi");
}
#[test]
fn parses_chat_response_tool_calls() {
let json = r#"{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "function",
"function": {
"index": 0,
"name": "add",
"arguments": {"a": 19, "b": 23}
}
}
]
}
}"#;
let parsed: OllamaChatResponse = serde_json::from_str(json).unwrap();
let calls = OllamaProvider::wire_tool_calls_to_tool_calls(&parsed.message.tool_calls);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "add");
assert_eq!(calls[0].id.as_deref(), Some("ollama-0"));
}
}