Skip to repository content838 lines · 29.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:47:59.020Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
ollama.rs
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