submilli_engine/runtime/llm.rs
1//! The embedder-supplied seam for `submilli:llm` — gated model calls from inside
2//! a Submilli program.
3//!
4//! `call`, `batch`, and `models()` all dispatch through the single
5//! embedder-provided [`LlmProvider`]: the actual HTTP, key resolution, retry, and
6//! provider-SDK dependencies live in the embedder (submilli-server), keeping this
7//! crate free of them — the same split [`crate::runtime::mcp::McpTransport`] makes.
8//!
9//! **Two taxonomies, answering different questions.** [`LlmCallError`] is the
10//! *dispatch-level* `Err`: the request never reached the model at all, or the
11//! transport died before any element ran. [`FailureReason`] is *per-element*: the
12//! request reached the model and that one element came back unusable or
13//! incomplete. Neither is a subset of the other, and a per-element failure must
14//! never be raised as an `Err` — a whole-batch `Err` would discard every element
15//! outcome including successes that were already billed, and would leave
16//! per-element budget reconciliation with nothing to reconcile against.
17//!
18//! **No prompt or completion text appears here.** Not in an error `Display`, not
19//! in a log line, not in a permission-check context, and not in an
20//! [`LlmFailure::message`]. The taxonomies carry the model name, the numbers, a
21//! status code, and a fixed classification string — never the payload. Provider
22//! response bodies are dropped at this boundary specifically: they carry
23//! completion fragments, request echoes, and account identifiers, so unlike
24//! [`McpCallError::Upstream`](crate::runtime::mcp::McpCallError::Upstream) no
25//! variant here interpolates one.
26
27use std::future::Future;
28use std::pin::Pin;
29
30pub use super::token_ledger::SharedTokenBudget;
31use super::token_ledger::{LedgerLimits, LedgerRefusal, TokenLedger};
32
33/// The single internal host module every `submilli:llm` call dispatches through.
34pub const LLM_MODULE_NAME: &str = "submilli:llm";
35
36/// Tokens one execution may spend across every model call it makes.
37pub const DEFAULT_MAX_EXECUTION_TOKENS: u64 = 1_000_000;
38/// Tokens every live execution may spend between them.
39pub const DEFAULT_MAX_ALL_EXECUTIONS_TOKENS: u64 = 20_000_000;
40/// Tokens one execution may hold as indeterminate (null-usage) reserve before
41/// further dispatch is refused. See [`LlmLimitKind::IndeterminateSpend`].
42pub const DEFAULT_MAX_HELD_TOKENS: u64 = 200_000;
43/// Output tokens reserved per prompt when the model declares no `outputReserve`.
44pub const DEFAULT_OUTPUT_CAP: u64 = 64_000;
45/// Elements in one `batch` slice.
46pub const DEFAULT_MAX_PROMPT_COUNT: u64 = 128;
47/// UTF-8 bytes in a single prompt element.
48pub const DEFAULT_MAX_PROMPT_BYTES: u64 = 256 * 1024;
49
50/// The ceilings a dispatch is checked against.
51///
52/// Token ceilings and prompt bounds live in one struct deliberately: an operator
53/// raising how much a program may spend almost always means to raise how much it
54/// may send, and splitting them across two config surfaces makes that two edits
55/// with a silent failure between them.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct LlmLimits {
58 /// Tokens one execution may spend in total.
59 pub per_execution_tokens: u64,
60 /// Tokens one execution may hold as indeterminate reserve before further
61 /// dispatch is refused.
62 pub max_held_tokens: u64,
63 /// Output tokens reserved per prompt when the model declares no
64 /// `outputReserve` of its own.
65 pub default_output_cap: u64,
66 /// Elements in one `batch` slice. Enforced at the stdlib boundary.
67 pub max_prompt_count: u64,
68 /// UTF-8 bytes in a single prompt element. Enforced at the stdlib boundary.
69 pub max_prompt_bytes: u64,
70}
71
72impl Default for LlmLimits {
73 fn default() -> Self {
74 Self {
75 per_execution_tokens: DEFAULT_MAX_EXECUTION_TOKENS,
76 max_held_tokens: DEFAULT_MAX_HELD_TOKENS,
77 default_output_cap: DEFAULT_OUTPUT_CAP,
78 max_prompt_count: DEFAULT_MAX_PROMPT_COUNT,
79 max_prompt_bytes: DEFAULT_MAX_PROMPT_BYTES,
80 }
81 }
82}
83
84/// Which token ceiling a refused dispatch ran into. Carries the numbers, never
85/// the prompts — the same rule
86/// [`SessionKvLimitKind`](crate::runtime::session_kv::SessionKvLimitKind)
87/// follows for values.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum LlmLimitKind {
90 /// This execution's own token ceiling.
91 PerExecutionTokens { requested: u64, limit: u64 },
92 /// The server-wide budget summed across every live execution. Distinct from
93 /// [`Self::PerExecutionTokens`] because it wants the opposite response: the
94 /// tokens in the way belong to other executions, so this execution making
95 /// fewer or shorter calls need not help.
96 AllExecutionsTokens { requested: u64, limit: u64 },
97 /// Cumulative reserve held for elements whose usage the provider never
98 /// reported. Token spend is monotonic — unlike session-KV bytes, a held
99 /// estimate is never superseded by a real number — so without this bound a
100 /// long-lived execution ratchets toward zero with no recovery. Distinct from
101 /// a genuine overspend so an operator can tell a null-usage storm from real
102 /// consumption.
103 IndeterminateSpend { held: u64, limit: u64 },
104}
105
106impl std::fmt::Display for LlmLimitKind {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 Self::PerExecutionTokens { requested, limit } => write!(
110 f,
111 "execution token budget: {requested} tokens exceeds the {limit} this execution \
112 may spend — use fewer or shorter calls, or split the work across executions"
113 ),
114 Self::AllExecutionsTokens { requested, limit } => write!(
115 f,
116 "server token budget: {requested} tokens exceeds the {limit} allowed across all \
117 live executions — this execution's own spend is not what is in the way, so \
118 using fewer or shorter calls need not help; the operator raises the budget \
119 with `--max-llm-tokens`"
120 ),
121 // No CLI rung names this ceiling: `max_held_tokens` is set by the
122 // embedder, unlike the two above. Saying otherwise would send an
123 // operator looking for a flag that does not exist, so the advice is
124 // the action they can actually take.
125 Self::IndeterminateSpend { held, limit } => write!(
126 f,
127 "indeterminate spend ceiling reached: {held} tokens are held for calls the \
128 provider never reported usage for, exceeding the {limit} allowed — this is \
129 unreported spend, not measured consumption; start a new execution, and if it \
130 recurs the provider is not reporting usage and the operator should raise the \
131 held-token ceiling in the server configuration"
132 ),
133 }
134 }
135}
136
137/// Why a model *dispatch* failed — the request never reached the model, or the
138/// transport died before any element ran.
139///
140/// Reserved for dispatch-level failure only. A model that answered and produced
141/// an unusable or incomplete element reports that in [`LlmOutcome`]'s failure
142/// arm, never here: returning `Err` for one bad element would throw away the
143/// sibling elements that did succeed.
144///
145/// Every variant names the model it was dispatched at and the fix the caller can
146/// act on. None carries prompt or completion text.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum LlmCallError {
149 /// No [`LlmProvider`] is wired into the runtime, so nothing can dispatch.
150 /// The pure-interpreter path leaves the provider `None`, and this is the
151 /// catchable configuration error a script sees there.
152 NotConfigured { model: String },
153 /// The model name is not one the provider serves. `available` lists what it
154 /// does serve, so the message can name the alternatives rather than only the
155 /// rejection.
156 UnknownModel {
157 model: String,
158 available: Vec<String>,
159 },
160 /// A token ceiling cannot cover this dispatch. Charged before any element
161 /// ran, so nothing was billed.
162 ///
163 /// `limit_kind` carries which ceiling refused, because the three want
164 /// different responses and a message that reads the same for all of them
165 /// sends a caller to shrink work that was never what filled the budget.
166 BudgetExceeded {
167 model: String,
168 limit_kind: LlmLimitKind,
169 },
170 /// The prompt slice is outside the bounds the host accepts — too many
171 /// elements, or one element too large. Counted in elements and bytes, never
172 /// quoted.
173 PromptBoundsExceeded {
174 model: String,
175 limit_kind: PromptBoundKind,
176 actual: u64,
177 limit: u64,
178 },
179 /// The provider rejected the credential, or none was resolvable when the
180 /// provider was constructed. One variant covers both because the fix is the
181 /// same key — this is the `LoadAPIKeyError`-vs-`401` split collapsed at the
182 /// point where the caller can act on it.
183 Unauthorized { model: String },
184 /// The request never completed, and died before any element ran. A transport
185 /// failure *after* elements started belongs in [`FailureReason::Transport`]
186 /// on the affected elements instead.
187 ///
188 /// `detail` is a **fixed classification string** — "connection reset",
189 /// "dns lookup failed", "tls handshake failed". It is the one free-form field
190 /// in this taxonomy, and the no-payload rule binds it: never a provider response body, and
191 /// never a request echo. The precedent this trait otherwise mirrors does
192 /// interpolate a body
193 /// ([`McpCallError::Upstream`](crate::runtime::mcp::McpCallError::Upstream)
194 /// renders `HTTP {status}: {body}`); this one deliberately does not, because a
195 /// model provider's body carries completion fragments.
196 Transport { model: String, detail: String },
197}
198
199/// Which prompt bound a dispatch ran into. Carries the numbers, never the
200/// prompts — the same rule
201/// [`SessionKvLimitKind`](crate::runtime::session_kv::SessionKvLimitKind) follows
202/// for values.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum PromptBoundKind {
205 /// Elements in one `call` / `batch` slice.
206 PromptCount,
207 /// UTF-8 bytes of a single prompt element.
208 PromptBytes,
209}
210
211impl std::fmt::Display for PromptBoundKind {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 match self {
214 Self::PromptCount => write!(f, "prompts in one batch"),
215 Self::PromptBytes => write!(f, "bytes in a single prompt"),
216 }
217 }
218}
219
220impl std::fmt::Display for LlmCallError {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 match self {
223 Self::NotConfigured { model } => write!(
224 f,
225 "llm.call(\"{model}\"): no model provider is configured for this runtime — the \
226 operator wires one before model calls can dispatch"
227 ),
228 Self::UnknownModel { model, available } => {
229 write!(
230 f,
231 "llm.call(\"{model}\"): the provider does not serve that model — "
232 )?;
233 if available.is_empty() {
234 write!(
235 f,
236 "it serves none, so declare a model in the blueprint before calling"
237 )
238 } else {
239 write!(
240 f,
241 "call llm.models() and use one of: {}",
242 available.join(", ")
243 )
244 }
245 }
246 Self::BudgetExceeded { model, limit_kind } => {
247 write!(f, "llm.call(\"{model}\") exceeded the {limit_kind}")
248 }
249 Self::PromptBoundsExceeded {
250 model,
251 limit_kind,
252 actual,
253 limit,
254 } => write!(
255 f,
256 "llm.call(\"{model}\"): prompt bound exceeded: {actual} {limit_kind} exceeds the \
257 {limit} allowed — send fewer prompts per call, or shorten each one"
258 ),
259 Self::Unauthorized { model } => write!(
260 f,
261 "llm.call(\"{model}\"): the provider rejected the API credential, or none was \
262 available — the operator sets a valid key for this provider"
263 ),
264 Self::Transport { model, detail } => write!(
265 f,
266 "llm.call(\"{model}\"): transport error before any prompt ran: {detail} — no \
267 tokens were billed; retry the call"
268 ),
269 }
270 }
271}
272
273impl std::error::Error for LlmCallError {}
274
275impl LlmCallError {
276 /// The model this dispatch was aimed at. Every variant names one, so a caller
277 /// need not match to attribute the failure.
278 pub fn model(&self) -> &str {
279 match self {
280 Self::NotConfigured { model }
281 | Self::UnknownModel { model, .. }
282 | Self::BudgetExceeded { model, .. }
283 | Self::PromptBoundsExceeded { model, .. }
284 | Self::Unauthorized { model }
285 | Self::Transport { model, .. } => model,
286 }
287 }
288
289 /// Token-budget refusals become catchable `QuotaExceededError`s at the
290 /// stdlib boundary. Prompt size/count bounds remain `RangeError`s.
291 pub fn is_budget_exceeded(&self) -> bool {
292 matches!(self, Self::BudgetExceeded { .. })
293 }
294}
295
296/// Why one *element* of a dispatch is not a clean completion.
297///
298/// Per-element, and orthogonal to [`LlmCallError`]: the request did reach the
299/// model. Several of these still carry usable text on the [`LlmOutcome`] — a
300/// truncated or content-filtered completion is `ok: false` and has a partial
301/// answer worth reading.
302///
303/// A closed set of nine. It is what a guest branches on, so a provider that
304/// meets something it cannot place reports [`Self::Incomplete`] and puts the raw
305/// provider spelling in [`LlmFailure::finish_reason`] rather than widening this.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
307pub enum FailureReason {
308 /// Hit the output-token ceiling mid-answer (`finishReason` of `length`). The
309 /// partial text is retained — that is the whole reason the failure arm has a
310 /// `text` field.
311 Truncated,
312 /// The provider's safety filter stopped or redacted the completion. Reached
313 /// from both directions: `generateText` resolves with a filtered result while
314 /// `generateObject` throws on the same input, and both normalize to here.
315 ContentFiltered,
316 /// The provider answered, but not with something usable: malformed output,
317 /// or output that ignored the requested schema. Distinct from
318 /// [`Self::Truncated`] — nothing was cut off, what arrived was wrong.
319 InvalidOutput,
320 /// The provider throttled this element (structurally, a 429). Honor any
321 /// reported retry-after before re-dispatching.
322 RateLimited,
323 /// The provider refused the request itself rather than failing to answer it:
324 /// a rejected credential, a malformed or oversized request, or a prompt that
325 /// does not fit the model's context window. These share an arm because they
326 /// share a shape — the request as sent will never succeed, so retrying it
327 /// unchanged cannot help.
328 RequestRejected,
329 /// The provider was reachable but could not serve the request (structurally,
330 /// a 5xx). Unlike [`Self::RequestRejected`] the request itself may be fine,
331 /// so the same bytes may succeed later.
332 ProviderUnavailable,
333 /// The connection failed for this element after dispatch had begun — no HTTP
334 /// status was ever observed. Sibling elements may still have succeeded.
335 Transport,
336 /// The element was abandoned before the model finished — deadline, shutdown,
337 /// or an aborted request. Classified separately from [`Self::Transport`]
338 /// because the cause is on this side, not the wire.
339 Cancelled,
340 /// The provider stopped for a reason this runtime does not classify. The raw
341 /// provider spelling travels in [`LlmFailure::finish_reason`] for diagnosis.
342 Incomplete,
343}
344
345impl FailureReason {
346 /// The stable kebab-case spelling. This is the wire form: it is what any
347 /// guest-facing rendering uses, so it must not drift.
348 pub fn as_str(&self) -> &'static str {
349 match self {
350 Self::Truncated => "truncated",
351 Self::ContentFiltered => "content-filtered",
352 Self::InvalidOutput => "invalid-output",
353 Self::RateLimited => "rate-limited",
354 Self::RequestRejected => "request-rejected",
355 Self::ProviderUnavailable => "provider-unavailable",
356 Self::Transport => "transport",
357 Self::Cancelled => "cancelled",
358 Self::Incomplete => "incomplete",
359 }
360 }
361
362 /// A fixed classification string for this reason, suitable as an
363 /// [`LlmFailure::message`] when a provider has nothing more specific that is
364 /// safe to say. Drawn from a closed vocabulary precisely so it cannot carry a
365 /// response body or completion fragment.
366 pub fn default_message(&self) -> &'static str {
367 match self {
368 Self::Truncated => "the model stopped at the output token cap",
369 Self::ContentFiltered => "the provider's safety filter stopped the completion",
370 Self::InvalidOutput => "the model returned output that did not match the request",
371 Self::RateLimited => "the provider throttled this request",
372 Self::RequestRejected => "the provider refused the request as sent",
373 Self::ProviderUnavailable => "the provider could not serve this request",
374 Self::Transport => "the connection failed before a response arrived",
375 Self::Cancelled => "the request was abandoned before the model finished",
376 Self::Incomplete => "the model stopped for an unclassified reason",
377 }
378 }
379
380 /// Whether re-dispatching the identical request could plausibly succeed.
381 ///
382 /// The default a provider starts from, not the last word: it may override
383 /// per element (see [`LlmFailure::retryable`]) when it knows better — a 429
384 /// carrying a retry-after is retryable, while one on an exhausted quota is
385 /// not.
386 pub fn retryable_by_default(&self) -> bool {
387 match self {
388 Self::RateLimited | Self::ProviderUnavailable | Self::Transport => true,
389 // The request as sent will not start succeeding, and a cancellation
390 // was someone's decision — retrying either is a guess, so the guest
391 // opts in rather than the runtime assuming.
392 Self::Truncated
393 | Self::ContentFiltered
394 | Self::InvalidOutput
395 | Self::RequestRejected
396 | Self::Cancelled
397 | Self::Incomplete => false,
398 }
399 }
400
401 /// Every reason, in the plan's declaration order. Kept so a mapper or a
402 /// guest-facing enumeration cannot silently omit one.
403 pub const ALL: [FailureReason; 9] = [
404 Self::Truncated,
405 Self::ContentFiltered,
406 Self::InvalidOutput,
407 Self::RateLimited,
408 Self::RequestRejected,
409 Self::ProviderUnavailable,
410 Self::Transport,
411 Self::Cancelled,
412 Self::Incomplete,
413 ];
414}
415
416impl std::fmt::Display for FailureReason {
417 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418 f.write_str(self.as_str())
419 }
420}
421
422/// The failure-arm payload of an [`LlmOutcome`]: everything that exists only
423/// when `ok` is false.
424///
425/// Held in an `Option` on the outcome rather than flattened into it, so the
426/// success arm *cannot* carry a `message`, a `retryable`, or a `status` — those
427/// fields are meaningless on a clean completion, and a flattened struct would
428/// have to invent values for them.
429#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
430pub struct LlmFailure {
431 /// The closed-set classification a guest branches on.
432 pub reason: FailureReason,
433 /// The degraded error string.
434 ///
435 /// This is the AI SDK's rule that an `error: unknown` becomes an
436 /// `errorText: string` at any serialization boundary — a guest cannot receive
437 /// a Rust error object, so it receives this. **The no-payload rule binds it absolutely: it is
438 /// a classification, never an echo.** No prompt, no completion fragment, and
439 /// no provider response body may reach it; provider bodies carry request
440 /// echoes and account identifiers, so they are dropped at this boundary.
441 /// [`FailureReason::default_message`] supplies a safe one.
442 pub message: String,
443 /// Whether re-dispatching the identical request could plausibly succeed.
444 /// Defaulted from [`FailureReason::retryable_by_default`] and overridable per
445 /// element, because a provider sometimes knows more than the class does.
446 pub retryable: bool,
447 /// The HTTP status, when one was observed. `None` is meaningful rather than
448 /// missing: a transport death never got a status, which is exactly what
449 /// distinguishes it from a provider that answered with an error.
450 pub status: Option<u16>,
451 /// The provider's own raw stop reason, verbatim, for diagnosis only.
452 ///
453 /// **Deliberately separate from [`Self::reason`].** `reason` is the closed
454 /// set a guest branches on; this is an open string that varies per provider
455 /// and per SDK version. Folding them together would either force the closed
456 /// set to grow with every provider quirk or silently discard the detail that
457 /// makes an unclassified stop diagnosable.
458 pub finish_reason: Option<String>,
459 /// Refused by this host's own configuration or network policy before the provider
460 /// was reached, rather than answered by it. Not part of what a guest sees: the call
461 /// log records such a failure as kind `local`, so a replay does not serve it.
462 pub local: bool,
463}
464
465impl LlmFailure {
466 /// A failure of this class carrying this classification message. `retryable`
467 /// starts from the reason's default; `status` and `finish_reason` start
468 /// absent.
469 pub fn new(reason: FailureReason, message: impl Into<String>) -> Self {
470 Self {
471 reason,
472 message: message.into(),
473 retryable: reason.retryable_by_default(),
474 status: None,
475 finish_reason: None,
476 local: false,
477 }
478 }
479
480 /// Override the class default when the provider knows better.
481 pub fn retryable(mut self, retryable: bool) -> Self {
482 self.retryable = retryable;
483 self
484 }
485
486 /// Record the observed HTTP status. Left absent when none was seen.
487 pub fn with_status(mut self, status: u16) -> Self {
488 self.status = Some(status);
489 self
490 }
491
492 /// Mark the failure as this host's own refusal; see [`Self::local`].
493 pub fn refused_locally(mut self) -> Self {
494 self.local = true;
495 self
496 }
497
498 /// Record the provider's raw stop reason for diagnosis.
499 pub fn with_finish_reason(mut self, finish_reason: impl Into<String>) -> Self {
500 self.finish_reason = Some(finish_reason.into());
501 self
502 }
503}
504
505/// One element's result, positionally matched to the prompt that produced it.
506///
507/// A struct rather than an enum because success and failure are not disjoint
508/// here: a truncated or content-filtered completion is `ok: false` and *still
509/// carries usable text*, and a caller that wants the partial answer should not
510/// have to reach through an error arm to get it.
511///
512/// `ok: true` means the provider reported a **natural stop** — not merely that no
513/// error was raised. A completion that returned cleanly but stopped on `length`
514/// is `ok: false` with [`FailureReason::Truncated`]. That choice trades a class
515/// of silent wrong-answer bugs for one honest data-loss risk, which is why the
516/// failure arm keeps its text rather than discarding it.
517///
518/// The arms differ in more than a flag: `text` is always present on success and
519/// nullable on failure, and the [`LlmFailure`] payload exists only on failure.
520#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
521pub struct LlmOutcome {
522 /// The provider reported a natural stop for this element.
523 pub ok: bool,
524 /// The completion text.
525 ///
526 /// Always `Some` on the success arm. On the failure arm it is `string | null`:
527 /// `Some` — including `Some("")` — when the model produced text, and `None`
528 /// when it produced none at all. The two must not collapse, since telling
529 /// "empty because filtered" from "empty because the model said nothing" is
530 /// the distinction the `ok` rule exists to preserve.
531 pub text: Option<String>,
532 /// The failure payload. `Some` exactly when `!ok`.
533 pub failure: Option<LlmFailure>,
534 /// Tokens the provider reported for the prompt. `None` means
535 /// **indeterminate, not free** — the provider reported nothing, or reported
536 /// something non-finite. Never `Some(0)` as a stand-in for "unknown": a
537 /// throttled call may still have been billed, so a reconciler holds a
538 /// conservative reserve for `None` rather than releasing it.
539 ///
540 /// Nullable on *both* arms: a provider can resolve successfully with no usage
541 /// reported, so this cannot be confined to the failure arm.
542 pub input_tokens: Option<u64>,
543 /// Tokens the provider reported for the completion. Same
544 /// `None`-means-indeterminate rule as [`Self::input_tokens`].
545 pub output_tokens: Option<u64>,
546}
547
548impl LlmOutcome {
549 /// A natural stop with the given text. Usage is left indeterminate; a
550 /// provider that reports counts sets them explicitly.
551 pub fn success(text: impl Into<String>) -> Self {
552 Self {
553 ok: true,
554 text: Some(text.into()),
555 failure: None,
556 input_tokens: None,
557 output_tokens: None,
558 }
559 }
560
561 /// A non-clean element. `text` is whatever the model did produce: `Some` when
562 /// it produced any, `None` when it produced none — retained rather than
563 /// discarded, so a partial answer survives.
564 pub fn failed(failure: LlmFailure, text: Option<impl Into<String>>) -> Self {
565 Self {
566 ok: false,
567 text: text.map(Into::into),
568 failure: Some(failure),
569 input_tokens: None,
570 output_tokens: None,
571 }
572 }
573
574 /// Attach reported usage. A non-finite or unreported count stays `None`.
575 pub fn with_usage(mut self, input_tokens: Option<u64>, output_tokens: Option<u64>) -> Self {
576 self.input_tokens = input_tokens;
577 self.output_tokens = output_tokens;
578 self
579 }
580}
581
582/// A model the provider serves.
583///
584/// Every optional may legitimately be `None`: models are blueprint-declared, and
585/// an operator may declare a name without a description, a context window, or an
586/// output reserve. Absent means unknown, not zero.
587#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
588pub struct LlmModel {
589 pub name: String,
590 pub description: Option<String>,
591 pub context_window: Option<u64>,
592}
593
594impl LlmModel {
595 pub fn new(name: impl Into<String>) -> Self {
596 Self {
597 name: name.into(),
598 description: None,
599 context_window: None,
600 }
601 }
602}
603
604/// The embedder-provided model provider: resolves the credential, performs the
605/// completions, and reports what each one cost.
606///
607/// Object-safe boxed-future trait (rather than `async fn`) so the interpreter
608/// stays free of the embedder's async stack while still `await`-ing the call from
609/// within the async host function — the shape
610/// [`McpTransport`](crate::runtime::mcp::McpTransport) uses.
611///
612/// # Obligations on every implementor
613///
614/// These are *contractual*, not enforced by the interpreter. A second
615/// implementor inherits them:
616///
617/// 1. **Positional ordering.** The returned `Vec<LlmOutcome>` has exactly one
618/// element per input prompt, in input order. Element `i` is the outcome of
619/// `prompts[i]`, including when it failed.
620/// 2. **Bounded concurrency.** `call` takes a *slice* rather than a single
621/// prompt because the guest is single-threaded Wasm holding one host call at a
622/// time: fan-out cannot live above this boundary, so it lives below it. The
623/// implementor is responsible for bounding that concurrency — the interpreter
624/// does not limit it.
625/// 3. **Per-element failure never becomes `Err`.** One bad element is an
626/// `LlmOutcome` with `ok: false`. `Err(LlmCallError)` is only for dispatch
627/// that never ran, or a transport that died before any element started; an
628/// `Err` discards the siblings that did succeed and were billed.
629/// 4. **No prompt or completion text in errors.** Nothing an implementor puts in
630/// an [`LlmCallError`] may quote the payload.
631pub trait LlmProvider: Send + Sync {
632 fn call<'a>(
633 &'a self,
634 model: &'a str,
635 prompts: &'a [String],
636 schema_json: Option<&'a str>,
637 ) -> Pin<Box<dyn Future<Output = Result<Vec<LlmOutcome>, LlmCallError>> + Send + 'a>>;
638
639 /// Returns the locally declared model catalog without network I/O or other
640 /// side effects. The caller must inspect these names before it can check
641 /// policy for each model, so this runs before per-model authorization.
642 fn models<'a>(
643 &'a self,
644 ) -> Pin<Box<dyn Future<Output = Result<Vec<LlmModel>, LlmCallError>> + Send + 'a>>;
645
646 /// The output cap declared for one model, consulted *before* dispatch.
647 ///
648 /// Separate from [`Self::models`] because the reservation needs one model's
649 /// reserve on the hot path of every `call`; using the whole catalog here
650 /// would add unnecessary work to each dispatch. An implementor that already
651 /// holds its declarations (the blueprint case) answers from memory, and one
652 /// that does not should return `None` rather than block.
653 ///
654 /// `None` means the model declared no reserve, or the implementor cannot
655 /// answer cheaply; the caller then applies
656 /// [`LlmLimits::default_output_cap`]. Returning `None` is always safe, which
657 /// is why this defaults rather than being required of every implementor.
658 fn output_reserve(&self, _model: &str) -> Option<u64> {
659 None
660 }
661}
662
663/// One execution's token budget, reserving against its own ceiling and the
664/// server-wide one together.
665///
666/// Both reservations are taken together and both unwound if either fails, so a
667/// refused dispatch leaves neither counter charged.
668pub struct ExecutionTokenBudget {
669 limits: LlmLimits,
670 ledger: TokenLedger,
671}
672
673impl ExecutionTokenBudget {
674 pub fn new(limits: LlmLimits, aggregate: SharedTokenBudget) -> Self {
675 let ledger = TokenLedger::new(
676 LedgerLimits {
677 per_execution_tokens: limits.per_execution_tokens,
678 max_held_tokens: limits.max_held_tokens,
679 },
680 aggregate,
681 );
682 Self { limits, ledger }
683 }
684
685 pub fn limits(&self) -> LlmLimits {
686 self.limits
687 }
688
689 /// Tokens this execution currently holds against its ceiling.
690 pub fn used(&self) -> u64 {
691 self.ledger.used()
692 }
693
694 /// The part of [`Self::used`] held for unreported usage.
695 pub fn held(&self) -> u64 {
696 self.ledger.held()
697 }
698 /// Apply committed per-run spend during replay, without a provider reservation.
699 pub fn restore_spend(&self, used: u64, held: u64) {
700 self.ledger.restore_spend(used, held);
701 }
702
703 /// Reserve `tokens` against this execution and the aggregate, before
704 /// dispatch.
705 ///
706 /// The caller passes the full reservation — `estimated_input_tokens +
707 /// (output_cap × prompt_count)`, via [`Self::reservation_for`] — because
708 /// reserving only input enforces the ceiling retroactively: output tokens
709 /// are typically the expensive half, and a small prompt can legitimately
710 /// produce a very large completion with nothing standing in the way.
711 pub fn reserve(&self, model: &str, tokens: u64) -> Result<(), LlmCallError> {
712 self.ledger.reserve(tokens).map_err(|refusal| {
713 let limit_kind = match refusal {
714 LedgerRefusal::HeldReserve { held, limit } => {
715 LlmLimitKind::IndeterminateSpend { held, limit }
716 }
717 LedgerRefusal::PerExecution { requested, limit } => {
718 LlmLimitKind::PerExecutionTokens { requested, limit }
719 }
720 LedgerRefusal::AllExecutions { requested, limit } => {
721 LlmLimitKind::AllExecutionsTokens { requested, limit }
722 }
723 };
724 LlmCallError::BudgetExceeded {
725 model: model.to_string(),
726 limit_kind,
727 }
728 })
729 }
730
731 /// The reservation a dispatch of `prompt_count` prompts needs:
732 /// `estimated_input_tokens + (output_cap × prompt_count)`.
733 ///
734 /// `output_cap` is the resolved per-model `outputReserve` when the model
735 /// declares one, and [`LlmLimits::default_output_cap`] otherwise. The same
736 /// cap is passed to the provider as the request's `maxOutputTokens`, which
737 /// makes the reservation an actual upper bound rather than an estimate.
738 pub fn reservation_for(
739 &self,
740 estimated_input_tokens: u64,
741 prompt_count: u64,
742 output_cap: Option<u64>,
743 ) -> u64 {
744 let cap = output_cap.unwrap_or(self.limits.default_output_cap);
745 estimated_input_tokens.saturating_add(cap.saturating_mul(prompt_count))
746 }
747
748 /// Reconcile a completed dispatch down from its `reserved` estimate to what
749 /// the provider actually reported.
750 ///
751 /// `reported` is the summed usage of the elements the provider gave counts
752 /// for; `indeterminate` is the reserve belonging to elements it reported
753 /// nothing for. The indeterminate part is **not** released — `None` means
754 /// indeterminate, not free, since a throttled call may still have been
755 /// billed — and is instead carried as held reserve, bounded by
756 /// [`LlmLimits::max_held_tokens`].
757 pub fn reconcile(&self, reserved: u64, reported: u64, indeterminate: u64) {
758 self.ledger.reconcile(reserved, reported, indeterminate);
759 }
760
761 /// Release a reservation for prompts that never dispatched — the remainder
762 /// of a batch abandoned partway.
763 pub fn release(&self, tokens: u64) {
764 self.reconcile(tokens, 0, 0);
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use std::sync::Arc;
771 use std::sync::atomic::{AtomicU64, Ordering};
772
773 use super::{
774 ExecutionTokenBudget, FailureReason, LlmCallError, LlmFailure, LlmLimitKind, LlmLimits,
775 LlmModel, LlmOutcome, LlmProvider, PromptBoundKind, SharedTokenBudget,
776 };
777
778 const MODEL: &str = "claude-sonnet-4";
779
780 /// An execution ends by having its budget holder dropped, not by reconciling
781 /// every reservation first. Without the release on drop the reservation of
782 /// every ended execution would be held forever and the server-wide budget
783 /// would ratchet to its cap, refusing every later execution.
784 #[test]
785 fn dropping_an_execution_budget_returns_its_tokens_to_the_aggregate() {
786 let aggregate = SharedTokenBudget::new(1_000);
787 {
788 let held = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
789 held.reserve(MODEL, 1_000)
790 .expect("the first execution fits");
791 assert_eq!(aggregate.used(), 1_000);
792 }
793 assert_eq!(
794 aggregate.used(),
795 0,
796 "the ended execution released its tokens"
797 );
798
799 let next = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate);
800 next.reserve(MODEL, 1_000)
801 .expect("a later execution reuses the capacity");
802 }
803
804 fn limits_with_cap(per_execution_tokens: u64) -> LlmLimits {
805 LlmLimits {
806 per_execution_tokens,
807 ..LlmLimits::default()
808 }
809 }
810
811 /// A panicking execution must still return its tokens: an unrelated panic
812 /// while the lock was held would otherwise strand the whole reservation, and
813 /// the aggregate would ratchet exactly as it does without any release.
814 #[test]
815 fn a_poisoned_lock_still_releases_on_drop() {
816 let aggregate = SharedTokenBudget::new(1_000);
817 {
818 let held = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
819 held.reserve(MODEL, 600).expect("reservation fits");
820
821 let poisoner = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
822 let _guard = held.ledger.lock_state().expect("lock");
823 panic!("poison the budget's lock");
824 }));
825 assert!(poisoner.is_err(), "the panic must have unwound");
826 assert!(held.ledger.state_is_poisoned(), "the lock must be poisoned");
827 }
828 assert_eq!(
829 aggregate.used(),
830 0,
831 "a poisoned execution still returns its tokens"
832 );
833 }
834
835 /// Two executions reserving at once must not both observe the same headroom.
836 /// With `fetch_add`-and-compensate both can read the pre-add value and both
837 /// conclude they fit; the CAS loop makes exactly one win.
838 #[test]
839 fn concurrent_reservations_cannot_both_claim_the_same_headroom() {
840 // Many rounds with many threads: a race that resolves correctly once may
841 // only have been serialized by luck, and two threads rarely collide
842 // inside the few instructions between the load and the store.
843 const THREADS: u64 = 16;
844 const ROUNDS: usize = 500;
845
846 let mut overspends = 0;
847 for _ in 0..ROUNDS {
848 // Headroom for exactly one of the racing reservations.
849 let aggregate = SharedTokenBudget::new(1_000);
850 let start = Arc::new(std::sync::Barrier::new(THREADS as usize));
851 let winners = Arc::new(AtomicU64::new(0));
852
853 let threads: Vec<_> = (0..THREADS)
854 .map(|_| {
855 let aggregate = aggregate.clone();
856 let start = Arc::clone(&start);
857 let winners = Arc::clone(&winners);
858 std::thread::spawn(move || {
859 let budget =
860 ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
861 // Release every thread into the reservation together.
862 start.wait();
863 if budget.reserve(MODEL, 1_000).is_ok() {
864 winners.fetch_add(1, Ordering::Relaxed);
865 }
866 // Hold every reservation until the round is over, so a
867 // win is never handed back before it is contested.
868 std::mem::forget(budget);
869 })
870 })
871 .collect();
872 for t in threads {
873 t.join().expect("reserving thread");
874 }
875
876 let winners = winners.load(Ordering::Relaxed);
877 assert_eq!(
878 winners, 1,
879 "exactly one of {THREADS} racing reservations may claim the only headroom"
880 );
881 if aggregate.used() > aggregate.cap() {
882 overspends += 1;
883 }
884 }
885 assert_eq!(
886 overspends, 0,
887 "the aggregate was overdrawn in {overspends}/{ROUNDS} rounds"
888 );
889 }
890
891 /// The two token ceilings refuse for opposite reasons and want opposite
892 /// responses: make fewer or shorter calls, versus wait or ask an operator. A
893 /// message that reads the same for both sends a program — or the model
894 /// writing it — to shrink work that was never what filled the budget.
895 #[test]
896 fn the_per_execution_and_server_wide_token_limits_name_different_fixes() {
897 let roomy = SharedTokenBudget::new(u64::MAX);
898 let own = ExecutionTokenBudget::new(limits_with_cap(100), roomy)
899 .reserve(MODEL, 500)
900 .expect_err("over this execution's own ceiling")
901 .to_string();
902
903 // An aggregate with no headroom left by another execution, so this one is
904 // refused without ever approaching its own per-execution ceiling.
905 let aggregate = SharedTokenBudget::new(100);
906 let neighbour = ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate.clone());
907 neighbour.reserve(MODEL, 100).expect("neighbour fits");
908 let shared = ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate);
909 let server_wide = shared
910 .reserve(MODEL, 100)
911 .expect_err("over the server-wide budget")
912 .to_string();
913
914 assert_ne!(own, server_wide);
915 assert!(own.contains("this execution may spend"), "{own}");
916 assert!(own.contains("use fewer or shorter calls"), "{own}");
917 assert!(
918 !own.contains("--max-llm-tokens"),
919 "the per-execution ceiling is not the operator's flag: {own}"
920 );
921 assert!(
922 server_wide.contains("across all live executions"),
923 "{server_wide}"
924 );
925 assert!(
926 server_wide.contains("is not what is in the way"),
927 "{server_wide}"
928 );
929 assert!(
930 server_wide.contains("--max-llm-tokens"),
931 "the aggregate refusal must name the flag that raises it: {server_wide}"
932 );
933 }
934
935 /// Each refusal must name the limit it hit and the ceiling, so an operator
936 /// reading a log knows which knob is in play.
937 #[test]
938 fn exceeding_either_ceiling_names_the_limit_and_the_ceiling_hit() {
939 let aggregate = SharedTokenBudget::new(u64::MAX);
940 let budget = ExecutionTokenBudget::new(limits_with_cap(64), aggregate);
941 let err = budget.reserve(MODEL, 4_096).expect_err("over the ceiling");
942 assert!(
943 matches!(
944 err,
945 LlmCallError::BudgetExceeded {
946 limit_kind: LlmLimitKind::PerExecutionTokens { .. },
947 ..
948 }
949 ),
950 "{err:?}"
951 );
952 let rendered = err.to_string();
953 assert!(rendered.contains(MODEL), "{rendered}");
954 assert!(rendered.contains("4096"), "the request: {rendered}");
955 assert!(rendered.contains("64"), "the ceiling: {rendered}");
956 }
957
958 /// a budget refusal carries the numbers, never the payload.
959 #[test]
960 fn no_budget_error_display_leaks_prompt_text() {
961 const SECRET: &str = "the patient's diagnosis is";
962 let aggregate = SharedTokenBudget::new(10);
963 let neighbour = ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate.clone());
964 neighbour.reserve(MODEL, 10).expect("neighbour fits");
965
966 let rendered: Vec<String> = [
967 ExecutionTokenBudget::new(limits_with_cap(1), SharedTokenBudget::new(u64::MAX))
968 .reserve(MODEL, 900)
969 .expect_err("per-execution"),
970 ExecutionTokenBudget::new(limits_with_cap(u64::MAX), aggregate)
971 .reserve(MODEL, 900)
972 .expect_err("server-wide"),
973 held_budget_at_its_ceiling()
974 .reserve(MODEL, 1)
975 .expect_err("indeterminate"),
976 ]
977 .iter()
978 .map(ToString::to_string)
979 .collect();
980
981 for message in &rendered {
982 assert!(!message.contains(SECRET), "{message}");
983 assert!(!message.contains("patient"), "{message}");
984 }
985 }
986
987 /// An execution whose held reserve is already over the bound.
988 fn held_budget_at_its_ceiling() -> ExecutionTokenBudget {
989 let budget = ExecutionTokenBudget::new(
990 LlmLimits {
991 per_execution_tokens: u64::MAX,
992 max_held_tokens: 100,
993 ..LlmLimits::default()
994 },
995 SharedTokenBudget::new(u64::MAX),
996 );
997 budget.reserve(MODEL, 500).expect("the dispatch fits");
998 // Every element came back with no usage reported, so the whole
999 // reservation is carried as held rather than released.
1000 budget.reconcile(500, 0, 500);
1001 budget
1002 }
1003
1004 /// A batch abandoned partway must not keep charging for the prompts that
1005 /// never dispatched.
1006 #[test]
1007 fn an_undispatched_remainder_is_released_after_a_partial_batch() {
1008 let aggregate = SharedTokenBudget::new(1_000);
1009 let budget = ExecutionTokenBudget::new(limits_with_cap(1_000), aggregate.clone());
1010 budget.reserve(MODEL, 900).expect("the whole batch fits");
1011
1012 // Four of ten prompts ran and reported 120 tokens; the rest never
1013 // dispatched, so their share of the reservation is released.
1014 budget.reconcile(900, 120, 0);
1015
1016 assert_eq!(budget.used(), 120);
1017 assert_eq!(aggregate.used(), 120, "the remainder returned");
1018 budget
1019 .reserve(MODEL, 800)
1020 .expect("the released capacity is reusable");
1021 }
1022
1023 /// reserving only input enforces the ceiling retroactively. A
1024 /// 100-token prompt can legitimately produce 100k output tokens, so the
1025 /// output cap must be part of the reservation and the refusal must land
1026 /// before dispatch, not after the provider has billed.
1027 #[test]
1028 fn the_reservation_covers_output_tokens_and_refuses_before_dispatch() {
1029 let aggregate = SharedTokenBudget::new(u64::MAX);
1030 let budget = ExecutionTokenBudget::new(
1031 LlmLimits {
1032 per_execution_tokens: 10_000,
1033 default_output_cap: 64_000,
1034 ..LlmLimits::default()
1035 },
1036 aggregate.clone(),
1037 );
1038
1039 // Input alone is trivially under the ceiling.
1040 let input_only = 100;
1041 assert!(input_only < budget.limits().per_execution_tokens);
1042
1043 let reservation = budget.reservation_for(input_only, 1, None);
1044 assert_eq!(
1045 reservation,
1046 100 + 64_000,
1047 "output cap is in the reservation"
1048 );
1049
1050 let err = budget
1051 .reserve(MODEL, reservation)
1052 .expect_err("refused before dispatch, not after the provider billed");
1053 assert!(
1054 matches!(
1055 err,
1056 LlmCallError::BudgetExceeded {
1057 limit_kind: LlmLimitKind::PerExecutionTokens { .. },
1058 ..
1059 }
1060 ),
1061 "{err:?}"
1062 );
1063 assert_eq!(budget.used(), 0, "a refused dispatch charges nothing");
1064 assert_eq!(aggregate.used(), 0);
1065
1066 // The cap scales with the prompt count, and a model's own reserve wins
1067 // over the default.
1068 assert_eq!(budget.reservation_for(100, 4, None), 100 + 64_000 * 4);
1069 assert_eq!(budget.reservation_for(100, 4, Some(1_000)), 100 + 4_000);
1070 }
1071
1072 /// held reserve releases from the per-execution budget on teardown —
1073 /// that execution is over — but stays charged against the aggregate.
1074 /// Releasing it from the aggregate would let indeterminate spend escape the
1075 /// server-wide ceiling: N executions each under the per-execution ceiling
1076 /// could exceed the server-wide one without ever tripping it.
1077 #[test]
1078 fn a_held_reserve_commits_against_the_aggregate_and_releases_only_per_execution() {
1079 let aggregate = SharedTokenBudget::new(1_500);
1080 let limits = LlmLimits {
1081 per_execution_tokens: 400,
1082 max_held_tokens: 10_000,
1083 ..LlmLimits::default()
1084 };
1085
1086 // Three executions that each spend 400 tokens the provider never reports.
1087 for _ in 0..3 {
1088 let budget = ExecutionTokenBudget::new(limits, aggregate.clone());
1089 budget.reserve(MODEL, 400).expect("within its own ceiling");
1090 budget.reconcile(400, 0, 400);
1091 assert_eq!(budget.held(), 400);
1092 }
1093
1094 assert_eq!(
1095 aggregate.used(),
1096 1_200,
1097 "indeterminate spend stays charged against the aggregate after teardown"
1098 );
1099
1100 // A fourth execution, itself well under the per-execution ceiling, is
1101 // now refused by the server-wide ceiling it would otherwise have walked
1102 // straight past.
1103 let next = ExecutionTokenBudget::new(limits, aggregate);
1104 let err = next.reserve(MODEL, 400).expect_err("the aggregate refuses");
1105 assert!(
1106 matches!(
1107 err,
1108 LlmCallError::BudgetExceeded {
1109 limit_kind: LlmLimitKind::AllExecutionsTokens { .. },
1110 ..
1111 }
1112 ),
1113 "{err:?}"
1114 );
1115 }
1116
1117 /// Token spend is monotonic, so a held estimate is never superseded by a real
1118 /// number and a long-lived execution accumulating null usage would ratchet to
1119 /// zero with no recovery. The bound names *indeterminate spend*, distinctly
1120 /// from a genuine overspend, so an operator can tell a null-usage storm from
1121 /// real consumption.
1122 #[test]
1123 fn crossing_the_held_reserve_bound_is_distinguishable_from_a_genuine_overspend() {
1124 let held = held_budget_at_its_ceiling();
1125 let indeterminate = held
1126 .reserve(MODEL, 1)
1127 .expect_err("the held bound is crossed");
1128 assert!(
1129 matches!(
1130 indeterminate,
1131 LlmCallError::BudgetExceeded {
1132 limit_kind: LlmLimitKind::IndeterminateSpend { .. },
1133 ..
1134 }
1135 ),
1136 "{indeterminate:?}"
1137 );
1138
1139 let overspend =
1140 ExecutionTokenBudget::new(limits_with_cap(10), SharedTokenBudget::new(1_000))
1141 .reserve(MODEL, 900)
1142 .expect_err("a genuine overspend");
1143
1144 let indeterminate = indeterminate.to_string();
1145 let overspend = overspend.to_string();
1146 assert_ne!(indeterminate, overspend);
1147 assert!(
1148 indeterminate.contains("indeterminate spend ceiling reached"),
1149 "{indeterminate}"
1150 );
1151 assert!(
1152 indeterminate.contains("never reported usage"),
1153 "the operator must be able to tell unreported spend from measured: {indeterminate}"
1154 );
1155 assert!(
1156 !overspend.contains("indeterminate"),
1157 "a real overspend must not read as a null-usage storm: {overspend}"
1158 );
1159 }
1160
1161 /// A ceiling is something a program can catch and adapt to, so it reaches the
1162 /// guest as a catchable `QuotaExceededError`. A dispatch that failed for
1163 /// a reason retrying smaller cannot fix
1164 /// must not masquerade as one.
1165 #[test]
1166 fn only_quota_refusals_are_classified_as_quota_exceeded_errors() {
1167 for error in every_variant() {
1168 let expected = matches!(error, LlmCallError::BudgetExceeded { .. });
1169 assert_eq!(
1170 error.is_budget_exceeded(),
1171 expected,
1172 "{error:?} is misclassified for the guest boundary"
1173 );
1174 }
1175
1176 // Every token ceiling, not just the one in `every_variant`.
1177 for limit_kind in [
1178 LlmLimitKind::PerExecutionTokens {
1179 requested: 2,
1180 limit: 1,
1181 },
1182 LlmLimitKind::AllExecutionsTokens {
1183 requested: 2,
1184 limit: 1,
1185 },
1186 LlmLimitKind::IndeterminateSpend { held: 2, limit: 1 },
1187 ] {
1188 let error = LlmCallError::BudgetExceeded {
1189 model: MODEL.to_string(),
1190 limit_kind,
1191 };
1192 assert!(error.is_budget_exceeded(), "{error:?}");
1193 }
1194 }
1195
1196 /// The prompt bounds U4 enforces live alongside the token ceilings so an
1197 /// operator raising one raises the other in one place.
1198 #[test]
1199 fn default_limits_carry_both_token_ceilings_and_prompt_bounds() {
1200 let limits = LlmLimits::default();
1201 assert_eq!(limits.max_prompt_count, 128);
1202 assert_eq!(limits.max_prompt_bytes, 256 * 1024);
1203 assert_eq!(
1204 limits.per_execution_tokens,
1205 super::DEFAULT_MAX_EXECUTION_TOKENS
1206 );
1207 assert_eq!(limits.default_output_cap, super::DEFAULT_OUTPUT_CAP);
1208 }
1209
1210 /// One of every dispatch-level variant, so a new variant fails to compile
1211 /// here until it is added.
1212 fn every_variant() -> Vec<LlmCallError> {
1213 vec![
1214 LlmCallError::NotConfigured {
1215 model: MODEL.to_string(),
1216 },
1217 LlmCallError::UnknownModel {
1218 model: MODEL.to_string(),
1219 available: vec!["claude-haiku-4".to_string()],
1220 },
1221 LlmCallError::BudgetExceeded {
1222 model: MODEL.to_string(),
1223 limit_kind: LlmLimitKind::PerExecutionTokens {
1224 requested: 4_000,
1225 limit: 120,
1226 },
1227 },
1228 LlmCallError::PromptBoundsExceeded {
1229 model: MODEL.to_string(),
1230 limit_kind: PromptBoundKind::PromptCount,
1231 actual: 64,
1232 limit: 32,
1233 },
1234 LlmCallError::Unauthorized {
1235 model: MODEL.to_string(),
1236 },
1237 LlmCallError::Transport {
1238 model: MODEL.to_string(),
1239 detail: "connection reset".to_string(),
1240 },
1241 ]
1242 }
1243
1244 #[test]
1245 fn six_dispatch_variants_each_name_the_model_and_a_distinct_fix() {
1246 let variants = every_variant();
1247 assert_eq!(variants.len(), 6, "LlmCallError has six dispatch variants");
1248
1249 let mut fixes: Vec<String> = Vec::new();
1250 for err in &variants {
1251 let rendered = err.to_string();
1252 assert!(
1253 rendered.contains(MODEL),
1254 "variant must name the model: {rendered}"
1255 );
1256 assert_eq!(err.model(), MODEL);
1257 let (_, fix) = rendered.split_once(" — ").unwrap_or_else(|| {
1258 panic!("variant must suggest a fix after an em dash: {rendered}")
1259 });
1260 assert!(!fix.trim().is_empty(), "empty fix: {rendered}");
1261 fixes.push(fix.to_string());
1262 }
1263
1264 // Distinct fixes: a shared message would tell the caller nothing about
1265 // which of the six they hit.
1266 let mut sorted = fixes.clone();
1267 sorted.sort();
1268 sorted.dedup();
1269 assert_eq!(
1270 sorted.len(),
1271 fixes.len(),
1272 "each variant needs its own fix: {fixes:?}"
1273 );
1274 }
1275
1276 /// no prompt or completion text may reach an error `Display`.
1277 #[test]
1278 fn no_dispatch_error_display_leaks_prompt_text() {
1279 const SECRET_PROMPT: &str = "patient SSN is 000-00-0000";
1280 const SECRET_COMPLETION: &str = "the diagnosis is confidential";
1281
1282 for err in every_variant() {
1283 let rendered = err.to_string();
1284 assert!(
1285 !rendered.contains(SECRET_PROMPT),
1286 "prompt text leaked into Display: {rendered}"
1287 );
1288 assert!(
1289 !rendered.contains(SECRET_COMPLETION),
1290 "completion text leaked into Display: {rendered}"
1291 );
1292 }
1293
1294 // The only free-form field any variant carries is `Transport { detail }`,
1295 // which is a transport diagnostic and never built from a payload. Prove
1296 // no *other* variant has a construction path that could carry one by
1297 // showing the rendered set is otherwise fully determined by model name
1298 // and numbers.
1299 let bounded = LlmCallError::PromptBoundsExceeded {
1300 model: MODEL.to_string(),
1301 limit_kind: PromptBoundKind::PromptBytes,
1302 actual: 900_000,
1303 limit: 65_536,
1304 };
1305 let rendered = bounded.to_string();
1306 assert!(rendered.contains("900000") && rendered.contains("65536"));
1307 assert!(
1308 rendered.contains("bytes in a single prompt"),
1309 "bound kind must be named: {rendered}"
1310 );
1311 }
1312
1313 /// nine, in the plan's declaration order. The spellings are the wire
1314 /// form a guest branches on, so drift here is a guest-visible break.
1315 #[test]
1316 fn nine_failure_reasons_have_stable_kebab_case_spellings() {
1317 let spellings: Vec<&str> = FailureReason::ALL
1318 .iter()
1319 .map(FailureReason::as_str)
1320 .collect();
1321 assert_eq!(
1322 spellings,
1323 [
1324 "truncated",
1325 "content-filtered",
1326 "invalid-output",
1327 "rate-limited",
1328 "request-rejected",
1329 "provider-unavailable",
1330 "transport",
1331 "cancelled",
1332 "incomplete",
1333 ]
1334 );
1335 for reason in FailureReason::ALL {
1336 assert_eq!(reason.to_string(), reason.as_str());
1337 }
1338 }
1339
1340 /// `ok` keys off a natural stop, not off "didn't throw"; absent usage is
1341 /// `None`, never `Some(0)`; and the failure arm keeps partial text.
1342 #[test]
1343 fn truncated_outcome_is_not_ok_but_keeps_its_text_and_indeterminate_usage() {
1344 let partial = LlmOutcome::failed(
1345 LlmFailure::new(FailureReason::Truncated, "output token ceiling reached"),
1346 Some("the answer begins"),
1347 );
1348 assert!(!partial.ok, "a length stop is not a natural stop");
1349 assert_eq!(
1350 partial.text.as_deref(),
1351 Some("the answer begins"),
1352 "partial text is retained"
1353 );
1354 let failure = partial.failure.as_ref().expect("failure arm present");
1355 assert_eq!(failure.reason, FailureReason::Truncated);
1356 assert_eq!(
1357 partial.input_tokens, None,
1358 "unreported usage is indeterminate"
1359 );
1360 assert_eq!(partial.output_tokens, None);
1361
1362 let clean = LlmOutcome::success("done").with_usage(Some(12), Some(0));
1363 assert!(clean.ok);
1364 assert!(clean.failure.is_none(), "a natural stop carries no reason");
1365 assert_eq!(
1366 clean.output_tokens,
1367 Some(0),
1368 "a real zero is Some(0); only absence is None"
1369 );
1370 }
1371
1372 /// `text` is `string | null` on the failure arm specifically — a model
1373 /// that produced nothing is distinguishable from one that produced "".
1374 #[test]
1375 fn failure_text_is_nullable_and_distinct_from_empty() {
1376 let nothing = LlmOutcome::failed(
1377 LlmFailure::new(FailureReason::ContentFiltered, "filtered before any output"),
1378 None::<String>,
1379 );
1380 assert_eq!(nothing.text, None, "produced nothing at all");
1381
1382 let empty = LlmOutcome::failed(
1383 LlmFailure::new(
1384 FailureReason::ContentFiltered,
1385 "filtered after an empty turn",
1386 ),
1387 Some(""),
1388 );
1389 assert_eq!(empty.text.as_deref(), Some(""), "produced an empty string");
1390 assert_ne!(
1391 nothing.text, empty.text,
1392 "null and empty must not collapse — the whole point is telling \
1393 'empty because filtered' from 'empty because it said nothing'"
1394 );
1395 }
1396
1397 /// the failure arm carries `message`, `retryable`, and `status`, and the
1398 /// success arm structurally cannot.
1399 #[test]
1400 fn failure_arm_carries_message_retryable_and_nullable_status() {
1401 let throttled =
1402 LlmFailure::new(FailureReason::RateLimited, "provider throttled the request")
1403 .retryable(true)
1404 .with_status(429);
1405 assert_eq!(throttled.message, "provider throttled the request");
1406 assert!(throttled.retryable);
1407 assert_eq!(throttled.status, Some(429));
1408
1409 // `status: number | null` — a transport death has no HTTP status at all.
1410 // That absence is the classification: it is exactly what distinguishes a
1411 // dead connection from a provider that answered with an error.
1412 let dead = LlmFailure::new(
1413 FailureReason::Transport,
1414 "connection closed before response",
1415 );
1416 assert_eq!(dead.status, None);
1417
1418 // `retryable` defaults per class rather than uniformly: a dead connection
1419 // may succeed on the same bytes, while a refused request will not.
1420 assert!(dead.retryable, "transport is retryable by class");
1421 let refused = LlmFailure::new(FailureReason::RequestRejected, "request refused as sent");
1422 assert!(
1423 !refused.retryable,
1424 "a request the provider refused will not start succeeding unchanged"
1425 );
1426 // ...and the class default is a starting point, not the last word.
1427 assert!(
1428 !LlmFailure::new(FailureReason::RateLimited, "quota exhausted")
1429 .retryable(false)
1430 .retryable,
1431 "a provider that knows better overrides the class default"
1432 );
1433
1434 // Raw `finishReason` is a *diagnostic* field, separate from `reason`,
1435 // which is what guests branch on. They must not fold together.
1436 let truncated = LlmFailure::new(FailureReason::Truncated, "stopped at the output cap")
1437 .with_finish_reason("length");
1438 assert_eq!(truncated.reason, FailureReason::Truncated);
1439 assert_eq!(truncated.finish_reason.as_deref(), Some("length"));
1440
1441 // A provider reporting a stop reason we do not classify still surfaces the
1442 // raw string for diagnosis while `reason` stays in the closed set.
1443 let odd = LlmFailure::new(FailureReason::Incomplete, "unclassified stop")
1444 .with_finish_reason("surprise-reason");
1445 assert_eq!(odd.reason, FailureReason::Incomplete);
1446 assert_eq!(odd.finish_reason.as_deref(), Some("surprise-reason"));
1447 }
1448
1449 /// The retryable split follows the spike's structural classification: 429 and
1450 /// 5xx and a dead connection may succeed on the same bytes; everything else
1451 /// describes a request that will not start succeeding unchanged.
1452 #[test]
1453 fn retryable_defaults_split_transient_classes_from_terminal_ones() {
1454 let retryable: Vec<&str> = FailureReason::ALL
1455 .iter()
1456 .filter(|r| r.retryable_by_default())
1457 .map(FailureReason::as_str)
1458 .collect();
1459 assert_eq!(
1460 retryable,
1461 ["rate-limited", "provider-unavailable", "transport"]
1462 );
1463 }
1464
1465 /// unlike `McpCallError::Upstream`, which renders `HTTP {status}:
1466 /// {body}`, no variant here interpolates a provider response body — those
1467 /// carry completion fragments and request echoes.
1468 #[test]
1469 fn no_dispatch_error_display_leaks_a_provider_response_body() {
1470 const BODY: &str = r#"{"error":{"message":"your prompt was: summarize the memo"}}"#;
1471 // The only free-form field in the taxonomy. A provider impl is
1472 // contracted to pass a fixed classification string; prove that when it
1473 // does, nothing body-shaped can reach the rendered message.
1474 let classified = LlmCallError::Transport {
1475 model: MODEL.to_string(),
1476 detail: "connection reset".to_string(),
1477 };
1478 let rendered = classified.to_string();
1479 assert!(!rendered.contains(BODY));
1480 assert!(!rendered.contains("summarize the memo"));
1481 assert!(!rendered.contains('{'), "no JSON body shape: {rendered}");
1482 }
1483
1484 /// The degraded-error string: `message` follows the AI SDK's
1485 /// `error: unknown` → `errorText: string` rule, and carries no payload.
1486 #[test]
1487 fn failure_message_is_a_classification_not_a_payload_echo() {
1488 // Every message a provider impl is expected to produce is drawn from a
1489 // fixed classification vocabulary, so none can carry a response body.
1490 for reason in FailureReason::ALL {
1491 let failure = LlmFailure::new(reason, reason.default_message());
1492 assert!(
1493 !failure.message.is_empty(),
1494 "{reason} needs a message: it is the degraded error string"
1495 );
1496 assert!(
1497 !failure.message.contains("patient SSN"),
1498 "classification must not echo a payload: {}",
1499 failure.message
1500 );
1501 }
1502 }
1503
1504 #[test]
1505 fn llm_model_optionals_may_both_be_absent() {
1506 let declared = LlmModel::new("operator-declared");
1507 assert_eq!(declared.description, None);
1508 assert_eq!(declared.context_window, None);
1509 }
1510
1511 struct NoopProvider;
1512
1513 impl LlmProvider for NoopProvider {
1514 fn call<'a>(
1515 &'a self,
1516 model: &'a str,
1517 prompts: &'a [String],
1518 _schema_json: Option<&'a str>,
1519 ) -> std::pin::Pin<
1520 Box<
1521 dyn std::future::Future<Output = Result<Vec<LlmOutcome>, LlmCallError>> + Send + 'a,
1522 >,
1523 > {
1524 let n = prompts.len();
1525 let model = model.to_string();
1526 Box::pin(async move {
1527 if model.is_empty() {
1528 return Err(LlmCallError::NotConfigured { model });
1529 }
1530 // Obligation 1: one outcome per prompt, in order.
1531 Ok((0..n).map(|i| LlmOutcome::success(i.to_string())).collect())
1532 })
1533 }
1534
1535 fn models<'a>(
1536 &'a self,
1537 ) -> std::pin::Pin<
1538 Box<dyn std::future::Future<Output = Result<Vec<LlmModel>, LlmCallError>> + Send + 'a>,
1539 > {
1540 Box::pin(async { Ok(vec![LlmModel::new("noop")]) })
1541 }
1542 }
1543
1544 /// The trait must stay object-safe: the store holds it as `Arc<dyn ...>`.
1545 #[tokio::test]
1546 async fn provider_trait_is_object_safe_behind_arc() {
1547 let provider: Arc<dyn LlmProvider> = Arc::new(NoopProvider);
1548 let prompts = vec!["a".to_string(), "b".to_string()];
1549 let outcomes = provider
1550 .call(MODEL, &prompts, None)
1551 .await
1552 .expect("dispatch succeeds");
1553 assert_eq!(outcomes.len(), prompts.len(), "positional: one per prompt");
1554 assert_eq!(outcomes[1].text.as_deref(), Some("1"));
1555 assert_eq!(provider.models().await.unwrap()[0].name, "noop");
1556 }
1557
1558 #[tokio::test]
1559 async fn unconfigured_store_data_has_no_provider() {
1560 // The default runtime leaves the provider absent so an unconfigured
1561 // call reports a catchable configuration error rather than silently
1562 // succeeding — the `session_kv` rule.
1563 let data = crate::runtime::StoreData::with_tempdir().unwrap();
1564 assert!(data.llm_provider.is_none());
1565 }
1566}