use std::future::Future;
use std::pin::Pin;
pub use super::token_ledger::SharedTokenBudget;
use super::token_ledger::{LedgerLimits, LedgerRefusal, TokenLedger};
pub const LLM_MODULE_NAME: &str = "submilli:llm";
pub const DEFAULT_MAX_EXECUTION_TOKENS: u64 = 1_000_000;
pub const DEFAULT_MAX_ALL_EXECUTIONS_TOKENS: u64 = 20_000_000;
pub const DEFAULT_MAX_HELD_TOKENS: u64 = 200_000;
pub const DEFAULT_OUTPUT_CAP: u64 = 64_000;
pub const DEFAULT_MAX_PROMPT_COUNT: u64 = 128;
pub const DEFAULT_MAX_PROMPT_BYTES: u64 = 256 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LlmLimits {
pub per_execution_tokens: u64,
pub max_held_tokens: u64,
pub default_output_cap: u64,
pub max_prompt_count: u64,
pub max_prompt_bytes: u64,
}
impl Default for LlmLimits {
fn default() -> Self {
Self {
per_execution_tokens: DEFAULT_MAX_EXECUTION_TOKENS,
max_held_tokens: DEFAULT_MAX_HELD_TOKENS,
default_output_cap: DEFAULT_OUTPUT_CAP,
max_prompt_count: DEFAULT_MAX_PROMPT_COUNT,
max_prompt_bytes: DEFAULT_MAX_PROMPT_BYTES,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmLimitKind {
PerExecutionTokens { requested: u64, limit: u64 },
AllExecutionsTokens { requested: u64, limit: u64 },
IndeterminateSpend { held: u64, limit: u64 },
}
impl std::fmt::Display for LlmLimitKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PerExecutionTokens { requested, limit } => write!(
f,
"execution token budget: {requested} tokens exceeds the {limit} this execution \
may spend — use fewer or shorter calls, or split the work across executions"
),
Self::AllExecutionsTokens { requested, limit } => write!(
f,
"server token budget: {requested} tokens exceeds the {limit} allowed across all \
live executions — this execution's own spend is not what is in the way, so \
using fewer or shorter calls need not help; the operator raises the budget \
with `--max-llm-tokens`"
),
Self::IndeterminateSpend { held, limit } => write!(
f,
"indeterminate spend ceiling reached: {held} tokens are held for calls the \
provider never reported usage for, exceeding the {limit} allowed — this is \
unreported spend, not measured consumption; start a new execution, and if it \
recurs the provider is not reporting usage and the operator should raise the \
held-token ceiling in the server configuration"
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LlmCallError {
NotConfigured { model: String },
UnknownModel {
model: String,
available: Vec<String>,
},
BudgetExceeded {
model: String,
limit_kind: LlmLimitKind,
},
PromptBoundsExceeded {
model: String,
limit_kind: PromptBoundKind,
actual: u64,
limit: u64,
},
Unauthorized { model: String },
Transport { model: String, detail: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptBoundKind {
PromptCount,
PromptBytes,
}
impl std::fmt::Display for PromptBoundKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PromptCount => write!(f, "prompts in one batch"),
Self::PromptBytes => write!(f, "bytes in a single prompt"),
}
}
}
impl std::fmt::Display for LlmCallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotConfigured { model } => write!(
f,
"llm.call(\"{model}\"): no model provider is configured for this runtime — the \
operator wires one before model calls can dispatch"
),
Self::UnknownModel { model, available } => {
write!(
f,
"llm.call(\"{model}\"): the provider does not serve that model — "
)?;
if available.is_empty() {
write!(
f,
"it serves none, so declare a model in the blueprint before calling"
)
} else {
write!(
f,
"call llm.models() and use one of: {}",
available.join(", ")
)
}
}
Self::BudgetExceeded { model, limit_kind } => {
write!(f, "llm.call(\"{model}\") exceeded the {limit_kind}")
}
Self::PromptBoundsExceeded {
model,
limit_kind,
actual,
limit,
} => write!(
f,
"llm.call(\"{model}\"): prompt bound exceeded: {actual} {limit_kind} exceeds the \
{limit} allowed — send fewer prompts per call, or shorten each one"
),
Self::Unauthorized { model } => write!(
f,
"llm.call(\"{model}\"): the provider rejected the API credential, or none was \
available — the operator sets a valid key for this provider"
),
Self::Transport { model, detail } => write!(
f,
"llm.call(\"{model}\"): transport error before any prompt ran: {detail} — no \
tokens were billed; retry the call"
),
}
}
}
impl std::error::Error for LlmCallError {}
impl LlmCallError {
pub fn model(&self) -> &str {
match self {
Self::NotConfigured { model }
| Self::UnknownModel { model, .. }
| Self::BudgetExceeded { model, .. }
| Self::PromptBoundsExceeded { model, .. }
| Self::Unauthorized { model }
| Self::Transport { model, .. } => model,
}
}
pub fn is_budget_exceeded(&self) -> bool {
matches!(self, Self::BudgetExceeded { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureReason {
Truncated,
ContentFiltered,
InvalidOutput,
RateLimited,
RequestRejected,
ProviderUnavailable,
Transport,
Cancelled,
Incomplete,
}
impl FailureReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::Truncated => "truncated",
Self::ContentFiltered => "content-filtered",
Self::InvalidOutput => "invalid-output",
Self::RateLimited => "rate-limited",
Self::RequestRejected => "request-rejected",
Self::ProviderUnavailable => "provider-unavailable",
Self::Transport => "transport",
Self::Cancelled => "cancelled",
Self::Incomplete => "incomplete",
}
}
pub fn default_message(&self) -> &'static str {
match self {
Self::Truncated => "the model stopped at the output token cap",
Self::ContentFiltered => "the provider's safety filter stopped the completion",
Self::InvalidOutput => "the model returned output that did not match the request",
Self::RateLimited => "the provider throttled this request",
Self::RequestRejected => "the provider refused the request as sent",
Self::ProviderUnavailable => "the provider could not serve this request",
Self::Transport => "the connection failed before a response arrived",
Self::Cancelled => "the request was abandoned before the model finished",
Self::Incomplete => "the model stopped for an unclassified reason",
}
}
pub fn retryable_by_default(&self) -> bool {
match self {
Self::RateLimited | Self::ProviderUnavailable | Self::Transport => true,
Self::Truncated
| Self::ContentFiltered
| Self::InvalidOutput
| Self::RequestRejected
| Self::Cancelled
| Self::Incomplete => false,
}
}
pub const ALL: [FailureReason; 9] = [
Self::Truncated,
Self::ContentFiltered,
Self::InvalidOutput,
Self::RateLimited,
Self::RequestRejected,
Self::ProviderUnavailable,
Self::Transport,
Self::Cancelled,
Self::Incomplete,
];
}
impl std::fmt::Display for FailureReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmFailure {
pub reason: FailureReason,
pub message: String,
pub retryable: bool,
pub status: Option<u16>,
pub finish_reason: Option<String>,
pub local: bool,
}
impl LlmFailure {
pub fn new(reason: FailureReason, message: impl Into<String>) -> Self {
Self {
reason,
message: message.into(),
retryable: reason.retryable_by_default(),
status: None,
finish_reason: None,
local: false,
}
}
pub fn retryable(mut self, retryable: bool) -> Self {
self.retryable = retryable;
self
}
pub fn with_status(mut self, status: u16) -> Self {
self.status = Some(status);
self
}
pub fn refused_locally(mut self) -> Self {
self.local = true;
self
}
pub fn with_finish_reason(mut self, finish_reason: impl Into<String>) -> Self {
self.finish_reason = Some(finish_reason.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmOutcome {
pub ok: bool,
pub text: Option<String>,
pub failure: Option<LlmFailure>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
}
impl LlmOutcome {
pub fn success(text: impl Into<String>) -> Self {
Self {
ok: true,
text: Some(text.into()),
failure: None,
input_tokens: None,
output_tokens: None,
}
}
pub fn failed(failure: LlmFailure, text: Option<impl Into<String>>) -> Self {
Self {
ok: false,
text: text.map(Into::into),
failure: Some(failure),
input_tokens: None,
output_tokens: None,
}
}
pub fn with_usage(mut self, input_tokens: Option<u64>, output_tokens: Option<u64>) -> Self {
self.input_tokens = input_tokens;
self.output_tokens = output_tokens;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmModel {
pub name: String,
pub description: Option<String>,
pub context_window: Option<u64>,
}
impl LlmModel {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
context_window: None,
}
}
}
pub trait LlmProvider: Send + Sync {
fn call<'a>(
&'a self,
model: &'a str,
prompts: &'a [String],
schema_json: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<Vec<LlmOutcome>, LlmCallError>> + Send + 'a>>;
fn models<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Result<Vec<LlmModel>, LlmCallError>> + Send + 'a>>;
fn output_reserve(&self, _model: &str) -> Option<u64> {
None
}
}
pub struct ExecutionTokenBudget {
limits: LlmLimits,
ledger: TokenLedger,
}
impl ExecutionTokenBudget {
pub fn new(limits: LlmLimits, aggregate: SharedTokenBudget) -> Self {
let ledger = TokenLedger::new(
LedgerLimits {
per_execution_tokens: limits.per_execution_tokens,
max_held_tokens: limits.max_held_tokens,
},
aggregate,
);
Self { limits, ledger }
}
pub fn limits(&self) -> LlmLimits {
self.limits
}
pub fn used(&self) -> u64 {
self.ledger.used()
}
pub fn held(&self) -> u64 {
self.ledger.held()
}
pub fn reserve(&self, model: &str, tokens: u64) -> Result<(), LlmCallError> {
self.ledger.reserve(tokens).map_err(|refusal| {
let limit_kind = match refusal {
LedgerRefusal::HeldReserve { held, limit } => {
LlmLimitKind::IndeterminateSpend { held, limit }
}
LedgerRefusal::PerExecution { requested, limit } => {
LlmLimitKind::PerExecutionTokens { requested, limit }
}
LedgerRefusal::AllExecutions { requested, limit } => {
LlmLimitKind::AllExecutionsTokens { requested, limit }
}
};
LlmCallError::BudgetExceeded {
model: model.to_string(),
limit_kind,
}
})
}
pub fn reservation_for(
&self,
estimated_input_tokens: u64,
prompt_count: u64,
output_cap: Option<u64>,
) -> u64 {
let cap = output_cap.unwrap_or(self.limits.default_output_cap);
estimated_input_tokens.saturating_add(cap.saturating_mul(prompt_count))
}
pub fn reconcile(&self, reserved: u64, reported: u64, indeterminate: u64) {
self.ledger.reconcile(reserved, reported, indeterminate);
}
pub fn release(&self, tokens: u64) {
self.reconcile(tokens, 0, 0);
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use super::{
ExecutionTokenBudget, FailureReason, LlmCallError, LlmFailure, LlmLimitKind, LlmLimits,
LlmModel, LlmOutcome, LlmProvider, PromptBoundKind, SharedTokenBudget,
};
const MODEL: &str = "claude-sonnet-4";
#[test]
fn dropping_an_execution_budget_returns_its_tokens_to_the_aggregate() {
let aggregate = SharedTokenBudget::new(1_000);
{
let held = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
held.reserve(MODEL, 1_000)
.expect("the first execution fits");
assert_eq!(aggregate.used(), 1_000);
}
assert_eq!(
aggregate.used(),
0,
"the ended execution released its tokens"
);
let next = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate);
next.reserve(MODEL, 1_000)
.expect("a later execution reuses the capacity");
}
fn limits_with_cap(per_execution_tokens: u64) -> LlmLimits {
LlmLimits {
per_execution_tokens,
..LlmLimits::default()
}
}
#[test]
fn a_poisoned_lock_still_releases_on_drop() {
let aggregate = SharedTokenBudget::new(1_000);
{
let held = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
held.reserve(MODEL, 600).expect("reservation fits");
let poisoner = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = held.ledger.lock_state().expect("lock");
panic!("poison the budget's lock");
}));
assert!(poisoner.is_err(), "the panic must have unwound");
assert!(held.ledger.state_is_poisoned(), "the lock must be poisoned");
}
assert_eq!(
aggregate.used(),
0,
"a poisoned execution still returns its tokens"
);
}
#[test]
fn concurrent_reservations_cannot_both_claim_the_same_headroom() {
const THREADS: u64 = 16;
const ROUNDS: usize = 500;
let mut overspends = 0;
for _ in 0..ROUNDS {
let aggregate = SharedTokenBudget::new(1_000);
let start = Arc::new(std::sync::Barrier::new(THREADS as usize));
let winners = Arc::new(AtomicU64::new(0));
let threads: Vec<_> = (0..THREADS)
.map(|_| {
let aggregate = aggregate.clone();
let start = Arc::clone(&start);
let winners = Arc::clone(&winners);
std::thread::spawn(move || {
let budget =
ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
start.wait();
if budget.reserve(MODEL, 1_000).is_ok() {
winners.fetch_add(1, Ordering::Relaxed);
}
std::mem::forget(budget);
})
})
.collect();
for t in threads {
t.join().expect("reserving thread");
}
let winners = winners.load(Ordering::Relaxed);
assert_eq!(
winners, 1,
"exactly one of {THREADS} racing reservations may claim the only headroom"
);
if aggregate.used() > aggregate.cap() {
overspends += 1;
}
}
assert_eq!(
overspends, 0,
"the aggregate was overdrawn in {overspends}/{ROUNDS} rounds"
);
}
#[test]
fn the_per_execution_and_server_wide_token_limits_name_different_fixes() {
let roomy = SharedTokenBudget::new(u64::MAX);
let own = ExecutionTokenBudget::new(limits_with_cap(100), roomy)
.reserve(MODEL, 500)
.expect_err("over this execution's own ceiling")
.to_string();
let aggregate = SharedTokenBudget::new(100);
let neighbour = ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate.clone());
neighbour.reserve(MODEL, 100).expect("neighbour fits");
let shared = ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate);
let server_wide = shared
.reserve(MODEL, 100)
.expect_err("over the server-wide budget")
.to_string();
assert_ne!(own, server_wide);
assert!(own.contains("this execution may spend"), "{own}");
assert!(own.contains("use fewer or shorter calls"), "{own}");
assert!(
!own.contains("--max-llm-tokens"),
"the per-execution ceiling is not the operator's flag: {own}"
);
assert!(
server_wide.contains("across all live executions"),
"{server_wide}"
);
assert!(
server_wide.contains("is not what is in the way"),
"{server_wide}"
);
assert!(
server_wide.contains("--max-llm-tokens"),
"the aggregate refusal must name the flag that raises it: {server_wide}"
);
}
#[test]
fn exceeding_either_ceiling_names_the_limit_and_the_ceiling_hit() {
let aggregate = SharedTokenBudget::new(u64::MAX);
let budget = ExecutionTokenBudget::new(limits_with_cap(64), aggregate);
let err = budget.reserve(MODEL, 4_096).expect_err("over the ceiling");
assert!(
matches!(
err,
LlmCallError::BudgetExceeded {
limit_kind: LlmLimitKind::PerExecutionTokens { .. },
..
}
),
"{err:?}"
);
let rendered = err.to_string();
assert!(rendered.contains(MODEL), "{rendered}");
assert!(rendered.contains("4096"), "the request: {rendered}");
assert!(rendered.contains("64"), "the ceiling: {rendered}");
}
#[test]
fn no_budget_error_display_leaks_prompt_text() {
const SECRET: &str = "the patient's diagnosis is";
let aggregate = SharedTokenBudget::new(10);
let neighbour = ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate.clone());
neighbour.reserve(MODEL, 10).expect("neighbour fits");
let rendered: Vec<String> = [
ExecutionTokenBudget::new(limits_with_cap(1), SharedTokenBudget::new(u64::MAX))
.reserve(MODEL, 900)
.expect_err("per-execution"),
ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate)
.reserve(MODEL, 900)
.expect_err("server-wide"),
held_budget_at_its_ceiling()
.reserve(MODEL, 1)
.expect_err("indeterminate"),
]
.iter()
.map(ToString::to_string)
.collect();
for message in &rendered {
assert!(!message.contains(SECRET), "{message}");
assert!(!message.contains("patient"), "{message}");
}
}
fn held_budget_at_its_ceiling() -> ExecutionTokenBudget {
let budget = ExecutionTokenBudget::new(
LlmLimits {
per_execution_tokens: u64::MAX,
max_held_tokens: 100,
..LlmLimits::default()
},
SharedTokenBudget::new(u64::MAX),
);
budget.reserve(MODEL, 500).expect("the dispatch fits");
budget.reconcile(500, 0, 500);
budget
}
#[test]
fn an_undispatched_remainder_is_released_after_a_partial_batch() {
let aggregate = SharedTokenBudget::new(1_000);
let budget = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
budget.reserve(MODEL, 900).expect("the whole batch fits");
budget.reconcile(900, 120, 0);
assert_eq!(budget.used(), 120);
assert_eq!(aggregate.used(), 120, "the remainder returned");
budget
.reserve(MODEL, 800)
.expect("the released capacity is reusable");
}
#[test]
fn the_reservation_covers_output_tokens_and_refuses_before_dispatch() {
let aggregate = SharedTokenBudget::new(u64::MAX);
let budget = ExecutionTokenBudget::new(
LlmLimits {
per_execution_tokens: 10_000,
default_output_cap: 64_000,
..LlmLimits::default()
},
aggregate.clone(),
);
let input_only = 100;
assert!(input_only < budget.limits().per_execution_tokens);
let reservation = budget.reservation_for(input_only, 1, None);
assert_eq!(
reservation,
100 + 64_000,
"output cap is in the reservation"
);
let err = budget
.reserve(MODEL, reservation)
.expect_err("refused before dispatch, not after the provider billed");
assert!(
matches!(
err,
LlmCallError::BudgetExceeded {
limit_kind: LlmLimitKind::PerExecutionTokens { .. },
..
}
),
"{err:?}"
);
assert_eq!(budget.used(), 0, "a refused dispatch charges nothing");
assert_eq!(aggregate.used(), 0);
assert_eq!(budget.reservation_for(100, 4, None), 100 + 64_000 * 4);
assert_eq!(budget.reservation_for(100, 4, Some(1_000)), 100 + 4_000);
}
#[test]
fn a_held_reserve_commits_against_the_aggregate_and_releases_only_per_execution() {
let aggregate = SharedTokenBudget::new(1_500);
let limits = LlmLimits {
per_execution_tokens: 400,
max_held_tokens: 10_000,
..LlmLimits::default()
};
for _ in 0..3 {
let budget = ExecutionTokenBudget::new(limits, aggregate.clone());
budget.reserve(MODEL, 400).expect("within its own ceiling");
budget.reconcile(400, 0, 400);
assert_eq!(budget.held(), 400);
}
assert_eq!(
aggregate.used(),
1_200,
"indeterminate spend stays charged against the aggregate after teardown"
);
let next = ExecutionTokenBudget::new(limits, aggregate);
let err = next.reserve(MODEL, 400).expect_err("the aggregate refuses");
assert!(
matches!(
err,
LlmCallError::BudgetExceeded {
limit_kind: LlmLimitKind::AllExecutionsTokens { .. },
..
}
),
"{err:?}"
);
}
#[test]
fn crossing_the_held_reserve_bound_is_distinguishable_from_a_genuine_overspend() {
let held = held_budget_at_its_ceiling();
let indeterminate = held
.reserve(MODEL, 1)
.expect_err("the held bound is crossed");
assert!(
matches!(
indeterminate,
LlmCallError::BudgetExceeded {
limit_kind: LlmLimitKind::IndeterminateSpend { .. },
..
}
),
"{indeterminate:?}"
);
let overspend =
ExecutionTokenBudget::new(limits_with_cap(10), SharedTokenBudget::new(1_000))
.reserve(MODEL, 900)
.expect_err("a genuine overspend");
let indeterminate = indeterminate.to_string();
let overspend = overspend.to_string();
assert_ne!(indeterminate, overspend);
assert!(
indeterminate.contains("indeterminate spend ceiling reached"),
"{indeterminate}"
);
assert!(
indeterminate.contains("never reported usage"),
"the operator must be able to tell unreported spend from measured: {indeterminate}"
);
assert!(
!overspend.contains("indeterminate"),
"a real overspend must not read as a null-usage storm: {overspend}"
);
}
#[test]
fn only_quota_refusals_are_classified_as_quota_exceeded_errors() {
for error in every_variant() {
let expected = matches!(error, LlmCallError::BudgetExceeded { .. });
assert_eq!(
error.is_budget_exceeded(),
expected,
"{error:?} is misclassified for the guest boundary"
);
}
for limit_kind in [
LlmLimitKind::PerExecutionTokens {
requested: 2,
limit: 1,
},
LlmLimitKind::AllExecutionsTokens {
requested: 2,
limit: 1,
},
LlmLimitKind::IndeterminateSpend { held: 2, limit: 1 },
] {
let error = LlmCallError::BudgetExceeded {
model: MODEL.to_string(),
limit_kind,
};
assert!(error.is_budget_exceeded(), "{error:?}");
}
}
#[test]
fn default_limits_carry_both_token_ceilings_and_prompt_bounds() {
let limits = LlmLimits::default();
assert_eq!(limits.max_prompt_count, 128);
assert_eq!(limits.max_prompt_bytes, 256 * 1024);
assert_eq!(
limits.per_execution_tokens,
super::DEFAULT_MAX_EXECUTION_TOKENS
);
assert_eq!(limits.default_output_cap, super::DEFAULT_OUTPUT_CAP);
}
fn every_variant() -> Vec<LlmCallError> {
vec![
LlmCallError::NotConfigured {
model: MODEL.to_string(),
},
LlmCallError::UnknownModel {
model: MODEL.to_string(),
available: vec!["claude-haiku-4".to_string()],
},
LlmCallError::BudgetExceeded {
model: MODEL.to_string(),
limit_kind: LlmLimitKind::PerExecutionTokens {
requested: 4_000,
limit: 120,
},
},
LlmCallError::PromptBoundsExceeded {
model: MODEL.to_string(),
limit_kind: PromptBoundKind::PromptCount,
actual: 64,
limit: 32,
},
LlmCallError::Unauthorized {
model: MODEL.to_string(),
},
LlmCallError::Transport {
model: MODEL.to_string(),
detail: "connection reset".to_string(),
},
]
}
#[test]
fn six_dispatch_variants_each_name_the_model_and_a_distinct_fix() {
let variants = every_variant();
assert_eq!(variants.len(), 6, "LlmCallError has six dispatch variants");
let mut fixes: Vec<String> = Vec::new();
for err in &variants {
let rendered = err.to_string();
assert!(
rendered.contains(MODEL),
"variant must name the model: {rendered}"
);
assert_eq!(err.model(), MODEL);
let (_, fix) = rendered.split_once(" — ").unwrap_or_else(|| {
panic!("variant must suggest a fix after an em dash: {rendered}")
});
assert!(!fix.trim().is_empty(), "empty fix: {rendered}");
fixes.push(fix.to_string());
}
let mut sorted = fixes.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
fixes.len(),
"each variant needs its own fix: {fixes:?}"
);
}
#[test]
fn no_dispatch_error_display_leaks_prompt_text() {
const SECRET_PROMPT: &str = "patient SSN is 000-00-0000";
const SECRET_COMPLETION: &str = "the diagnosis is confidential";
for err in every_variant() {
let rendered = err.to_string();
assert!(
!rendered.contains(SECRET_PROMPT),
"prompt text leaked into Display: {rendered}"
);
assert!(
!rendered.contains(SECRET_COMPLETION),
"completion text leaked into Display: {rendered}"
);
}
let bounded = LlmCallError::PromptBoundsExceeded {
model: MODEL.to_string(),
limit_kind: PromptBoundKind::PromptBytes,
actual: 900_000,
limit: 65_536,
};
let rendered = bounded.to_string();
assert!(rendered.contains("900000") && rendered.contains("65536"));
assert!(
rendered.contains("bytes in a single prompt"),
"bound kind must be named: {rendered}"
);
}
#[test]
fn nine_failure_reasons_have_stable_kebab_case_spellings() {
let spellings: Vec<&str> = FailureReason::ALL
.iter()
.map(FailureReason::as_str)
.collect();
assert_eq!(
spellings,
[
"truncated",
"content-filtered",
"invalid-output",
"rate-limited",
"request-rejected",
"provider-unavailable",
"transport",
"cancelled",
"incomplete",
]
);
for reason in FailureReason::ALL {
assert_eq!(reason.to_string(), reason.as_str());
}
}
#[test]
fn truncated_outcome_is_not_ok_but_keeps_its_text_and_indeterminate_usage() {
let partial = LlmOutcome::failed(
LlmFailure::new(FailureReason::Truncated, "output token ceiling reached"),
Some("the answer begins"),
);
assert!(!partial.ok, "a length stop is not a natural stop");
assert_eq!(
partial.text.as_deref(),
Some("the answer begins"),
"partial text is retained"
);
let failure = partial.failure.as_ref().expect("failure arm present");
assert_eq!(failure.reason, FailureReason::Truncated);
assert_eq!(
partial.input_tokens, None,
"unreported usage is indeterminate"
);
assert_eq!(partial.output_tokens, None);
let clean = LlmOutcome::success("done").with_usage(Some(12), Some(0));
assert!(clean.ok);
assert!(clean.failure.is_none(), "a natural stop carries no reason");
assert_eq!(
clean.output_tokens,
Some(0),
"a real zero is Some(0); only absence is None"
);
}
#[test]
fn failure_text_is_nullable_and_distinct_from_empty() {
let nothing = LlmOutcome::failed(
LlmFailure::new(FailureReason::ContentFiltered, "filtered before any output"),
None::<String>,
);
assert_eq!(nothing.text, None, "produced nothing at all");
let empty = LlmOutcome::failed(
LlmFailure::new(
FailureReason::ContentFiltered,
"filtered after an empty turn",
),
Some(""),
);
assert_eq!(empty.text.as_deref(), Some(""), "produced an empty string");
assert_ne!(
nothing.text, empty.text,
"null and empty must not collapse — the whole point is telling \
'empty because filtered' from 'empty because it said nothing'"
);
}
#[test]
fn failure_arm_carries_message_retryable_and_nullable_status() {
let throttled =
LlmFailure::new(FailureReason::RateLimited, "provider throttled the request")
.retryable(true)
.with_status(429);
assert_eq!(throttled.message, "provider throttled the request");
assert!(throttled.retryable);
assert_eq!(throttled.status, Some(429));
let dead = LlmFailure::new(
FailureReason::Transport,
"connection closed before response",
);
assert_eq!(dead.status, None);
assert!(dead.retryable, "transport is retryable by class");
let refused = LlmFailure::new(FailureReason::RequestRejected, "request refused as sent");
assert!(
!refused.retryable,
"a request the provider refused will not start succeeding unchanged"
);
assert!(
!LlmFailure::new(FailureReason::RateLimited, "quota exhausted")
.retryable(false)
.retryable,
"a provider that knows better overrides the class default"
);
let truncated = LlmFailure::new(FailureReason::Truncated, "stopped at the output cap")
.with_finish_reason("length");
assert_eq!(truncated.reason, FailureReason::Truncated);
assert_eq!(truncated.finish_reason.as_deref(), Some("length"));
let odd = LlmFailure::new(FailureReason::Incomplete, "unclassified stop")
.with_finish_reason("surprise-reason");
assert_eq!(odd.reason, FailureReason::Incomplete);
assert_eq!(odd.finish_reason.as_deref(), Some("surprise-reason"));
}
#[test]
fn retryable_defaults_split_transient_classes_from_terminal_ones() {
let retryable: Vec<&str> = FailureReason::ALL
.iter()
.filter(|r| r.retryable_by_default())
.map(FailureReason::as_str)
.collect();
assert_eq!(
retryable,
["rate-limited", "provider-unavailable", "transport"]
);
}
#[test]
fn no_dispatch_error_display_leaks_a_provider_response_body() {
const BODY: &str = r#"{"error":{"message":"your prompt was: summarize the memo"}}"#;
let classified = LlmCallError::Transport {
model: MODEL.to_string(),
detail: "connection reset".to_string(),
};
let rendered = classified.to_string();
assert!(!rendered.contains(BODY));
assert!(!rendered.contains("summarize the memo"));
assert!(!rendered.contains('{'), "no JSON body shape: {rendered}");
}
#[test]
fn failure_message_is_a_classification_not_a_payload_echo() {
for reason in FailureReason::ALL {
let failure = LlmFailure::new(reason, reason.default_message());
assert!(
!failure.message.is_empty(),
"{reason} needs a message: it is the degraded error string"
);
assert!(
!failure.message.contains("patient SSN"),
"classification must not echo a payload: {}",
failure.message
);
}
}
#[test]
fn llm_model_optionals_may_both_be_absent() {
let declared = LlmModel::new("operator-declared");
assert_eq!(declared.description, None);
assert_eq!(declared.context_window, None);
}
struct NoopProvider;
impl LlmProvider for NoopProvider {
fn call<'a>(
&'a self,
model: &'a str,
prompts: &'a [String],
_schema_json: Option<&'a str>,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<Vec<LlmOutcome>, LlmCallError>> + Send + 'a,
>,
> {
let n = prompts.len();
let model = model.to_string();
Box::pin(async move {
if model.is_empty() {
return Err(LlmCallError::NotConfigured { model });
}
Ok((0..n).map(|i| LlmOutcome::success(i.to_string())).collect())
})
}
fn models<'a>(
&'a self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<LlmModel>, LlmCallError>> + Send + 'a>,
> {
Box::pin(async { Ok(vec![LlmModel::new("noop")]) })
}
}
#[tokio::test]
async fn provider_trait_is_object_safe_behind_arc() {
let provider: Arc<dyn LlmProvider> = Arc::new(NoopProvider);
let prompts = vec!["a".to_string(), "b".to_string()];
let outcomes = provider
.call(MODEL, &prompts, None)
.await
.expect("dispatch succeeds");
assert_eq!(outcomes.len(), prompts.len(), "positional: one per prompt");
assert_eq!(outcomes[1].text.as_deref(), Some("1"));
assert_eq!(provider.models().await.unwrap()[0].name, "noop");
}
#[tokio::test]
async fn unconfigured_store_data_has_no_provider() {
let data = crate::runtime::StoreData::with_tempdir().unwrap();
assert!(data.llm_provider.is_none());
}
}