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)]
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)]
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)]
507pub struct EmbeddingBatch {
508    vectors: Vec<f32>,
509    count: usize,
510    dimensions: usize,
511    identity: String,
512    model: String,
513    input_tokens: Option<u64>,
514    settlements: Vec<SubBatchSettlement>,
515}
516
517impl EmbeddingBatch {
518    /// Validates `vectors.len() == count * dimensions` (checked multiply).
519    pub fn new(
520        vectors: Vec<f32>,
521        count: usize,
522        dimensions: usize,
523        identity: impl Into<String>,
524        model: impl Into<String>,
525    ) -> Result<Self, EmbeddingShapeError> {
526        let expected = count.checked_mul(dimensions);
527        if expected != Some(vectors.len()) {
528            return Err(EmbeddingShapeError::LengthMismatch {
529                len: vectors.len(),
530                count,
531                dimensions,
532            });
533        }
534        Ok(Self {
535            vectors,
536            count,
537            dimensions,
538            identity: identity.into(),
539            model: model.into(),
540            input_tokens: None,
541            settlements: Vec::new(),
542        })
543    }
544
545    /// Provider-reported total input tokens, when it reported any.
546    pub fn with_input_tokens(mut self, input_tokens: Option<u64>) -> Self {
547        self.input_tokens = input_tokens;
548        self
549    }
550
551    /// The settlements of every sub-batch this call sent, for
552    /// [`EmbeddingTokenBudget::settle`].
553    pub fn with_settlements(mut self, settlements: Vec<SubBatchSettlement>) -> Self {
554        self.settlements = settlements;
555        self
556    }
557
558    pub fn count(&self) -> usize {
559        self.count
560    }
561
562    pub fn dimensions(&self) -> usize {
563        self.dimensions
564    }
565
566    pub fn identity(&self) -> &str {
567        &self.identity
568    }
569
570    pub fn model(&self) -> &str {
571        &self.model
572    }
573
574    pub fn input_tokens(&self) -> Option<u64> {
575        self.input_tokens
576    }
577
578    pub fn settlements(&self) -> &[SubBatchSettlement] {
579        &self.settlements
580    }
581
582    /// All values, row-major.
583    pub fn values(&self) -> &[f32] {
584        &self.vectors
585    }
586
587    /// Row `index`, or an error past `count`. Never panics.
588    pub fn row(&self, index: usize) -> Result<&[f32], EmbeddingShapeError> {
589        let out_of_range = EmbeddingShapeError::RowOutOfRange {
590            index,
591            count: self.count,
592        };
593        if index >= self.count {
594            return Err(out_of_range);
595        }
596        let start = index.checked_mul(self.dimensions).ok_or(out_of_range)?;
597        let end = start.checked_add(self.dimensions).ok_or(out_of_range)?;
598        self.vectors.get(start..end).ok_or(out_of_range)
599    }
600}
601
602/// An embedding model alias the provider serves.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct EmbeddingModel {
605    pub name: String,
606    pub description: Option<String>,
607    pub dimensions: u64,
608    pub max_input_tokens: Option<u64>,
609    pub max_input_bytes: u64,
610    /// The embedding-space identity string vectors are labeled with.
611    pub identity: String,
612}
613
614/// The embedder-provided embedding provider: resolves credentials, performs the
615/// requests, and reports what each cost.
616///
617/// # Obligations on every implementor
618///
619/// 1. **Positional ordering.** The returned batch has one row per input text,
620///    in input order, all of `dimensions` width and finite.
621/// 2. **Bounded concurrency.** The guest is single-threaded; fan-out across
622///    sub-batches lives here, bounded by the implementor.
623/// 3. **Count before sending.** Call [`EmbeddingTokenBudget::mark_sent`] with
624///    the sub-batch's estimate immediately before each outbound request, and
625///    never send if it refuses. Retries are outbound requests too.
626/// 4. **Settle honestly.** Record a [`SubBatchSettlement`] per sent sub-batch
627///    per the module's outcome table; unknown spend is `indeterminate`, not
628///    free. Return them on the batch or inside every post-send error
629///    ([`EmbeddingError::with_settlements`]).
630/// 5. **Drop safety.** The future may be dropped at any await. State the budget
631///    holds must stay correct then: do `mark_sent` and the send with no await
632///    between them that could skip the send but keep the hold (a hold without a
633///    send is merely conservative, never unsafe).
634/// 6. **No input text in errors**, and no provider response bodies.
635pub trait EmbeddingProvider: Send + Sync {
636    fn embed<'a>(
637        &'a self,
638        alias: &'a str,
639        texts: &'a [String],
640        purpose: Purpose,
641        budget: &'a EmbeddingTokenBudget,
642    ) -> Pin<Box<dyn Future<Output = Result<EmbeddingBatch, EmbeddingError>> + Send + 'a>>;
643
644    /// The locally declared catalog, without network I/O.
645    fn models<'a>(
646        &'a self,
647    ) -> Pin<Box<dyn Future<Output = Result<Vec<EmbeddingModel>, EmbeddingError>> + Send + 'a>>;
648
649    /// Per-alias input byte limit, consulted before dispatch. `None` means the
650    /// alias is not served.
651    fn max_input_bytes(&self, alias: &str) -> Option<u64>;
652
653    /// Token estimate for reserving `texts` against `alias`. The default is
654    /// `ceil(utf8_bytes / 3)` per input; saturating.
655    fn estimate_tokens(&self, _alias: &str, texts: &[String]) -> u64 {
656        texts.iter().fold(0u64, |total, text| {
657            total.saturating_add(estimate_embedding_tokens(text))
658        })
659    }
660}
661
662/// One execution's embedding budget: tokens against its own ceiling and the
663/// server-wide one, plus the outbound request counter.
664pub struct EmbeddingTokenBudget {
665    limits: EmbeddingLimits,
666    ledger: TokenLedger,
667    requests: AtomicU64,
668}
669
670impl EmbeddingTokenBudget {
671    pub fn new(limits: EmbeddingLimits, aggregate: SharedTokenBudget) -> Self {
672        let ledger = TokenLedger::new(
673            LedgerLimits {
674                per_execution_tokens: limits.per_execution_tokens,
675                max_held_tokens: limits.max_held_tokens,
676            },
677            aggregate,
678        );
679        Self {
680            limits,
681            ledger,
682            requests: AtomicU64::new(0),
683        }
684    }
685
686    /// A budget that never refuses, for runs with no operator-installed budget.
687    pub fn unmetered() -> Self {
688        Self::new(
689            EmbeddingLimits {
690                per_execution_tokens: u64::MAX,
691                max_held_tokens: u64::MAX,
692                max_requests: u64::MAX,
693                ..EmbeddingLimits::default()
694            },
695            SharedTokenBudget::new(u64::MAX),
696        )
697    }
698
699    pub fn limits(&self) -> EmbeddingLimits {
700        self.limits
701    }
702
703    pub fn used(&self) -> u64 {
704        self.ledger.used()
705    }
706
707    pub fn held(&self) -> u64 {
708        self.ledger.held()
709    }
710
711    /// Outbound requests counted so far.
712    pub fn requests(&self) -> u64 {
713        self.requests.load(Ordering::Relaxed)
714    }
715
716    /// Reserve a call's whole estimate before dispatch. Like `submilli:llm`,
717    /// refused once held reserve is already past its cap, so held spend per run
718    /// is bounded by the cap plus one call's sent estimate.
719    pub fn reserve(&self, alias: &str, tokens: u64) -> Result<(), EmbeddingError> {
720        let refused = |limit_kind| EmbeddingError::BudgetExceeded {
721            alias: alias.to_string(),
722            limit_kind,
723            settlements: Vec::new(),
724        };
725        self.ledger.reserve(tokens).map_err(|refusal| {
726            refused(match refusal {
727                LedgerRefusal::HeldReserve { held, limit } => {
728                    EmbeddingLimitKind::HeldTokens { held, limit }
729                }
730                LedgerRefusal::PerExecution { requested, limit } => {
731                    EmbeddingLimitKind::PerExecutionTokens { requested, limit }
732                }
733                LedgerRefusal::AllExecutions { requested, limit } => {
734                    EmbeddingLimitKind::AllExecutionsTokens { requested, limit }
735                }
736            })
737        })
738    }
739
740    /// Count one outbound request and move `estimate` (already reserved) to
741    /// held. Call immediately before sending; on `Err`, do not send. The error
742    /// is a [`EmbeddingError::BudgetExceeded`] with no settlements — attach the
743    /// provider's with [`EmbeddingError::with_settlements`].
744    pub fn mark_sent(&self, alias: &str, estimate: u64) -> Result<(), EmbeddingError> {
745        let max = self.limits.max_requests;
746        let counted = self
747            .requests
748            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |sent| {
749                (sent < max).then(|| sent.saturating_add(1))
750            });
751        if counted.is_err() {
752            return Err(EmbeddingError::BudgetExceeded {
753                alias: alias.to_string(),
754                limit_kind: EmbeddingLimitKind::Requests { limit: max },
755                settlements: Vec::new(),
756            });
757        }
758        self.ledger.reconcile(estimate, 0, estimate);
759        Ok(())
760    }
761
762    /// Apply a call's settlements and release whatever of `reserved` was never
763    /// sent. Idempotence is not provided: call once per reservation.
764    pub fn settle(&self, reserved: u64, settlements: &[SubBatchSettlement]) {
765        let mut sent = 0u64;
766        for settlement in settlements {
767            self.ledger.settle_held(
768                settlement.estimate,
769                settlement.reported,
770                settlement.indeterminate,
771            );
772            sent = sent.saturating_add(settlement.estimate);
773        }
774        let unsent = reserved.saturating_sub(sent);
775        if unsent > 0 {
776            self.ledger.reconcile(unsent, 0, 0);
777        }
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    const SAMPLE: &str = "SECRET-SAMPLE-INPUT-TEXT";
786
787    fn limits() -> EmbeddingLimits {
788        EmbeddingLimits {
789            per_execution_tokens: 1_000,
790            max_held_tokens: 500,
791            max_requests: 3,
792            ..EmbeddingLimits::default()
793        }
794    }
795
796    fn budget(aggregate: &SharedTokenBudget) -> EmbeddingTokenBudget {
797        EmbeddingTokenBudget::new(limits(), aggregate.clone())
798    }
799
800    fn settle_one(estimate: u64, reported: u64, indeterminate: u64) -> SubBatchSettlement {
801        SubBatchSettlement {
802            estimate,
803            reported,
804            indeterminate,
805        }
806    }
807
808    #[test]
809    fn default_limits_match_the_plan() {
810        let l = EmbeddingLimits::default();
811        assert_eq!(l.per_execution_tokens, 2_000_000);
812        assert_eq!(l.max_held_tokens, 200_000);
813        assert_eq!(l.max_requests, 1_000);
814        assert_eq!(l.max_texts_per_call, 128);
815        assert_eq!(l.max_bytes_per_call, 2 * 1024 * 1024);
816    }
817
818    #[test]
819    fn estimate_is_ceil_bytes_over_three() {
820        assert_eq!(estimate_embedding_tokens(""), 0);
821        assert_eq!(estimate_embedding_tokens("ab"), 1);
822        assert_eq!(estimate_embedding_tokens("abcd"), 2);
823        assert_eq!(estimate_embedding_tokens("é"), 1);
824    }
825
826    #[test]
827    fn reserve_over_the_per_run_cap_charges_nothing() {
828        let aggregate = SharedTokenBudget::new(10_000);
829        let b = EmbeddingTokenBudget::new(
830            EmbeddingLimits {
831                max_held_tokens: 5_000,
832                ..limits()
833            },
834            aggregate.clone(),
835        );
836        let err = b.reserve("m", 1_001).expect_err("over cap");
837        assert!(matches!(
838            err,
839            EmbeddingError::BudgetExceeded {
840                limit_kind: EmbeddingLimitKind::PerExecutionTokens {
841                    requested: 1_001,
842                    limit: 1_000
843                },
844                ..
845            }
846        ));
847        assert!(err.to_string().contains("--max-execution-embedding-tokens"));
848        assert_eq!((b.used(), aggregate.used()), (0, 0));
849    }
850
851    #[test]
852    fn aggregate_refusal_names_the_server_flag() {
853        let aggregate = SharedTokenBudget::new(10);
854        let b = budget(&aggregate);
855        let err = b.reserve("m", 11).expect_err("over aggregate");
856        assert!(err.to_string().contains("--max-embedding-tokens"));
857        assert_eq!((b.used(), aggregate.used()), (0, 0));
858    }
859
860    #[test]
861    fn reported_usage_below_estimate_refunds_the_difference() {
862        let aggregate = SharedTokenBudget::new(10_000);
863        let b = budget(&aggregate);
864        b.reserve("m", 300).expect("fits");
865        b.mark_sent("m", 300).expect("sent");
866        b.settle(300, &[settle_one(300, 120, 0)]);
867        assert_eq!((b.used(), b.held(), aggregate.used()), (120, 0, 120));
868    }
869
870    #[test]
871    fn reported_usage_above_the_estimate_is_charged() {
872        let aggregate = SharedTokenBudget::new(10_000);
873        let b = budget(&aggregate);
874        b.reserve("m", 1).expect("fits");
875        b.mark_sent("m", 1).expect("sent");
876        b.settle(1, &[settle_one(1, 7, 0)]);
877        assert_eq!((b.used(), b.held(), aggregate.used()), (7, 0, 7));
878    }
879
880    #[test]
881    fn no_usage_keeps_the_estimate_held_past_the_run() {
882        let aggregate = SharedTokenBudget::new(10_000);
883        {
884            let b = budget(&aggregate);
885            b.reserve("m", 300).expect("fits");
886            b.mark_sent("m", 300).expect("sent");
887            b.settle(300, &[settle_one(300, 0, 300)]);
888            assert_eq!((b.used(), b.held()), (300, 300));
889        }
890        assert_eq!(aggregate.used(), 300);
891    }
892
893    #[test]
894    fn failed_call_charges_reported_and_indeterminate_and_releases_the_rest() {
895        let aggregate = SharedTokenBudget::new(10_000);
896        let b = budget(&aggregate);
897        b.reserve("m", 400).expect("fits");
898        b.mark_sent("m", 100).expect("first sent");
899        b.mark_sent("m", 100).expect("second sent");
900        // First succeeded with usage 40; second failed with no usage; the
901        // remaining 200 were never sent.
902        b.settle(400, &[settle_one(100, 40, 0), settle_one(100, 0, 100)]);
903        assert_eq!((b.used(), b.held(), aggregate.used()), (140, 100, 140));
904    }
905
906    #[test]
907    fn rejected_sub_batch_releases_everything() {
908        let aggregate = SharedTokenBudget::new(10_000);
909        let b = budget(&aggregate);
910        b.reserve("m", 100).expect("fits");
911        b.mark_sent("m", 100).expect("sent");
912        b.settle(100, &[settle_one(100, 0, 0)]);
913        assert_eq!((b.used(), b.held(), aggregate.used()), (0, 0, 0));
914    }
915
916    #[test]
917    fn sent_then_dropped_stays_held_and_unsent_is_returned() {
918        let aggregate = SharedTokenBudget::new(10_000);
919        {
920            let b = budget(&aggregate);
921            b.reserve("m", 300).expect("fits");
922            b.mark_sent("m", 100).expect("sent");
923            // Future dropped here: no settle.
924        }
925        assert_eq!(aggregate.used(), 100);
926    }
927
928    #[test]
929    fn a_call_is_refused_once_held_reserve_is_past_the_cap() {
930        let aggregate = SharedTokenBudget::new(10_000);
931        let b = budget(&aggregate);
932        b.reserve("m", 600)
933            .expect("one call may exceed the held cap");
934        b.mark_sent("m", 600).expect("sent");
935        b.settle(600, &[settle_one(600, 0, 600)]);
936        let err = b.reserve("m", 1).expect_err("held is past the cap");
937        assert!(matches!(
938            err,
939            EmbeddingError::BudgetExceeded {
940                limit_kind: EmbeddingLimitKind::HeldTokens {
941                    held: 600,
942                    limit: 500
943                },
944                ..
945            }
946        ));
947        assert!(err.to_string().contains("held"));
948        assert_eq!(b.used(), 600);
949    }
950
951    #[test]
952    fn held_reserve_at_the_cap_still_admits_a_call() {
953        let aggregate = SharedTokenBudget::new(10_000);
954        let b = budget(&aggregate);
955        b.reserve("m", 500).expect("fits");
956        b.mark_sent("m", 500).expect("sent");
957        b.settle(500, &[settle_one(500, 0, 500)]);
958        b.reserve("m", 100)
959            .expect("held equals the cap, not past it");
960    }
961
962    #[test]
963    fn request_past_the_cap_is_refused_before_sent() {
964        let aggregate = SharedTokenBudget::new(10_000);
965        let b = budget(&aggregate);
966        b.reserve("m", 100).expect("fits");
967        for _ in 0..3 {
968            b.mark_sent("m", 1).expect("within cap");
969        }
970        let held_before = b.held();
971        let err = b.mark_sent("m", 1).expect_err("fourth refused");
972        assert!(matches!(
973            err,
974            EmbeddingError::BudgetExceeded {
975                limit_kind: EmbeddingLimitKind::Requests { limit: 3 },
976                ..
977            }
978        ));
979        assert!(
980            err.to_string()
981                .contains("--max-execution-embedding-requests")
982        );
983        assert_eq!(b.requests(), 3);
984        assert_eq!(b.held(), held_before, "a refused request holds nothing");
985    }
986
987    #[test]
988    fn dropping_the_budget_returns_unspent_tokens() {
989        let aggregate = SharedTokenBudget::new(10_000);
990        {
991            let b = budget(&aggregate);
992            b.reserve("m", 250).expect("fits");
993            assert_eq!(aggregate.used(), 250);
994        }
995        assert_eq!(aggregate.used(), 0);
996    }
997
998    #[test]
999    fn unmetered_budget_never_refuses() {
1000        let b = EmbeddingTokenBudget::unmetered();
1001        b.reserve("m", 5_000_000).expect("fits");
1002        for _ in 0..2_000 {
1003            b.mark_sent("m", 0).expect("uncapped");
1004        }
1005    }
1006
1007    #[test]
1008    fn batch_row_access_is_checked() {
1009        let batch = EmbeddingBatch::new(vec![1.0, 2.0, 3.0, 4.0], 2, 2, "id", "m").expect("shape");
1010        assert_eq!(batch.row(0), Ok(&[1.0, 2.0][..]));
1011        assert_eq!(batch.row(1), Ok(&[3.0, 4.0][..]));
1012        assert_eq!(
1013            batch.row(2),
1014            Err(EmbeddingShapeError::RowOutOfRange { index: 2, count: 2 })
1015        );
1016        assert!(batch.row(usize::MAX).is_err());
1017    }
1018
1019    #[test]
1020    fn batch_construction_validates_length_with_checked_mul() {
1021        assert!(EmbeddingBatch::new(vec![1.0; 3], 2, 2, "id", "m").is_err());
1022        assert!(EmbeddingBatch::new(vec![], usize::MAX, 2, "id", "m").is_err());
1023        assert!(EmbeddingBatch::new(vec![], 0, 5, "id", "m").is_ok());
1024    }
1025
1026    #[test]
1027    fn no_error_display_contains_input_text() {
1028        let settlements = vec![settle_one(1, 0, 1)];
1029        let errors = vec![
1030            EmbeddingError::NotConfigured { alias: "m".into() },
1031            EmbeddingError::UnknownModel {
1032                alias: "m".into(),
1033                available: vec!["a".into()],
1034            },
1035            EmbeddingError::InputTooLong {
1036                alias: "m".into(),
1037                index: Some(2),
1038                limit: Some(9),
1039                settlements: Vec::new(),
1040            },
1041            EmbeddingError::InputTooLong {
1042                alias: "m".into(),
1043                index: Some(2),
1044                limit: None,
1045                settlements: Vec::new(),
1046            },
1047            EmbeddingError::BoundsExceeded {
1048                alias: "m".into(),
1049                kind: EmbeddingBoundKind::TextCount,
1050                actual: 200,
1051                limit: 128,
1052            },
1053            EmbeddingError::BudgetExceeded {
1054                alias: "m".into(),
1055                limit_kind: EmbeddingLimitKind::Requests { limit: 1 },
1056                settlements: settlements.clone(),
1057            },
1058            EmbeddingError::Unauthorized {
1059                alias: "m".into(),
1060                settlements: settlements.clone(),
1061            },
1062            EmbeddingError::Provider {
1063                alias: "m".into(),
1064                reason: EmbeddingFailureReason::Transport,
1065                settlements: settlements.clone(),
1066            },
1067            EmbeddingError::Malformed {
1068                alias: "m".into(),
1069                reason: EmbeddingMalformedReason::CountMismatch,
1070                settlements: settlements.clone(),
1071            },
1072            EmbeddingError::Internal {
1073                alias: "m".into(),
1074                settlements,
1075            },
1076        ];
1077        for error in errors {
1078            let text = error.to_string();
1079            assert!(!text.contains(SAMPLE), "{text}");
1080            assert!(text.contains("\"m\""), "{text}");
1081        }
1082    }
1083
1084    #[test]
1085    fn a_provider_side_length_rejection_does_not_claim_a_byte_limit() {
1086        let provider_side = EmbeddingError::InputTooLong {
1087            alias: "m".into(),
1088            index: Some(1),
1089            limit: None,
1090            settlements: Vec::new(),
1091        }
1092        .to_string();
1093        assert!(
1094            provider_side.contains("the provider rejected text 1 as too long"),
1095            "{provider_side}"
1096        );
1097        assert!(!provider_side.contains("bytes"), "{provider_side}");
1098        let pre_send = EmbeddingError::InputTooLong {
1099            alias: "m".into(),
1100            index: Some(1),
1101            limit: Some(9),
1102            settlements: Vec::new(),
1103        }
1104        .to_string();
1105        assert!(pre_send.contains("exceeds the 9 bytes"), "{pre_send}");
1106    }
1107
1108    #[test]
1109    fn post_send_errors_carry_settlements() {
1110        let err = EmbeddingError::Internal {
1111            alias: "m".into(),
1112            settlements: Vec::new(),
1113        }
1114        .with_settlements(vec![settle_one(5, 0, 5)]);
1115        assert_eq!(err.settlements(), &[settle_one(5, 0, 5)]);
1116        let pre = EmbeddingError::NotConfigured { alias: "m".into() }
1117            .with_settlements(vec![settle_one(5, 0, 5)]);
1118        assert!(pre.settlements().is_empty());
1119    }
1120
1121    #[test]
1122    fn failure_reason_spellings_are_stable_kebab_case() {
1123        let spelled: Vec<&str> = EmbeddingFailureReason::ALL
1124            .iter()
1125            .map(EmbeddingFailureReason::as_str)
1126            .collect();
1127        assert_eq!(
1128            spelled,
1129            [
1130                "rate-limited",
1131                "request-rejected",
1132                "provider-unavailable",
1133                "transport",
1134                "timeout",
1135                "blocked-by-network-policy"
1136            ]
1137        );
1138    }
1139}