uni_xervo/provider/llamacpp/
chat.rs1use 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#[derive(Debug, Clone)]
74pub(crate) struct ChatConfig {
75 pub common: LlamaCppCommon,
76}
77
78impl ChatConfig {
79 pub(crate) fn from_options(options: &Value) -> Result<Self> {
82 Ok(Self {
83 common: LlamaCppCommon::from_options(options)?,
84 })
85 }
86}
87
88pub(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 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 None,
128 )
129 .await?;
130 parse_chat_response(&response)
131 }
132}
133
134fn 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 "stream": false,
145 });
146
147 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
162fn 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 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
202fn 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
229pub 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 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#[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 #[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 #[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 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 #[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 #[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 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}