//! Ollama-shaped chat client. //! //! v0.1.0 targets the Ollama `/api/chat` endpoint. OpenAI- and //! Anthropic-compatible adapters are deliberately deferred — each //! has its own request/response shape that warrants its own crate //! (or at least its own module here) once a flow needs it. //! //! All HTTP I/O lives behind a `LlmClient` trait so unit tests can //! exercise prompt building, response parsing, and the digest //! probe on the host without making real network calls. use serde::Serialize; #[allow(dead_code)] #[derive(Debug, thiserror::Error)] pub enum LlmError { #[error("missing required input '{0}'")] MissingInput(&'static str), #[error("http error: {0}")] Http(String), #[error("non-success status: {0}")] Status(u16), #[error("response body could not be parsed as Ollama schema: {0}")] Decode(String), } pub trait LlmClient { fn post_json(&self, url: &str, body: &str, api_key: &str) -> Result; } #[derive(Debug, Clone)] pub struct ChatParams<'a> { pub endpoint: &'a str, pub model: &'a str, pub api_key: &'a str, pub system_prompt: &'a str, pub prompt: &'a str, } #[derive(Serialize)] struct OllamaMessage<'a> { role: &'a str, content: &'a str, } #[derive(Serialize)] struct OllamaRequest<'a> { model: &'a str, messages: Vec>, stream: bool, } /// Build the request body for an Ollama `/api/chat` invocation. /// The system prompt is omitted from the messages list when empty /// so a deployment can use the model's built-in system prompt. pub fn build_ollama_body(p: &ChatParams) -> String { let mut messages = Vec::with_capacity(2); if !p.system_prompt.is_empty() { messages.push(OllamaMessage { role: "system", content: p.system_prompt, }); } messages.push(OllamaMessage { role: "user", content: p.prompt, }); let req = OllamaRequest { model: p.model, messages, stream: false, }; serde_json::to_string(&req).unwrap_or_else(|_| String::from("{}")) } /// Extract the assistant's message text from an Ollama /// /api/chat response. pub fn extract_ollama_content(body: &str) -> Result { let v: serde_json::Value = serde_json::from_str(body).map_err(|e| LlmError::Decode(e.to_string()))?; v.get("message") .and_then(|m| m.get("content")) .and_then(|c| c.as_str()) .map(|s| s.to_string()) .ok_or_else(|| LlmError::Decode("missing message.content".into())) } #[derive(Debug, Clone)] pub struct ChatWithIdentity { pub response: String, pub model_digest: Option, } /// Run an Ollama chat call AND probe the model digest. Probe is /// best-effort — non-Ollama endpoints (no `/api/chat` suffix) and /// transient failures yield `model_digest = None` rather than /// failing the whole call. pub fn chat_with_identity( client: &C, p: &ChatParams, ) -> Result { if p.endpoint.is_empty() { return Err(LlmError::MissingInput("endpoint")); } if p.model.is_empty() { return Err(LlmError::MissingInput("model")); } if p.prompt.is_empty() { return Err(LlmError::MissingInput("prompt")); } let body = build_ollama_body(p); let response_body = client.post_json(p.endpoint, &body, p.api_key)?; let response = extract_ollama_content(&response_body)?; let model_digest = probe_model_digest(client, p); Ok(ChatWithIdentity { response, model_digest, }) } fn probe_model_digest(client: &C, p: &ChatParams) -> Option { let show_url = derive_show_url(p.endpoint)?; let body = serde_json::to_string(&serde_json::json!({ "name": p.model })).ok()?; let response = client.post_json(&show_url, &body, p.api_key).ok()?; extract_show_digest(&response) } pub fn derive_show_url(chat_endpoint: &str) -> Option { if chat_endpoint.ends_with("/api/chat") { let head_len = chat_endpoint.len() - "/api/chat".len(); let mut url = String::with_capacity(head_len + "/api/show".len()); url.push_str(&chat_endpoint[..head_len]); url.push_str("/api/show"); Some(url) } else { None } } pub fn extract_show_digest(body: &str) -> Option { let v: serde_json::Value = serde_json::from_str(body).ok()?; let candidates = [ v.get("digest"), v.get("details").and_then(|d| d.get("digest")), v.get("model_info").and_then(|d| d.get("digest")), ]; for cand in candidates { if let Some(s) = cand.and_then(|v| v.as_str()) { if !s.is_empty() { return Some(s.to_string()); } } } None } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; use std::cell::RefCell; struct MockClient { responses: RefCell>>, } impl MockClient { fn new(responses: Vec>) -> Self { Self { responses: RefCell::new(responses), } } } impl LlmClient for MockClient { fn post_json(&self, _url: &str, _body: &str, _api_key: &str) -> Result { self.responses .borrow_mut() .pop() .unwrap_or(Err(LlmError::Http("no more mock responses".into()))) } } #[test] fn ollama_body_includes_system_when_provided() { let p = ChatParams { endpoint: "http://x/api/chat", model: "qwen", api_key: "", system_prompt: "be helpful", prompt: "hello", }; let body = build_ollama_body(&p); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); let messages = v["messages"].as_array().unwrap(); assert_eq!(messages.len(), 2); assert_eq!(messages[0]["role"], "system"); assert_eq!(messages[0]["content"], "be helpful"); assert_eq!(messages[1]["role"], "user"); assert_eq!(messages[1]["content"], "hello"); } #[test] fn ollama_body_omits_system_when_empty() { let p = ChatParams { endpoint: "http://x/api/chat", model: "qwen", api_key: "", system_prompt: "", prompt: "hello", }; let body = build_ollama_body(&p); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); let messages = v["messages"].as_array().unwrap(); assert_eq!(messages.len(), 1); assert_eq!(messages[0]["role"], "user"); } #[test] fn extract_content_pulls_message_content() { let body = r#"{"message":{"role":"assistant","content":"hi there"},"done":true}"#; assert_eq!(extract_ollama_content(body).unwrap(), "hi there"); } #[test] fn extract_content_errors_on_missing_field() { let body = r#"{"done":true}"#; assert!(matches!( extract_ollama_content(body), Err(LlmError::Decode(_)) )); } #[test] fn derive_show_url_swaps_chat_suffix() { assert_eq!( derive_show_url("http://localhost:11434/api/chat").as_deref(), Some("http://localhost:11434/api/show") ); } #[test] fn derive_show_url_returns_none_for_non_ollama() { assert!(derive_show_url("https://api.openai.com/v1/chat/completions").is_none()); } #[test] fn extract_show_digest_finds_top_level() { let body = r#"{"digest":"sha256:abc"}"#; assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:abc")); } #[test] fn extract_show_digest_falls_back_to_nested() { let body = r#"{"details":{"digest":"sha256:nested"}}"#; assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:nested")); } #[test] fn chat_with_identity_records_digest_when_show_responds() { let canned_chat = r#"{"message":{"content":"the response"},"done":true}"#.to_string(); let canned_show = r#"{"digest":"sha256:deadbeef"}"#.to_string(); let client = MockClient::new(vec![Ok(canned_show), Ok(canned_chat)]); let p = ChatParams { endpoint: "http://localhost:11434/api/chat", model: "qwen", api_key: "", system_prompt: "", prompt: "hi", }; let result = chat_with_identity(&client, &p).unwrap(); assert_eq!(result.response, "the response"); assert_eq!(result.model_digest.as_deref(), Some("sha256:deadbeef")); } #[test] fn chat_with_identity_swallows_show_failure() { let canned_chat = r#"{"message":{"content":"ok"}}"#.to_string(); let client = MockClient::new(vec![Err(LlmError::Status(500)), Ok(canned_chat)]); let p = ChatParams { endpoint: "http://localhost:11434/api/chat", model: "qwen", api_key: "", system_prompt: "", prompt: "hi", }; let result = chat_with_identity(&client, &p).unwrap(); assert_eq!(result.response, "ok"); assert_eq!(result.model_digest, None); } #[test] fn chat_with_identity_skips_probe_for_non_ollama() { let canned_chat = r#"{"message":{"content":"ok"}}"#.to_string(); let client = MockClient::new(vec![Ok(canned_chat)]); let p = ChatParams { endpoint: "https://api.openai.com/v1/chat/completions", model: "gpt", api_key: "k", system_prompt: "", prompt: "hi", }; let result = chat_with_identity(&client, &p).unwrap(); assert_eq!(result.response, "ok"); assert_eq!(result.model_digest, None); } #[test] fn empty_prompt_is_rejected() { let client = MockClient::new(vec![]); let p = ChatParams { endpoint: "http://x/api/chat", model: "x", api_key: "", system_prompt: "", prompt: "", }; assert!(matches!( chat_with_identity(&client, &p), Err(LlmError::MissingInput("prompt")) )); } }