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 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 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-6-")
199 || model.starts_with("gpt-6.1-")
200 || model.starts_with("gpt-5")
201 || model.starts_with("gpt-4.1")
202 || model.starts_with("gpt-4o")
203 || model.starts_with("gpt-4.5")
204 || model.starts_with("o1")
205 || model.starts_with("o3")
206 || model.starts_with("o4")
207 || model.starts_with("codex-")
208 {
209 return Some(TokenEncodingFamily::O200KBase);
210 }
211 if model.starts_with("gpt-4")
212 || model.starts_with("gpt-3.5-turbo")
213 || model.starts_with("text-embedding-3")
214 || model == "text-embedding-ada-002"
215 {
216 return Some(TokenEncodingFamily::Cl100KBase);
217 }
218 None
219}
220
221pub(crate) fn count_supported_provider_conversation_item_tokens(
226 provider_id: &str,
227 model: &str,
228 item: &ProviderConversationItem,
229) -> Option<usize> {
230 tokenizer_for_provider_model(provider_id, model)
231 .map(|bpe| tokenizer_count_conversation_item_tokens(bpe, item))
232}
233
234impl TokenEncodingFamily {
235 pub(crate) const fn as_str(self) -> &'static str {
236 match self {
237 Self::O200KBase => "o200k_base",
238 Self::Cl100KBase => "cl100k_base",
239 }
240 }
241}
242
243fn tokenizer_count_text_tokens(bpe: &Tokenizer, text: &str) -> usize {
244 bpe.count(text)
246}
247
248trait TokenCounter {
249 fn count_text(&self, text: &str) -> usize;
250
251 fn counts_message_role(&self) -> bool {
252 false
253 }
254}
255
256struct BpeCounter<'a> {
257 bpe: &'a Tokenizer,
258}
259
260impl TokenCounter for BpeCounter<'_> {
261 fn count_text(&self, text: &str) -> usize {
262 tokenizer_count_text_tokens(self.bpe, text)
263 }
264
265 fn counts_message_role(&self) -> bool {
266 true
267 }
268}
269
270struct FallbackCounter;
271
272impl TokenCounter for FallbackCounter {
273 fn count_text(&self, text: &str) -> usize {
274 estimate_text_tokens(text)
275 }
276}
277
278fn count_json_value_tokens(counter: &dyn TokenCounter, value: &serde_json::Value) -> usize {
279 match value {
280 serde_json::Value::String(text) => counter.count_text(text),
281 serde_json::Value::Array(items) => items
282 .iter()
283 .map(|item| count_json_value_tokens(counter, item))
284 .sum::<usize>()
285 .max(1),
286 serde_json::Value::Object(fields) => fields
287 .iter()
288 .map(|(key, value)| counter.count_text(key) + count_json_value_tokens(counter, value))
289 .sum::<usize>()
290 .max(1),
291 serde_json::Value::Null => 1,
292 other => counter.count_text(&other.to_string()),
293 }
294}
295
296fn count_response_item_tokens(counter: &dyn TokenCounter, item: &serde_json::Value) -> usize {
297 match item.get("type").and_then(serde_json::Value::as_str) {
298 Some("function_call") => {
299 counter.count_text("function_call")
300 + item
301 .get("call_id")
302 .and_then(serde_json::Value::as_str)
303 .map(|text| counter.count_text(text))
304 .unwrap_or(0)
305 + item
306 .get("name")
307 .and_then(serde_json::Value::as_str)
308 .map(|text| counter.count_text(text))
309 .unwrap_or(0)
310 + item
311 .get("arguments")
312 .map(|value| count_json_value_tokens(counter, value))
313 .unwrap_or(0)
314 }
315 Some("function_call_output") => {
316 counter.count_text("function_call_output")
317 + item
318 .get("call_id")
319 .and_then(serde_json::Value::as_str)
320 .map(|text| counter.count_text(text))
321 .unwrap_or(0)
322 + item
323 .get("output")
324 .map(|value| count_json_value_tokens(counter, value))
325 .unwrap_or(0)
326 }
327 Some("reasoning") => {
328 counter.count_text("reasoning")
329 + item
330 .get("summary")
331 .map(|value| count_json_value_tokens(counter, value))
332 .unwrap_or(0)
333 + item
334 .get("content")
335 .map(|value| count_json_value_tokens(counter, value))
336 .unwrap_or(0)
337 }
338 _ => {
339 if let Some(role) = item.get("role").and_then(serde_json::Value::as_str) {
340 counter.count_text(role)
341 + item
342 .get("content")
343 .map(|value| count_json_value_tokens(counter, value))
344 .unwrap_or(0)
345 + item
346 .get("tool_calls")
347 .map(|value| count_json_value_tokens(counter, value))
348 .unwrap_or(0)
349 + item
350 .get("tool_call_id")
351 .and_then(serde_json::Value::as_str)
352 .map(|text| counter.count_text(text))
353 .unwrap_or(0)
354 } else {
355 counter.count_text(&item.to_string())
356 }
357 }
358 }
359}
360
361fn count_conversation_item_tokens(
362 counter: &dyn TokenCounter,
363 item: &ProviderConversationItem,
364) -> usize {
365 match item {
366 ProviderConversationItem::ReasoningSelection { .. } => 16,
367 ProviderConversationItem::Message(message) => {
368 let role_tokens = if counter.counts_message_role() {
369 counter.count_text(message.role.as_api_str())
370 } else {
371 0
372 };
373 role_tokens + counter.count_text(&message.content) + 4
374 }
375 ProviderConversationItem::ResponseItem(item) => {
376 count_response_item_tokens(counter, item) + 4
377 }
378 ProviderConversationItem::ToolResult(result) => {
379 counter.count_text(&result.call_id)
380 + counter.count_text(&result.tool_name)
381 + counter.count_text(&result.output)
382 + 4
383 }
384 ProviderConversationItem::LegacyReplayNote {
385 event_type,
386 content,
387 } => counter.count_text(event_type) + counter.count_text(content) + 4,
388 }
389}
390
391fn tokenizer_count_conversation_item_tokens(
392 bpe: &Tokenizer,
393 item: &ProviderConversationItem,
394) -> usize {
395 count_conversation_item_tokens(&BpeCounter { bpe }, item)
396}
397
398fn fallback_estimate_conversation_item_tokens(item: &ProviderConversationItem) -> usize {
399 count_conversation_item_tokens(&FallbackCounter, item)
400}