Skip to main content

uni_xervo/provider/
llamacpp.rs

1//! Remote embedding provider for a llama.cpp [`llama-server`](https://github.com/ggml-org/llama.cpp/tree/master/tools/server).
2//!
3//! `llama-server` exposes an OpenAI-compatible `POST /v1/embeddings` endpoint,
4//! but it differs from hosted embedding APIs in one way that matters for
5//! production ingestion: **it never truncates embedding input**. Encoder
6//! models such as BGE (BERT) are non-causal, so the whole prompt has to fit in
7//! a single physical batch (`--ubatch-size`) and inside the context window
8//! (`--ctx-size`); anything longer is rejected with an HTTP error instead of
9//! being cut to size.
10//!
11//! This provider therefore runs a deterministic, tokenizer-aware pre-pass
12//! before every embedding call:
13//!
14//! 1. Texts that provably fit are sent as plain strings. Every WordPiece token
15//!    consumes at least one character, so a text with at most
16//!    `max_input_tokens - 2` characters cannot exceed the budget once the two
17//!    special tokens (`[CLS]`/`[SEP]`) are added.
18//! 2. Longer texts are sent to the server's native `POST /tokenize` endpoint
19//!    with `add_special: true`, which returns the exact id sequence the server
20//!    would embed, special tokens included.
21//! 3. If that sequence exceeds `max_input_tokens`, the provider keeps the
22//!    first `max_input_tokens - 1` ids and re-appends the trailing special
23//!    token, then sends the **token array** (not text) to `/v1/embeddings`.
24//!    llama.cpp passes integer arrays through verbatim, so the server embeds
25//!    exactly the bounded sequence — no lossy detokenize round trip.
26//!
27//! Batches preserve input order and produce exactly one vector per input.
28//!
29//! # Options
30//!
31//! | key | type | required | meaning |
32//! |-----|------|----------|---------|
33//! | `base_url` | string | yes | OpenAI-compatible root including `/v1`, e.g. `http://127.0.0.1:8080/v1` |
34//! | `tokenizer_base_url` | string | no | server root for `/tokenize`; defaults to `base_url` with a trailing `/v1` removed |
35//! | `max_input_tokens` | integer ≥ 3 | yes | total token budget **including** special tokens (512 for BGE) |
36//! | `embedding_dimensions` | integer > 0 | yes | expected vector width (384 for BGE Small) |
37//! | `api_key_env` | string | no | env var holding a bearer token; omit when the server runs without `--api-key` |
38//! | `request_timeout_secs` | integer > 0 | no | per-HTTP-request timeout, default 60 |
39//!
40//! `max_input_tokens` must not exceed the server's `--ubatch-size` or
41//! `--ctx-size`; if it does, the server's rejection surfaces as
42//! [`RuntimeError::InferenceError`] naming the option.
43
44use crate::api::{ModelAliasSpec, ModelTask};
45use crate::error::{Result, RuntimeError};
46use crate::provider::remote_common::{RemoteProviderBase, parse_openai_embeddings_response};
47use crate::traits::{
48    EmbedResult, EmbeddingModel, LoadedModelHandle, ModelProvider, ProviderCapabilities,
49    ProviderHealth,
50};
51use async_trait::async_trait;
52use futures::future::try_join_all;
53use reqwest::Client;
54use serde_json::{Value, json};
55use std::sync::Arc;
56use std::time::Duration;
57
58/// Provider id used in [`ModelAliasSpec::provider_id`].
59pub const PROVIDER_ID: &str = "remote/llamacpp";
60
61/// Default per-request HTTP timeout when `request_timeout_secs` is unset.
62pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
63
64/// Smallest meaningful `max_input_tokens`: two special tokens plus one
65/// content token.
66pub const MIN_MAX_INPUT_TOKENS: usize = 3;
67
68/// Remote provider for a llama.cpp `llama-server`. Supports
69/// [`ModelTask::Embed`] only.
70pub struct RemoteLlamaCppProvider {
71    base: RemoteProviderBase,
72}
73
74impl Default for RemoteLlamaCppProvider {
75    fn default() -> Self {
76        Self {
77            base: RemoteProviderBase::new(),
78        }
79    }
80}
81
82impl RemoteLlamaCppProvider {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    #[cfg(test)]
88    fn insert_test_breaker(&self, key: crate::api::ModelRuntimeKey, age: Duration) {
89        self.base.insert_test_breaker(key, age);
90    }
91
92    #[cfg(test)]
93    fn breaker_count(&self) -> usize {
94        self.base.breaker_count()
95    }
96
97    #[cfg(test)]
98    fn force_cleanup_now_for_test(&self) {
99        self.base.force_cleanup_now_for_test();
100    }
101}
102
103#[async_trait]
104impl ModelProvider for RemoteLlamaCppProvider {
105    fn provider_id(&self) -> &'static str {
106        PROVIDER_ID
107    }
108
109    fn capabilities(&self) -> ProviderCapabilities {
110        ProviderCapabilities {
111            supported_tasks: vec![ModelTask::Embed],
112        }
113    }
114
115    async fn load(&self, spec: &ModelAliasSpec) -> Result<LoadedModelHandle> {
116        match spec.task {
117            ModelTask::Embed => {
118                let cfg = LlamaCppConfig::from_options(&spec.options)?;
119                let model = LlamaCppEmbeddingModel {
120                    client: self.base.client.clone(),
121                    cb: self.base.circuit_breaker_for(spec),
122                    model_id: spec.model_id.clone(),
123                    cfg,
124                };
125                let handle: Arc<dyn EmbeddingModel> = Arc::new(model);
126                Ok(Arc::new(handle) as LoadedModelHandle)
127            }
128            other => Err(RuntimeError::CapabilityMismatch(format!(
129                "llama.cpp provider does not support task {:?}",
130                other
131            ))),
132        }
133    }
134
135    async fn health(&self) -> ProviderHealth {
136        ProviderHealth::Healthy
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Configuration
142// ---------------------------------------------------------------------------
143
144/// Resolved, validated provider configuration for one alias.
145#[derive(Debug, Clone)]
146pub(crate) struct LlamaCppConfig {
147    /// OpenAI-compatible root including `/v1`, no trailing slash.
148    pub base_url: String,
149    /// Server root for `/tokenize`, no trailing slash.
150    pub tokenizer_base_url: String,
151    /// Total token budget including special tokens.
152    pub max_input_tokens: usize,
153    /// Expected embedding width.
154    pub dimensions: u32,
155    /// Bearer token, if `api_key_env` was configured.
156    pub api_key: Option<String>,
157    /// Per-HTTP-request timeout.
158    pub request_timeout: Duration,
159}
160
161impl LlamaCppConfig {
162    /// Parse the alias `options`. The catalog validator already enforces the
163    /// same rules, but `ModelProvider::load` is public, so this is defensive.
164    pub(crate) fn from_options(options: &Value) -> Result<Self> {
165        let cfg_err = |msg: String| RuntimeError::Config(msg);
166        let required = |key: &str| {
167            cfg_err(format!(
168                "Option '{}' for provider '{}' is required",
169                key, PROVIDER_ID
170            ))
171        };
172
173        let base_url = options
174            .get("base_url")
175            .and_then(|v| v.as_str())
176            .map(|s| s.trim().trim_end_matches('/').to_string())
177            .filter(|s| !s.is_empty())
178            .ok_or_else(|| required("base_url"))?;
179
180        let tokenizer_base_url = match options.get("tokenizer_base_url").and_then(|v| v.as_str()) {
181            Some(raw) if !raw.trim().is_empty() => raw.trim().trim_end_matches('/').to_string(),
182            _ => resolve_tokenizer_base_url(&base_url),
183        };
184
185        let max_input_tokens = options
186            .get("max_input_tokens")
187            .and_then(|v| v.as_u64())
188            .ok_or_else(|| required("max_input_tokens"))? as usize;
189        if max_input_tokens < MIN_MAX_INPUT_TOKENS {
190            return Err(cfg_err(format!(
191                "Option 'max_input_tokens' for provider '{}' must be at least {} \
192                 (two special tokens plus one content token)",
193                PROVIDER_ID, MIN_MAX_INPUT_TOKENS
194            )));
195        }
196
197        let dimensions = options
198            .get("embedding_dimensions")
199            .and_then(|v| v.as_u64())
200            .filter(|d| *d > 0 && *d <= u32::MAX as u64)
201            .ok_or_else(|| required("embedding_dimensions"))? as u32;
202
203        let api_key = match options.get("api_key_env").and_then(|v| v.as_str()) {
204            None => None,
205            Some(env_name) => Some(
206                std::env::var(env_name)
207                    .map_err(|_| cfg_err(format!("{} env var not set", env_name)))?,
208            ),
209        };
210
211        let request_timeout = Duration::from_secs(
212            options
213                .get("request_timeout_secs")
214                .and_then(|v| v.as_u64())
215                .filter(|s| *s > 0)
216                .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS),
217        );
218
219        Ok(Self {
220            base_url,
221            tokenizer_base_url,
222            max_input_tokens,
223            dimensions,
224            api_key,
225            request_timeout,
226        })
227    }
228}
229
230/// Derive the server root for `/tokenize` from an OpenAI-style `base_url`:
231/// strip trailing slashes, then exactly one trailing `/v1` path segment.
232///
233/// ```text
234/// http://h:8080/v1      -> http://h:8080
235/// http://h:8080/v1/     -> http://h:8080
236/// http://h:8080/api/v1  -> http://h:8080/api
237/// http://h:8080         -> http://h:8080   (unchanged)
238/// http://h:8080/v10     -> http://h:8080/v10 (unchanged)
239/// ```
240pub(crate) fn resolve_tokenizer_base_url(base_url: &str) -> String {
241    let trimmed = base_url.trim_end_matches('/');
242    match trimmed.strip_suffix("/v1") {
243        Some(root) if !root.is_empty() => root.to_string(),
244        _ => trimmed.to_string(),
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Input planning (pure)
250// ---------------------------------------------------------------------------
251
252/// Wire form of one element of the `/v1/embeddings` `input` array.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub(crate) enum EmbedInput {
255    /// Send the original text; the server adds special tokens itself.
256    Text(String),
257    /// Send a pre-bounded token id sequence; the server embeds it verbatim.
258    Tokens(Vec<u32>),
259}
260
261impl EmbedInput {
262    pub(crate) fn to_json(&self) -> Value {
263        match self {
264            EmbedInput::Text(t) => Value::String(t.clone()),
265            EmbedInput::Tokens(ids) => json!(ids),
266        }
267    }
268}
269
270/// `true` when `text` might exceed the budget and must be tokenized to know.
271///
272/// Every WordPiece token consumes at least one character, so a text with at
273/// most `max_input_tokens - 2` characters (two slots reserved for the special
274/// tokens) provably fits without a tokenizer round trip.
275pub(crate) fn needs_tokenize(text: &str, max_input_tokens: usize) -> bool {
276    text.chars().count() > max_input_tokens.saturating_sub(2)
277}
278
279/// Decide the wire form for `text` given its full `add_special: true` token
280/// sequence. Fits → original text. Too long → the first
281/// `max_input_tokens - 1` ids plus the original trailing special token.
282pub(crate) fn plan_input(text: &str, tokens: &[u32], max_input_tokens: usize) -> EmbedInput {
283    if tokens.is_empty() || tokens.len() <= max_input_tokens {
284        return EmbedInput::Text(text.to_string());
285    }
286    let keep = max_input_tokens.saturating_sub(1).max(1);
287    let mut bounded: Vec<u32> = tokens[..keep].to_vec();
288    bounded.push(*tokens.last().expect("non-empty"));
289    EmbedInput::Tokens(bounded)
290}
291
292// ---------------------------------------------------------------------------
293// Error mapping (pure)
294// ---------------------------------------------------------------------------
295
296/// Classification of a llama.cpp error body.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub(crate) enum LlamaCppErrorKind {
299    /// Prompt longer than the physical batch (`--ubatch-size`); HTTP 500 in
300    /// current builds.
301    InputTooLarge,
302    /// Prompt longer than the context window (`--ctx-size`).
303    ExceedsContext,
304    /// Anything else.
305    Other,
306}
307
308const ERROR_SNIPPET_MAX_CHARS: usize = 200;
309
310/// Parse `{"error":{"code","message","type"}}` leniently and classify it.
311/// Returns the kind plus a human-readable message (or a truncated raw snippet
312/// when the body is not that shape).
313pub(crate) fn classify_error_body(body: &str) -> (LlamaCppErrorKind, String) {
314    let parsed: Option<Value> = serde_json::from_str(body).ok();
315    let err_obj = parsed.as_ref().and_then(|v| v.get("error"));
316    let message = err_obj
317        .and_then(|e| e.get("message"))
318        .and_then(|m| m.as_str())
319        .map(|s| s.to_string())
320        .unwrap_or_else(|| body.chars().take(ERROR_SNIPPET_MAX_CHARS).collect());
321    let err_type = err_obj
322        .and_then(|e| e.get("type"))
323        .and_then(|t| t.as_str())
324        .unwrap_or("");
325
326    let lower = message.to_ascii_lowercase();
327    let kind = if lower.contains("too large to process") {
328        LlamaCppErrorKind::InputTooLarge
329    } else if err_type == "exceed_context_size"
330        || lower.contains("exceeds the available context size")
331    {
332        LlamaCppErrorKind::ExceedsContext
333    } else {
334        LlamaCppErrorKind::Other
335    };
336    (kind, message)
337}
338
339/// Map a non-2xx llama.cpp response to a [`RuntimeError`].
340///
341/// Input-size rejections are deterministic client-side configuration problems
342/// (the server's `--ubatch-size`/`--ctx-size` is smaller than
343/// `max_input_tokens`), so they become [`RuntimeError::InferenceError`] — not
344/// retryable and, see [`LlamaCppEmbeddingModel::embed`], not counted against
345/// the circuit breaker — regardless of the HTTP status the server chose.
346pub(crate) fn map_llamacpp_status(
347    status: u16,
348    body: &str,
349    max_input_tokens: usize,
350) -> RuntimeError {
351    let (kind, message) = classify_error_body(body);
352    match kind {
353        LlamaCppErrorKind::InputTooLarge | LlamaCppErrorKind::ExceedsContext => {
354            RuntimeError::InferenceError(format!(
355                "llama.cpp server rejected the input length ({}); max_input_tokens={} is \
356                 larger than the server allows — lower max_input_tokens or start llama-server \
357                 with --ubatch-size and --ctx-size of at least that many tokens",
358                message, max_input_tokens
359            ))
360        }
361        LlamaCppErrorKind::Other => match status {
362            429 => RuntimeError::RateLimited,
363            401 | 403 => RuntimeError::Unauthorized,
364            500..=599 => RuntimeError::Unavailable,
365            _ => RuntimeError::ApiError(format!("llama.cpp API error: {}: {}", status, message)),
366        },
367    }
368}
369
370/// Map a reqwest transport error.
371fn map_reqwest_error(e: reqwest::Error) -> RuntimeError {
372    if e.is_timeout() {
373        RuntimeError::Timeout
374    } else {
375        RuntimeError::ApiError(e.to_string())
376    }
377}
378
379/// Check every vector has the configured width.
380pub(crate) fn validate_dimensions(vectors: &[Vec<f32>], expected: u32) -> Result<()> {
381    for (i, v) in vectors.iter().enumerate() {
382        if v.len() != expected as usize {
383            return Err(RuntimeError::ApiError(format!(
384                "llama.cpp returned a {}-dimensional vector at index {}, expected {} \
385                 (check the embedding_dimensions option)",
386                v.len(),
387                i,
388                expected
389            )));
390        }
391    }
392    Ok(())
393}
394
395// ---------------------------------------------------------------------------
396// Embedding model
397// ---------------------------------------------------------------------------
398
399/// Embedding model backed by a llama.cpp server.
400pub struct LlamaCppEmbeddingModel {
401    client: Client,
402    cb: crate::reliability::CircuitBreakerWrapper,
403    model_id: String,
404    cfg: LlamaCppConfig,
405}
406
407impl LlamaCppEmbeddingModel {
408    fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
409        match &self.cfg.api_key {
410            Some(key) => req.header("Authorization", format!("Bearer {}", key)),
411            None => req,
412        }
413    }
414
415    /// POST JSON, apply timeout/auth, and map non-2xx responses.
416    async fn post_json(&self, url: String, payload: &Value) -> Result<Value> {
417        let response = self
418            .apply_auth(self.client.post(url))
419            .timeout(self.cfg.request_timeout)
420            .json(payload)
421            .send()
422            .await
423            .map_err(map_reqwest_error)?;
424
425        let status = response.status().as_u16();
426        let text = response.text().await.map_err(map_reqwest_error)?;
427        if !(200..300).contains(&status) {
428            return Err(map_llamacpp_status(
429                status,
430                &text,
431                self.cfg.max_input_tokens,
432            ));
433        }
434        serde_json::from_str(&text).map_err(|e| {
435            RuntimeError::ApiError(format!("llama.cpp returned malformed JSON: {}", e))
436        })
437    }
438
439    /// Call `/tokenize` with special tokens so the count matches what the
440    /// server would embed for the plain string.
441    async fn tokenize(&self, text: &str) -> Result<Vec<u32>> {
442        let body = self
443            .post_json(
444                format!("{}/tokenize", self.cfg.tokenizer_base_url),
445                &json!({
446                    // `model` is required in router mode so the request reaches
447                    // the tokenizer of the model that will do the embedding;
448                    // single-model servers ignore it.
449                    "model": self.model_id,
450                    "content": text,
451                    "add_special": true,
452                    "parse_special": false,
453                }),
454            )
455            .await?;
456        let tokens = body
457            .get("tokens")
458            .and_then(|t| t.as_array())
459            .ok_or_else(|| {
460                RuntimeError::ApiError(
461                    "llama.cpp /tokenize response malformed: missing 'tokens' array".to_string(),
462                )
463            })?;
464        tokens
465            .iter()
466            .enumerate()
467            .map(|(i, t)| {
468                t.as_u64()
469                    .filter(|v| *v <= u32::MAX as u64)
470                    .map(|v| v as u32)
471                    .ok_or_else(|| {
472                        RuntimeError::ApiError(format!(
473                            "llama.cpp /tokenize response malformed: token {} is not an integer id",
474                            i
475                        ))
476                    })
477            })
478            .collect()
479    }
480
481    /// Decide the wire form of every input, tokenizing only those that might
482    /// exceed the budget. `try_join_all` preserves input order.
483    async fn plan_inputs(&self, texts: &[String]) -> Result<Vec<EmbedInput>> {
484        let max = self.cfg.max_input_tokens;
485        try_join_all(texts.iter().map(|text| async move {
486            if !needs_tokenize(text, max) {
487                return Ok(EmbedInput::Text(text.clone()));
488            }
489            let tokens = self.tokenize(text).await?;
490            Ok(plan_input(text, &tokens, max))
491        }))
492        .await
493    }
494
495    async fn post_embeddings(&self, inputs: &[EmbedInput]) -> Result<Value> {
496        let input: Vec<Value> = inputs.iter().map(EmbedInput::to_json).collect();
497        self.post_json(
498            format!("{}/embeddings", self.cfg.base_url),
499            &json!({ "model": self.model_id, "input": input }),
500        )
501        .await
502    }
503
504    async fn embed_pipeline(&self, texts: &[String]) -> Result<EmbedResult> {
505        let inputs = self.plan_inputs(texts).await?;
506        let body = self.post_embeddings(&inputs).await?;
507        let result = parse_openai_embeddings_response("llama.cpp", &body, Some(texts.len()))?;
508        validate_dimensions(&result.vectors, self.cfg.dimensions)?;
509        Ok(result)
510    }
511}
512
513#[async_trait]
514impl EmbeddingModel for LlamaCppEmbeddingModel {
515    async fn embed(&self, texts: &[&str]) -> Result<EmbedResult> {
516        if texts.is_empty() {
517            return Ok(EmbedResult {
518                vectors: Vec::new(),
519                usage: None,
520            });
521        }
522        let texts: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
523
524        // One breaker call covers tokenize + embed. Deterministic input-size
525        // rejections (`InferenceError`, produced only by `map_llamacpp_status`
526        // in this module) are a configuration problem, not a server outage, so
527        // they are returned as `Ok(Err(..))` from the closure — the breaker
528        // sees a success and stays closed — and flattened here.
529        self.cb
530            .call(move || async move {
531                match self.embed_pipeline(&texts).await {
532                    Err(e @ RuntimeError::InferenceError(_)) => Ok(Err(e)),
533                    other => other.map(Ok),
534                }
535            })
536            .await?
537    }
538
539    fn dimensions(&self) -> u32 {
540        self.cfg.dimensions
541    }
542}
543
544impl crate::traits::ModelInfo for LlamaCppEmbeddingModel {
545    fn model_id(&self) -> &str {
546        &self.model_id
547    }
548}
549
550// ---------------------------------------------------------------------------
551// Tests (no HTTP)
552// ---------------------------------------------------------------------------
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::api::ModelRuntimeKey;
558    use crate::provider::remote_common::RemoteProviderBase;
559
560    static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
561
562    fn options() -> Value {
563        json!({
564            "base_url": "http://127.0.0.1:8080/v1",
565            "max_input_tokens": 512,
566            "embedding_dimensions": 384
567        })
568    }
569
570    fn spec(alias: &str, task: ModelTask, model_id: &str, options: Value) -> ModelAliasSpec {
571        ModelAliasSpec {
572            alias: alias.to_string(),
573            task,
574            provider_id: PROVIDER_ID.to_string(),
575            model_id: model_id.to_string(),
576            revision: None,
577            warmup: crate::api::WarmupPolicy::Lazy,
578            required: false,
579            timeout: None,
580            load_timeout: None,
581            retry: None,
582            options,
583        }
584    }
585
586    // --- provider / breaker -------------------------------------------------
587
588    #[tokio::test]
589    async fn provider_id_and_capabilities() {
590        let p = RemoteLlamaCppProvider::new();
591        assert_eq!(p.provider_id(), "remote/llamacpp");
592        assert_eq!(p.capabilities().supported_tasks, vec![ModelTask::Embed]);
593        assert!(matches!(p.health().await, ProviderHealth::Healthy));
594    }
595
596    #[tokio::test]
597    async fn load_rejects_non_embed_tasks() {
598        let p = RemoteLlamaCppProvider::new();
599        for task in [ModelTask::Rerank, ModelTask::Generate, ModelTask::Raw] {
600            let err = p
601                .load(&spec("x/y", task, "bge", options()))
602                .await
603                .expect_err("must fail");
604            assert!(
605                matches!(err, RuntimeError::CapabilityMismatch(_)),
606                "{err:?}"
607            );
608        }
609    }
610
611    #[tokio::test]
612    async fn load_exposes_configured_dimensions_and_model_id() {
613        let p = RemoteLlamaCppProvider::new();
614        let handle = p
615            .load(&spec("embed/a", ModelTask::Embed, "bge-small", options()))
616            .await
617            .unwrap();
618        let model = handle.downcast_ref::<Arc<dyn EmbeddingModel>>().unwrap();
619        assert_eq!(model.dimensions(), 384);
620        assert_eq!(model.model_id(), "bge-small");
621    }
622
623    #[tokio::test]
624    async fn breaker_reused_for_same_runtime_key() {
625        let p = RemoteLlamaCppProvider::new();
626        let _ = p
627            .load(&spec("embed/a", ModelTask::Embed, "bge", options()))
628            .await
629            .unwrap();
630        let _ = p
631            .load(&spec("embed/b", ModelTask::Embed, "bge", options()))
632            .await
633            .unwrap();
634        assert_eq!(p.breaker_count(), 1);
635    }
636
637    #[tokio::test]
638    async fn breaker_isolated_by_model() {
639        let p = RemoteLlamaCppProvider::new();
640        let _ = p
641            .load(&spec("embed/a", ModelTask::Embed, "bge", options()))
642            .await
643            .unwrap();
644        let _ = p
645            .load(&spec("embed/b", ModelTask::Embed, "nomic", options()))
646            .await
647            .unwrap();
648        assert_eq!(p.breaker_count(), 2);
649    }
650
651    #[tokio::test]
652    async fn breaker_cleanup_evicts_stale_entries() {
653        let p = RemoteLlamaCppProvider::new();
654        let stale = spec("embed/stale", ModelTask::Embed, "old", options());
655        let fresh = spec("embed/fresh", ModelTask::Embed, "new", options());
656        p.insert_test_breaker(
657            ModelRuntimeKey::new(&stale),
658            RemoteProviderBase::BREAKER_TTL + Duration::from_secs(5),
659        );
660        p.insert_test_breaker(ModelRuntimeKey::new(&fresh), Duration::from_secs(1));
661        assert_eq!(p.breaker_count(), 2);
662        p.force_cleanup_now_for_test();
663        let _ = p.load(&fresh).await.unwrap();
664        assert_eq!(p.breaker_count(), 1);
665    }
666
667    // --- config -------------------------------------------------------------
668
669    #[test]
670    fn config_happy_path_and_defaults() {
671        let cfg = LlamaCppConfig::from_options(&options()).unwrap();
672        assert_eq!(cfg.base_url, "http://127.0.0.1:8080/v1");
673        assert_eq!(cfg.tokenizer_base_url, "http://127.0.0.1:8080");
674        assert_eq!(cfg.max_input_tokens, 512);
675        assert_eq!(cfg.dimensions, 384);
676        assert!(cfg.api_key.is_none());
677        assert_eq!(
678            cfg.request_timeout,
679            Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)
680        );
681    }
682
683    #[test]
684    fn config_strips_trailing_slash_and_honours_explicit_tokenizer_url_and_timeout() {
685        let cfg = LlamaCppConfig::from_options(&json!({
686            "base_url": "http://h:1/v1/",
687            "tokenizer_base_url": "http://tok:2/",
688            "max_input_tokens": 3,
689            "embedding_dimensions": 4,
690            "request_timeout_secs": 7
691        }))
692        .unwrap();
693        assert_eq!(cfg.base_url, "http://h:1/v1");
694        assert_eq!(cfg.tokenizer_base_url, "http://tok:2");
695        assert_eq!(cfg.request_timeout, Duration::from_secs(7));
696    }
697
698    #[test]
699    fn config_requires_base_url_max_tokens_and_dimensions() {
700        for missing in ["base_url", "max_input_tokens", "embedding_dimensions"] {
701            let mut o = options();
702            o.as_object_mut().unwrap().remove(missing);
703            let err = LlamaCppConfig::from_options(&o).expect_err("must fail");
704            match err {
705                RuntimeError::Config(msg) => assert!(msg.contains(missing), "{msg}"),
706                other => panic!("expected Config, got {other:?}"),
707            }
708        }
709        assert!(LlamaCppConfig::from_options(&Value::Null).is_err());
710    }
711
712    #[test]
713    fn config_rejects_tiny_max_input_tokens() {
714        let mut o = options();
715        o["max_input_tokens"] = json!(2);
716        let err = LlamaCppConfig::from_options(&o).err().unwrap();
717        assert!(err.to_string().contains("at least 3"), "{err}");
718    }
719
720    #[tokio::test]
721    async fn config_api_key_env_present_and_missing() {
722        let _lock = ENV_LOCK.lock().await;
723        let mut o = options();
724        o["api_key_env"] = json!("UNI_XERVO_LLAMACPP_TEST_KEY");
725
726        // SAFETY: protected by ENV_LOCK
727        unsafe { std::env::remove_var("UNI_XERVO_LLAMACPP_TEST_KEY") };
728        let err = LlamaCppConfig::from_options(&o).err().unwrap();
729        assert!(
730            err.to_string().contains("UNI_XERVO_LLAMACPP_TEST_KEY"),
731            "{err}"
732        );
733
734        // SAFETY: protected by ENV_LOCK
735        unsafe { std::env::set_var("UNI_XERVO_LLAMACPP_TEST_KEY", "sekrit") };
736        let cfg = LlamaCppConfig::from_options(&o).unwrap();
737        assert_eq!(cfg.api_key.as_deref(), Some("sekrit"));
738        // SAFETY: protected by ENV_LOCK
739        unsafe { std::env::remove_var("UNI_XERVO_LLAMACPP_TEST_KEY") };
740    }
741
742    #[test]
743    fn tokenizer_base_url_derivation() {
744        assert_eq!(
745            resolve_tokenizer_base_url("http://h:8080/v1"),
746            "http://h:8080"
747        );
748        assert_eq!(
749            resolve_tokenizer_base_url("http://h:8080/v1/"),
750            "http://h:8080"
751        );
752        assert_eq!(
753            resolve_tokenizer_base_url("http://h:8080/api/v1"),
754            "http://h:8080/api"
755        );
756        assert_eq!(resolve_tokenizer_base_url("http://h:8080"), "http://h:8080");
757        assert_eq!(
758            resolve_tokenizer_base_url("http://h:8080/v10"),
759            "http://h:8080/v10"
760        );
761        assert_eq!(resolve_tokenizer_base_url("/v1"), "/v1");
762    }
763
764    // --- input planning -----------------------------------------------------
765
766    #[test]
767    fn needs_tokenize_boundaries_use_char_count() {
768        assert!(!needs_tokenize("", 10));
769        assert!(!needs_tokenize("12345678", 10)); // == max - 2
770        assert!(needs_tokenize("123456789", 10)); // == max - 1
771        // 8 CJK chars are 24 bytes but only 8 chars.
772        assert!(!needs_tokenize("你好世界你好世界", 10));
773        assert!(needs_tokenize("你好世界你好世界你", 10));
774        // Degenerate budgets never underflow.
775        assert!(needs_tokenize("a", 1));
776        assert!(!needs_tokenize("", 0));
777    }
778
779    #[test]
780    fn plan_input_fits_sends_text() {
781        let tokens: Vec<u32> = (0..10).collect();
782        assert_eq!(
783            plan_input("t", &tokens, 10),
784            EmbedInput::Text("t".to_string())
785        );
786        assert_eq!(plan_input("t", &[], 10), EmbedInput::Text("t".to_string()));
787    }
788
789    #[test]
790    fn plan_input_truncates_keeping_leading_ids_and_trailing_special() {
791        // [CLS]=101, content 1..=9, [SEP]=102 → 11 tokens, budget 10.
792        let mut tokens = vec![101u32];
793        tokens.extend(1..=9);
794        tokens.push(102);
795        match plan_input("t", &tokens, 10) {
796            EmbedInput::Tokens(ids) => {
797                assert_eq!(ids.len(), 10);
798                assert_eq!(ids[0], 101);
799                assert_eq!(&ids[1..9], &[1, 2, 3, 4, 5, 6, 7, 8]);
800                assert_eq!(*ids.last().unwrap(), 102);
801            }
802            other => panic!("expected Tokens, got {other:?}"),
803        }
804    }
805
806    #[test]
807    fn embed_input_json_shapes() {
808        assert_eq!(
809            EmbedInput::Text("hi".into()).to_json(),
810            Value::String("hi".into())
811        );
812        assert_eq!(EmbedInput::Tokens(vec![1, 2]).to_json(), json!([1, 2]));
813    }
814
815    // --- error mapping ------------------------------------------------------
816
817    #[test]
818    fn classify_error_bodies() {
819        let too_large = r#"{"error":{"code":500,"message":"input is too large to process. increase the physical batch size","type":"server_error"}}"#;
820        let (k, m) = classify_error_body(too_large);
821        assert_eq!(k, LlamaCppErrorKind::InputTooLarge);
822        assert!(m.starts_with("input is too large"));
823
824        let ctx_type =
825            r#"{"error":{"code":400,"message":"whatever","type":"exceed_context_size"}}"#;
826        assert_eq!(
827            classify_error_body(ctx_type).0,
828            LlamaCppErrorKind::ExceedsContext
829        );
830        let ctx_msg = r#"{"error":{"message":"prompt exceeds the available context size. increase context size"}}"#;
831        assert_eq!(
832            classify_error_body(ctx_msg).0,
833            LlamaCppErrorKind::ExceedsContext
834        );
835
836        let (k, m) = classify_error_body("<html>502 bad gateway</html>");
837        assert_eq!(k, LlamaCppErrorKind::Other);
838        assert_eq!(m, "<html>502 bad gateway</html>");
839
840        let long = "x".repeat(1000);
841        assert_eq!(
842            classify_error_body(&long).1.chars().count(),
843            ERROR_SNIPPET_MAX_CHARS
844        );
845    }
846
847    #[test]
848    fn status_mapping() {
849        let too_large = r#"{"error":{"message":"input is too large to process. increase the physical batch size"}}"#;
850        let e = map_llamacpp_status(500, too_large, 512);
851        assert!(matches!(e, RuntimeError::InferenceError(_)), "{e:?}");
852        assert!(!e.is_retryable());
853        assert!(e.to_string().contains("max_input_tokens=512"), "{e}");
854
855        let e = map_llamacpp_status(
856            400,
857            r#"{"error":{"type":"exceed_context_size","message":"x"}}"#,
858            512,
859        );
860        assert!(matches!(e, RuntimeError::InferenceError(_)), "{e:?}");
861
862        assert!(matches!(
863            map_llamacpp_status(500, "boom", 512),
864            RuntimeError::Unavailable
865        ));
866        assert!(matches!(
867            map_llamacpp_status(503, "", 512),
868            RuntimeError::Unavailable
869        ));
870        assert!(matches!(
871            map_llamacpp_status(429, "", 512),
872            RuntimeError::RateLimited
873        ));
874        assert!(matches!(
875            map_llamacpp_status(401, "", 512),
876            RuntimeError::Unauthorized
877        ));
878        assert!(matches!(
879            map_llamacpp_status(403, "", 512),
880            RuntimeError::Unauthorized
881        ));
882        match map_llamacpp_status(404, r#"{"error":{"message":"no route"}}"#, 512) {
883            RuntimeError::ApiError(m) => {
884                assert!(m.contains("404") && m.contains("no route"), "{m}")
885            }
886            other => panic!("{other:?}"),
887        }
888    }
889
890    #[test]
891    fn dimension_validation() {
892        assert!(validate_dimensions(&[vec![0.0; 4], vec![1.0; 4]], 4).is_ok());
893        let err = validate_dimensions(&[vec![0.0; 4], vec![1.0; 3]], 4)
894            .err()
895            .unwrap();
896        let msg = err.to_string();
897        assert!(
898            msg.contains("3-dimensional") && msg.contains("index 1"),
899            "{msg}"
900        );
901        assert!(msg.contains("embedding_dimensions"), "{msg}");
902    }
903}