Skip to main content

magi_code/context/
tokens.rs

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