1use bytes::Bytes;
10use futures::{Stream, StreamExt};
11use reqwest::Client;
12use serde::Deserialize;
13use serde_json::Value as JsonValue;
14use std::pin::Pin;
15use std::sync::Arc;
16
17use super::shared_client;
18use super::sse::split_complete_lines;
19use crate::{
20 Api, AssistantMessage, ContentBlock, Context, Message, MessageContent, Model, Provider,
21 ProviderEvent, StopReason, StreamOptions, StreamResult, TextContent, ThinkingContent, ToolCall,
22 Usage, error::ProviderError,
23};
24
25const DEFAULT_BASE_URL: &str = "http://localhost:11434";
27
28#[derive(Clone)]
30pub struct OllamaProvider {
31 client: &'static Client,
32 base_url: String,
33 api_key: Option<String>,
35}
36
37impl OllamaProvider {
38 pub fn new() -> Self {
40 Self {
41 client: shared_client(),
42 base_url: DEFAULT_BASE_URL.to_string(),
43 api_key: None,
44 }
45 }
46
47 pub fn with_base_url(base_url: &str) -> Self {
49 Self {
50 client: shared_client(),
51 base_url: normalize_base_url(base_url),
52 api_key: None,
53 }
54 }
55
56 pub fn with_config(base_url: &str, api_key: Option<String>) -> Self {
58 Self {
59 client: shared_client(),
60 base_url: normalize_base_url(base_url),
61 api_key,
62 }
63 }
64}
65
66impl Default for OllamaProvider {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72fn normalize_base_url(url: &str) -> String {
74 let url = url.trim_end_matches('/');
75 url.strip_suffix("/api").unwrap_or(url).to_string()
76}
77
78#[derive(Debug, Deserialize)]
84struct OllamaChunk {
85 #[serde(default)]
86 message: Option<OllamaMessage>,
87 #[serde(default)]
88 done: bool,
89 #[serde(default)]
90 done_reason: Option<String>,
91 #[serde(default)]
92 prompt_eval_count: Option<usize>,
93 #[serde(default)]
94 eval_count: Option<usize>,
95 #[serde(default)]
97 error: Option<String>,
98}
99
100#[derive(Debug, Deserialize)]
101struct OllamaMessage {
102 #[serde(default)]
103 content: Option<String>,
104 #[serde(default)]
105 thinking: Option<String>,
106 #[serde(default)]
107 tool_calls: Option<Vec<OllamaToolCall>>,
108}
109
110#[derive(Debug, Deserialize)]
111struct OllamaToolCall {
112 function: OllamaFunction,
113}
114
115#[derive(Debug, Deserialize)]
116struct OllamaFunction {
117 name: String,
118 arguments: Option<JsonValue>,
120}
121
122fn build_request_body(
128 model: &Model,
129 context: &Context,
130 options: &Option<StreamOptions>,
131) -> JsonValue {
132 let mut messages = Vec::new();
133
134 if let Some(prompt) = &context.system_prompt {
136 messages.push(serde_json::json!({
137 "role": "system",
138 "content": prompt,
139 }));
140 }
141
142 for msg in &context.messages {
144 match msg {
145 Message::User(user_msg) => {
146 let text = match &user_msg.content {
147 MessageContent::Text(s) => s.clone(),
148 MessageContent::Blocks(blocks) => blocks
149 .iter()
150 .filter_map(|b| b.as_text())
151 .collect::<Vec<_>>()
152 .join("\n"),
153 };
154 messages.push(serde_json::json!({
155 "role": "user",
156 "content": text,
157 }));
158 }
159 Message::Assistant(asst_msg) => {
160 let text = asst_msg.text_content();
161 let tool_calls: Vec<JsonValue> = asst_msg
162 .content
163 .iter()
164 .filter_map(|b| {
165 if let ContentBlock::ToolCall(tc) = b {
166 Some(serde_json::json!({
167 "type": "function",
168 "function": {
169 "name": tc.name,
170 "arguments": tc.arguments,
171 }
172 }))
173 } else {
174 None
175 }
176 })
177 .collect();
178
179 let mut obj = serde_json::json!({
180 "role": "assistant",
181 "content": text,
182 });
183 if !tool_calls.is_empty() {
184 obj["tool_calls"] = JsonValue::Array(tool_calls);
185 }
186 messages.push(obj);
187 }
188 Message::ToolResult(tr) => {
189 let text: String = tr
190 .content
191 .iter()
192 .filter_map(|b| b.as_text())
193 .collect::<Vec<_>>()
194 .join("\n");
195 messages.push(serde_json::json!({
196 "role": "tool",
197 "content": text,
198 }));
199 }
200 }
201 }
202
203 let mut body = serde_json::json!({
204 "model": model.id,
205 "messages": messages,
206 "stream": true,
207 });
208
209 if !context.tools.is_empty() {
211 let tools: Vec<JsonValue> = context
212 .tools
213 .iter()
214 .map(|t| {
215 serde_json::json!({
216 "type": "function",
217 "function": {
218 "name": t.name,
219 "description": t.description,
220 "parameters": sanitize_schema_for_ollama(&t.parameters),
221 }
222 })
223 })
224 .collect();
225 body["tools"] = JsonValue::Array(tools);
226 }
227
228 if let Some(opts) = options {
230 if let Some(level) = opts.thinking_level
231 && let Some(s) = level.as_str()
232 {
233 body["think"] = JsonValue::String(s.to_string());
234 }
235 if let Some(max_tokens) = opts.max_tokens {
236 body["options"] = serde_json::json!({ "num_predict": max_tokens });
237 }
238 }
239
240 body
241}
242
243fn sanitize_schema_for_ollama(schema: &JsonValue) -> JsonValue {
248 match schema {
249 JsonValue::Bool(true) => serde_json::json!({}),
250 JsonValue::Bool(false) => serde_json::json!({"not": {}}),
251 JsonValue::Object(map) => {
252 let mut out = serde_json::Map::new();
253 for (key, value) in map {
254 match key.as_str() {
255 "additionalProperties" if value.is_boolean() => {
256 if value.as_bool() == Some(false) {
258 out.insert(key.clone(), JsonValue::Bool(false));
259 }
260 }
262 _ => {
263 out.insert(key.clone(), sanitize_schema_for_ollama(value));
264 }
265 }
266 }
267 JsonValue::Object(out)
268 }
269 JsonValue::Array(arr) => {
270 JsonValue::Array(arr.iter().map(sanitize_schema_for_ollama).collect())
271 }
272 other => other.clone(),
273 }
274}
275
276impl Provider for OllamaProvider {
281 fn stream<'a>(
282 &'a self,
283 model: &'a Model,
284 context: &'a Context,
285 options: Option<StreamOptions>,
286 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
287 Box::pin(async move {
288 let url = format!("{}/api/chat", self.base_url);
289 let body = build_request_body(model, context, &options);
290
291 let mut req = self.client.post(&url).json(&body);
292 if let Some(key) = &self.api_key {
293 req = req.bearer_auth(key);
294 }
295 let response = req.send().await?;
296
297 let status = response.status();
298 if !status.is_success() {
299 let body_text = response.text().await.unwrap_or_default();
300 return Err(ProviderError::HttpError(crate::HttpErrorDetail {
301 status: status.as_u16(),
302 body: body_text,
303 provider: Some("ollama".to_string()),
304 request_id: None,
305 }));
306 }
307
308 let byte_stream = response.bytes_stream();
309 let model_id = model.id.clone();
310 let provider_name = "ollama".to_string();
311
312 let partial =
314 AssistantMessage::new(Api::OllamaChat, provider_name.clone(), model_id.clone());
315 let trailing_bytes: Vec<u8> = Vec::new();
316
317 let start_event = ProviderEvent::Start {
319 partial: Arc::new(AssistantMessage::new(Api::OllamaChat, "ollama", &model_id)),
320 };
321
322 let events = byte_stream.scan(
323 (partial, trailing_bytes, false, false, 0usize),
324 move |(partial, trailing, thinking_started, text_started, tc_counter),
325 chunk_result| {
326 let chunk: Bytes = match chunk_result {
327 Ok(c) => c,
328 Err(e) => {
329 let mut err_msg = AssistantMessage::new(
330 Api::OllamaChat,
331 provider_name.clone(),
332 model_id.clone(),
333 );
334 err_msg.error_message = Some(format!("Stream error: {e}"));
335 err_msg.stop_reason = StopReason::Error;
336 return futures::future::ready(Some(vec![ProviderEvent::Error {
337 reason: StopReason::Error,
338 error: err_msg,
339 }]));
340 }
341 };
342
343 let mut buf = std::mem::take(trailing);
345 buf.extend_from_slice(&chunk);
346
347 let (complete, new_trailing) = split_complete_lines(&buf);
348 *trailing = new_trailing;
349
350 let mut events: Vec<ProviderEvent> = Vec::new();
351
352 for line in complete.lines() {
353 let line = line.trim();
354 if line.is_empty() {
355 continue;
356 }
357
358 let parsed: OllamaChunk = match serde_json::from_str(line) {
359 Ok(c) => c,
360 Err(_) => continue,
361 };
362
363 if let Some(err) = &parsed.error {
365 let mut err_msg = AssistantMessage::new(
366 Api::OllamaChat,
367 provider_name.clone(),
368 model_id.clone(),
369 );
370 err_msg.error_message = Some(err.clone());
371 err_msg.stop_reason = StopReason::Error;
372 events.push(ProviderEvent::Error {
373 reason: StopReason::Error,
374 error: err_msg,
375 });
376 return futures::future::ready(Some(events));
377 }
378
379 if let Some(msg) = &parsed.message {
380 if let Some(thinking) = &msg.thinking
382 && !thinking.is_empty()
383 {
384 let idx = if !*thinking_started {
385 *thinking_started = true;
386 partial
387 .content
388 .push(ContentBlock::Thinking(ThinkingContent::new("")));
389 let idx = partial.content.len() - 1;
390 events.push(ProviderEvent::ThinkingStart {
391 content_index: idx,
392 partial: Arc::new(partial.clone()),
393 });
394 idx
395 } else {
396 partial.content.len() - 1
397 };
398 if let Some(ContentBlock::Thinking(t)) = partial.content.last_mut()
399 {
400 t.thinking.push_str(thinking);
401 }
402 events.push(ProviderEvent::ThinkingDelta {
403 content_index: idx,
404 delta: thinking.clone(),
405 partial: Arc::new(partial.clone()),
406 });
407 }
408
409 if let Some(content) = &msg.content
411 && !content.is_empty()
412 {
413 let idx = if !*text_started {
414 *text_started = true;
415 partial
416 .content
417 .push(ContentBlock::Text(TextContent::new("")));
418 let idx = partial.content.len() - 1;
419 events.push(ProviderEvent::TextStart {
420 content_index: idx,
421 partial: Arc::new(partial.clone()),
422 });
423 idx
424 } else {
425 partial.content.len() - 1
426 };
427 if let Some(ContentBlock::Text(t)) = partial.content.last_mut() {
428 t.text.push_str(content);
429 }
430 events.push(ProviderEvent::TextDelta {
431 content_index: idx,
432 delta: content.clone(),
433 partial: Arc::new(partial.clone()),
434 });
435 }
436
437 if let Some(tool_calls) = &msg.tool_calls {
439 for tc in tool_calls {
440 let id = format!("ollama_tc_{}", *tc_counter);
441 *tc_counter += 1;
442
443 let arguments = tc
444 .function
445 .arguments
446 .clone()
447 .unwrap_or(JsonValue::Object(Default::default()));
448
449 let tool_call =
450 ToolCall::new(&id, &tc.function.name, arguments);
451 partial
452 .content
453 .push(ContentBlock::ToolCall(tool_call.clone()));
454 let idx = partial.content.len() - 1;
455
456 events.push(ProviderEvent::ToolCallStart {
457 content_index: idx,
458 tool_call_id: Some(id),
459 tool_name: Some(tc.function.name.clone()),
460 partial: Arc::new(partial.clone()),
461 });
462 events.push(ProviderEvent::ToolCallEnd {
463 content_index: idx,
464 tool_call,
465 partial: Arc::new(partial.clone()),
466 });
467 }
468 }
469 }
470
471 if parsed.done {
473 if *thinking_started {
475 let idx = partial.content.len() - 1;
476 let content = partial
477 .content
478 .iter()
479 .find_map(|b| {
480 if let ContentBlock::Thinking(t) = b {
481 Some(t.thinking.clone())
482 } else {
483 None
484 }
485 })
486 .unwrap_or_default();
487 events.push(ProviderEvent::ThinkingEnd {
488 content_index: idx,
489 content,
490 partial: Arc::new(partial.clone()),
491 });
492 }
493 if *text_started {
495 let idx = partial.content.len() - 1;
496 let content = partial.text_content();
497 events.push(ProviderEvent::TextEnd {
498 content_index: idx,
499 content,
500 partial: Arc::new(partial.clone()),
501 });
502 }
503
504 partial.usage = Usage {
506 input: parsed.prompt_eval_count.unwrap_or(0),
507 output: parsed.eval_count.unwrap_or(0),
508 ..Default::default()
509 };
510
511 partial.stop_reason = match parsed.done_reason.as_deref() {
513 Some("length") => StopReason::Length,
514 Some("tool_calls") => StopReason::ToolUse,
515 _ => {
516 if *tc_counter > 0 {
517 StopReason::ToolUse
518 } else {
519 StopReason::Stop
520 }
521 }
522 };
523
524 events.push(ProviderEvent::Done {
525 reason: partial.stop_reason,
526 message: partial.clone(),
527 });
528 return futures::future::ready(Some(events));
529 }
530 }
531
532 if events.is_empty() {
533 futures::future::ready(None)
534 } else {
535 futures::future::ready(Some(events))
536 }
537 },
538 );
539
540 let stream = futures::stream::once(async { start_event })
543 .chain(events.flat_map(futures::stream::iter));
544
545 Ok(Box::pin(stream) as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
546 })
547 }
548}
549
550#[cfg(test)]
555mod tests {
556 use super::*;
557
558 #[test]
559 fn test_normalize_base_url() {
560 assert_eq!(
561 normalize_base_url("http://localhost:11434"),
562 "http://localhost:11434"
563 );
564 assert_eq!(
565 normalize_base_url("http://localhost:11434/"),
566 "http://localhost:11434"
567 );
568 assert_eq!(
569 normalize_base_url("http://localhost:11434/api"),
570 "http://localhost:11434"
571 );
572 assert_eq!(
573 normalize_base_url("http://localhost:11434/api/"),
574 "http://localhost:11434"
575 );
576 }
577
578 #[test]
579 fn test_sanitize_schema_boolean_subschema() {
580 let schema = serde_json::json!({
581 "type": "object",
582 "properties": {
583 "name": { "type": "string" },
584 "extra": true,
585 },
586 "additionalProperties": true,
587 });
588 let result = sanitize_schema_for_ollama(&schema);
589 assert_eq!(result["properties"]["extra"], serde_json::json!({}));
591 assert!(result.get("additionalProperties").is_none());
593 }
594
595 #[test]
596 fn test_sanitize_schema_false_additional_properties() {
597 let schema = serde_json::json!({
598 "type": "object",
599 "additionalProperties": false,
600 });
601 let result = sanitize_schema_for_ollama(&schema);
602 assert_eq!(result["additionalProperties"], serde_json::json!(false));
603 }
604
605 #[test]
606 fn test_build_request_body_basic() {
607 let model = Model::new("llama3", "Llama 3", Api::OllamaChat, "ollama", "");
608 let mut ctx = Context::new();
609 ctx.system_prompt = Some("You are helpful.".to_string());
610
611 let body = build_request_body(&model, &ctx, &None);
612 assert_eq!(body["model"], "llama3");
613 assert_eq!(body["stream"], true);
614 assert_eq!(body["messages"][0]["role"], "system");
615 assert_eq!(body["messages"][0]["content"], "You are helpful.");
616 }
617
618 #[test]
619 fn test_ollama_chunk_deserialization() {
620 let json = r#"{"message":{"role":"assistant","content":"Hello"},"done":false}"#;
621 let chunk: OllamaChunk = serde_json::from_str(json).unwrap();
622 assert!(!chunk.done);
623 assert_eq!(chunk.message.unwrap().content.unwrap(), "Hello");
624 }
625
626 #[test]
627 fn test_ollama_chunk_done() {
628 let json = r#"{"message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","prompt_eval_count":10,"eval_count":20}"#;
629 let chunk: OllamaChunk = serde_json::from_str(json).unwrap();
630 assert!(chunk.done);
631 assert_eq!(chunk.done_reason.unwrap(), "stop");
632 assert_eq!(chunk.prompt_eval_count.unwrap(), 10);
633 assert_eq!(chunk.eval_count.unwrap(), 20);
634 }
635
636 #[test]
637 fn test_ollama_chunk_tool_calls() {
638 let json = r#"{"message":{"role":"assistant","content":"","tool_calls":[{"type":"function","function":{"name":"read_file","arguments":{"path":"src/main.rs"}}}]},"done":false}"#;
639 let chunk: OllamaChunk = serde_json::from_str(json).unwrap();
640 let msg = chunk.message.unwrap();
641 let tcs = msg.tool_calls.unwrap();
642 assert_eq!(tcs.len(), 1);
643 assert_eq!(tcs[0].function.name, "read_file");
644 assert_eq!(
645 tcs[0].function.arguments.as_ref().unwrap()["path"],
646 "src/main.rs"
647 );
648 }
649
650 #[test]
651 fn test_ollama_chunk_thinking() {
652 let json = r#"{"message":{"role":"assistant","content":"","thinking":"Let me think..."},"done":false}"#;
653 let chunk: OllamaChunk = serde_json::from_str(json).unwrap();
654 let msg = chunk.message.unwrap();
655 assert_eq!(msg.thinking.unwrap(), "Let me think...");
656 }
657
658 #[test]
659 fn test_ollama_chunk_error() {
660 let json = r#"{"error":"model 'foo' not found"}"#;
661 let chunk: OllamaChunk = serde_json::from_str(json).unwrap();
662 assert_eq!(chunk.error.unwrap(), "model 'foo' not found");
663 }
664}