Skip to main content

submilli_engine/runtime/
embedding.rs

1//! The embedder-supplied seam for `submilli:embedding` — batches of text embedded
2//! by a remote provider from inside a Submilli program.
3//!
4//! Mirrors [`crate::runtime::llm`]: the HTTP, key resolution and retry live in
5//! the embedder behind [`EmbeddingProvider`], and token accounting runs on the
6//! shared [`TokenLedger`].
7//!
8//! **No input text appears here.** Not in an error `Display`, not in a log line,
9//! not in a settlement. Errors carry the alias, numbers, an input *index*, and a
10//! fixed kebab-case classification string; provider response bodies are dropped
11//! at this boundary.
12//!
13//! # Settlement protocol
14//!
15//! A call is split by the provider into sub-batches, each sent as one outbound
16//! request. The stdlib reserves the whole estimate up front
17//! ([`EmbeddingTokenBudget::reserve`]) and hands the provider the budget. For
18//! every sub-batch the provider:
19//!
20//! 1. calls [`EmbeddingTokenBudget::mark_sent`] with that sub-batch's estimate
21//!    *immediately before* sending. This counts the request against the per-run
22//!    cap (refusing past it) and moves the estimate from "reserved" to "held",
23//!    so a future dropped mid-flight leaves the sub-batch held while unsent
24//!    sub-batches are returned when the budget drops;
25//! 2. records a [`SubBatchSettlement`] when the response (or failure) arrives.
26//!
27//! Settlements travel back on [`EmbeddingBatch::settlements`] or inside the
28//! error, and the stdlib applies them with [`EmbeddingTokenBudget::settle`],
29//! which also releases whatever was reserved but never sent. The outcome table
30//! the provider applies to each *sent* sub-batch:
31//!
32//! | Sub-batch outcome | `reported` | `indeterminate` |
33//! |---|---|---|
34//! | Succeeded with nonzero usage | usage | 0 |
35//! | Succeeded without usage, or usage 0 | 0 | estimate |
36//! | Never sent (credential unresolved, blocked by network policy, connect failure) | 0 | 0 (released) |
37//! | Rejected by provider with 4xx (incl. 429) | 0 | 0 (released) |
38//! | Sent, then 5xx / transport / timeout / failed validation | usage if reported | else estimate |
39//! | Sent, then the future is dropped (cancelled) | 0 | estimate |
40//!
41//! Sub-batches never sent have no settlement; they are released by `settle`.
42
43use std::future::Future;
44use std::pin::Pin;
45use std::sync::atomic::{AtomicU64, Ordering};
46
47pub use super::token_ledger::SharedTokenBudget;
48use super::token_ledger::{LedgerLimits, LedgerRefusal, TokenLedger};
49
50/// The single internal host module every `submilli:embedding` call dispatches
51/// through.
52pub const EMBEDDING_MODULE_NAME: &str = "submilli:embedding";
53
54/// Tokens one execution may spend across every embedding call it makes.
55pub const DEFAULT_MAX_EXECUTION_EMBEDDING_TOKENS: u64 = 2_000_000;
56/// Tokens one execution may hold as indeterminate reserve.
57pub const DEFAULT_MAX_HELD_EMBEDDING_TOKENS: u64 = 200_000;
58/// Outbound provider requests one execution may send.
59pub const DEFAULT_MAX_EMBEDDING_REQUESTS: u64 = 1_000;
60/// Texts in one call.
61pub const DEFAULT_MAX_TEXTS_PER_CALL: u64 = 128;
62/// UTF-8 bytes across one call's texts.
63pub const DEFAULT_MAX_BYTES_PER_CALL: u64 = 2 * 1024 * 1024;
64
65/// The ceilings an embedding call is checked against.
66///
67/// Concurrency per call is deliberately *not* here: it is how a provider fans
68/// out its sub-batches, so it lives in the provider's configuration.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct EmbeddingLimits {
71    pub per_execution_tokens: u64,
72    pub max_held_tokens: u64,
73    pub max_requests: u64,
74    pub max_texts_per_call: u64,
75    pub max_bytes_per_call: u64,
76}
77
78impl Default for EmbeddingLimits {
79    fn default() -> Self {
80        Self {
81            per_execution_tokens: DEFAULT_MAX_EXECUTION_EMBEDDING_TOKENS,
82            max_held_tokens: DEFAULT_MAX_HELD_EMBEDDING_TOKENS,
83            max_requests: DEFAULT_MAX_EMBEDDING_REQUESTS,
84            max_texts_per_call: DEFAULT_MAX_TEXTS_PER_CALL,
85            max_bytes_per_call: DEFAULT_MAX_BYTES_PER_CALL,
86        }
87    }
88}
89
90/// The token estimate for one input: `ceil(utf8_bytes / 3)`.
91pub fn estimate_embedding_tokens(text: &str) -> u64 {
92    (text.len() as u64).div_ceil(3)
93}
94
95/// What an embedding is for. Some providers embed queries and documents
96/// differently.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
98pub enum Purpose {
99    Query,
100    Document,
101}
102
103impl Purpose {
104    pub fn as_str(&self) -> &'static str {
105        match self {
106            Self::Query => "query",
107            Self::Document => "document",
108        }
109    }
110}
111
112/// Which ceiling refused a call. Carries numbers, never inputs.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum EmbeddingLimitKind {
115    PerExecutionTokens {
116        requested: u64,
117        limit: u64,
118    },
119    AllExecutionsTokens {
120        requested: u64,
121        limit: u64,
122    },
123    /// Held (unreported-usage) reserve, including the call's own worst case.
124    HeldTokens {
125        held: u64,
126        limit: u64,
127    },
128    /// Outbound provider requests in this execution.
129    Requests {
130        limit: u64,
131    },
132}
133
134impl std::fmt::Display for EmbeddingLimitKind {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::PerExecutionTokens { requested, limit } => write!(
138                f,
139                "execution embedding token budget: {requested} tokens exceeds the {limit} this \
140                 execution may spend — embed fewer or shorter texts, or split the work across \
141                 executions; the operator raises it with `--max-execution-embedding-tokens`"
142            ),
143            Self::AllExecutionsTokens { requested, limit } => write!(
144                f,
145                "server embedding token budget: {requested} tokens exceeds the {limit} allowed \
146                 across all live executions — this execution's own spend is not what is in the \
147                 way; the operator raises the budget with `--max-embedding-tokens`"
148            ),
149            Self::HeldTokens { held, limit } => write!(
150                f,
151                "indeterminate embedding spend ceiling: {held} tokens are already held for requests \
152                 whose usage the provider did not report, past the {limit} a single execution \
153                 may hold — start a new execution to embed more"
154            ),
155            Self::Requests { limit } => write!(
156                f,
157                "embedding request limit: this execution already sent {limit} provider requests, \
158                 the most it may send — embed more texts per call instead of calling more often; \
159                 the operator raises it with `--max-execution-embedding-requests`"
160            ),
161        }
162    }
163}
164
165/// Which input bound a call ran into.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum EmbeddingBoundKind {
168    TextCount,
169    TotalBytes,
170}
171
172impl std::fmt::Display for EmbeddingBoundKind {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::TextCount => write!(f, "texts in one call"),
176            Self::TotalBytes => write!(f, "bytes in one call"),
177        }
178    }
179}
180
181/// Why a sent request failed, as a closed vocabulary (the wire form is
182/// [`Self::as_str`]).
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum EmbeddingFailureReason {
185    RateLimited,
186    RequestRejected,
187    ProviderUnavailable,
188    Transport,
189    Timeout,
190    /// The provider's `base_url` is refused by the network policy. Nothing
191    /// was sent.
192    BlockedByNetworkPolicy,
193}
194
195impl EmbeddingFailureReason {
196    pub fn as_str(&self) -> &'static str {
197        match self {
198            Self::RateLimited => "rate-limited",
199            Self::RequestRejected => "request-rejected",
200            Self::ProviderUnavailable => "provider-unavailable",
201            Self::Transport => "transport",
202            Self::Timeout => "timeout",
203            Self::BlockedByNetworkPolicy => "blocked-by-network-policy",
204        }
205    }
206
207    pub const ALL: [EmbeddingFailureReason; 6] = [
208        Self::RateLimited,
209        Self::RequestRejected,
210        Self::ProviderUnavailable,
211        Self::Transport,
212        Self::Timeout,
213        Self::BlockedByNetworkPolicy,
214    ];
215}
216
217impl std::fmt::Display for EmbeddingFailureReason {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.write_str(self.as_str())
220    }
221}
222
223/// Why a provider response was unusable.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum EmbeddingMalformedReason {
226    CountMismatch,
227    DimensionMismatch,
228    NonFiniteValue,
229    InvalidBody,
230}
231
232impl EmbeddingMalformedReason {
233    pub fn as_str(&self) -> &'static str {
234        match self {
235            Self::CountMismatch => "count-mismatch",
236            Self::DimensionMismatch => "dimension-mismatch",
237            Self::NonFiniteValue => "non-finite-value",
238            Self::InvalidBody => "invalid-body",
239        }
240    }
241}
242
243impl std::fmt::Display for EmbeddingMalformedReason {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        f.write_str(self.as_str())
246    }
247}
248
249/// What one *sent* sub-batch cost. `estimate` is what was passed to
250/// [`EmbeddingTokenBudget::mark_sent`]; see the module table for the other two.
251/// The remainder (`estimate - reported - indeterminate`) is released.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
253pub struct SubBatchSettlement {
254    pub estimate: u64,
255    pub reported: u64,
256    pub indeterminate: u64,
257}
258
259/// Why an embedding call failed. Variants after the pre-send group carry the
260/// settlements of every sub-batch that was sent before the failure.
261///
262/// None carries input text.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum EmbeddingError {
265    NotConfigured {
266        alias: String,
267    },
268    UnknownModel {
269        alias: String,
270        available: Vec<String>,
271    },
272    /// An input exceeds the alias's `max_input_bytes`, or the provider rejected
273    /// one as too long. `index` names the input when known. `limit` is the byte
274    /// limit for the pre-send check and `None` for a provider-side rejection,
275    /// which carries the settlements of sub-batches already sent.
276    InputTooLong {
277        alias: String,
278        index: Option<usize>,
279        limit: Option<u64>,
280        settlements: Vec<SubBatchSettlement>,
281    },
282    BoundsExceeded {
283        alias: String,
284        kind: EmbeddingBoundKind,
285        actual: u64,
286        limit: u64,
287    },
288    /// A ceiling refused the call. Pre-send, `settlements` is empty; the
289    /// request cap can also trip between sub-batches.
290    BudgetExceeded {
291        alias: String,
292        limit_kind: EmbeddingLimitKind,
293        settlements: Vec<SubBatchSettlement>,
294    },
295    Unauthorized {
296        alias: String,
297        settlements: Vec<SubBatchSettlement>,
298    },
299    Provider {
300        alias: String,
301        reason: EmbeddingFailureReason,
302        settlements: Vec<SubBatchSettlement>,
303    },
304    Malformed {
305        alias: String,
306        reason: EmbeddingMalformedReason,
307        settlements: Vec<SubBatchSettlement>,
308    },
309    /// The provider's own bookkeeping failed (a closed pool, a missing slot).
310    /// Never guest-catchable: the stdlib settles the budget and ends the run.
311    Internal {
312        alias: String,
313        settlements: Vec<SubBatchSettlement>,
314    },
315}
316
317impl EmbeddingError {
318    pub fn alias(&self) -> &str {
319        match self {
320            Self::NotConfigured { alias }
321            | Self::UnknownModel { alias, .. }
322            | Self::InputTooLong { alias, .. }
323            | Self::BoundsExceeded { alias, .. }
324            | Self::BudgetExceeded { alias, .. }
325            | Self::Unauthorized { alias, .. }
326            | Self::Provider { alias, .. }
327            | Self::Malformed { alias, .. }
328            | Self::Internal { alias, .. } => alias,
329        }
330    }
331
332    /// Per-sub-batch settlements carried by a post-send failure; empty for
333    /// pre-send variants.
334    pub fn settlements(&self) -> &[SubBatchSettlement] {
335        match self {
336            Self::BudgetExceeded { settlements, .. }
337            | Self::Unauthorized { settlements, .. }
338            | Self::Provider { settlements, .. }
339            | Self::Malformed { settlements, .. }
340            | Self::Internal { settlements, .. }
341            | Self::InputTooLong { settlements, .. } => settlements,
342            Self::NotConfigured { .. }
343            | Self::UnknownModel { .. }
344            | Self::BoundsExceeded { .. } => &[],
345        }
346    }
347
348    /// Token-budget refusals become catchable `QuotaExceededError`s at the
349    /// stdlib boundary.
350    pub fn is_budget_exceeded(&self) -> bool {
351        matches!(self, Self::BudgetExceeded { .. })
352    }
353
354    /// A provider-side invariant failure that must end the run.
355    pub fn is_internal(&self) -> bool {
356        matches!(self, Self::Internal { .. })
357    }
358
359    /// Attach settlements to a failure raised by [`EmbeddingTokenBudget::mark_sent`]
360    /// or built before the provider had them. A no-op on pre-send variants.
361    pub fn with_settlements(mut self, collected: Vec<SubBatchSettlement>) -> Self {
362        match &mut self {
363            Self::BudgetExceeded { settlements, .. }
364            | Self::Unauthorized { settlements, .. }
365            | Self::Provider { settlements, .. }
366            | Self::Malformed { settlements, .. }
367            | Self::Internal { settlements, .. }
368            | Self::InputTooLong { settlements, .. } => *settlements = collected,
369            Self::NotConfigured { .. }
370            | Self::UnknownModel { .. }
371            | Self::BoundsExceeded { .. } => {}
372        }
373        self
374    }
375}
376
377impl std::fmt::Display for EmbeddingError {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        match self {
380            Self::NotConfigured { alias } => write!(
381                f,
382                "embedding.embed(\"{alias}\"): no embedding provider is configured for this \
383                 runtime — the operator wires one before embeddings can run"
384            ),
385            Self::UnknownModel { alias, available } => {
386                write!(
387                    f,
388                    "embedding.embed(\"{alias}\"): the provider does not serve that alias — "
389                )?;
390                if available.is_empty() {
391                    write!(
392                        f,
393                        "it serves none, so declare an embedding model in the blueprint"
394                    )
395                } else {
396                    write!(
397                        f,
398                        "call embedding.models() and use one of: {}",
399                        available.join(", ")
400                    )
401                }
402            }
403            Self::InputTooLong {
404                alias,
405                index,
406                limit,
407                ..
408            } => {
409                write!(f, "embedding.embed(\"{alias}\"): ")?;
410                match (limit, index) {
411                    (Some(limit), Some(index)) => write!(
412                        f,
413                        "text {index} exceeds the {limit} bytes this model accepts — shorten or \
414                         split it"
415                    ),
416                    (Some(limit), None) => write!(
417                        f,
418                        "a text exceeds the {limit} bytes this model accepts — shorten or split it"
419                    ),
420                    (None, Some(index)) => write!(
421                        f,
422                        "the provider rejected text {index} as too long for this model — shorten \
423                         or split it"
424                    ),
425                    (None, None) => write!(
426                        f,
427                        "the provider rejected a text as too long for this model — shorten or \
428                         split it"
429                    ),
430                }
431            }
432            Self::BoundsExceeded {
433                alias,
434                kind,
435                actual,
436                limit,
437            } => write!(
438                f,
439                "embedding.embed(\"{alias}\"): input bound exceeded: {actual} {kind} exceeds the \
440                 {limit} allowed — send fewer or shorter texts per call"
441            ),
442            Self::BudgetExceeded {
443                alias, limit_kind, ..
444            } => write!(f, "embedding.embed(\"{alias}\") exceeded the {limit_kind}"),
445            Self::Unauthorized { alias, .. } => write!(
446                f,
447                "embedding.embed(\"{alias}\"): the provider rejected the API credential, or none \
448                 was available — the operator sets a valid key for this provider"
449            ),
450            Self::Provider { alias, reason, .. } => write!(
451                f,
452                "embedding.embed(\"{alias}\"): the provider request failed: {reason}"
453            ),
454            Self::Malformed { alias, reason, .. } => write!(
455                f,
456                "embedding.embed(\"{alias}\"): the provider returned an unusable response: \
457                 {reason}"
458            ),
459            Self::Internal { alias, .. } => {
460                write!(f, "embedding.embed(\"{alias}\"): internal host failure")
461            }
462        }
463    }
464}
465
466impl std::error::Error for EmbeddingError {}
467
468/// Row access or construction outside an [`EmbeddingBatch`]'s shape.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum EmbeddingShapeError {
471    /// `vectors.len()` is not `count * dimensions`, or the product overflows.
472    LengthMismatch {
473        len: usize,
474        count: usize,
475        dimensions: usize,
476    },
477    RowOutOfRange {
478        index: usize,
479        count: usize,
480    },
481}
482
483impl std::fmt::Display for EmbeddingShapeError {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        match self {
486            Self::LengthMismatch {
487                len,
488                count,
489                dimensions,
490            } => write!(
491                f,
492                "embedding batch holds {len} values but {count} rows of {dimensions} dimensions \
493                 were declared"
494            ),
495            Self::RowOutOfRange { index, count } => {
496                write!(f, "embedding row {index} is out of range for {count} rows")
497            }
498        }
499    }
500}
501
502impl std::error::Error for EmbeddingShapeError {}
503
504/// The result of one successful call: `count` vectors of `dimensions` floats,
505/// row-major, labeled with the embedding-space `identity`.
506#[derive(Debug, Clone, PartialEq, serde::Serialize)]
507pub struct EmbeddingBatch {
508    #[serde(with = "float_bytes")]
509    vectors: Vec<f32>,
510    count: usize,
511    dimensions: usize,
512    identity: String,
513    model: String,
514    input_tokens: Option<u64>,
515    settlements: Vec<SubBatchSettlement>,
516}
517
518// Decode through the same shape check used by providers, before a guest accessor
519// can observe dimensions or index a row.
520impl<'de> serde::Deserialize<'de> for EmbeddingBatch {
521    fn deserialize<D: serde::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
522        #[derive(serde::Deserialize)]
523        struct Batch {
524            #[serde(with = "float_bytes")]
525            vectors: Vec<f32>,
526            count: usize,
527            dimensions: usize,
528            identity: String,
529            model: String,
530            input_tokens: Option<u64>,
531            settlements: Vec<SubBatchSettlement>,
532        }
533        let batch = Batch::deserialize(decoder)?;
534        EmbeddingBatch::new(
535            batch.vectors,
536            batch.count,
537            batch.dimensions,
538            batch.identity,
539            batch.model,
540        )
541        .map(|value| {
542            value
543                .with_input_tokens(batch.input_tokens)
544                .with_settlements(batch.settlements)
545        })
546        .map_err(serde::de::Error::custom)
547    }
548}
549
550impl EmbeddingBatch {
551    /// Validates `vectors.len() == count * dimensions` (checked multiply).
552    pub fn new(
553        vectors: Vec<f32>,
554        count: usize,
555        dimensions: usize,
556        identity: impl Into<String>,
557        model: impl Into<String>,
558    ) -> Result<Self, EmbeddingShapeError> {
559        let expected = count.checked_mul(dimensions);
560        if expected != Some(vectors.len()) {
561            return Err(EmbeddingShapeError::LengthMismatch {
562                len: vectors.len(),
563                count,
564                dimensions,
565            });
566        }
567        Ok(Self {
568            vectors,
569            count,
570            dimensions,
571            identity: identity.into(),
572            model: model.into(),
573            input_tokens: None,
574            settlements: Vec::new(),
575        })
576    }
577
578    /// Provider-reported total input tokens, when it reported any.
579    pub fn with_input_tokens(mut self, input_tokens: Option<u64>) -> Self {
580        self.input_tokens = input_tokens;
581        self
582    }
583
584    /// The settlements of every sub-batch this call sent, for
585    /// [`EmbeddingTokenBudget::settle`].
586    pub fn with_settlements(mut self, settlements: Vec<SubBatchSettlement>) -> Self {
587        self.settlements = settlements;
588        self
589    }
590
591    pub fn count(&self) -> usize {
592        self.count
593    }
594
595    pub fn dimensions(&self) -> usize {
596        self.dimensions
597    }
598
599    pub fn identity(&self) -> &str {
600        &self.identity
601    }
602
603    pub fn model(&self) -> &str {
604        &self.model
605    }
606
607    pub fn input_tokens(&self) -> Option<u64> {
608        self.input_tokens
609    }
610
611    pub fn settlements(&self) -> &[SubBatchSettlement] {
612        &self.settlements
613    }
614
615    /// All values, row-major.
616    pub fn values(&self) -> &[f32] {
617        &self.vectors
618    }
619
620    /// Row `index`, or an error past `count`. Never panics.
621    pub fn row(&self, index: usize) -> Result<&[f32], EmbeddingShapeError> {
622        let out_of_range = EmbeddingShapeError::RowOutOfRange {
623            index,
624            count: self.count,
625        };
626        if index >= self.count {
627            return Err(out_of_range);
628        }
629        let start = index.checked_mul(self.dimensions).ok_or(out_of_range)?;
630        let end = start.checked_add(self.dimensions).ok_or(out_of_range)?;
631        self.vectors.get(start..end).ok_or(out_of_range)
632    }
633}
634
635/// An embedding model alias the provider serves.
636#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
637pub struct EmbeddingModel {
638    pub name: String,
639    pub description: Option<String>,
640    pub dimensions: u64,
641    pub max_input_tokens: Option<u64>,
642    pub max_input_bytes: u64,
643    /// The embedding-space identity string vectors are labeled with.
644    pub identity: String,
645}
646
647/// The embedder-provided embedding provider: resolves credentials, performs the
648/// requests, and reports what each cost.
649///
650/// # Obligations on every implementor
651///
652/// 1. **Positional ordering.** The returned batch has one row per input text,
653///    in input order, all of `dimensions` width and finite.
654/// 2. **Bounded concurrency.** The guest is single-threaded; fan-out across
655///    sub-batches lives here, bounded by the implementor.
656/// 3. **Count before sending.** Call [`EmbeddingTokenBudget::mark_sent`] with
657///    the sub-batch's estimate immediately before each outbound request, and
658///    never send if it refuses. Retries are outbound requests too.
659/// 4. **Settle honestly.** Record a [`SubBatchSettlement`] per sent sub-batch
660///    per the module's outcome table; unknown spend is `indeterminate`, not
661///    free. Return them on the batch or inside every post-send error
662///    ([`EmbeddingError::with_settlements`]).
663/// 5. **Drop safety.** The future may be dropped at any await. State the budget
664///    holds must stay correct then: do `mark_sent` and the send with no await
665///    between them that could skip the send but keep the hold (a hold without a
666///    send is merely conservative, never unsafe).
667/// 6. **No input text in errors**, and no provider response bodies.
668pub trait EmbeddingProvider: Send + Sync {
669    fn embed<'a>(
670        &'a self,
671        alias: &'a str,
672        texts: &'a [String],
673        purpose: Purpose,
674        budget: &'a EmbeddingTokenBudget,
675    ) -> Pin<Box<dyn Future<Output = Result<EmbeddingBatch, EmbeddingError>> + Send + 'a>>;
676
677    /// The locally declared catalog, without network I/O.
678    fn models<'a>(
679        &'a self,
680    ) -> Pin<Box<dyn Future<Output = Result<Vec<EmbeddingModel>, EmbeddingError>> + Send + 'a>>;
681
682    /// Per-alias input byte limit, consulted before dispatch. `None` means the
683    /// alias is not served.
684    fn max_input_bytes(&self, alias: &str) -> Option<u64>;
685
686    /// Token estimate for reserving `texts` against `alias`. The default is
687    /// `ceil(utf8_bytes / 3)` per input; saturating.
688    fn estimate_tokens(&self, _alias: &str, texts: &[String]) -> u64 {
689        texts.iter().fold(0u64, |total, text| {
690            total.saturating_add(estimate_embedding_tokens(text))
691        })
692    }
693}
694
695/// One execution's embedding budget: tokens against its own ceiling and the
696/// server-wide one, plus the outbound request counter.
697pub struct EmbeddingTokenBudget {
698    limits: EmbeddingLimits,
699    ledger: TokenLedger,
700    requests: AtomicU64,
701}
702
703impl EmbeddingTokenBudget {
704    pub fn new(limits: EmbeddingLimits, aggregate: SharedTokenBudget) -> Self {
705        let ledger = TokenLedger::new(
706            LedgerLimits {
707                per_execution_tokens: limits.per_execution_tokens,
708                max_held_tokens: limits.max_held_tokens,
709            },
710            aggregate,
711        );
712        Self {
713            limits,
714            ledger,
715            requests: AtomicU64::new(0),
716        }
717    }
718
719    /// A budget that never refuses, for runs with no operator-installed budget.
720    pub fn unmetered() -> Self {
721        Self::new(
722            EmbeddingLimits {
723                per_execution_tokens: u64::MAX,
724                max_held_tokens: u64::MAX,
725                max_requests: u64::MAX,
726                ..EmbeddingLimits::default()
727            },
728            SharedTokenBudget::new(u64::MAX),
729        )
730    }
731
732    pub fn limits(&self) -> EmbeddingLimits {
733        self.limits
734    }
735
736    pub fn used(&self) -> u64 {
737        self.ledger.used()
738    }
739
740    pub fn held(&self) -> u64 {
741        self.ledger.held()
742    }
743    /// Apply committed per-run spend during replay, without a provider reservation.
744    pub fn restore_spend(&self, used: u64, held: u64, requests: u64) {
745        self.ledger.restore_spend(used, held);
746        let _ = self
747            .requests
748            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
749                Some(count.saturating_add(requests))
750            });
751    }
752
753    /// Outbound requests counted so far.
754    pub fn requests(&self) -> u64 {
755        self.requests.load(Ordering::Relaxed)
756    }
757
758    /// Reserve a call's whole estimate before dispatch. Like `submilli:llm`,
759    /// refused once held reserve is already past its cap, so held spend per run
760    /// is bounded by the cap plus one call's sent estimate.
761    pub fn reserve(&self, alias: &str, tokens: u64) -> Result<(), EmbeddingError> {
762        let refused = |limit_kind| EmbeddingError::BudgetExceeded {
763            alias: alias.to_string(),
764            limit_kind,
765            settlements: Vec::new(),
766        };
767        self.ledger.reserve(tokens).map_err(|refusal| {
768            refused(match refusal {
769                LedgerRefusal::HeldReserve { held, limit } => {
770                    EmbeddingLimitKind::HeldTokens { held, limit }
771                }
772                LedgerRefusal::PerExecution { requested, limit } => {
773                    EmbeddingLimitKind::PerExecutionTokens { requested, limit }
774                }
775                LedgerRefusal::AllExecutions { requested, limit } => {
776                    EmbeddingLimitKind::AllExecutionsTokens { requested, limit }
777                }
778            })
779        })
780    }
781
782    /// Count one outbound request and move `estimate` (already reserved) to
783    /// held. Call immediately before sending; on `Err`, do not send. The error
784    /// is a [`EmbeddingError::BudgetExceeded`] with no settlements — attach the
785    /// provider's with [`EmbeddingError::with_settlements`].
786    pub fn mark_sent(&self, alias: &str, estimate: u64) -> Result<(), EmbeddingError> {
787        let max = self.limits.max_requests;
788        let counted = self
789            .requests
790            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |sent| {
791                (sent < max).then(|| sent.saturating_add(1))
792            });
793        if counted.is_err() {
794            return Err(EmbeddingError::BudgetExceeded {
795                alias: alias.to_string(),
796                limit_kind: EmbeddingLimitKind::Requests { limit: max },
797                settlements: Vec::new(),
798            });
799        }
800        self.ledger.reconcile(estimate, 0, estimate);
801        Ok(())
802    }
803
804    /// Apply a call's settlements and release whatever of `reserved` was never
805    /// sent. Idempotence is not provided: call once per reservation.
806    pub fn settle(&self, reserved: u64, settlements: &[SubBatchSettlement]) {
807        let mut sent = 0u64;
808        for settlement in settlements {
809            self.ledger.settle_held(
810                settlement.estimate,
811                settlement.reported,
812                settlement.indeterminate,
813            );
814            sent = sent.saturating_add(settlement.estimate);
815        }
816        let unsent = reserved.saturating_sub(sent);
817        if unsent > 0 {
818            self.ledger.reconcile(unsent, 0, 0);
819        }
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826
827    const SAMPLE: &str = "SECRET-SAMPLE-INPUT-TEXT";
828
829    fn limits() -> EmbeddingLimits {
830        EmbeddingLimits {
831            per_execution_tokens: 1_000,
832            max_held_tokens: 500,
833            max_requests: 3,
834            ..EmbeddingLimits::default()
835        }
836    }
837
838    fn budget(aggregate: &SharedTokenBudget) -> EmbeddingTokenBudget {
839        EmbeddingTokenBudget::new(limits(), aggregate.clone())
840    }
841
842    fn settle_one(estimate: u64, reported: u64, indeterminate: u64) -> SubBatchSettlement {
843        SubBatchSettlement {
844            estimate,
845            reported,
846            indeterminate,
847        }
848    }
849
850    #[test]
851    fn default_limits_match_the_plan() {
852        let l = EmbeddingLimits::default();
853        assert_eq!(l.per_execution_tokens, 2_000_000);
854        assert_eq!(l.max_held_tokens, 200_000);
855        assert_eq!(l.max_requests, 1_000);
856        assert_eq!(l.max_texts_per_call, 128);
857        assert_eq!(l.max_bytes_per_call, 2 * 1024 * 1024);
858    }
859
860    #[test]
861    fn estimate_is_ceil_bytes_over_three() {
862        assert_eq!(estimate_embedding_tokens(""), 0);
863        assert_eq!(estimate_embedding_tokens("ab"), 1);
864        assert_eq!(estimate_embedding_tokens("abcd"), 2);
865        assert_eq!(estimate_embedding_tokens("é"), 1);
866    }
867
868    #[test]
869    fn reserve_over_the_per_run_cap_charges_nothing() {
870        let aggregate = SharedTokenBudget::new(10_000);
871        let b = EmbeddingTokenBudget::new(
872            EmbeddingLimits {
873                max_held_tokens: 5_000,
874                ..limits()
875            },
876            aggregate.clone(),
877        );
878        let err = b.reserve("m", 1_001).expect_err("over cap");
879        assert!(matches!(
880            err,
881            EmbeddingError::BudgetExceeded {
882                limit_kind: EmbeddingLimitKind::PerExecutionTokens {
883                    requested: 1_001,
884                    limit: 1_000
885                },
886                ..
887            }
888        ));
889        assert!(err.to_string().contains("--max-execution-embedding-tokens"));
890        assert_eq!((b.used(), aggregate.used()), (0, 0));
891    }
892
893    #[test]
894    fn aggregate_refusal_names_the_server_flag() {
895        let aggregate = SharedTokenBudget::new(10);
896        let b = budget(&aggregate);
897        let err = b.reserve("m", 11).expect_err("over aggregate");
898        assert!(err.to_string().contains("--max-embedding-tokens"));
899        assert_eq!((b.used(), aggregate.used()), (0, 0));
900    }
901
902    #[test]
903    fn reported_usage_below_estimate_refunds_the_difference() {
904        let aggregate = SharedTokenBudget::new(10_000);
905        let b = budget(&aggregate);
906        b.reserve("m", 300).expect("fits");
907        b.mark_sent("m", 300).expect("sent");
908        b.settle(300, &[settle_one(300, 120, 0)]);
909        assert_eq!((b.used(), b.held(), aggregate.used()), (120, 0, 120));
910    }
911
912    #[test]
913    fn reported_usage_above_the_estimate_is_charged() {
914        let aggregate = SharedTokenBudget::new(10_000);
915        let b = budget(&aggregate);
916        b.reserve("m", 1).expect("fits");
917        b.mark_sent("m", 1).expect("sent");
918        b.settle(1, &[settle_one(1, 7, 0)]);
919        assert_eq!((b.used(), b.held(), aggregate.used()), (7, 0, 7));
920    }
921
922    #[test]
923    fn no_usage_keeps_the_estimate_held_past_the_run() {
924        let aggregate = SharedTokenBudget::new(10_000);
925        {
926            let b = budget(&aggregate);
927            b.reserve("m", 300).expect("fits");
928            b.mark_sent("m", 300).expect("sent");
929            b.settle(300, &[settle_one(300, 0, 300)]);
930            assert_eq!((b.used(), b.held()), (300, 300));
931        }
932        assert_eq!(aggregate.used(), 300);
933    }
934
935    #[test]
936    fn failed_call_charges_reported_and_indeterminate_and_releases_the_rest() {
937        let aggregate = SharedTokenBudget::new(10_000);
938        let b = budget(&aggregate);
939        b.reserve("m", 400).expect("fits");
940        b.mark_sent("m", 100).expect("first sent");
941        b.mark_sent("m", 100).expect("second sent");
942        // First succeeded with usage 40; second failed with no usage; the
943        // remaining 200 were never sent.
944        b.settle(400, &[settle_one(100, 40, 0), settle_one(100, 0, 100)]);
945        assert_eq!((b.used(), b.held(), aggregate.used()), (140, 100, 140));
946    }
947
948    #[test]
949    fn rejected_sub_batch_releases_everything() {
950        let aggregate = SharedTokenBudget::new(10_000);
951        let b = budget(&aggregate);
952        b.reserve("m", 100).expect("fits");
953        b.mark_sent("m", 100).expect("sent");
954        b.settle(100, &[settle_one(100, 0, 0)]);
955        assert_eq!((b.used(), b.held(), aggregate.used()), (0, 0, 0));
956    }
957
958    #[test]
959    fn sent_then_dropped_stays_held_and_unsent_is_returned() {
960        let aggregate = SharedTokenBudget::new(10_000);
961        {
962            let b = budget(&aggregate);
963            b.reserve("m", 300).expect("fits");
964            b.mark_sent("m", 100).expect("sent");
965            // Future dropped here: no settle.
966        }
967        assert_eq!(aggregate.used(), 100);
968    }
969
970    #[test]
971    fn a_call_is_refused_once_held_reserve_is_past_the_cap() {
972        let aggregate = SharedTokenBudget::new(10_000);
973        let b = budget(&aggregate);
974        b.reserve("m", 600)
975            .expect("one call may exceed the held cap");
976        b.mark_sent("m", 600).expect("sent");
977        b.settle(600, &[settle_one(600, 0, 600)]);
978        let err = b.reserve("m", 1).expect_err("held is past the cap");
979        assert!(matches!(
980            err,
981            EmbeddingError::BudgetExceeded {
982                limit_kind: EmbeddingLimitKind::HeldTokens {
983                    held: 600,
984                    limit: 500
985                },
986                ..
987            }
988        ));
989        assert!(err.to_string().contains("held"));
990        assert_eq!(b.used(), 600);
991    }
992
993    #[test]
994    fn held_reserve_at_the_cap_still_admits_a_call() {
995        let aggregate = SharedTokenBudget::new(10_000);
996        let b = budget(&aggregate);
997        b.reserve("m", 500).expect("fits");
998        b.mark_sent("m", 500).expect("sent");
999        b.settle(500, &[settle_one(500, 0, 500)]);
1000        b.reserve("m", 100)
1001            .expect("held equals the cap, not past it");
1002    }
1003
1004    #[test]
1005    fn request_past_the_cap_is_refused_before_sent() {
1006        let aggregate = SharedTokenBudget::new(10_000);
1007        let b = budget(&aggregate);
1008        b.reserve("m", 100).expect("fits");
1009        for _ in 0..3 {
1010            b.mark_sent("m", 1).expect("within cap");
1011        }
1012        let held_before = b.held();
1013        let err = b.mark_sent("m", 1).expect_err("fourth refused");
1014        assert!(matches!(
1015            err,
1016            EmbeddingError::BudgetExceeded {
1017                limit_kind: EmbeddingLimitKind::Requests { limit: 3 },
1018                ..
1019            }
1020        ));
1021        assert!(
1022            err.to_string()
1023                .contains("--max-execution-embedding-requests")
1024        );
1025        assert_eq!(b.requests(), 3);
1026        assert_eq!(b.held(), held_before, "a refused request holds nothing");
1027    }
1028
1029    #[test]
1030    fn dropping_the_budget_returns_unspent_tokens() {
1031        let aggregate = SharedTokenBudget::new(10_000);
1032        {
1033            let b = budget(&aggregate);
1034            b.reserve("m", 250).expect("fits");
1035            assert_eq!(aggregate.used(), 250);
1036        }
1037        assert_eq!(aggregate.used(), 0);
1038    }
1039
1040    #[test]
1041    fn unmetered_budget_never_refuses() {
1042        let b = EmbeddingTokenBudget::unmetered();
1043        b.reserve("m", 5_000_000).expect("fits");
1044        for _ in 0..2_000 {
1045            b.mark_sent("m", 0).expect("uncapped");
1046        }
1047    }
1048
1049    #[test]
1050    fn batch_row_access_is_checked() {
1051        let batch = EmbeddingBatch::new(vec![1.0, 2.0, 3.0, 4.0], 2, 2, "id", "m").expect("shape");
1052        assert_eq!(batch.row(0), Ok(&[1.0, 2.0][..]));
1053        assert_eq!(batch.row(1), Ok(&[3.0, 4.0][..]));
1054        assert_eq!(
1055            batch.row(2),
1056            Err(EmbeddingShapeError::RowOutOfRange { index: 2, count: 2 })
1057        );
1058        assert!(batch.row(usize::MAX).is_err());
1059    }
1060
1061    #[test]
1062    fn batch_construction_validates_length_with_checked_mul() {
1063        assert!(EmbeddingBatch::new(vec![1.0; 3], 2, 2, "id", "m").is_err());
1064        assert!(EmbeddingBatch::new(vec![], usize::MAX, 2, "id", "m").is_err());
1065        assert!(EmbeddingBatch::new(vec![], 0, 5, "id", "m").is_ok());
1066    }
1067
1068    #[test]
1069    fn no_error_display_contains_input_text() {
1070        let settlements = vec![settle_one(1, 0, 1)];
1071        let errors = vec![
1072            EmbeddingError::NotConfigured { alias: "m".into() },
1073            EmbeddingError::UnknownModel {
1074                alias: "m".into(),
1075                available: vec!["a".into()],
1076            },
1077            EmbeddingError::InputTooLong {
1078                alias: "m".into(),
1079                index: Some(2),
1080                limit: Some(9),
1081                settlements: Vec::new(),
1082            },
1083            EmbeddingError::InputTooLong {
1084                alias: "m".into(),
1085                index: Some(2),
1086                limit: None,
1087                settlements: Vec::new(),
1088            },
1089            EmbeddingError::BoundsExceeded {
1090                alias: "m".into(),
1091                kind: EmbeddingBoundKind::TextCount,
1092                actual: 200,
1093                limit: 128,
1094            },
1095            EmbeddingError::BudgetExceeded {
1096                alias: "m".into(),
1097                limit_kind: EmbeddingLimitKind::Requests { limit: 1 },
1098                settlements: settlements.clone(),
1099            },
1100            EmbeddingError::Unauthorized {
1101                alias: "m".into(),
1102                settlements: settlements.clone(),
1103            },
1104            EmbeddingError::Provider {
1105                alias: "m".into(),
1106                reason: EmbeddingFailureReason::Transport,
1107                settlements: settlements.clone(),
1108            },
1109            EmbeddingError::Malformed {
1110                alias: "m".into(),
1111                reason: EmbeddingMalformedReason::CountMismatch,
1112                settlements: settlements.clone(),
1113            },
1114            EmbeddingError::Internal {
1115                alias: "m".into(),
1116                settlements,
1117            },
1118        ];
1119        for error in errors {
1120            let text = error.to_string();
1121            assert!(!text.contains(SAMPLE), "{text}");
1122            assert!(text.contains("\"m\""), "{text}");
1123        }
1124    }
1125
1126    #[test]
1127    fn a_provider_side_length_rejection_does_not_claim_a_byte_limit() {
1128        let provider_side = EmbeddingError::InputTooLong {
1129            alias: "m".into(),
1130            index: Some(1),
1131            limit: None,
1132            settlements: Vec::new(),
1133        }
1134        .to_string();
1135        assert!(
1136            provider_side.contains("the provider rejected text 1 as too long"),
1137            "{provider_side}"
1138        );
1139        assert!(!provider_side.contains("bytes"), "{provider_side}");
1140        let pre_send = EmbeddingError::InputTooLong {
1141            alias: "m".into(),
1142            index: Some(1),
1143            limit: Some(9),
1144            settlements: Vec::new(),
1145        }
1146        .to_string();
1147        assert!(pre_send.contains("exceeds the 9 bytes"), "{pre_send}");
1148    }
1149
1150    #[test]
1151    fn post_send_errors_carry_settlements() {
1152        let err = EmbeddingError::Internal {
1153            alias: "m".into(),
1154            settlements: Vec::new(),
1155        }
1156        .with_settlements(vec![settle_one(5, 0, 5)]);
1157        assert_eq!(err.settlements(), &[settle_one(5, 0, 5)]);
1158        let pre = EmbeddingError::NotConfigured { alias: "m".into() }
1159            .with_settlements(vec![settle_one(5, 0, 5)]);
1160        assert!(pre.settlements().is_empty());
1161    }
1162
1163    #[test]
1164    fn failure_reason_spellings_are_stable_kebab_case() {
1165        let spelled: Vec<&str> = EmbeddingFailureReason::ALL
1166            .iter()
1167            .map(EmbeddingFailureReason::as_str)
1168            .collect();
1169        assert_eq!(
1170            spelled,
1171            [
1172                "rate-limited",
1173                "request-rejected",
1174                "provider-unavailable",
1175                "transport",
1176                "timeout",
1177                "blocked-by-network-policy"
1178            ]
1179        );
1180    }
1181}
1182
1183// Binary little-endian IEEE-754, preserving exact provider float bits.
1184mod float_bytes {
1185    use serde::{Deserializer, Serializer};
1186    pub fn serialize<S: Serializer>(values: &[f32], serializer: S) -> Result<S::Ok, S::Error> {
1187        let bytes: Vec<_> = values
1188            .iter()
1189            .flat_map(|value| value.to_bits().to_le_bytes())
1190            .collect();
1191        serializer.serialize_bytes(&bytes)
1192    }
1193    pub fn deserialize<'de, D: Deserializer<'de>>(decoder: D) -> Result<Vec<f32>, D::Error> {
1194        struct Floats;
1195        impl<'de> serde::de::Visitor<'de> for Floats {
1196            type Value = Vec<f32>;
1197            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1198                f.write_str("binary float vector")
1199            }
1200            fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
1201                if !bytes.len().is_multiple_of(4) {
1202                    return Err(E::custom("misaligned float vector"));
1203                }
1204                let mut values = Vec::new();
1205                values
1206                    .try_reserve_exact(bytes.len() / 4)
1207                    .map_err(E::custom)?;
1208                for word in bytes.as_chunks::<4>().0 {
1209                    values.push(f32::from_bits(u32::from_le_bytes(*word)));
1210                }
1211                Ok(values)
1212            }
1213        }
1214        decoder.deserialize_bytes(Floats)
1215    }
1216
1217    #[cfg(test)]
1218    mod tests {
1219        #[derive(serde::Serialize, serde::Deserialize)]
1220        struct Floats(#[serde(with = "super")] Vec<f32>);
1221
1222        #[test]
1223        fn replay_vectors_preserve_bits_without_geometric_capacity_growth() {
1224            let mut values = vec![1.0; 65_537];
1225            values[0] = -0.0;
1226            values[1] = f32::from_bits(0x7fc0_1234);
1227            let encoded = rmp_serde::to_vec(&Floats(values.clone())).unwrap();
1228            let restored: Floats = rmp_serde::from_slice(&encoded).unwrap();
1229            assert_eq!(restored.0.capacity(), values.len());
1230            assert!(
1231                restored
1232                    .0
1233                    .iter()
1234                    .zip(values)
1235                    .all(|(a, b)| a.to_bits() == b.to_bits())
1236            );
1237            assert!(rmp_serde::from_slice::<Floats>(&[0xc4, 3, 0, 0, 0]).is_err());
1238        }
1239    }
1240}