#![allow(dead_code)]
use futures::Stream;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::error::Error;
use std::io;
use std::pin::Pin;
use tokio::sync::mpsc;
#[derive(Clone, Debug)]
pub struct OllamaClient {
base_url: String,
client: Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaResponse {
pub response: String,
pub done: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaModel {
pub name: String,
pub size: u64,
pub digest: String,
pub modified_at: String,
#[serde(default)]
pub details: Option<ModelDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelDetails {
pub format: String,
pub family: String,
pub families: Option<Vec<String>>,
pub parameter_size: String,
pub quantization_level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaModelsResponse {
pub models: Vec<OllamaModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<OllamaOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OllamaOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repeat_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_predict: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub model: String,
pub created_at: String,
pub message: ChatMessage,
pub done: bool,
#[serde(default)]
pub total_duration: Option<u64>,
#[serde(default)]
pub load_duration: Option<u64>,
#[serde(default)]
pub prompt_eval_count: Option<u32>,
#[serde(default)]
pub prompt_eval_duration: Option<u64>,
#[serde(default)]
pub eval_count: Option<u32>,
#[serde(default)]
pub eval_duration: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullProgressResponse {
pub status: String,
#[serde(default)]
pub digest: Option<String>,
#[serde(default)]
pub total: Option<u64>,
#[serde(default)]
pub completed: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfoResponse {
#[serde(default)]
pub license: String,
#[serde(default)]
pub modelfile: String,
#[serde(default)]
pub parameters: String,
#[serde(default)]
pub template: String,
#[serde(default)]
pub details: Option<ModelDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateModelRequest {
pub name: String,
pub modelfile: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub stream: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopyModelRequest {
pub source: String,
pub destination: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteModelRequest {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingRequest {
pub model: String,
pub prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingResponse {
pub embedding: Vec<f32>,
}
pub type OllamaStream =
Pin<Box<dyn Stream<Item = Result<String, Box<dyn Error + Send + Sync>>> + Send>>;
impl OllamaClient {
pub fn new(base_url: String) -> Self {
Self {
base_url,
client: Client::new(),
}
}
fn matches_model_name(candidate: &str, requested: &str) -> bool {
candidate == requested || candidate.eq_ignore_ascii_case(requested)
}
pub async fn health(&self) -> Result<bool, Box<dyn Error>> {
match self
.client
.get(format!("{}/api/tags", self.base_url))
.send()
.await
{
Ok(resp) => Ok(resp.status().is_success()),
Err(_) => Ok(false),
}
}
pub async fn list_models(&self) -> Result<Vec<String>, Box<dyn Error>> {
let resp = self
.client
.get(format!("{}/api/tags", self.base_url))
.send()
.await?;
let data: OllamaModelsResponse = resp.json().await?;
let models = data.models.iter().map(|m| m.name.clone()).collect();
Ok(models)
}
pub async fn list_models_detailed(&self) -> Result<Vec<OllamaModel>, Box<dyn Error>> {
let resp = self
.client
.get(format!("{}/api/tags", self.base_url))
.send()
.await?;
let data: OllamaModelsResponse = resp.json().await?;
Ok(data.models)
}
#[allow(clippy::too_many_arguments)]
pub async fn generate(
&self,
model: &str,
prompt: &str,
temperature: f32,
top_p: f32,
top_k: usize,
repeat_penalty: f32,
max_tokens: usize,
) -> Result<String, Box<dyn Error>> {
let payload = json!({
"model": model,
"prompt": prompt,
"stream": false,
"options": {
"temperature": temperature.clamp(0.0, 2.0),
"top_p": top_p.clamp(0.0, 1.0),
"top_k": top_k.clamp(1, 200),
"repeat_penalty": repeat_penalty.clamp(0.0, 2.0),
"num_predict": max_tokens,
}
});
let resp = self
.client
.post(format!("{}/api/generate", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama generate failed: HTTP {status} - {body}"
))));
}
let data: OllamaResponse = resp.json().await?;
Ok(data.response)
}
#[allow(clippy::too_many_arguments)]
pub async fn generate_stream(
&self,
model: &str,
prompt: &str,
temperature: f32,
top_p: f32,
top_k: usize,
repeat_penalty: f32,
max_tokens: usize,
) -> Result<OllamaStream, Box<dyn Error>> {
let payload = json!({
"model": model,
"prompt": prompt,
"stream": true,
"options": {
"temperature": temperature.clamp(0.0, 2.0),
"top_p": top_p.clamp(0.0, 1.0),
"top_k": top_k.clamp(1, 200),
"repeat_penalty": repeat_penalty.clamp(0.0, 2.0),
"num_predict": max_tokens,
}
});
let resp = self
.client
.post(format!("{}/api/generate", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama generate failed: HTTP {status} - {body}"
))));
}
let stream = Box::pin(tokio_stream::wrappers::ReceiverStream::new(
Self::stream_response(resp).await?,
));
Ok(stream)
}
async fn stream_response(
resp: reqwest::Response,
) -> Result<mpsc::Receiver<Result<String, Box<dyn Error + Send + Sync>>>, Box<dyn Error>> {
let (tx, rx) = mpsc::channel(100);
tokio::spawn(async move {
use futures::StreamExt;
let mut byte_stream = resp.bytes_stream();
let mut buf = String::new();
while let Some(chunk) = byte_stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Err(Box::new(e) as Box<dyn Error + Send + Sync>))
.await;
return;
}
};
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buf.find('\n') {
let line = buf[..pos].trim().to_string();
buf.drain(..=pos);
if line.is_empty() {
continue;
}
let json_str = line.strip_prefix("data: ").unwrap_or(&line);
if let Ok(data) = serde_json::from_str::<Value>(json_str) {
if let Some(response) = data.get("response").and_then(|v| v.as_str()) {
if tx.send(Ok(response.to_string())).await.is_err() {
return; }
}
if data.get("done").and_then(|v| v.as_bool()).unwrap_or(false) {
return;
}
}
}
}
});
Ok(rx)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub async fn chat(
&self,
model: &str,
messages: &[ChatMessage],
temperature: Option<f32>,
top_p: Option<f32>,
top_k: Option<usize>,
repeat_penalty: Option<f32>,
max_tokens: Option<usize>,
tools: Option<Vec<Value>>,
format: Option<Value>,
) -> Result<ChatResponse, Box<dyn Error>> {
let request = ChatRequest {
model: model.to_string(),
messages: messages.to_vec(),
stream: Some(false),
options: Some(OllamaOptions {
temperature,
top_p,
top_k,
repeat_penalty,
num_predict: max_tokens,
}),
tools,
format,
};
let resp = self
.client
.post(format!("{}/api/chat", self.base_url))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama chat failed: HTTP {status} - {body}"
))));
}
let data: ChatResponse = resp.json().await?;
Ok(data)
}
#[allow(clippy::too_many_arguments)]
pub async fn chat_stream(
&self,
model: &str,
messages: &[ChatMessage],
temperature: Option<f32>,
top_p: Option<f32>,
top_k: Option<usize>,
repeat_penalty: Option<f32>,
max_tokens: Option<usize>,
) -> Result<
Pin<Box<dyn Stream<Item = Result<ChatResponse, Box<dyn Error + Send + Sync>>> + Send>>,
Box<dyn Error>,
> {
let request = ChatRequest {
model: model.to_string(),
messages: messages.to_vec(),
stream: Some(true),
options: Some(OllamaOptions {
temperature,
top_p,
top_k,
repeat_penalty,
num_predict: max_tokens,
}),
tools: None,
format: None,
};
let resp = self
.client
.post(format!("{}/api/chat", self.base_url))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama chat failed: HTTP {status} - {body}"
))));
}
let stream = Box::pin(tokio_stream::wrappers::ReceiverStream::new(
Self::stream_chat_response(resp).await?,
));
Ok(stream)
}
async fn stream_chat_response(
resp: reqwest::Response,
) -> Result<mpsc::Receiver<Result<ChatResponse, Box<dyn Error + Send + Sync>>>, Box<dyn Error>>
{
let (tx, rx) = mpsc::channel(100);
tokio::spawn(async move {
use futures::StreamExt;
let mut byte_stream = resp.bytes_stream();
let mut buf = String::new();
while let Some(chunk) = byte_stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Err(Box::new(e) as Box<dyn Error + Send + Sync>))
.await;
return;
}
};
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buf.find('\n') {
let line = buf[..pos].trim().to_string();
buf.drain(..=pos);
if line.is_empty() {
continue;
}
let json_str = line.strip_prefix("data: ").unwrap_or(&line);
if let Ok(data) = serde_json::from_str::<ChatResponse>(json_str) {
let done = data.done;
if tx.send(Ok(data)).await.is_err() {
return; }
if done {
return;
}
}
}
}
});
Ok(rx)
}
pub async fn pull_model(&self, model_name: &str) -> Result<String, Box<dyn Error>> {
let payload = json!({
"name": model_name,
"stream": false,
});
let resp = self
.client
.post(format!("{}/api/pull", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama pull failed: HTTP {status} - {body}"
))));
}
match resp.text().await {
Ok(text) => Ok(text),
Err(e) => Err(Box::new(e)),
}
}
pub async fn pull_model_stream(
&self,
model_name: &str,
) -> Result<
Pin<
Box<
dyn Stream<Item = Result<PullProgressResponse, Box<dyn Error + Send + Sync>>>
+ Send,
>,
>,
Box<dyn Error>,
> {
let payload = json!({
"name": model_name,
"stream": true,
});
let resp = self
.client
.post(format!("{}/api/pull", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama pull failed: HTTP {status} - {body}"
))));
}
let stream = Box::pin(tokio_stream::wrappers::ReceiverStream::new(
Self::stream_pull_progress(resp).await?,
));
Ok(stream)
}
async fn stream_pull_progress(
resp: reqwest::Response,
) -> Result<
mpsc::Receiver<Result<PullProgressResponse, Box<dyn Error + Send + Sync>>>,
Box<dyn Error>,
> {
let (tx, rx) = mpsc::channel(100);
tokio::spawn(async move {
use futures::StreamExt;
let mut byte_stream = resp.bytes_stream();
let mut buf = String::new();
while let Some(chunk) = byte_stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Err(Box::new(e) as Box<dyn Error + Send + Sync>))
.await;
return;
}
};
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buf.find('\n') {
let line = buf[..pos].trim().to_string();
buf.drain(..=pos);
if line.is_empty() {
continue;
}
if let Ok(data) = serde_json::from_str::<PullProgressResponse>(&line) {
let status = data.status.clone();
if tx.send(Ok(data)).await.is_err() {
return; }
if status == "success" || status == "error" {
return;
}
}
}
}
});
Ok(rx)
}
pub async fn show_model(&self, model_name: &str) -> Result<ModelInfoResponse, Box<dyn Error>> {
let payload = json!({
"name": model_name,
});
let resp = self
.client
.post(format!("{}/api/show", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama show failed: HTTP {status} - {body}"
))));
}
let body = resp.text().await?;
match serde_json::from_str::<ModelInfoResponse>(&body) {
Ok(data) => Ok(data),
Err(primary) => {
if serde_json::from_str::<Value>(&body).is_ok() {
Ok(ModelInfoResponse {
license: String::new(),
modelfile: String::new(),
parameters: String::new(),
template: String::new(),
details: None,
})
} else {
Err(Box::new(io::Error::other(format!(
"Ollama show decode failed: {primary}; body={}",
body.chars().take(200).collect::<String>()
))))
}
}
}
}
pub async fn unload_model(&self, model_name: &str) -> Result<String, Box<dyn Error>> {
let running_models = self.list_running().await?;
if !running_models
.iter()
.any(|model| Self::matches_model_name(&model.name, model_name))
{
return Ok(format!(
"model '{model_name}' is not running; unload skipped"
));
}
let payload = json!({
"model": model_name,
"prompt": "",
"stream": false,
"keep_alive": 0,
});
let resp = self
.client
.post(format!("{}/api/generate", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama unload failed: HTTP {status} - {body}"
))));
}
let body = resp.text().await.unwrap_or_default();
Ok(body)
}
pub async fn create_model(
&self,
name: &str,
modelfile: &str,
) -> Result<String, Box<dyn Error>> {
let payload = json!({
"name": name,
"modelfile": modelfile,
"stream": false,
});
let resp = self
.client
.post(format!("{}/api/create", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama create failed: HTTP {status} - {body}"
))));
}
match resp.text().await {
Ok(text) => Ok(text),
Err(e) => Err(Box::new(e)),
}
}
pub async fn copy_model(
&self,
source: &str,
destination: &str,
) -> Result<String, Box<dyn Error>> {
let payload = json!({
"source": source,
"destination": destination,
});
let resp = self
.client
.post(format!("{}/api/copy", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama copy failed: HTTP {status} - {body}"
))));
}
match resp.text().await {
Ok(text) => Ok(text),
Err(e) => Err(Box::new(e)),
}
}
pub async fn delete_model(&self, name: &str) -> Result<String, Box<dyn Error>> {
let payload = json!({
"name": name,
});
let resp = self
.client
.delete(format!("{}/api/delete", self.base_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(Box::new(io::Error::other(format!(
"Ollama delete failed: HTTP {status} - {body}"
))));
}
match resp.text().await {
Ok(text) => Ok(text),
Err(e) => Err(Box::new(e)),
}
}
pub async fn list_running(&self) -> Result<Vec<OllamaModel>, Box<dyn Error>> {
let resp = self
.client
.get(format!("{}/api/ps", self.base_url))
.send()
.await?;
let data: OllamaModelsResponse = resp.json().await?;
Ok(data.models)
}
pub async fn embeddings(&self, model: &str, prompt: &str) -> Result<Vec<f32>, Box<dyn Error>> {
let payload = json!({
"model": model,
"prompt": prompt,
});
let resp = self
.client
.post(format!("{}/api/embeddings", self.base_url))
.json(&payload)
.send()
.await?;
let data: EmbeddingResponse = resp.json().await?;
Ok(data.embedding)
}
pub async fn version(&self) -> Result<String, Box<dyn Error>> {
let resp = self
.client
.get(format!("{}/api/version", self.base_url))
.send()
.await?;
let data: Value = resp.json().await?;
Ok(data["version"].as_str().unwrap_or("unknown").to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_ollama_client_creation() {
let client = OllamaClient::new("http://localhost:11434".to_string());
assert_eq!(client.base_url, "http://localhost:11434");
}
#[test]
fn chat_request_nests_sampling_params_under_options() {
fn assert_json_number_close(value: &Value, expected: f64, label: &str) {
let actual = value
.as_f64()
.unwrap_or_else(|| panic!("{label} must be a JSON number"));
let delta = (actual - expected).abs();
assert!(
delta <= 1e-6,
"{label} expected {expected} but was {actual} (|delta|={delta})"
);
}
let request = ChatRequest {
model: "llama3".to_string(),
messages: vec![],
stream: Some(false),
options: Some(OllamaOptions {
temperature: Some(0.5),
top_p: Some(0.8),
top_k: Some(30),
repeat_penalty: Some(1.2),
num_predict: Some(256),
}),
tools: None,
format: None,
};
let value = serde_json::to_value(&request).expect("serialize ChatRequest");
assert!(
value.get("temperature").is_none(),
"temperature must not be top-level"
);
assert!(value.get("top_p").is_none(), "top_p must not be top-level");
let options = value
.get("options")
.expect("options object must be present");
assert_json_number_close(&options["temperature"], 0.5, "temperature");
assert_json_number_close(&options["top_p"], 0.8, "top_p");
assert_eq!(options["top_k"], 30);
assert_json_number_close(&options["repeat_penalty"], 1.2, "repeat_penalty");
assert_eq!(options["num_predict"], 256);
}
}