Skip to main content

oxicode_ai/providers/
ollama.rs

1//! Ollama provider — local LLM server via NDJSON streaming.
2//!
3//! Implements the `Provider` trait for Ollama's `/api/chat` endpoint.
4//! Ollama streams newline-delimited JSON (NDJSON), not SSE. Tool calls
5//! arrive as complete objects per chunk (no delta accumulation needed).
6//!
7//! Port of omp `packages/ai/src/providers/ollama.ts` (minimal working subset).
8
9use 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
25/// Default Ollama server URL.
26const DEFAULT_BASE_URL: &str = "http://localhost:11434";
27
28/// Ollama provider for local LLM inference.
29#[derive(Clone)]
30pub struct OllamaProvider {
31    client: &'static Client,
32    base_url: String,
33    /// Optional API key for Ollama Cloud (Bearer token).
34    api_key: Option<String>,
35}
36
37impl OllamaProvider {
38    /// Create a new Ollama provider pointing at the default local server.
39    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    /// Create with a custom base URL (e.g. remote Ollama instance).
48    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    /// Create with a custom base URL and optional API key (Ollama Cloud).
57    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
72/// Strip trailing `/api` if present (users often paste the full endpoint).
73fn 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// ---------------------------------------------------------------------------
79// Wire types
80// ---------------------------------------------------------------------------
81
82/// A single NDJSON chunk from Ollama's `/api/chat`.
83#[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    /// Error field present on error responses.
96    #[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 can be an object or a JSON string.
119    arguments: Option<JsonValue>,
120}
121
122// ---------------------------------------------------------------------------
123// Request construction
124// ---------------------------------------------------------------------------
125
126/// Build the JSON request body for `/api/chat`.
127fn build_request_body(
128    model: &Model,
129    context: &Context,
130    options: &Option<StreamOptions>,
131) -> JsonValue {
132    let mut messages = Vec::new();
133
134    // System prompt as a system message.
135    if let Some(prompt) = &context.system_prompt {
136        messages.push(serde_json::json!({
137            "role": "system",
138            "content": prompt,
139        }));
140    }
141
142    // Convert conversation messages.
143    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    // Tools.
210    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    // Thinking level → Ollama `think` parameter.
229    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
243/// Sanitize JSON Schema for Ollama's llama.cpp grammar backend.
244///
245/// llama.cpp rejects boolean subschemas (`true`/`false`) and boolean
246/// `additionalProperties`. This recursively normalizes them.
247fn 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                        // Drop boolean additionalProperties entirely.
257                        if value.as_bool() == Some(false) {
258                            out.insert(key.clone(), JsonValue::Bool(false));
259                        }
260                        // `true` → omit (permissive is the default).
261                    }
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
276// ---------------------------------------------------------------------------
277// Provider trait
278// ---------------------------------------------------------------------------
279
280impl 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            // Accumulator state for the NDJSON stream.
313            let partial =
314                AssistantMessage::new(Api::OllamaChat, provider_name.clone(), model_id.clone());
315            let trailing_bytes: Vec<u8> = Vec::new();
316
317            // Prepend Start event before the scan closure captures model_id.
318            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                    // Prepend trailing bytes from previous chunk.
344                    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                        // Error response from Ollama.
364                        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                            // Thinking delta.
381                            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                            // Text content delta.
410                            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                            // Tool calls (complete objects, not deltas).
438                            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                        // Terminal chunk.
472                        if parsed.done {
473                            // Close open thinking block.
474                            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                            // Close open text block.
494                            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                            // Usage.
505                            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                            // Stop reason.
512                            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            // Flatten Vec<ProviderEvent> into individual events, prepend Start.
541
542            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// ---------------------------------------------------------------------------
551// Tests
552// ---------------------------------------------------------------------------
553
554#[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        // `true` in properties → `{}`
590        assert_eq!(result["properties"]["extra"], serde_json::json!({}));
591        // `additionalProperties: true` → removed
592        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}