Skip to main content

uni_xervo/provider/llamacpp/
chat.rs

1//! [`ModelTask::Generate`](crate::api::ModelTask::Generate) on
2//! `POST /v1/chat/completions`.
3//!
4//! `llama-server`'s chat endpoint is OpenAI-compatible, including the
5//! multimodal `content` parts array. A server started with `--mmproj` loads a
6//! projector alongside the language model and accepts
7//! `{"type": "image_url", ...}` parts; one started without it is text-only and
8//! rejects them. **Vision is therefore a property of the server process, not of
9//! the alias**, which is why there is deliberately no `pipeline`-style option
10//! here: point an alias at a vision-capable server and images work.
11//!
12//! [`LlamaCppChatClient`] is the shared request/response seam. It is public to
13//! the crate so other tasks that are really "a chat call with a fixed prompt"
14//! can reuse it rather than re-deriving the wire format.
15//!
16//! # Options
17//!
18//! None beyond the shared keys documented on the [parent module](super).
19//!
20//! # Input length
21//!
22//! There is no client-side truncation pre-pass here, unlike
23//! [`embed`](super::embed). The embedding pre-pass works by asking the server's
24//! `/tokenize` endpoint to count a prompt exactly, but that endpoint only sees
25//! text: the tokens an image expands into are produced inside the multimodal
26//! projector and are invisible to it, so a budget covering a vision request
27//! cannot be computed. Overflow is left to the server, whose rejection becomes
28//! a non-retryable [`RuntimeError::InferenceError`] naming `--ctx-size` — see
29//! [`map_llamacpp_status`](super::map_llamacpp_status).
30//!
31//! # Reasoning models
32//!
33//! On a model with a thinking phase, `llama-server` splits the assistant turn
34//! in two: `reasoning_content` carries the chain of thought and `content`
35//! carries the answer. Only `content` becomes
36//! [`GenerationResult::text`](crate::traits::GenerationResult::text) — folding
37//! the reasoning in would corrupt every caller that parses the output, such as
38//! document extraction.
39//!
40//! One consequence is worth knowing when a result comes back empty: if
41//! `max_tokens` is exhausted *during* the thinking phase, the server returns
42//! `finish_reason: "length"` with all the tokens in `reasoning_content` and
43//! `content` empty. The generation succeeded and the usage counts are
44//! non-zero; the budget simply never reached the answer. Raise `max_tokens`.
45//!
46//! # Unused result fields
47//!
48//! [`GenerationResult::images`] and [`GenerationResult::audio`] are always
49//! empty: `llama-server` has no image-generation endpoint, and its speech
50//! synthesis lives on a separate native `POST /tts` route rather than on chat
51//! completions. [`GenerationOptions::width`] and `height` are ignored for the
52//! same reason.
53
54use super::{LlamaCppCommon, post_json};
55use crate::error::{Result, RuntimeError};
56use crate::provider::remote_common::build_openai_chat_messages;
57use crate::traits::{
58    GenerationOptions, GenerationResult, GeneratorModel, Message, ModelInfo, TokenUsage,
59};
60use async_trait::async_trait;
61use reqwest::Client;
62use serde_json::{Value, json};
63
64// ---------------------------------------------------------------------------
65// Configuration
66// ---------------------------------------------------------------------------
67
68/// Resolved generation configuration for one alias.
69///
70/// Generation adds no keys of its own, so this is the shared set verbatim. It
71/// exists as a named type so the `load` dispatch reads the same way for every
72/// task.
73#[derive(Debug, Clone)]
74pub(crate) struct ChatConfig {
75    pub common: LlamaCppCommon,
76}
77
78impl ChatConfig {
79    /// Parse the alias `options`. The catalog validator already enforces the
80    /// same rules, but `ModelProvider::load` is public, so this is defensive.
81    pub(crate) fn from_options(options: &Value) -> Result<Self> {
82        Ok(Self {
83            common: LlamaCppCommon::from_options(options)?,
84        })
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Chat client (no circuit breaker)
90// ---------------------------------------------------------------------------
91
92/// The `/v1/chat/completions` request/response seam for one model.
93///
94/// Deliberately free of reliability concerns: the caller owns the circuit
95/// breaker, because a task built on top of this (a per-page document
96/// extraction loop, say) wants one breaker call around the whole job rather
97/// than one per HTTP request.
98pub(crate) struct LlamaCppChatClient {
99    client: Client,
100    common: LlamaCppCommon,
101    model_id: String,
102}
103
104impl LlamaCppChatClient {
105    pub(crate) fn new(client: Client, common: LlamaCppCommon, model_id: String) -> Self {
106        Self {
107            client,
108            common,
109            model_id,
110        }
111    }
112
113    /// Run one chat completion.
114    pub(crate) async fn complete(
115        &self,
116        messages: &[Message],
117        options: &GenerationOptions,
118    ) -> Result<GenerationResult> {
119        let body = build_chat_payload(&self.model_id, messages, options);
120        let response = post_json(
121            &self.client,
122            &self.common,
123            format!("{}/chat/completions", self.common.base_url),
124            &body,
125            // Generation has no `max_input_tokens` option, so an overflow can
126            // only be answered with the server's own flags.
127            None,
128        )
129        .await?;
130        parse_chat_response(&response)
131    }
132}
133
134/// Build the `/v1/chat/completions` request body.
135///
136/// Sampling keys are omitted entirely when `None` so the server applies its
137/// own defaults rather than being pinned to ours.
138fn build_chat_payload(model_id: &str, messages: &[Message], options: &GenerationOptions) -> Value {
139    let mut body = json!({
140        "model": model_id,
141        "messages": build_openai_chat_messages(messages),
142        // `llama-server` can stream, but `GeneratorModel::generate` returns a
143        // whole result, so ask for one response rather than reassembling SSE.
144        "stream": false,
145    });
146
147    // `max_tokens` rather than OpenAI's newer `max_completion_tokens`: both
148    // are accepted by current builds, but only `max_tokens` works all the way
149    // back, and `llama-server` treats them identically.
150    if let Some(max_tokens) = options.max_tokens {
151        body["max_tokens"] = json!(max_tokens);
152    }
153    if let Some(temperature) = options.temperature {
154        body["temperature"] = json!(temperature);
155    }
156    if let Some(top_p) = options.top_p {
157        body["top_p"] = json!(top_p);
158    }
159    body
160}
161
162/// Read the first choice's message content and the usage block.
163///
164/// A response with no `choices` is a protocol violation worth surfacing, not a
165/// silent empty string: callers such as the document extractor cannot tell an
166/// empty page from a broken server otherwise.
167fn parse_chat_response(body: &Value) -> Result<GenerationResult> {
168    let choice = body
169        .get("choices")
170        .and_then(|c| c.as_array())
171        .and_then(|c| c.first())
172        .ok_or_else(|| {
173            RuntimeError::ApiError(
174                "llama.cpp /v1/chat/completions response malformed: missing 'choices'".to_string(),
175            )
176        })?;
177
178    // An assistant turn that stopped immediately has `content: null`, which is
179    // a legitimately empty generation rather than a malformed response. So is
180    // a reasoning model that spent its whole budget on `reasoning_content`
181    // (see the module header) — deliberately not read here.
182    let text = match choice.pointer("/message/content") {
183        Some(Value::String(s)) => s.clone(),
184        Some(Value::Null) | None => String::new(),
185        Some(other) => {
186            return Err(RuntimeError::ApiError(format!(
187                "llama.cpp /v1/chat/completions response malformed: \
188                 message content is {} rather than a string",
189                kind_of(other)
190            )));
191        }
192    };
193
194    Ok(GenerationResult {
195        text,
196        usage: parse_usage(body.get("usage")),
197        images: vec![],
198        audio: None,
199    })
200}
201
202/// Map `usage` when the server reported it.
203///
204/// `total_tokens` is derived when absent so a caller asserting on it does not
205/// have to special-case a server that only reports the two halves.
206fn parse_usage(usage: Option<&Value>) -> Option<TokenUsage> {
207    let usage = usage?;
208    let field = |key: &str| usage.get(key).and_then(|v| v.as_u64()).map(|v| v as usize);
209    let prompt_tokens = field("prompt_tokens").unwrap_or(0);
210    let completion_tokens = field("completion_tokens").unwrap_or(0);
211    Some(TokenUsage {
212        prompt_tokens,
213        completion_tokens,
214        total_tokens: field("total_tokens").unwrap_or(prompt_tokens + completion_tokens),
215    })
216}
217
218fn kind_of(value: &Value) -> &'static str {
219    match value {
220        Value::Null => "null",
221        Value::Bool(_) => "a boolean",
222        Value::Number(_) => "a number",
223        Value::String(_) => "a string",
224        Value::Array(_) => "an array",
225        Value::Object(_) => "an object",
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Generator model
231// ---------------------------------------------------------------------------
232
233/// Text and vision generation backed by a llama.cpp server.
234pub struct LlamaCppGeneratorModel {
235    cb: crate::reliability::CircuitBreakerWrapper,
236    model_id: String,
237    chat: LlamaCppChatClient,
238}
239
240impl LlamaCppGeneratorModel {
241    pub(crate) fn new(
242        client: Client,
243        cb: crate::reliability::CircuitBreakerWrapper,
244        model_id: String,
245        cfg: ChatConfig,
246    ) -> Self {
247        Self {
248            cb,
249            model_id: model_id.clone(),
250            chat: LlamaCppChatClient::new(client, cfg.common, model_id),
251        }
252    }
253}
254
255#[async_trait]
256impl GeneratorModel for LlamaCppGeneratorModel {
257    async fn generate(
258        &self,
259        messages: &[Message],
260        options: GenerationOptions,
261    ) -> Result<GenerationResult> {
262        let messages = messages.to_vec();
263
264        // Deterministic input-size rejections (`InferenceError`, produced only
265        // by `map_llamacpp_status`) are a configuration problem rather than a
266        // server outage, so they are returned as `Ok(Err(..))` — the breaker
267        // sees a success and stays closed — and flattened here. Same contract
268        // as `embed`.
269        self.cb
270            .call(move || async move {
271                match self.chat.complete(&messages, &options).await {
272                    Err(e @ RuntimeError::InferenceError(_)) => Ok(Err(e)),
273                    other => other.map(Ok),
274                }
275            })
276            .await?
277    }
278}
279
280impl ModelInfo for LlamaCppGeneratorModel {
281    fn model_id(&self) -> &str {
282        &self.model_id
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Tests (no HTTP)
288// ---------------------------------------------------------------------------
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::traits::{ContentBlock, ImageInput, MessageRole};
294
295    fn opts() -> Value {
296        json!({ "base_url": "http://127.0.0.1:8080/v1" })
297    }
298
299    // --- config -------------------------------------------------------------
300
301    #[test]
302    fn config_needs_only_the_shared_keys() {
303        let cfg = ChatConfig::from_options(&opts()).unwrap();
304        assert_eq!(cfg.common.base_url, "http://127.0.0.1:8080/v1");
305    }
306
307    #[test]
308    fn config_requires_base_url() {
309        let err = ChatConfig::from_options(&json!({})).expect_err("must fail");
310        assert!(err.to_string().contains("base_url"), "{err}");
311    }
312
313    // --- payload ------------------------------------------------------------
314
315    #[test]
316    fn payload_omits_every_unset_sampling_key() {
317        let body = build_chat_payload(
318            "qwen",
319            &[Message::user("hi")],
320            &GenerationOptions::default(),
321        );
322        assert_eq!(body["model"], json!("qwen"));
323        assert_eq!(body["stream"], json!(false));
324        assert_eq!(body["messages"][0]["role"], json!("user"));
325        assert_eq!(body["messages"][0]["content"], json!("hi"));
326        for key in ["max_tokens", "temperature", "top_p"] {
327            assert!(body.get(key).is_none(), "{key} should be absent");
328        }
329    }
330
331    #[test]
332    fn payload_carries_the_sampling_keys_that_are_set() {
333        let body = build_chat_payload(
334            "qwen",
335            &[Message::user("hi")],
336            &GenerationOptions {
337                max_tokens: Some(64),
338                temperature: Some(0.5),
339                top_p: Some(0.75),
340                // Diffusion-only; must not reach the wire.
341                width: Some(512),
342                height: Some(512),
343            },
344        );
345        assert_eq!(body["max_tokens"], json!(64));
346        assert_eq!(body["temperature"], json!(0.5));
347        assert_eq!(body["top_p"], json!(0.75));
348        assert!(body.get("width").is_none());
349        assert!(body.get("height").is_none());
350    }
351
352    #[test]
353    fn payload_sends_images_as_content_parts() {
354        let body = build_chat_payload(
355            "smolvlm",
356            &[Message {
357                role: MessageRole::User,
358                content: vec![
359                    ContentBlock::Text("describe".into()),
360                    ContentBlock::Image(ImageInput::Bytes {
361                        data: vec![1, 2, 3],
362                        media_type: "image/png".into(),
363                    }),
364                ],
365            }],
366            &GenerationOptions::default(),
367        );
368        let parts = body["messages"][0]["content"].as_array().unwrap();
369        assert_eq!(parts.len(), 2);
370        assert_eq!(parts[0]["type"], json!("text"));
371        assert_eq!(parts[1]["type"], json!("image_url"));
372        assert_eq!(
373            parts[1]["image_url"]["url"],
374            json!("data:image/png;base64,AQID")
375        );
376    }
377
378    #[test]
379    fn payload_preserves_role_and_message_order() {
380        let body = build_chat_payload(
381            "qwen",
382            &[
383                Message::system("be terse"),
384                Message::user("hi"),
385                Message::assistant("hello"),
386            ],
387            &GenerationOptions::default(),
388        );
389        let roles: Vec<_> = body["messages"]
390            .as_array()
391            .unwrap()
392            .iter()
393            .map(|m| m["role"].as_str().unwrap().to_string())
394            .collect();
395        assert_eq!(roles, ["system", "user", "assistant"]);
396    }
397
398    // --- response -----------------------------------------------------------
399
400    #[test]
401    fn response_maps_text_and_usage() {
402        let body = json!({
403            "choices": [{ "message": { "role": "assistant", "content": "a moon" } }],
404            "usage": { "prompt_tokens": 11, "completion_tokens": 3, "total_tokens": 14 }
405        });
406        let result = parse_chat_response(&body).unwrap();
407        assert_eq!(result.text, "a moon");
408        assert!(result.images.is_empty());
409        assert!(result.audio.is_none());
410        let usage = result.usage.unwrap();
411        assert_eq!(usage.prompt_tokens, 11);
412        assert_eq!(usage.completion_tokens, 3);
413        assert_eq!(usage.total_tokens, 14);
414    }
415
416    #[test]
417    fn response_derives_a_missing_total() {
418        let body = json!({
419            "choices": [{ "message": { "content": "x" } }],
420            "usage": { "prompt_tokens": 5, "completion_tokens": 2 }
421        });
422        assert_eq!(
423            parse_chat_response(&body)
424                .unwrap()
425                .usage
426                .unwrap()
427                .total_tokens,
428            7
429        );
430    }
431
432    /// A reasoning model's chain of thought is not the answer, and must not
433    /// leak into `text` — not even when `content` is empty because the token
434    /// budget ran out mid-thought.
435    #[test]
436    fn response_never_folds_reasoning_content_into_text() {
437        let body = json!({
438            "choices": [{
439                "finish_reason": "length",
440                "message": {
441                    "role": "assistant",
442                    "content": "",
443                    "reasoning_content": "Thinking Process: 1. Analyse the request",
444                },
445            }],
446            "usage": { "prompt_tokens": 32, "completion_tokens": 64, "total_tokens": 96 }
447        });
448        let result = parse_chat_response(&body).unwrap();
449        assert_eq!(result.text, "");
450        // The call still succeeded, and usage proves the tokens were spent.
451        assert_eq!(result.usage.unwrap().completion_tokens, 64);
452    }
453
454    #[test]
455    fn response_without_usage_reports_none() {
456        let body = json!({ "choices": [{ "message": { "content": "x" } }] });
457        assert!(parse_chat_response(&body).unwrap().usage.is_none());
458    }
459
460    #[test]
461    fn response_treats_null_or_absent_content_as_empty_text() {
462        for message in [
463            json!({ "content": Value::Null }),
464            json!({ "role": "assistant" }),
465        ] {
466            let body = json!({ "choices": [{ "message": message }] });
467            assert_eq!(parse_chat_response(&body).unwrap().text, "");
468        }
469    }
470
471    #[test]
472    fn response_without_choices_is_an_error() {
473        for body in [
474            json!({}),
475            json!({ "choices": [] }),
476            json!({ "choices": "x" }),
477        ] {
478            let err = parse_chat_response(&body).expect_err("must fail");
479            assert!(
480                matches!(err, RuntimeError::ApiError(_)) && err.to_string().contains("choices"),
481                "{err:?}"
482            );
483        }
484    }
485
486    #[test]
487    fn response_with_non_string_content_is_an_error() {
488        let body = json!({ "choices": [{ "message": { "content": [{ "type": "text" }] } }] });
489        let err = parse_chat_response(&body).expect_err("must fail");
490        assert!(err.to_string().contains("an array"), "{err}");
491    }
492}