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