link_assistant_router/token_reservation.rs
1//! Pre-dispatch spend reservations for per-token `max_tokens` budgets.
2//!
3//! A spend cap cannot be enforced from recorded usage alone. Usage is only known
4//! once a response completes, so checking `used_tokens < max_tokens` at admission
5//! lets a single large answer push the persisted total arbitrarily far past the
6//! cap (issue #195).
7//!
8//! The router therefore reserves budget *before* dispatching. Every protocol
9//! declares, or is given, a maximum output size; that figure plus an estimate of
10//! the prompt is reserved against the cap at admission and released when the real
11//! usage arrives. Because the reservation happens inside the same locked
12//! read-modify-write that increments the request counters, concurrent requests
13//! cannot collectively overshoot either.
14//!
15//! # Enforcement contract
16//!
17//! * A request is admitted only when `used + reserved + estimate <= max_tokens`.
18//! * A request whose declared output budget cannot fit is rejected up front with
19//! `token_budget_exceeded` rather than being truncated mid-answer.
20//! * Actual usage is always recorded in full, even when it exceeds the estimate,
21//! so the persisted total never understates real spend.
22//! * The bound is therefore exact against *declared* budgets. Providers that
23//! report more tokens than the caller's declared maximum (hidden reasoning
24//! tokens, for example) can still land above the cap by that provider-side
25//! excess; the overshoot is bounded by one request's unreported surplus rather
26//! than being unbounded, and both figures are visible to administrators.
27
28use serde_json::Value;
29
30/// Characters per token used by the router's local estimator.
31///
32/// Matches [`crate::output_limit`] and
33/// the bridge request-shape estimator so one request is not
34/// measured two different ways.
35const CHARS_PER_TOKEN: u64 = 4;
36
37/// Output budget assumed when a request declares none.
38///
39/// Protocol adapters already default to this figure when shaping upstream
40/// requests, so reserving it keeps the reservation aligned with what the
41/// upstream is actually allowed to return.
42pub const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 4096;
43
44/// The spend a request could report, used to reserve budget before dispatch.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub struct RequestBudget {
47 /// Estimated prompt tokens.
48 pub input: u64,
49 /// Declared (or defaulted) maximum output tokens.
50 pub output: u64,
51}
52
53impl RequestBudget {
54 /// Total tokens to reserve for this request.
55 #[must_use]
56 pub const fn total(self) -> u64 {
57 self.input.saturating_add(self.output)
58 }
59}
60
61/// Estimate the spend a request body could report.
62///
63/// Reads whichever output-cap field the request's protocol uses:
64/// `max_output_tokens` (Responses), `max_completion_tokens`/`max_tokens` (Chat
65/// Completions and Anthropic Messages), and
66/// `generationConfig.maxOutputTokens` (Gemini). When none is present the
67/// protocol default applies, matching what the adapters forward upstream.
68#[must_use]
69pub fn estimate(body: &Value) -> RequestBudget {
70 RequestBudget {
71 input: estimate_input_tokens(body),
72 output: declared_output_tokens(body).unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS),
73 }
74}
75
76/// The caller's declared output cap, in whichever field its protocol uses.
77#[must_use]
78pub fn declared_output_tokens(body: &Value) -> Option<u64> {
79 for field in ["max_output_tokens", "max_completion_tokens", "max_tokens"] {
80 if let Some(value) = body.get(field).and_then(Value::as_u64) {
81 return Some(value);
82 }
83 }
84 body.get("generationConfig")
85 .and_then(|config| config.get("maxOutputTokens"))
86 .and_then(Value::as_u64)
87}
88
89/// Estimate prompt tokens from the request's text payload.
90///
91/// The router has no upstream tokenizer, so it measures the serialized textual
92/// content with the same ~4-characters-per-token heuristic used elsewhere. This
93/// only has to be good enough to keep a reservation in the right order of
94/// magnitude; the real figure replaces it at settlement.
95#[must_use]
96pub fn estimate_input_tokens(body: &Value) -> u64 {
97 let mut characters = 0u64;
98 for field in ["messages", "input", "contents", "system", "prompt", "tools"] {
99 if let Some(value) = body.get(field) {
100 characters = characters.saturating_add(text_length(value));
101 }
102 }
103 characters.div_ceil(CHARS_PER_TOKEN)
104}
105
106/// Total length of every string in a JSON value.
107fn text_length(value: &Value) -> u64 {
108 match value {
109 Value::String(text) => text.len() as u64,
110 Value::Array(items) => items
111 .iter()
112 .map(text_length)
113 .fold(0u64, u64::saturating_add),
114 Value::Object(fields) => fields
115 .values()
116 .map(text_length)
117 .fold(0u64, u64::saturating_add),
118 _ => 0,
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use serde_json::json;
126
127 #[test]
128 fn reads_the_responses_output_cap() {
129 let body = json!({"max_output_tokens": 1500});
130 assert_eq!(declared_output_tokens(&body), Some(1500));
131 assert_eq!(estimate(&body).output, 1500);
132 }
133
134 #[test]
135 fn reads_the_chat_completions_output_caps() {
136 assert_eq!(
137 declared_output_tokens(&json!({"max_completion_tokens": 700})),
138 Some(700)
139 );
140 assert_eq!(
141 declared_output_tokens(&json!({"max_tokens": 800})),
142 Some(800)
143 );
144 }
145
146 #[test]
147 fn reads_the_gemini_output_cap() {
148 let body = json!({"generationConfig": {"maxOutputTokens": 256}});
149 assert_eq!(declared_output_tokens(&body), Some(256));
150 }
151
152 #[test]
153 fn falls_back_to_the_protocol_default_when_no_cap_is_declared() {
154 let body = json!({"messages": []});
155 assert_eq!(declared_output_tokens(&body), None);
156 assert_eq!(estimate(&body).output, DEFAULT_MAX_OUTPUT_TOKENS);
157 }
158
159 #[test]
160 fn estimates_prompt_tokens_from_message_text() {
161 // 40 characters of content over the 4-characters-per-token heuristic.
162 let body = json!({"messages": [{"role": "user", "content": "a".repeat(40)}]});
163 // "user" (4) + 40 characters = 44 characters => 11 tokens.
164 assert_eq!(estimate_input_tokens(&body), 11);
165 }
166
167 #[test]
168 fn counts_gemini_contents_and_anthropic_system_prompts() {
169 let gemini = json!({"contents": [{"parts": [{"text": "b".repeat(8)}]}]});
170 assert!(estimate_input_tokens(&gemini) > 0);
171 let anthropic = json!({"system": "c".repeat(16), "messages": []});
172 assert!(estimate_input_tokens(&anthropic) >= 4);
173 }
174
175 #[test]
176 fn total_combines_input_and_output() {
177 let budget = RequestBudget {
178 input: 10,
179 output: 20,
180 };
181 assert_eq!(budget.total(), 30);
182 }
183
184 #[test]
185 fn saturates_rather_than_overflowing_on_absurd_declared_budgets() {
186 let budget = RequestBudget {
187 input: u64::MAX,
188 output: u64::MAX,
189 };
190 assert_eq!(budget.total(), u64::MAX);
191 }
192
193 #[test]
194 fn ignores_non_textual_payload_fields() {
195 assert_eq!(estimate_input_tokens(&json!({"temperature": 0.7})), 0);
196 }
197}