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 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 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
209pub(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}