Skip to main content

magi_code/context/
tokens.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    output::ContextUsageSource,
5    providers::{ProviderConversationItem, ProviderRequest},
6};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
11pub struct ContextBudget {
12    #[serde(default = "default_context_enabled")]
13    pub enabled: bool,
14    #[serde(default = "default_max_tokens")]
15    pub max_tokens: usize,
16    #[serde(default = "default_reserve_tokens")]
17    pub reserve_tokens: usize,
18    #[serde(default = "default_keep_recent_tokens")]
19    pub keep_recent_tokens: usize,
20    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
21    pub model_overrides: BTreeMap<String, ContextBudgetOverride>,
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
25pub struct ContextBudgetOverride {
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub max_tokens: Option<usize>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub reserve_tokens: Option<usize>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub keep_recent_tokens: Option<usize>,
32}
33
34impl Default for ContextBudget {
35    fn default() -> Self {
36        Self {
37            enabled: true,
38            max_tokens: default_max_tokens(),
39            reserve_tokens: default_reserve_tokens(),
40            keep_recent_tokens: default_keep_recent_tokens(),
41            model_overrides: BTreeMap::new(),
42        }
43    }
44}
45
46fn default_context_enabled() -> bool {
47    true
48}
49fn default_max_tokens() -> usize {
50    128_000
51}
52fn default_reserve_tokens() -> usize {
53    16_384
54}
55fn default_keep_recent_tokens() -> usize {
56    20_000
57}
58
59impl ContextBudgetOverride {
60    pub(crate) fn is_empty(&self) -> bool {
61        self.max_tokens.is_none()
62            && self.reserve_tokens.is_none()
63            && self.keep_recent_tokens.is_none()
64    }
65}
66
67impl ContextBudget {
68    pub(crate) fn threshold_tokens(&self) -> usize {
69        self.max_tokens.saturating_sub(self.reserve_tokens)
70    }
71
72    pub(crate) fn apply_model_limits(&mut self, provider: &str, model: &str) {
73        if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
74            // CLI capacity replaces published windows and the generic fallback.
75            self.max_tokens = crate::providers::claude_subscription_prompt_limit(model);
76        }
77        let key = format!("{provider}/{model}");
78        if let Some(model_override) = self.model_overrides.get(&key) {
79            if let Some(max_tokens) = model_override.max_tokens {
80                self.max_tokens = max_tokens;
81            }
82            if let Some(reserve_tokens) = model_override.reserve_tokens {
83                self.reserve_tokens = reserve_tokens;
84            }
85            if let Some(keep_recent_tokens) = model_override.keep_recent_tokens {
86                self.keep_recent_tokens = keep_recent_tokens;
87            }
88        }
89        // Catalog metadata and user overrides cannot raise the CLI transport limit.
90        if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
91            self.max_tokens = self
92                .max_tokens
93                .min(crate::providers::claude_subscription_prompt_limit(model));
94        }
95    }
96}
97
98pub(crate) fn estimate_text_tokens(text: &str) -> usize {
99    text.chars().count().div_ceil(4).max(1)
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) struct ContextTokenCount {
104    pub(crate) tokens: usize,
105    pub(crate) source: ContextUsageSource,
106}
107
108pub(crate) fn project_provider_request_input_tokens(
109    provider_id: &str,
110    request: &ProviderRequest,
111) -> ContextTokenCount {
112    let mut projection = project_provider_conversation_item_tokens(
113        provider_id,
114        &request.model,
115        request.conversation_items_iter(),
116    );
117    if let Some(tool_definitions) = request.tool_definitions_json_if_enabled() {
118        let tool_tokens =
119            if let Some(counter) = tokenizer_for_provider_model(provider_id, &request.model) {
120                count_json_value_tokens(&counter, &tool_definitions)
121            } else {
122                count_json_value_tokens(&FallbackCounter, &tool_definitions)
123            };
124        projection.tokens = projection.tokens.saturating_add(tool_tokens);
125    }
126    if provider_id == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
127        projection.tokens = projection.tokens.saturating_add(
128            project_text_tokens(
129                provider_id,
130                &request.model,
131                crate::providers::claude_subscription::CLAUDE_CLI_PROMPT,
132            )
133            .tokens,
134        );
135    }
136    projection
137}
138
139pub(crate) fn project_provider_conversation_items_tokens(
140    provider_id: &str,
141    model: &str,
142    items: &[ProviderConversationItem],
143) -> ContextTokenCount {
144    project_provider_conversation_item_tokens(provider_id, model, items.iter())
145}
146
147fn project_provider_conversation_item_tokens<'a>(
148    provider_id: &str,
149    model: &str,
150    items: impl Iterator<Item = &'a ProviderConversationItem>,
151) -> ContextTokenCount {
152    let Some(counter) = tokenizer_for_provider_model(provider_id, model) else {
153        return ContextTokenCount {
154            tokens: items.map(fallback_estimate_conversation_item_tokens).sum(),
155            source: ContextUsageSource::FallbackEstimate,
156        };
157    };
158    ContextTokenCount {
159        tokens: items
160            .map(|item| count_conversation_item_tokens(&counter, item))
161            .sum(),
162        source: ContextUsageSource::TokenizerEstimate,
163    }
164}
165
166pub(crate) fn project_text_tokens(provider_id: &str, model: &str, text: &str) -> ContextTokenCount {
167    if let Some(counter) = tokenizer_for_provider_model(provider_id, model) {
168        return ContextTokenCount {
169            tokens: counter.count_text(text),
170            source: ContextUsageSource::TokenizerProjection,
171        };
172    }
173    ContextTokenCount {
174        tokens: estimate_text_tokens(text),
175        source: ContextUsageSource::FallbackProjection,
176    }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub(crate) enum TokenEncodingFamily {
181    O200KBase,
182}
183
184fn tokenizer_for_provider_model(provider_id: &str, model: &str) -> Option<TokenizerCounter> {
185    if provider_id != crate::providers::OPENAI_CODEX_PROVIDER {
186        return None;
187    }
188    token_encoding_family_for_model(model).map(|_| TokenizerCounter)
189}
190
191pub(crate) fn token_encoding_family_for_model(model: &str) -> Option<TokenEncodingFamily> {
192    let model = model.to_ascii_lowercase();
193    if model.starts_with("gpt-6-")
194        || model.starts_with("gpt-6.1-")
195        || model.starts_with("gpt-5")
196        || model.starts_with("gpt-4.1")
197        || model.starts_with("gpt-4o")
198        || model.starts_with("gpt-4.5")
199        || model.starts_with("o1")
200        || model.starts_with("o3")
201        || model.starts_with("o4")
202        || model.starts_with("codex-")
203    {
204        return Some(TokenEncodingFamily::O200KBase);
205    }
206    None
207}
208
209/// Counts one complete provider conversation item with a supported local BPE.
210///
211/// This is intentionally separate from the existing projection functions: offline
212/// measurement must fail closed instead of using their fallback estimate.
213pub(crate) fn count_supported_provider_conversation_item_tokens(
214    provider_id: &str,
215    model: &str,
216    item: &ProviderConversationItem,
217) -> Option<usize> {
218    tokenizer_for_provider_model(provider_id, model)
219        .map(|counter| count_conversation_item_tokens(&counter, item))
220}
221
222impl TokenEncodingFamily {
223    pub(crate) const fn as_str(self) -> &'static str {
224        match self {
225            Self::O200KBase => "o200k_base",
226        }
227    }
228}
229
230trait TokenCounter {
231    fn count_text(&self, text: &str) -> usize;
232
233    fn counts_message_role(&self) -> bool {
234        false
235    }
236}
237
238struct TokenizerCounter;
239
240impl TokenCounter for TokenizerCounter {
241    fn count_text(&self, text: &str) -> usize {
242        magi_tokenizer::count_ordinary(text)
243    }
244
245    fn counts_message_role(&self) -> bool {
246        true
247    }
248}
249
250struct FallbackCounter;
251
252impl TokenCounter for FallbackCounter {
253    fn count_text(&self, text: &str) -> usize {
254        estimate_text_tokens(text)
255    }
256}
257
258fn count_json_value_tokens(counter: &dyn TokenCounter, value: &serde_json::Value) -> usize {
259    match value {
260        serde_json::Value::String(text) => counter.count_text(text),
261        serde_json::Value::Array(items) => items
262            .iter()
263            .map(|item| count_json_value_tokens(counter, item))
264            .sum::<usize>()
265            .max(1),
266        serde_json::Value::Object(fields) => fields
267            .iter()
268            .map(|(key, value)| counter.count_text(key) + count_json_value_tokens(counter, value))
269            .sum::<usize>()
270            .max(1),
271        serde_json::Value::Null => 1,
272        other => counter.count_text(&other.to_string()),
273    }
274}
275
276fn count_response_item_tokens(counter: &dyn TokenCounter, item: &serde_json::Value) -> usize {
277    match item.get("type").and_then(serde_json::Value::as_str) {
278        Some("function_call") => {
279            counter.count_text("function_call")
280                + item
281                    .get("call_id")
282                    .and_then(serde_json::Value::as_str)
283                    .map(|text| counter.count_text(text))
284                    .unwrap_or(0)
285                + item
286                    .get("name")
287                    .and_then(serde_json::Value::as_str)
288                    .map(|text| counter.count_text(text))
289                    .unwrap_or(0)
290                + item
291                    .get("arguments")
292                    .map(|value| count_json_value_tokens(counter, value))
293                    .unwrap_or(0)
294        }
295        Some("function_call_output") => {
296            counter.count_text("function_call_output")
297                + item
298                    .get("call_id")
299                    .and_then(serde_json::Value::as_str)
300                    .map(|text| counter.count_text(text))
301                    .unwrap_or(0)
302                + item
303                    .get("output")
304                    .map(|value| count_json_value_tokens(counter, value))
305                    .unwrap_or(0)
306        }
307        Some("reasoning") => {
308            counter.count_text("reasoning")
309                + item
310                    .get("summary")
311                    .map(|value| count_json_value_tokens(counter, value))
312                    .unwrap_or(0)
313                + item
314                    .get("content")
315                    .map(|value| count_json_value_tokens(counter, value))
316                    .unwrap_or(0)
317        }
318        _ => {
319            if let Some(role) = item.get("role").and_then(serde_json::Value::as_str) {
320                counter.count_text(role)
321                    + item
322                        .get("content")
323                        .map(|value| count_json_value_tokens(counter, value))
324                        .unwrap_or(0)
325                    + item
326                        .get("tool_calls")
327                        .map(|value| count_json_value_tokens(counter, value))
328                        .unwrap_or(0)
329                    + item
330                        .get("tool_call_id")
331                        .and_then(serde_json::Value::as_str)
332                        .map(|text| counter.count_text(text))
333                        .unwrap_or(0)
334            } else {
335                counter.count_text(&item.to_string())
336            }
337        }
338    }
339}
340
341fn count_conversation_item_tokens(
342    counter: &dyn TokenCounter,
343    item: &ProviderConversationItem,
344) -> usize {
345    match item {
346        ProviderConversationItem::ReasoningSelection { .. } => 16,
347        ProviderConversationItem::Message(message) => {
348            let role_tokens = if counter.counts_message_role() {
349                counter.count_text(message.role.as_api_str())
350            } else {
351                0
352            };
353            role_tokens + counter.count_text(&message.content) + 4
354        }
355        ProviderConversationItem::ResponseItem(item) => {
356            count_response_item_tokens(counter, item) + 4
357        }
358        ProviderConversationItem::ToolResult(result) => {
359            counter.count_text(&result.call_id)
360                + counter.count_text(&result.tool_name)
361                + counter.count_text(&result.output)
362                + 4
363        }
364        ProviderConversationItem::LegacyReplayNote {
365            event_type,
366            content,
367        } => counter.count_text(event_type) + counter.count_text(content) + 4,
368    }
369}
370
371fn fallback_estimate_conversation_item_tokens(item: &ProviderConversationItem) -> usize {
372    count_conversation_item_tokens(&FallbackCounter, item)
373}