Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:47:59.020Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

ollama.rs

838 lines · 29.3 KB · rust
1use anyhow::{Context, Result};
2
3use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
4use http_client::{
5    AsyncBody, CustomHeaders, HttpClient, HttpRequestExt, Method, Request as HttpRequest,
6    RequestBuilderExt,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10pub use settings::KeepAlive;
11
12pub const OLLAMA_API_URL: &str = "http://localhost:11434";
13
14#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
15#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
16pub struct Model {
17    pub name: String,
18    pub display_name: Option<String>,
19    pub max_tokens: u64,
20    pub keep_alive: Option<KeepAlive>,
21    pub supports_tools: Option<bool>,
22    pub supports_vision: Option<bool>,
23    pub supports_thinking: Option<bool>,
24    pub disabled: Option<String>,
25}
26
27fn get_max_tokens(_name: &str) -> u64 {
28    const DEFAULT_TOKENS: u64 = 4096;
29    DEFAULT_TOKENS
30}
31
32impl Model {
33    pub fn new(
34        name: &str,
35        max_tokens: Option<u64>,
36        supports_tools: Option<bool>,
37        supports_vision: Option<bool>,
38        supports_thinking: Option<bool>,
39    ) -> Self {
40        Self {
41            name: name.to_owned(),
42            display_name: name.strip_suffix(":latest").map(ToString::to_string),
43            max_tokens: max_tokens.unwrap_or_else(|| get_max_tokens(name)),
44            keep_alive: Some(KeepAlive::indefinite()),
45            supports_tools,
46            supports_vision,
47            supports_thinking,
48            disabled: None,
49        }
50    }
51
52    pub fn new_disabled(name: &str, reason: String) -> Self {
53        Self {
54            name: name.to_owned(),
55            display_name: name.strip_suffix(":latest").map(ToString::to_string),
56            max_tokens: get_max_tokens(name),
57            keep_alive: Some(KeepAlive::indefinite()),
58            supports_tools: None,
59            supports_vision: None,
60            supports_thinking: None,
61            disabled: Some(reason),
62        }
63    }
64
65    pub fn id(&self) -> &str {
66        &self.name
67    }
68
69    pub fn display_name(&self) -> &str {
70        self.display_name.as_ref().unwrap_or(&self.name)
71    }
72
73    pub fn max_token_count(&self) -> u64 {
74        self.max_tokens
75    }
76}
77
78#[derive(Serialize, Deserialize, Debug)]
79#[serde(tag = "role", rename_all = "lowercase")]
80pub enum ChatMessage {
81    Assistant {
82        content: String,
83        tool_calls: Option<Vec<OllamaToolCall>>,
84        #[serde(skip_serializing_if = "Option::is_none")]
85        images: Option<Vec<String>>,
86        thinking: Option<String>,
87    },
88    User {
89        content: String,
90        #[serde(skip_serializing_if = "Option::is_none")]
91        images: Option<Vec<String>>,
92    },
93    System {
94        content: String,
95    },
96    Tool {
97        tool_name: String,
98        content: String,
99    },
100}
101
102#[derive(Serialize, Deserialize, Debug)]
103pub struct OllamaToolCall {
104    pub id: String,
105    pub function: OllamaFunctionCall,
106}
107
108#[derive(Serialize, Deserialize, Debug)]
109pub struct OllamaFunctionCall {
110    pub name: String,
111    pub arguments: Value,
112}
113
114#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
115pub struct OllamaFunctionTool {
116    pub name: String,
117    pub description: Option<String>,
118    pub parameters: Option<Value>,
119}
120
121#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
122#[serde(tag = "type", rename_all = "lowercase")]
123pub enum OllamaTool {
124    Function { function: OllamaFunctionTool },
125}
126
127#[derive(Serialize, Debug)]
128pub struct ChatRequest {
129    pub model: String,
130    pub messages: Vec<ChatMessage>,
131    pub stream: bool,
132    pub keep_alive: KeepAlive,
133    pub options: Option<ChatOptions>,
134    pub tools: Vec<OllamaTool>,
135    pub think: Option<bool>,
136}
137
138// https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values
139#[derive(Serialize, Default, Debug)]
140pub struct ChatOptions {
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub num_ctx: Option<u64>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub num_predict: Option<isize>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub stop: Option<Vec<String>>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub temperature: Option<f32>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub top_p: Option<f32>,
151}
152
153#[derive(Deserialize, Debug)]
154pub struct ChatResponseDelta {
155    pub model: String,
156    pub created_at: String,
157    pub message: ChatMessage,
158    pub done_reason: Option<String>,
159    pub done: bool,
160    pub prompt_eval_count: Option<u64>,
161    pub eval_count: Option<u64>,
162}
163
164#[derive(Serialize, Deserialize)]
165pub struct LocalModelsResponse {
166    pub models: Vec<LocalModelListing>,
167}
168
169#[derive(Serialize, Deserialize)]
170pub struct LocalModelListing {
171    pub name: String,
172    pub modified_at: String,
173    pub size: u64,
174    pub digest: String,
175    pub details: ModelDetails,
176}
177
178#[derive(Serialize, Deserialize)]
179pub struct LocalModel {
180    pub modelfile: String,
181    pub parameters: String,
182    pub template: String,
183    pub details: ModelDetails,
184}
185
186#[derive(Serialize, Deserialize)]
187pub struct ModelDetails {
188    pub format: String,
189    pub family: String,
190    pub families: Option<Vec<String>>,
191    pub parameter_size: String,
192    pub quantization_level: String,
193}
194
195#[derive(Debug)]
196pub struct ModelShow {
197    pub capabilities: Vec<String>,
198    pub context_length: Option<u64>,
199    pub architecture: Option<String>,
200}
201
202impl<'de> Deserialize<'de> for ModelShow {
203    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
204    where
205        D: serde::Deserializer<'de>,
206    {
207        use serde::de::{self, MapAccess, Visitor};
208        use std::fmt;
209
210        struct ModelShowVisitor;
211
212        impl<'de> Visitor<'de> for ModelShowVisitor {
213            type Value = ModelShow;
214
215            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
216                formatter.write_str("a ModelShow object")
217            }
218
219            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
220            where
221                A: MapAccess<'de>,
222            {
223                let mut capabilities: Vec<String> = Vec::new();
224                let mut architecture: Option<String> = None;
225                let mut context_length: Option<u64> = None;
226                let mut num_ctx: Option<u64> = None;
227
228                while let Some(key) = map.next_key::<String>()? {
229                    match key.as_str() {
230                        "capabilities" => {
231                            capabilities = map.next_value()?;
232                        }
233                        "parameters" => {
234                            let params_str: String = map.next_value()?;
235                            for line in params_str.lines() {
236                                if let Some(start) = line.find("num_ctx") {
237                                    let value_part = &line[start + 7..];
238                                    if let Ok(value) = value_part.trim().parse::<u64>() {
239                                        num_ctx = Some(value);
240                                        break;
241                                    }
242                                }
243                            }
244                        }
245                        "model_info" => {
246                            let model_info: Value = map.next_value()?;
247                            if let Value::Object(obj) = model_info {
248                                architecture = obj
249                                    .get("general.architecture")
250                                    .and_then(|v| v.as_str())
251                                    .map(String::from);
252
253                                if let Some(arch) = &architecture {
254                                    context_length = obj
255                                        .get(&format!("{}.context_length", arch))
256                                        .and_then(|v| v.as_u64());
257                                }
258                            }
259                        }
260                        _ => {
261                            let _: de::IgnoredAny = map.next_value()?;
262                        }
263                    }
264                }
265
266                let context_length = num_ctx.or(context_length);
267                Ok(ModelShow {
268                    capabilities,
269                    context_length,
270                    architecture,
271                })
272            }
273        }
274
275        deserializer.deserialize_map(ModelShowVisitor)
276    }
277}
278
279impl ModelShow {
280    pub fn supports_tools(&self) -> bool {
281        // .contains expects &String, which would require an additional allocation
282        self.capabilities.iter().any(|v| v == "tools")
283    }
284
285    pub fn supports_vision(&self) -> bool {
286        self.capabilities.iter().any(|v| v == "vision")
287    }
288
289    pub fn supports_thinking(&self) -> bool {
290        self.capabilities.iter().any(|v| v == "thinking")
291    }
292}
293
294pub async fn stream_chat_completion(
295    client: &dyn HttpClient,
296    api_url: &str,
297    api_key: Option<&str>,
298    request: ChatRequest,
299    extra_headers: &CustomHeaders,
300) -> Result<BoxStream<'static, Result<ChatResponseDelta>>> {
301    let uri = format!("{api_url}/api/chat");
302    let request = HttpRequest::builder()
303        .method(Method::POST)
304        .uri(uri)
305        .header("Content-Type", "application/json")
306        .when_some(api_key, |builder, api_key| {
307            builder.header("Authorization", format!("Bearer {api_key}"))
308        })
309        .extra_headers(extra_headers)
310        .body(AsyncBody::from(serde_json::to_string(&request)?))?;
311
312    let mut response = client.send(request).await?;
313    if response.status().is_success() {
314        let reader = BufReader::new(response.into_body());
315
316        Ok(reader
317            .lines()
318            .map(|line| match line {
319                Ok(line) => serde_json::from_str(&line).context("Unable to parse chat response"),
320                Err(e) => Err(e.into()),
321            })
322            .boxed())
323    } else {
324        let mut body = String::new();
325        response.body_mut().read_to_string(&mut body).await?;
326        anyhow::bail!(
327            "Failed to connect to Ollama API: {} {}",
328            response.status(),
329            body,
330        );
331    }
332}
333
334pub async fn get_models(
335    client: &dyn HttpClient,
336    api_url: &str,
337    api_key: Option<&str>,
338    extra_headers: &CustomHeaders,
339) -> Result<Vec<LocalModelListing>> {
340    let uri = format!("{api_url}/api/tags");
341    let request = HttpRequest::builder()
342        .method(Method::GET)
343        .uri(uri)
344        .header("Accept", "application/json")
345        .when_some(api_key, |builder, api_key| {
346            builder.header("Authorization", format!("Bearer {api_key}"))
347        })
348        .extra_headers(extra_headers)
349        .body(AsyncBody::default())?;
350
351    let mut response = client.send(request).await?;
352
353    let mut body = String::new();
354    response.body_mut().read_to_string(&mut body).await?;
355
356    anyhow::ensure!(
357        response.status().is_success(),
358        "Failed to connect to Ollama API: {} {}",
359        response.status(),
360        body,
361    );
362    let response: LocalModelsResponse =
363        serde_json::from_str(&body).context("Unable to parse Ollama tag listing")?;
364    Ok(response.models)
365}
366
367/// Fetch details of a model, used to determine model capabilities
368pub async fn show_model(
369    client: &dyn HttpClient,
370    api_url: &str,
371    api_key: Option<&str>,
372    model: &str,
373    extra_headers: &CustomHeaders,
374) -> Result<ModelShow> {
375    let uri = format!("{api_url}/api/show");
376    let request = HttpRequest::builder()
377        .method(Method::POST)
378        .uri(uri)
379        .header("Content-Type", "application/json")
380        .when_some(api_key, |builder, api_key| {
381            builder.header("Authorization", format!("Bearer {api_key}"))
382        })
383        .extra_headers(extra_headers)
384        .body(AsyncBody::from(
385            serde_json::json!({ "model": model }).to_string(),
386        ))?;
387
388    let mut response = client.send(request).await?;
389    let mut body = String::new();
390    response.body_mut().read_to_string(&mut body).await?;
391
392    anyhow::ensure!(
393        response.status().is_success(),
394        "Failed to connect to Ollama API: {} {}",
395        response.status(),
396        body,
397    );
398    let details: ModelShow = serde_json::from_str(body.as_str())?;
399    Ok(details)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn parse_completion() {
408        let response = serde_json::json!({
409        "model": "llama3.2",
410        "created_at": "2023-12-12T14:13:43.416799Z",
411        "message": {
412            "role": "assistant",
413            "content": "Hello! How are you today?"
414        },
415        "done": true,
416        "total_duration": 5191566416u64,
417        "load_duration": 2154458,
418        "prompt_eval_count": 26,
419        "prompt_eval_duration": 383809000,
420        "eval_count": 298,
421        "eval_duration": 4799921000u64
422        });
423        let _: ChatResponseDelta = serde_json::from_value(response).unwrap();
424    }
425
426    #[test]
427    fn parse_streaming_completion() {
428        let partial = serde_json::json!({
429        "model": "llama3.2",
430        "created_at": "2023-08-04T08:52:19.385406455-07:00",
431        "message": {
432            "role": "assistant",
433            "content": "The",
434            "images": null
435        },
436        "done": false
437        });
438
439        let _: ChatResponseDelta = serde_json::from_value(partial).unwrap();
440
441        let last = serde_json::json!({
442        "model": "llama3.2",
443        "created_at": "2023-08-04T19:22:45.499127Z",
444        "message": {
445            "role": "assistant",
446            "content": ""
447        },
448        "done": true,
449        "total_duration": 4883583458u64,
450        "load_duration": 1334875,
451        "prompt_eval_count": 26,
452        "prompt_eval_duration": 342546000,
453        "eval_count": 282,
454        "eval_duration": 4535599000u64
455        });
456
457        let _: ChatResponseDelta = serde_json::from_value(last).unwrap();
458    }
459
460    #[test]
461    fn parse_tool_call() {
462        let response = serde_json::json!({
463            "model": "llama3.2:3b",
464            "created_at": "2025-04-28T20:02:02.140489Z",
465            "message": {
466                "role": "assistant",
467                "content": "",
468                "tool_calls": [
469                    {
470                        "id": "call_llama3.2:3b_145155",
471                        "function": {
472                            "name": "weather",
473                            "arguments": {
474                                "city": "london",
475                            }
476                        }
477                    }
478                ]
479            },
480            "done_reason": "stop",
481            "done": true,
482            "total_duration": 2758629166u64,
483            "load_duration": 1770059875,
484            "prompt_eval_count": 147,
485            "prompt_eval_duration": 684637583,
486            "eval_count": 16,
487            "eval_duration": 302561917,
488        });
489
490        let result: ChatResponseDelta = serde_json::from_value(response).unwrap();
491        match result.message {
492            ChatMessage::Assistant {
493                content,
494                tool_calls,
495                images: _,
496                thinking,
497            } => {
498                assert!(content.is_empty());
499                assert!(tool_calls.is_some_and(|v| !v.is_empty()));
500                assert!(thinking.is_none());
501            }
502            _ => panic!("Deserialized wrong role"),
503        }
504    }
505
506    #[test]
507    fn parse_show_model() {
508        let response = serde_json::json!({
509            "license": "LLAMA 3.2 COMMUNITY LICENSE AGREEMENT...",
510            "details": {
511                "parent_model": "",
512                "format": "gguf",
513                "family": "llama",
514                "families": ["llama"],
515                "parameter_size": "3.2B",
516                "quantization_level": "Q4_K_M"
517            },
518            "model_info": {
519                "general.architecture": "llama",
520                "general.basename": "Llama-3.2",
521                "general.file_type": 15,
522                "general.finetune": "Instruct",
523                "general.languages": ["en", "de", "fr", "it", "pt", "hi", "es", "th"],
524                "general.parameter_count": 3212749888u64,
525                "general.quantization_version": 2,
526                "general.size_label": "3B",
527                "general.tags": ["facebook", "meta", "pytorch", "llama", "llama-3", "text-generation"],
528                "general.type": "model",
529                "llama.attention.head_count": 24,
530                "llama.attention.head_count_kv": 8,
531                "llama.attention.key_length": 128,
532                "llama.attention.layer_norm_rms_epsilon": 0.00001,
533                "llama.attention.value_length": 128,
534                "llama.block_count": 28,
535                "llama.context_length": 131072,
536                "llama.embedding_length": 3072,
537                "llama.feed_forward_length": 8192,
538                "llama.rope.dimension_count": 128,
539                "llama.rope.freq_base": 500000,
540                "llama.vocab_size": 128256,
541                "tokenizer.ggml.bos_token_id": 128000,
542                "tokenizer.ggml.eos_token_id": 128009,
543                "tokenizer.ggml.merges": null,
544                "tokenizer.ggml.model": "gpt2",
545                "tokenizer.ggml.pre": "llama-bpe",
546                "tokenizer.ggml.token_type": null,
547                "tokenizer.ggml.tokens": null
548            },
549            "tensors": [
550                { "name": "rope_freqs.weight", "type": "F32", "shape": [64] },
551                { "name": "token_embd.weight", "type": "Q4_K_S", "shape": [3072, 128256] }
552            ],
553            "capabilities": ["completion", "tools"],
554            "modified_at": "2025-04-29T21:24:41.445877632+03:00"
555        });
556
557        let result: ModelShow = serde_json::from_value(response).unwrap();
558        assert!(result.supports_tools());
559        assert!(result.capabilities.contains(&"tools".to_string()));
560        assert!(result.capabilities.contains(&"completion".to_string()));
561
562        assert_eq!(result.architecture, Some("llama".to_string()));
563        assert_eq!(result.context_length, Some(131072));
564    }
565
566    #[test]
567    fn parse_show_model_with_num_ctx_preference() {
568        let response = serde_json::json!({
569            "license": "LLAMA 3.2 COMMUNITY LICENSE AGREEMENT...",
570            "parameters": "num_ctx                        32768\npresence_penalty               1.5\ntemperature                    1\ntop_k                          20\ntop_p                          0.95",
571            "details": {
572                "parent_model": "",
573                "format": "gguf",
574                "family": "llama",
575                "families": ["llama"],
576                "parameter_size": "3.2B",
577                "quantization_level": "Q4_K_M"
578            },
579            "model_info": {
580                "general.architecture": "llama",
581                "general.basename": "Llama-3.2",
582                "general.file_type": 15,
583                "general.finetune": "Instruct",
584                "general.languages": ["en", "de", "fr", "it", "pt", "hi", "es", "th"],
585                "general.parameter_count": 3212749888u64,
586                "general.quantization_version": 2,
587                "general.size_label": "3B",
588                "general.tags": ["facebook", "meta", "pytorch", "llama", "llama-3", "text-generation"],
589                "general.type": "model",
590                "llama.attention.head_count": 24,
591                "llama.attention.head_count_kv": 8,
592                "llama.attention.key_length": 128,
593                "llama.attention.layer_norm_rms_epsilon": 0.00001,
594                "llama.attention.value_length": 128,
595                "llama.block_count": 28,
596                "llama.context_length": 131072,
597                "llama.embedding_length": 3072,
598                "llama.feed_forward_length": 8192,
599                "llama.rope.dimension_count": 128,
600                "llama.rope.freq_base": 500000,
601                "llama.vocab_size": 128256,
602                "tokenizer.ggml.bos_token_id": 128000,
603                "tokenizer.ggml.eos_token_id": 128009,
604                "tokenizer.ggml.merges": null,
605                "tokenizer.ggml.model": "gpt2",
606                "tokenizer.ggml.pre": "llama-bpe",
607                "tokenizer.ggml.token_type": null,
608                "tokenizer.ggml.tokens": null
609            },
610            "tensors": [
611                { "name": "rope_freqs.weight", "type": "F32", "shape": [64] },
612                { "name": "token_embd.weight", "type": "Q4_K_S", "shape": [3072, 128256] }
613            ],
614            "capabilities": ["completion", "tools"],
615            "modified_at": "2025-04-29T21:24:41.445877632+03:00"
616        });
617
618        let result: ModelShow = serde_json::from_value(response).unwrap();
619
620        assert_eq!(result.context_length, Some(32768));
621    }
622
623    #[test]
624    fn parse_show_model_without_num_ctx_in_parameters_fallback() {
625        let response = serde_json::json!({
626            "license": "LLAMA 3.2 COMMUNITY LICENSE AGREEMENT...",
627            "parameters": "presence_penalty               1.5\ntemperature                    1\ntop_k                          20\ntop_p                          0.95",
628            "details": {
629                "parent_model": "",
630                "format": "gguf",
631                "family": "llama",
632                "families": ["llama"],
633                "parameter_size": "3.2B",
634                "quantization_level": "Q4_K_M"
635            },
636            "model_info": {
637                "general.architecture": "llama",
638                "general.basename": "Llama-3.2",
639                "general.file_type": 15,
640                "general.finetune": "Instruct",
641                "general.languages": ["en", "de", "fr", "it", "pt", "hi", "es", "th"],
642                "general.parameter_count": 3212749888u64,
643                "general.quantization_version": 2,
644                "general.size_label": "3B",
645                "general.tags": ["facebook", "meta", "pytorch", "llama", "llama-3", "text-generation"],
646                "general.type": "model",
647                "llama.attention.head_count": 24,
648                "llama.attention.head_count_kv": 8,
649                "llama.attention.key_length": 128,
650                "llama.attention.layer_norm_rms_epsilon": 0.00001,
651                "llama.attention.value_length": 128,
652                "llama.block_count": 28,
653                "llama.context_length": 131072,
654                "llama.embedding_length": 3072,
655                "llama.feed_forward_length": 8192,
656                "llama.rope.dimension_count": 128,
657                "llama.rope.freq_base": 500000,
658                "llama.vocab_size": 128256,
659                "tokenizer.ggml.bos_token_id": 128000,
660                "tokenizer.ggml.eos_token_id": 128009,
661                "tokenizer.ggml.merges": null,
662                "tokenizer.ggml.model": "gpt2",
663                "tokenizer.ggml.pre": "llama-bpe",
664                "tokenizer.ggml.token_type": null,
665                "tokenizer.ggml.tokens": null
666            },
667            "tensors": [
668                { "name": "rope_freqs.weight", "type": "F32", "shape": [64] },
669                { "name": "token_embd.weight", "type": "Q4_K_S", "shape": [3072, 128256] }
670            ],
671            "capabilities": ["completion", "tools"],
672            "modified_at": "2025-04-29T21:24:41.445877632+03:00"
673        });
674
675        let result: ModelShow = serde_json::from_value(response).unwrap();
676
677        assert_eq!(result.context_length, Some(131072));
678    }
679
680    #[test]
681    fn serialize_chat_request_with_images() {
682        let base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
683
684        let request = ChatRequest {
685            model: "llava".to_string(),
686            messages: vec![ChatMessage::User {
687                content: "What do you see in this image?".to_string(),
688                images: Some(vec![base64_image.to_string()]),
689            }],
690            stream: false,
691            keep_alive: KeepAlive::default(),
692            options: None,
693            think: None,
694            tools: vec![],
695        };
696
697        let serialized = serde_json::to_string(&request).unwrap();
698        assert!(serialized.contains("images"));
699        assert!(serialized.contains(base64_image));
700    }
701
702    #[test]
703    fn serialize_chat_request_without_images() {
704        let request = ChatRequest {
705            model: "llama3.2".to_string(),
706            messages: vec![ChatMessage::User {
707                content: "Hello, world!".to_string(),
708                images: None,
709            }],
710            stream: false,
711            keep_alive: KeepAlive::default(),
712            options: None,
713            think: None,
714            tools: vec![],
715        };
716
717        let serialized = serde_json::to_string(&request).unwrap();
718        assert!(!serialized.contains("images"));
719    }
720
721    #[test]
722    fn test_json_format_with_images() {
723        let base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
724
725        let request = ChatRequest {
726            model: "llava".to_string(),
727            messages: vec![ChatMessage::User {
728                content: "What do you see?".to_string(),
729                images: Some(vec![base64_image.to_string()]),
730            }],
731            stream: false,
732            keep_alive: KeepAlive::default(),
733            options: None,
734            think: None,
735            tools: vec![],
736        };
737
738        let serialized = serde_json::to_string(&request).unwrap();
739
740        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
741        let message_images = parsed["messages"][0]["images"].as_array().unwrap();
742        assert_eq!(message_images.len(), 1);
743        assert_eq!(message_images[0].as_str().unwrap(), base64_image);
744    }
745
746    #[test]
747    fn test_chat_options_serialization() {
748        // When stop is None, it should not appear in JSON at all
749        // This allows Ollama to use the model's default stop tokens
750        let options_no_stop = ChatOptions {
751            num_ctx: Some(4096),
752            stop: None,
753            temperature: Some(0.7),
754            ..Default::default()
755        };
756        let serialized = serde_json::to_string(&options_no_stop).unwrap();
757        assert!(
758            !serialized.contains("stop"),
759            "stop should not be in JSON when None"
760        );
761        assert!(serialized.contains("num_ctx"));
762        assert!(serialized.contains("temperature"));
763
764        // When stop has values, they should be serialized
765        let options_with_stop = ChatOptions {
766            stop: Some(vec!["<|eot_id|>".to_string()]),
767            ..Default::default()
768        };
769        let serialized = serde_json::to_string(&options_with_stop).unwrap();
770        assert!(serialized.contains("stop"));
771        assert!(serialized.contains("<|eot_id|>"));
772
773        // All None options should result in empty object
774        let options_all_none = ChatOptions::default();
775        let serialized = serde_json::to_string(&options_all_none).unwrap();
776        assert_eq!(serialized, "{}");
777    }
778
779    #[test]
780    fn test_chat_request_with_stop_tokens() {
781        let request = ChatRequest {
782            model: "rnj-1:8b".to_string(),
783            messages: vec![ChatMessage::User {
784                content: "Hello".to_string(),
785                images: None,
786            }],
787            stream: true,
788            keep_alive: KeepAlive::default(),
789            options: Some(ChatOptions {
790                stop: Some(vec!["<|eot_id|>".to_string(), "<|end|>".to_string()]),
791                ..Default::default()
792            }),
793            think: None,
794            tools: vec![],
795        };
796
797        let serialized = serde_json::to_string(&request).unwrap();
798        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
799
800        let stop = parsed["options"]["stop"].as_array().unwrap();
801        assert_eq!(stop.len(), 2);
802        assert_eq!(stop[0].as_str().unwrap(), "<|eot_id|>");
803        assert_eq!(stop[1].as_str().unwrap(), "<|end|>");
804    }
805
806    #[test]
807    fn test_chat_request_without_stop_tokens_omits_field() {
808        // This tests the fix for issue #47798
809        // When no stop tokens are provided, the field should be omitted
810        // so Ollama uses the model's default stop tokens from Modelfile
811        let request = ChatRequest {
812            model: "rnj-1:8b".to_string(),
813            messages: vec![ChatMessage::User {
814                content: "Hello".to_string(),
815                images: None,
816            }],
817            stream: true,
818            keep_alive: KeepAlive::default(),
819            options: Some(ChatOptions {
820                num_ctx: Some(4096),
821                stop: None, // No stop tokens - should be omitted from JSON
822                ..Default::default()
823            }),
824            think: None,
825            tools: vec![],
826        };
827
828        let serialized = serde_json::to_string(&request).unwrap();
829
830        // The key check: "stop" should not appear in the serialized JSON
831        assert!(
832            !serialized.contains("\"stop\""),
833            "stop field should be omitted when None, got: {}",
834            serialized
835        );
836    }
837}
838
Served at tenant.openagents/omega Member data and write actions are omitted.