Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:35:53.802Z 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

llama_cpp.rs

912 lines · 30.8 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.