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 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 if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
97 self.enabled = true;
98 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
228pub(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}