1use 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
50pub const EMBEDDING_MODULE_NAME: &str = "submilli:embedding";
53
54pub const DEFAULT_MAX_EXECUTION_EMBEDDING_TOKENS: u64 = 2_000_000;
56pub const DEFAULT_MAX_HELD_EMBEDDING_TOKENS: u64 = 200_000;
58pub const DEFAULT_MAX_EMBEDDING_REQUESTS: u64 = 1_000;
60pub const DEFAULT_MAX_TEXTS_PER_CALL: u64 = 128;
62pub const DEFAULT_MAX_BYTES_PER_CALL: u64 = 2 * 1024 * 1024;
64
65#[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
90pub fn estimate_embedding_tokens(text: &str) -> u64 {
92 (text.len() as u64).div_ceil(3)
93}
94
95#[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#[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 HeldTokens {
125 held: u64,
126 limit: u64,
127 },
128 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum EmbeddingFailureReason {
185 RateLimited,
186 RequestRejected,
187 ProviderUnavailable,
188 Transport,
189 Timeout,
190 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#[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#[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#[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 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 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 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 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 pub fn is_budget_exceeded(&self) -> bool {
351 matches!(self, Self::BudgetExceeded { .. })
352 }
353
354 pub fn is_internal(&self) -> bool {
356 matches!(self, Self::Internal { .. })
357 }
358
359 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum EmbeddingShapeError {
471 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#[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 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 pub fn with_input_tokens(mut self, input_tokens: Option<u64>) -> Self {
547 self.input_tokens = input_tokens;
548 self
549 }
550
551 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 pub fn values(&self) -> &[f32] {
584 &self.vectors
585 }
586
587 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#[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 pub identity: String,
612}
613
614pub 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 fn models<'a>(
646 &'a self,
647 ) -> Pin<Box<dyn Future<Output = Result<Vec<EmbeddingModel>, EmbeddingError>> + Send + 'a>>;
648
649 fn max_input_bytes(&self, alias: &str) -> Option<u64>;
652
653 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
662pub 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 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 pub fn requests(&self) -> u64 {
713 self.requests.load(Ordering::Relaxed)
714 }
715
716 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 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 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 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 }
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}