Skip to repository content912 lines · 30.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:35:53.802Z 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
llama_cpp.rs
1use anyhow::{Context as _, Result, anyhow};
2use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
3use http_client::{
4 AsyncBody, CustomHeaders, HttpClient, HttpRequestExt, Method, Request as HttpRequest,
5 RequestBuilderExt, http,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use url::Url;
10
11pub const LLAMA_CPP_API_URL: &str = "http://localhost:8080";
12
13const DEFAULT_CONTEXT_LENGTH: u64 = 4096;
14
15/// A model exposed to the rest of Zed, after merging API discovery with
16/// user-configured overrides.
17#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
18#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
19pub struct Model {
20 pub name: String,
21 pub display_name: Option<String>,
22 pub max_tokens: u64,
23 pub supports_tools: bool,
24 pub supports_images: bool,
25 pub supports_thinking: bool,
26}
27
28impl Model {
29 pub fn new(
30 name: &str,
31 display_name: Option<&str>,
32 max_tokens: Option<u64>,
33 supports_tools: bool,
34 supports_images: bool,
35 supports_thinking: bool,
36 ) -> Self {
37 Self {
38 name: name.to_owned(),
39 display_name: display_name.map(ToString::to_string),
40 max_tokens: max_tokens.unwrap_or(DEFAULT_CONTEXT_LENGTH),
41 supports_tools,
42 supports_images,
43 supports_thinking,
44 }
45 }
46
47 pub fn display_name(&self) -> &str {
48 self.display_name.as_deref().unwrap_or(&self.name)
49 }
50}
51
52#[derive(Debug, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum ToolChoice {
55 Auto,
56 Required,
57 None,
58}
59
60#[derive(Clone, Deserialize, Serialize, Debug)]
61#[serde(tag = "type", rename_all = "snake_case")]
62pub enum ToolDefinition {
63 Function { function: FunctionDefinition },
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize)]
67pub struct FunctionDefinition {
68 pub name: String,
69 pub description: Option<String>,
70 pub parameters: Option<Value>,
71}
72
73#[derive(Serialize, Deserialize, Debug)]
74#[serde(tag = "role", rename_all = "lowercase")]
75pub enum ChatMessage {
76 Assistant {
77 #[serde(default)]
78 content: Option<MessageContent>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 reasoning_content: Option<String>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 tool_calls: Vec<ToolCall>,
83 },
84 User {
85 content: MessageContent,
86 },
87 System {
88 content: MessageContent,
89 },
90 Tool {
91 content: MessageContent,
92 tool_call_id: String,
93 },
94}
95
96#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
97#[serde(untagged)]
98pub enum MessageContent {
99 Plain(String),
100 Multipart(Vec<MessagePart>),
101}
102
103impl MessageContent {
104 pub fn push_part(&mut self, part: MessagePart) {
105 match self {
106 MessageContent::Plain(text) => {
107 *self =
108 MessageContent::Multipart(vec![MessagePart::Text { text: text.clone() }, part]);
109 }
110 MessageContent::Multipart(parts) if parts.is_empty() => match part {
111 MessagePart::Text { text } => *self = MessageContent::Plain(text),
112 MessagePart::Image { .. } => *self = MessageContent::Multipart(vec![part]),
113 },
114 MessageContent::Multipart(parts) => parts.push(part),
115 }
116 }
117}
118
119impl From<Vec<MessagePart>> for MessageContent {
120 fn from(mut parts: Vec<MessagePart>) -> Self {
121 if let [MessagePart::Text { text }] = parts.as_mut_slice() {
122 MessageContent::Plain(std::mem::take(text))
123 } else {
124 MessageContent::Multipart(parts)
125 }
126 }
127}
128
129#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
130#[serde(tag = "type", rename_all = "snake_case")]
131pub enum MessagePart {
132 Text {
133 text: String,
134 },
135 #[serde(rename = "image_url")]
136 Image {
137 image_url: ImageUrl,
138 },
139}
140
141#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
142pub struct ImageUrl {
143 pub url: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub detail: Option<String>,
146}
147
148#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
149pub struct ToolCall {
150 pub id: String,
151 #[serde(flatten)]
152 pub content: ToolCallContent,
153}
154
155#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
156#[serde(tag = "type", rename_all = "lowercase")]
157pub enum ToolCallContent {
158 Function { function: FunctionContent },
159}
160
161#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
162pub struct FunctionContent {
163 pub name: String,
164 pub arguments: String,
165}
166
167#[derive(Serialize, Debug)]
168pub struct ChatCompletionRequest {
169 pub model: String,
170 pub messages: Vec<ChatMessage>,
171 pub stream: bool,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub max_tokens: Option<i32>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub stop: Option<Vec<String>>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub temperature: Option<f32>,
178 #[serde(skip_serializing_if = "Vec::is_empty")]
179 pub tools: Vec<ToolDefinition>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub tool_choice: Option<ToolChoice>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub stream_options: Option<StreamOptions>,
184}
185
186/// Asks the server to include a final `usage` chunk in the stream.
187#[derive(Serialize, Debug)]
188pub struct StreamOptions {
189 pub include_usage: bool,
190}
191
192#[derive(Serialize, Deserialize, Debug)]
193pub struct Usage {
194 pub prompt_tokens: u64,
195 pub completion_tokens: u64,
196 pub total_tokens: u64,
197}
198
199#[derive(Serialize, Deserialize, Debug)]
200pub struct LlamaCppError {
201 pub message: String,
202}
203
204#[derive(Serialize, Deserialize, Debug)]
205#[serde(untagged)]
206pub enum ResponseStreamResult {
207 Ok(ResponseStreamEvent),
208 Err { error: LlamaCppError },
209}
210
211#[derive(Serialize, Deserialize, Debug)]
212pub struct ResponseStreamEvent {
213 pub model: String,
214 pub object: String,
215 pub choices: Vec<ChoiceDelta>,
216 pub usage: Option<Usage>,
217}
218
219#[derive(Serialize, Deserialize, Debug)]
220pub struct ChoiceDelta {
221 pub index: u32,
222 pub delta: ResponseMessageDelta,
223 pub finish_reason: Option<String>,
224}
225
226#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
227pub struct ResponseMessageDelta {
228 pub content: Option<String>,
229 /// `llama-server` emits reasoning as a dedicated `reasoning_content` field
230 /// when started with a reasoning format (e.g. `--reasoning-format deepseek`).
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub reasoning_content: Option<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub tool_calls: Option<Vec<ToolCallChunk>>,
235}
236
237#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
238pub struct ToolCallChunk {
239 pub index: usize,
240 pub id: Option<String>,
241 pub function: Option<FunctionChunk>,
242}
243
244#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
245pub struct FunctionChunk {
246 pub name: Option<String>,
247 pub arguments: Option<String>,
248}
249
250/// Response of `GET /v1/models`.
251///
252/// In single-model mode `data` has exactly one entry describing the loaded
253/// model; in router mode it lists every model the server knows about.
254#[derive(Deserialize, Debug)]
255pub struct ListModelsResponse {
256 #[serde(default)]
257 pub data: Vec<ModelEntry>,
258}
259
260#[derive(Clone, Debug, Deserialize, PartialEq)]
261pub struct ModelEntry {
262 pub id: String,
263 /// Present in single-model mode; carries context information.
264 #[serde(default)]
265 pub meta: Option<ModelMeta>,
266 /// Present in router mode; reports the modalities the model accepts.
267 #[serde(default)]
268 pub architecture: Option<Architecture>,
269 /// Present in router mode; reports whether the model is currently loaded.
270 #[serde(default)]
271 pub status: Option<ModelStatus>,
272}
273
274impl ModelEntry {
275 /// Whether this entry came from a server running in router mode.
276 pub fn is_router_entry(&self) -> bool {
277 self.status.is_some()
278 }
279
280 /// Whether the model is loaded and can be probed for capabilities without
281 /// triggering a (potentially expensive) load.
282 pub fn is_loaded(&self) -> bool {
283 self.status
284 .as_ref()
285 .is_some_and(|status| status.value == "loaded")
286 }
287
288 /// Whether the model is currently loading, so a progress label applies.
289 pub fn is_loading(&self) -> bool {
290 self.status
291 .as_ref()
292 .is_some_and(|status| status.value == "loading")
293 }
294
295 pub fn supports_images_hint(&self) -> bool {
296 self.architecture
297 .as_ref()
298 .is_some_and(|architecture| architecture.input_modalities.iter().any(|m| m == "image"))
299 }
300}
301
302#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
303pub struct ModelMeta {
304 /// The runtime per-slot context size.
305 #[serde(default)]
306 pub n_ctx: Option<u64>,
307 /// The context size the model was trained with.
308 #[serde(default)]
309 pub n_ctx_train: Option<u64>,
310}
311
312#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
313pub struct Architecture {
314 #[serde(default)]
315 pub input_modalities: Vec<String>,
316}
317
318#[derive(Clone, Debug, Deserialize, PartialEq)]
319pub struct ModelStatus {
320 /// One of `loaded`, `loading`, `unloaded`, `downloading`, `downloaded`, `sleeping`.
321 pub value: String,
322}
323
324/// Response of `GET /props`, describing the loaded model.
325#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
326pub struct Props {
327 #[serde(default)]
328 pub default_generation_settings: Option<GenerationSettings>,
329 #[serde(default)]
330 pub modalities: Option<Modalities>,
331 #[serde(default)]
332 pub chat_template_caps: Option<ChatTemplateCaps>,
333}
334
335impl Props {
336 /// The runtime context length the loaded model was configured with.
337 pub fn context_length(&self) -> Option<u64> {
338 self.default_generation_settings
339 .as_ref()
340 .and_then(|settings| settings.n_ctx)
341 .filter(|n_ctx| *n_ctx > 0)
342 }
343
344 pub fn supports_images(&self) -> bool {
345 self.modalities
346 .as_ref()
347 .is_some_and(|modalities| modalities.vision)
348 }
349
350 pub fn supports_tools(&self) -> bool {
351 self.chat_template_caps
352 .as_ref()
353 .is_some_and(|caps| caps.supports_tool_calls || caps.supports_tools)
354 }
355
356 pub fn supports_thinking(&self) -> bool {
357 self.chat_template_caps
358 .as_ref()
359 .is_some_and(|caps| caps.supports_preserve_reasoning)
360 }
361}
362
363#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
364pub struct GenerationSettings {
365 #[serde(default)]
366 pub n_ctx: Option<u64>,
367}
368
369#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
370pub struct Modalities {
371 #[serde(default)]
372 pub vision: bool,
373}
374
375#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
376pub struct ChatTemplateCaps {
377 /// llama.cpp's `/props` reports both of these keys, so we read them into
378 /// separate fields (aliasing one to the other would be a serde duplicate-field
379 /// error) and treat either being true as tool support. `supports_tools` is the
380 /// older name; some builds report only one of the two.
381 #[serde(default)]
382 pub supports_tool_calls: bool,
383 #[serde(default)]
384 pub supports_tools: bool,
385 #[serde(default)]
386 pub supports_preserve_reasoning: bool,
387}
388
389/// An event from the router's `/models/sse` feed, which the provider subscribes
390/// to so model capabilities stay current as models load and unload. `model` is
391/// `*` for events that aren't about a single model (e.g. the list reloading).
392#[derive(Clone, Debug, Deserialize, PartialEq)]
393pub struct ModelEvent {
394 #[serde(default)]
395 pub model: String,
396 pub event: String,
397 #[serde(default)]
398 pub data: Option<ModelEventData>,
399}
400
401#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
402pub struct ModelEventData {
403 #[serde(default)]
404 pub status: Option<String>,
405 /// Present on an `unloaded` status; non-zero means the model failed to load.
406 #[serde(default)]
407 pub exit_code: Option<i32>,
408 /// Present on a `loading` status, reporting per-stage load progress.
409 #[serde(default)]
410 pub progress: Option<LoadProgress>,
411}
412
413/// Per-stage load progress carried by a `loading` event. A model loads its
414/// stages in order (the text model, plus an optional draft and/or multimodal
415/// projector), each reporting a `0.0..=1.0` fraction.
416#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
417pub struct LoadProgress {
418 #[serde(default)]
419 pub stages: Vec<String>,
420 #[serde(default)]
421 pub current: String,
422 #[serde(default)]
423 pub value: f32,
424}
425
426impl LoadProgress {
427 /// A human label for the stage currently loading, matching the llama.cpp
428 /// WebUI's labels. We report the current stage's own `value` rather than a
429 /// blended overall percentage (as the WebUI does), so each stage runs
430 /// `0→100%` and the label says which stage it is.
431 pub fn stage_label(&self) -> &'static str {
432 match self.current.as_str() {
433 "text_model" => "Loading weights",
434 "spec_model" => "Loading draft",
435 "mmproj_model" => "Loading projector",
436 _ => "Loading",
437 }
438 }
439
440 /// The full load-status label shown in the model selector, e.g.
441 /// `"Loading weights 42%"`: the current stage plus its own progress rounded
442 /// to a percentage.
443 pub fn progress_label(&self) -> String {
444 format!(
445 "{} {}%",
446 self.stage_label(),
447 (self.value * 100.0).round() as u32
448 )
449 }
450}
451
452impl ModelEvent {
453 /// Whether this event means the set of models or their loaded state changed,
454 /// so the provider should re-run discovery. Intermediate `loading` progress
455 /// ticks return `false` — only terminal load/unload and list changes matter.
456 pub fn changes_model_state(&self) -> bool {
457 match self.event.as_str() {
458 "models_reload" | "model_remove" => true,
459 _ => matches!(
460 self.data.as_ref().and_then(|data| data.status.as_deref()),
461 Some("loaded" | "unloaded")
462 ),
463 }
464 }
465
466 /// The non-zero exit code of a failed load, if this event reports one.
467 pub fn load_failure(&self) -> Option<i32> {
468 let data = self.data.as_ref()?;
469 if data.status.as_deref() == Some("unloaded") {
470 data.exit_code.filter(|code| *code != 0)
471 } else {
472 None
473 }
474 }
475
476 /// This event's load progress if it is a loading event carrying usable stage
477 /// data, else `None`.
478 pub fn load_progress(&self) -> Option<&LoadProgress> {
479 let data = self.data.as_ref()?;
480 if data.status.as_deref() != Some("loading") {
481 return None;
482 }
483 let progress = data.progress.as_ref()?;
484 // The server also emits bare stage-transition markers (e.g.
485 // `{"stage": "mmproj_model"}`) with no `stages`/`current`/`value`. Skip
486 // them so the indicator holds its last value rather than dropping to 0%.
487 if progress.stages.is_empty() || progress.current.is_empty() {
488 return None;
489 }
490 Some(progress)
491 }
492}
493
494pub async fn stream_chat_completion(
495 client: &dyn HttpClient,
496 api_url: &str,
497 api_key: Option<&str>,
498 request: ChatCompletionRequest,
499 extra_headers: &CustomHeaders,
500) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
501 let uri = format!("{api_url}/v1/chat/completions");
502 let request_builder = http::Request::builder()
503 .method(Method::POST)
504 .uri(uri)
505 .header("Content-Type", "application/json")
506 .when_some(api_key, |builder, api_key| {
507 builder.header("Authorization", format!("Bearer {api_key}"))
508 });
509
510 let request = request_builder
511 .extra_headers(extra_headers)
512 .body(AsyncBody::from(serde_json::to_string(&request)?))?;
513 let mut response = client.send(request).await?;
514 if response.status().is_success() {
515 let reader = BufReader::new(response.into_body());
516 Ok(reader
517 .lines()
518 .filter_map(|line| async move {
519 match line {
520 Ok(line) => {
521 let line = line.strip_prefix("data: ")?;
522 if line == "[DONE]" {
523 None
524 } else {
525 match serde_json::from_str(line) {
526 Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
527 Ok(ResponseStreamResult::Err { error }) => {
528 Some(Err(anyhow!(error.message)))
529 }
530 Err(error) => Some(Err(anyhow!(error))),
531 }
532 }
533 }
534 Err(error) => Some(Err(anyhow!(error))),
535 }
536 })
537 .boxed())
538 } else {
539 let mut body = String::new();
540 response.body_mut().read_to_string(&mut body).await?;
541 anyhow::bail!(
542 "Failed to connect to llama.cpp API: {} {}",
543 response.status(),
544 body,
545 );
546 }
547}
548
549/// Lists the models the server is serving via `GET /v1/models`.
550pub async fn get_models(
551 client: &dyn HttpClient,
552 api_url: &str,
553 api_key: Option<&str>,
554 extra_headers: &CustomHeaders,
555) -> Result<Vec<ModelEntry>> {
556 let uri = format!("{api_url}/v1/models");
557 let request = HttpRequest::builder()
558 .method(Method::GET)
559 .uri(uri)
560 .header("Accept", "application/json")
561 .when_some(api_key, |builder, api_key| {
562 builder.header("Authorization", format!("Bearer {api_key}"))
563 })
564 .extra_headers(extra_headers)
565 .body(AsyncBody::default())?;
566
567 let mut response = client.send(request).await?;
568
569 let mut body = String::new();
570 response.body_mut().read_to_string(&mut body).await?;
571
572 anyhow::ensure!(
573 response.status().is_success(),
574 "Failed to connect to llama.cpp API: {} {}",
575 response.status(),
576 body,
577 );
578 let response: ListModelsResponse =
579 serde_json::from_str(&body).context("Unable to parse llama.cpp models response")?;
580 Ok(response.data)
581}
582
583/// Fetches `GET /props` to discover the loaded model's capabilities.
584///
585/// In router mode `model` selects which model instance to query; passing
586/// `None` queries the single loaded model in single-model mode.
587pub async fn get_props(
588 client: &dyn HttpClient,
589 api_url: &str,
590 api_key: Option<&str>,
591 model: Option<&str>,
592 extra_headers: &CustomHeaders,
593) -> Result<Props> {
594 // Router-mode `/props` takes a `model` query parameter selecting which
595 // instance to describe. Model ids contain `/` and `:` (e.g.
596 // `unsloth/Qwen3.5-2B-GGUF:Q4_1`), so the value is URL-encoded.
597 let uri = match model {
598 Some(model) => Url::parse_with_params(&format!("{api_url}/props"), [("model", model)])
599 .context("invalid llama.cpp API URL")?
600 .to_string(),
601 None => format!("{api_url}/props"),
602 };
603 let request = HttpRequest::builder()
604 .method(Method::GET)
605 .uri(uri)
606 .header("Accept", "application/json")
607 .when_some(api_key, |builder, api_key| {
608 builder.header("Authorization", format!("Bearer {api_key}"))
609 })
610 .extra_headers(extra_headers)
611 .body(AsyncBody::default())?;
612
613 let mut response = client.send(request).await?;
614
615 let mut body = String::new();
616 response.body_mut().read_to_string(&mut body).await?;
617
618 anyhow::ensure!(
619 response.status().is_success(),
620 "Failed to connect to llama.cpp API: {} {}",
621 response.status(),
622 body,
623 );
624 let props: Props =
625 serde_json::from_str(&body).context("Unable to parse llama.cpp props response")?;
626 Ok(props)
627}
628
629/// Opens the router's `GET /models/sse` event stream. Each item is one parsed
630/// event; the stream ends when the connection closes. Only available on builds
631/// that expose `/models/sse` (router mode).
632pub async fn stream_model_events(
633 client: &dyn HttpClient,
634 api_url: &str,
635 api_key: Option<&str>,
636 extra_headers: &CustomHeaders,
637) -> Result<BoxStream<'static, Result<ModelEvent>>> {
638 let uri = format!("{api_url}/models/sse");
639 let request = HttpRequest::builder()
640 .method(Method::GET)
641 .uri(uri)
642 .header("Accept", "text/event-stream")
643 .when_some(api_key, |builder, api_key| {
644 builder.header("Authorization", format!("Bearer {api_key}"))
645 })
646 .extra_headers(extra_headers)
647 .body(AsyncBody::default())?;
648
649 let mut response = client.send(request).await?;
650 if !response.status().is_success() {
651 let mut body = String::new();
652 response.body_mut().read_to_string(&mut body).await?;
653 anyhow::bail!(
654 "Failed to open llama.cpp model event stream: {} {}",
655 response.status(),
656 body,
657 );
658 }
659
660 let reader = BufReader::new(response.into_body());
661 Ok(reader
662 .lines()
663 .filter_map(|line| async move {
664 // Each event is a single `data:` line carrying the JSON envelope;
665 // other SSE lines (comments, blank separators) are ignored.
666 let line = line.ok()?;
667 let payload = line.strip_prefix("data:")?.trim_start();
668 Some(serde_json::from_str::<ModelEvent>(payload).map_err(|error| anyhow!(error)))
669 })
670 .boxed())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn parse_single_model_listing() {
679 let response = serde_json::json!({
680 "object": "list",
681 "data": [
682 {
683 "id": "../models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
684 "object": "model",
685 "created": 1735142223u64,
686 "owned_by": "llamacpp",
687 "meta": {
688 "n_ctx": 8192,
689 "n_ctx_train": 131072,
690 "n_embd": 4096,
691 }
692 }
693 ]
694 });
695 let response: ListModelsResponse = serde_json::from_value(response).unwrap();
696 assert_eq!(response.data.len(), 1);
697 let entry = &response.data[0];
698 assert!(!entry.is_router_entry());
699 assert_eq!(entry.meta.as_ref().unwrap().n_ctx, Some(8192));
700 assert_eq!(entry.meta.as_ref().unwrap().n_ctx_train, Some(131072));
701 }
702
703 #[test]
704 fn parse_router_model_listing() {
705 let response = serde_json::json!({
706 "object": "list",
707 "data": [
708 {
709 "id": "qwen2.5-coder",
710 "object": "model",
711 "owned_by": "llamacpp",
712 "status": { "value": "loaded", "args": [] },
713 "architecture": {
714 "input_modalities": ["text", "image"],
715 "output_modalities": ["text"]
716 }
717 },
718 {
719 "id": "gemma-3",
720 "object": "model",
721 "owned_by": "llamacpp",
722 "status": { "value": "unloaded" },
723 "architecture": { "input_modalities": ["text"] }
724 }
725 ]
726 });
727 let response: ListModelsResponse = serde_json::from_value(response).unwrap();
728 assert_eq!(response.data.len(), 2);
729
730 let loaded = &response.data[0];
731 assert!(loaded.is_router_entry());
732 assert!(loaded.is_loaded());
733 assert!(loaded.supports_images_hint());
734
735 let unloaded = &response.data[1];
736 assert!(unloaded.is_router_entry());
737 assert!(!unloaded.is_loaded());
738 assert!(!unloaded.supports_images_hint());
739 }
740
741 #[test]
742 fn parse_props() {
743 let response = serde_json::json!({
744 "default_generation_settings": {
745 "id": 0,
746 "n_ctx": 8192,
747 "params": {}
748 },
749 "total_slots": 1,
750 "model_path": "../models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
751 "modalities": { "vision": true, "audio": false },
752 // The real `/props` reports both keys; parsing must not treat that as
753 // a duplicate field.
754 "chat_template_caps": {
755 "supports_tools": true,
756 "supports_tool_calls": true,
757 "supports_preserve_reasoning": true,
758 "supports_system_role": true
759 }
760 });
761 let props: Props = serde_json::from_value(response).unwrap();
762 assert_eq!(props.context_length(), Some(8192));
763 assert!(props.supports_images());
764 assert!(props.supports_tools());
765 assert!(props.supports_thinking());
766 }
767
768 fn model_event(value: serde_json::Value) -> ModelEvent {
769 serde_json::from_value(value).unwrap()
770 }
771
772 #[test]
773 fn model_event_state_changes() {
774 // Terminal load/unload and list changes warrant re-discovery.
775 assert!(
776 model_event(serde_json::json!({
777 "model": "m", "event": "status_change", "data": { "status": "loaded" }
778 }))
779 .changes_model_state()
780 );
781 assert!(
782 model_event(serde_json::json!({
783 "model": "m", "event": "status_change", "data": { "status": "unloaded" }
784 }))
785 .changes_model_state()
786 );
787 assert!(
788 model_event(serde_json::json!({ "model": "*", "event": "models_reload" }))
789 .changes_model_state()
790 );
791 assert!(
792 model_event(serde_json::json!({ "model": "m", "event": "model_remove" }))
793 .changes_model_state()
794 );
795
796 // Intermediate loading-progress ticks do not.
797 assert!(
798 !model_event(serde_json::json!({
799 "model": "m", "event": "status_change",
800 "data": { "status": "loading", "progress": { "value": 0.4 } }
801 }))
802 .changes_model_state()
803 );
804 assert!(
805 !model_event(serde_json::json!({ "model": "m", "event": "download_progress" }))
806 .changes_model_state()
807 );
808 }
809
810 #[test]
811 fn model_event_load_failure() {
812 let failed = model_event(serde_json::json!({
813 "model": "m", "event": "status_change", "data": { "status": "unloaded", "exit_code": 1 }
814 }));
815 assert_eq!(failed.load_failure(), Some(1));
816
817 // A clean unload (exit code 0 or absent) is not a failure.
818 let clean = model_event(serde_json::json!({
819 "model": "m", "event": "status_change", "data": { "status": "unloaded", "exit_code": 0 }
820 }));
821 assert_eq!(clean.load_failure(), None);
822 let loaded = model_event(serde_json::json!({
823 "model": "m", "event": "status_change", "data": { "status": "loaded" }
824 }));
825 assert_eq!(loaded.load_failure(), None);
826 }
827
828 #[test]
829 fn model_event_load_progress() {
830 // We report the current stage's own value (0→1), not a blended overall.
831 let weights = model_event(serde_json::json!({
832 "model": "m", "event": "status_change",
833 "data": { "status": "loading",
834 "progress": { "stages": ["text_model"], "current": "text_model", "value": 0.4 } }
835 }));
836 let progress = weights.load_progress().unwrap();
837 assert!((progress.value - 0.4).abs() < 1e-4);
838 assert_eq!(progress.stage_label(), "Loading weights");
839 assert_eq!(progress.progress_label(), "Loading weights 40%");
840
841 // The projector stage runs 0→1 on its own (not 90→100%).
842 let projector = model_event(serde_json::json!({
843 "model": "m", "event": "status_change",
844 "data": { "status": "loading",
845 "progress": { "stages": ["text_model", "mmproj_model"],
846 "current": "mmproj_model", "value": 0.5 } }
847 }));
848 let progress = projector.load_progress().unwrap();
849 assert!((progress.value - 0.5).abs() < 1e-4);
850 assert_eq!(progress.stage_label(), "Loading projector");
851
852 // Non-loading events carry no progress.
853 let loaded = model_event(serde_json::json!({
854 "model": "m", "event": "status_change", "data": { "status": "loaded" }
855 }));
856 assert!(loaded.load_progress().is_none());
857
858 // Bare stage-transition markers (no stages/current/value) are skipped so
859 // the indicator doesn't flicker to 0% between stages.
860 let transition = model_event(serde_json::json!({
861 "model": "m", "event": "status_change",
862 "data": { "status": "loading", "progress": { "stage": "mmproj_model" } }
863 }));
864 assert!(transition.load_progress().is_none());
865 }
866
867 #[test]
868 fn parse_streaming_reasoning_and_tool_calls() {
869 let event = serde_json::json!({
870 "model": "llama",
871 "object": "chat.completion.chunk",
872 "choices": [
873 {
874 "index": 0,
875 "delta": {
876 "role": "assistant",
877 "content": null,
878 "reasoning_content": "thinking...",
879 "tool_calls": [
880 {
881 "index": 0,
882 "id": "call_1",
883 "function": { "name": "weather", "arguments": "{\"city\":" }
884 }
885 ]
886 },
887 "finish_reason": null
888 }
889 ]
890 });
891 let event: ResponseStreamEvent = serde_json::from_value(event).unwrap();
892 let delta = &event.choices[0].delta;
893 assert_eq!(delta.reasoning_content.as_deref(), Some("thinking..."));
894 assert_eq!(delta.tool_calls.as_ref().unwrap().len(), 1);
895 }
896
897 #[test]
898 fn encodes_router_model_id_for_props_query() {
899 // Router ids contain `/` and `:`, which must be URL-encoded in the
900 // `?model=` query, while unreserved characters (`.`, `-`) stay literal.
901 let url = Url::parse_with_params(
902 "http://localhost:8080/props",
903 [("model", "unsloth/Qwen3.5-2B-GGUF:Q4_1")],
904 )
905 .unwrap();
906 assert_eq!(
907 url.as_str(),
908 "http://localhost:8080/props?model=unsloth%2FQwen3.5-2B-GGUF%3AQ4_1"
909 );
910 }
911}
912