Skip to main content

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}