1use serde_json::Value;
43
44use crate::SharedMessage;
45
46pub const SAFETY_MARGIN_PERCENT: u64 = 15;
49
50pub const EXPECTED_RESPONSE_TOKENS: u64 = 16_384;
65
66pub const ZERO_BUDGET_THINKING_RESERVE_TOKENS: u64 =
72 crate::core::models::DEFAULT_LEGACY_ADAPTIVE_FALLBACK as u64;
73
74pub const MIN_COMPACTION_MESSAGES: usize = 4;
78
79pub const IMAGE_TOKEN_ESTIMATE: u64 = 1_600;
86
87const PER_MESSAGE_FRAMING_TOKENS: u64 = 4;
90
91const TOOL_RESULT_BYTES_PER_TOKEN: u64 = 3;
95
96pub fn conservative_token_estimate(text: &str) -> u64 {
99 let mut fifths: u64 = 0;
103 for c in text.chars() {
104 if c.is_ascii() {
105 fifths += 2;
106 } else {
107 fifths += 5 * c.len_utf8() as u64;
108 }
109 }
110 fifths.div_ceil(5)
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum TokenEstimator {
117 Conservative,
120}
121
122impl TokenEstimator {
123 pub fn estimate(&self, text: &str) -> u64 {
124 match self {
125 TokenEstimator::Conservative => conservative_token_estimate(text),
126 }
127 }
128}
129
130pub fn estimator_for_model(_model: &str) -> TokenEstimator {
135 TokenEstimator::Conservative
136}
137
138#[derive(Debug, Clone, Copy)]
141pub struct ContextBudgetInputs<'a> {
142 pub model: &'a str,
144 pub provider_window: u64,
146 pub system_prompt: Option<&'a str>,
148 pub tools_schema: &'a [Value],
150 pub messages: &'a [SharedMessage],
152 pub skill_contents: &'a [&'a str],
154 pub memory_contents: &'a [&'a str],
156 pub thinking_budget_tokens: u64,
159 pub next_tool_result_bytes: u64,
162 pub max_output_tokens: u64,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct ContextBreakdown {
170 pub system_tokens: u64,
171 pub tool_schema_tokens: u64,
172 pub history_tokens: u64,
173 pub framing_tokens: u64,
174 pub skill_tokens: u64,
175 pub memory_tokens: u64,
176}
177
178impl ContextBreakdown {
179 pub fn total(&self) -> u64 {
180 self.system_tokens
181 + self.tool_schema_tokens
182 + self.history_tokens
183 + self.framing_tokens
184 + self.skill_tokens
185 + self.memory_tokens
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ThinkingAccounting {
195 InsideOutputBudget,
201 SeparateFromOutput,
206}
207
208pub fn thinking_accounting_for_model(model: &str) -> ThinkingAccounting {
211 use crate::runtime::openai::WireProtocol;
212 match crate::runtime::openai::resolve_route(model).map(|route| route.wire) {
213 Some(
214 WireProtocol::AnthropicMessages
215 | WireProtocol::OpenAiChatCompletions
216 | WireProtocol::OpenAiResponses
217 | WireProtocol::CodexResponses,
218 ) => ThinkingAccounting::InsideOutputBudget,
219 Some(WireProtocol::GoogleGeminiCodeAssist) | None => ThinkingAccounting::SeparateFromOutput,
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct ContextReserves {
227 pub thinking_tokens: u64,
230 pub tool_result_tokens: u64,
231 pub output_tokens: u64,
238 pub safety_margin_tokens: u64,
239 pub thinking_accounting: ThinkingAccounting,
241}
242
243impl ContextReserves {
244 pub fn total(&self) -> u64 {
245 self.thinking_tokens
246 + self.tool_result_tokens
247 + self.output_tokens
248 + self.safety_margin_tokens
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct ContextAssessment {
256 pub provider_window: u64,
257 pub breakdown: ContextBreakdown,
258 pub reserves: ContextReserves,
259 pub history_messages: usize,
260 pub anchored_tokens: Option<u64>,
263}
264
265impl ContextAssessment {
266 pub fn used_tokens(&self) -> u64 {
269 self.anchored_tokens
270 .unwrap_or_else(|| self.estimated_tokens())
271 }
272
273 pub fn estimated_tokens(&self) -> u64 {
277 self.breakdown.total()
278 }
279
280 pub fn fixed_tokens(&self) -> u64 {
283 let b = &self.breakdown;
284 b.total() - b.history_tokens - b.framing_tokens
285 }
286
287 pub fn anchored(mut self, anchor: Option<&UsageAnchor>, messages: &[SharedMessage]) -> Self {
290 self.anchored_tokens =
291 anchor.and_then(|a| a.footprint(self.fixed_tokens(), messages));
292 self
293 }
294
295 pub fn budget_tokens(&self) -> u64 {
299 self.provider_window.saturating_sub(self.reserves.total())
300 }
301
302 pub fn remaining_tokens(&self) -> u64 {
304 self.budget_tokens().saturating_sub(self.used_tokens())
305 }
306
307 pub fn should_compact(&self) -> bool {
310 self.history_messages >= MIN_COMPACTION_MESSAGES
311 && self.used_tokens() >= self.budget_tokens()
312 }
313}
314
315pub fn assess(inputs: &ContextBudgetInputs<'_>) -> ContextAssessment {
319 let estimator = estimator_for_model(inputs.model);
320
321 let system_tokens = inputs
322 .system_prompt
323 .map(|s| estimator.estimate(s))
324 .unwrap_or(0);
325
326 let tool_schema_tokens: u64 = inputs
329 .tools_schema
330 .iter()
331 .map(|schema| estimate_serialized(&estimator, schema))
332 .sum();
333
334 let history_tokens: u64 = inputs
335 .messages
336 .iter()
337 .map(|msg| estimate_message(&estimator, msg))
338 .sum();
339 let framing_tokens = PER_MESSAGE_FRAMING_TOKENS * inputs.messages.len() as u64;
340
341 let skill_tokens: u64 = inputs
342 .skill_contents
343 .iter()
344 .map(|s| estimator.estimate(s))
345 .sum();
346 let memory_tokens: u64 = inputs
347 .memory_contents
348 .iter()
349 .map(|s| estimator.estimate(s))
350 .sum();
351
352 ContextAssessment {
353 provider_window: inputs.provider_window,
354 breakdown: ContextBreakdown {
355 system_tokens,
356 tool_schema_tokens,
357 history_tokens,
358 framing_tokens,
359 skill_tokens,
360 memory_tokens,
361 },
362 reserves: {
363 let accounting = thinking_accounting_for_model(inputs.model);
364 let thinking = match inputs.thinking_budget_tokens {
365 0 => ZERO_BUDGET_THINKING_RESERVE_TOKENS,
366 budget => budget,
367 };
368 let cap = inputs.max_output_tokens;
369 let (thinking_tokens, output_tokens) = match accounting {
370 ThinkingAccounting::InsideOutputBudget => (
371 0,
372 cap.min(thinking.saturating_add(EXPECTED_RESPONSE_TOKENS)),
373 ),
374 ThinkingAccounting::SeparateFromOutput => {
375 (thinking, cap.min(EXPECTED_RESPONSE_TOKENS))
376 }
377 };
378 ContextReserves {
379 thinking_tokens,
380 tool_result_tokens: inputs
381 .next_tool_result_bytes
382 .div_ceil(TOOL_RESULT_BYTES_PER_TOKEN),
383 output_tokens,
384 safety_margin_tokens: inputs.provider_window * SAFETY_MARGIN_PERCENT / 100,
385 thinking_accounting: accounting,
386 }
387 },
388 history_messages: inputs.messages.len(),
389 anchored_tokens: None,
390 }
391}
392
393#[derive(Clone, Debug)]
404pub struct UsageAnchor {
405 pub provider_tokens: u64,
406 pub fixed_tokens: u64,
409 pub messages: Vec<SharedMessage>,
411}
412
413impl UsageAnchor {
414 pub fn footprint(&self, fixed_tokens: u64, messages: &[SharedMessage]) -> Option<u64> {
426 let shared = self
427 .messages
428 .iter()
429 .zip(messages)
430 .take_while(|(a, b)| super::continuation::same_on_wire(a, b))
431 .count();
432 (shared + 1 >= self.messages.len()).then(|| {
433 self.provider_tokens
434 .saturating_add(estimate_history(&messages[shared..]))
435 .saturating_add(fixed_tokens.saturating_sub(self.fixed_tokens))
436 })
437 }
438}
439
440fn estimate_serialized<T: serde::Serialize + ?Sized>(estimator: &TokenEstimator, value: &T) -> u64 {
441 match serde_json::to_string(value) {
442 Ok(s) => estimator.estimate(&s),
443 Err(_) => u64::MAX / 1024,
446 }
447}
448
449pub(crate) fn estimate_history(messages: &[SharedMessage]) -> u64 {
452 messages.iter().fold(0u64, |sum, message| {
453 sum.saturating_add(estimate_message(&TokenEstimator::Conservative, message))
454 .saturating_add(PER_MESSAGE_FRAMING_TOKENS)
455 })
456}
457
458fn estimate_message(estimator: &TokenEstimator, msg: &Value) -> u64 {
465 let wire = super::continuation::WireMessage(msg);
466 let Some(blocks) = msg["content"].as_array() else {
467 return estimate_serialized(estimator, &wire);
468 };
469 if !blocks.iter().any(|b| {
472 is_base64_image(b)
473 || is_reasoning_block(b)
474 || b["content"]
475 .as_array()
476 .is_some_and(|a| a.iter().any(is_base64_image))
477 }) {
478 return estimate_serialized(estimator, &wire);
479 }
480 let mut total = 0u64;
481 for block in blocks {
482 if is_base64_image(block) {
483 total += IMAGE_TOKEN_ESTIMATE;
484 continue;
485 }
486 if is_reasoning_block(block) {
487 total += estimate_reasoning_block(estimator, block);
488 continue;
489 }
490 if block["type"] == "tool_result" {
491 if let Some(inner) = block["content"].as_array() {
492 total += 8; for b in inner {
494 total += if is_base64_image(b) {
495 IMAGE_TOKEN_ESTIMATE
496 } else {
497 estimate_serialized(estimator, b)
498 };
499 }
500 continue;
501 }
502 }
503 total += estimate_serialized(estimator, block);
504 }
505 total
506}
507
508const REASONING_SIGNATURE_CHARS_PER_TOKEN: u64 = 4;
511
512const REASONING_BLOCK_FRAMING_TOKENS: u64 = 8;
514
515fn is_reasoning_block(b: &Value) -> bool {
516 b["type"] == "thinking" || b["type"] == "redacted_thinking"
517}
518
519fn estimate_reasoning_block(estimator: &TokenEstimator, block: &Value) -> u64 {
538 let text = block["thinking"].as_str().map_or(0, |t| estimator.estimate(t));
539 let opaque = block["signature"]
540 .as_str()
541 .or_else(|| block["data"].as_str())
542 .map_or(0, |s| s.len() as u64 / REASONING_SIGNATURE_CHARS_PER_TOKEN);
543 text.max(opaque) + REASONING_BLOCK_FRAMING_TOKENS
544}
545
546fn is_base64_image(b: &Value) -> bool {
547 b["type"] == "image" && b["source"]["type"] == "base64"
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use serde_json::json;
554 use std::sync::Arc;
555
556 fn history_tokens_of(messages: &[SharedMessage]) -> u64 {
557 let inputs = ContextBudgetInputs {
558 model: "claude-sonnet-4-6",
559 provider_window: 200_000,
560 system_prompt: None,
561 tools_schema: &[],
562 messages,
563 skill_contents: &[],
564 memory_contents: &[],
565 thinking_budget_tokens: 0,
566 next_tool_result_bytes: 0,
567 max_output_tokens: 0,
568 };
569 assess(&inputs).breakdown.history_tokens
570 }
571
572 fn image_block(len: usize) -> Value {
573 json!({
574 "type": "image",
575 "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(len)}
576 })
577 }
578
579 fn shape_fixture(json: &str) -> (u64, Vec<SharedMessage>, u64) {
583 let fixture: Value = serde_json::from_str(json).unwrap();
584 let n = |v: &Value| v.as_u64().unwrap() as usize;
585 let filler = |len: usize| "x".repeat(len);
586 let base64 = |len: usize| {
587 const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
588 (0..len).map(|i| B64[(i * 7 + i / 64) % 64] as char).collect::<String>()
589 };
590 let messages = fixture["messages"]
591 .as_array()
592 .unwrap()
593 .iter()
594 .map(|m| {
595 let role = m[0].as_str().unwrap();
596 let blocks = m[1].as_array().unwrap();
597 let content = match blocks[0][0].as_str().unwrap() {
598 "s" => json!(filler(n(&blocks[0][1]))),
599 _ => Value::Array(
600 blocks
601 .iter()
602 .map(|b| match b[0].as_str().unwrap() {
603 "b" => json!({"type": "text", "text": filler(n(&b[1]).saturating_sub(25))}),
605 "k" => json!({
606 "type": "thinking",
607 "thinking": filler(n(&b[1])),
608 "signature": base64(n(&b[2])),
609 }),
610 "i" => image_block(1_000_000),
611 other => panic!("block kind {other}"),
612 })
613 .collect(),
614 ),
615 };
616 Arc::new(json!({"role": role, "content": content})) as SharedMessage
617 })
618 .collect();
619 let fixed = conservative_token_estimate(&filler(
620 n(&fixture["system_bytes"]) + n(&fixture["tool_schema_bytes"]),
621 ));
622 (fixed, messages, fixture["billed"].as_u64().unwrap())
623 }
624
625 #[test]
632 fn xhigh_reasoning_history_estimate_tracks_its_billed_input() {
633 let (fixed, messages, billed) =
634 shape_fixture(include_str!("../../tests/fixtures/context_estimate/xhigh_worker_197k.json"));
635 assert_eq!(billed, 197_253);
636 assert!(messages.iter().any(|m| m["content"]
637 .as_array()
638 .is_some_and(|b| b.iter().any(|b| b["signature"].as_str().is_some_and(|s| s.len() > 1_000)))));
639 let estimate = fixed + estimate_history(&messages);
640 let ratio = estimate as f64 / billed as f64;
641 assert!((0.9..=1.1).contains(&ratio), "estimate {estimate} vs billed {billed}: {ratio:.3}");
642
643 let serialized: u64 = messages
645 .iter()
646 .map(|m| {
647 estimate_serialized(&TokenEstimator::Conservative, &**m) + PER_MESSAGE_FRAMING_TOKENS
648 })
649 .sum();
650 let old = (fixed + serialized) as f64 / billed as f64;
651 assert!(old > 1.25, "the old charge read {old:.3}×");
652 }
653
654 #[test]
655 fn reasoning_blocks_charge_the_larger_of_text_and_signature() {
656 let block = |text: usize, sig: usize| {
657 json!({"type": "thinking", "thinking": "t".repeat(text), "signature": "S".repeat(sig)})
658 };
659 let one = |b: Value| {
660 estimate_history(&[Arc::new(json!({"role": "assistant", "content": [b]}))])
661 - PER_MESSAGE_FRAMING_TOKENS
662 - REASONING_BLOCK_FRAMING_TOKENS
663 };
664 assert_eq!(one(block(10_000, 4_000)), 4_000);
666 assert_eq!(one(block(0, 40_000)), 10_000);
669 assert_eq!(one(block(2_500, 40_000)), 10_000);
670 assert_eq!(
671 one(json!({"type": "redacted_thinking", "data": "D".repeat(8_000)})),
672 2_000
673 );
674 let mixed = estimate_history(&[Arc::new(json!({"role": "assistant", "content": [
676 block(0, 400),
677 {"type": "text", "text": "a".repeat(2_500)},
678 ]}))]);
679 assert!((1_100..1_130).contains(&mixed), "{mixed}");
680 }
681
682 #[test]
685 fn host_only_keys_are_not_charged() {
686 let plain = json!({"role": "user", "content": "hello there"});
687 let mut stamped = plain.clone();
688 stamped["_synaps_at"] = json!("2026-10-30T11:00:00.000Z");
689 stamped["_synaps_turn"] = json!({"id": "x".repeat(400)});
690 assert_eq!(
691 estimate_history(&[Arc::new(stamped.clone())]),
692 estimate_history(&[Arc::new(plain)])
693 );
694 let mut reasoning = json!({"role": "assistant", "content": [
695 {"type": "thinking", "thinking": "plan", "signature": "S".repeat(40)},
696 ]});
697 let bare = estimate_history(&[Arc::new(reasoning.clone())]);
698 reasoning["_synaps_turn"] = json!({"id": "x".repeat(400)});
699 assert_eq!(estimate_history(&[Arc::new(reasoning)]), bare);
700 }
701
702 fn msg(role: &str, text: &str) -> SharedMessage {
703 Arc::new(json!({"role": role, "content": text}))
704 }
705
706 fn assessment_of(system: &str, messages: &[SharedMessage]) -> ContextAssessment {
707 assess(&ContextBudgetInputs {
708 model: "claude-opus-5-5",
709 provider_window: 1_000_000,
710 system_prompt: Some(system),
711 tools_schema: &[],
712 messages,
713 skill_contents: &[],
714 memory_contents: &[],
715 thinking_budget_tokens: 0,
716 next_tool_result_bytes: 0,
717 max_output_tokens: 128_000,
718 })
719 }
720
721 #[test]
725 fn footprint_is_anchored_to_the_last_reported_usage() {
726 let system = "s".repeat(2_500);
727 let earlier = vec![msg("user", "task"), msg("assistant", &"a".repeat(250_000))];
728 let anchor = UsageAnchor {
729 provider_tokens: 60_000,
730 fixed_tokens: assessment_of(&system, &earlier).fixed_tokens(),
731 messages: earlier.clone(),
732 };
733 let appended = [msg("user", &"r".repeat(25_000))];
734 let mut next = earlier.clone();
735 next.extend(appended.iter().cloned());
736 let assessment = assessment_of(&system, &next).anchored(Some(&anchor), &next);
737 assert_eq!(
738 assessment.used_tokens(),
739 60_000 + estimate_history(&appended),
740 "provider count + appended estimate"
741 );
742 assert!(assessment.estimated_tokens() > 100_000);
744 assert_eq!(assessment.anchored_tokens, Some(assessment.used_tokens()));
745
746 let mut stamped = next.clone();
748 let mut first = (*stamped[0]).clone();
749 first["_synaps_at"] = json!("2026-10-30T11:00:00.000Z");
750 stamped[0] = Arc::new(first);
751 assert_eq!(
752 assessment_of(&system, &stamped).anchored(Some(&anchor), &stamped).used_tokens(),
753 assessment.used_tokens()
754 );
755
756 let grown = "s".repeat(5_000);
759 assert_eq!(
760 assessment_of(&grown, &next).anchored(Some(&anchor), &next).used_tokens(),
761 assessment.used_tokens() + 1_000
762 );
763 assert_eq!(
764 assessment_of("", &next).anchored(Some(&anchor), &next).used_tokens(),
765 assessment.used_tokens()
766 );
767 }
768
769 #[test]
774 fn a_changed_request_tail_keeps_the_anchor_and_errs_high() {
775 let base = vec![msg("user", "task"), msg("assistant", &"a".repeat(25_000))];
776 let mut injected = base.clone();
777 injected.push(msg("user", &format!("next{}", "i".repeat(5_000))));
778 let anchor = UsageAnchor {
779 provider_tokens: 30_000,
780 fixed_tokens: 0,
781 messages: injected,
782 };
783 let mut next = base.clone();
784 next.push(msg("user", "next"));
785 next.push(msg("assistant", &"b".repeat(2_500)));
786 assert_eq!(
787 anchor.footprint(0, &next),
788 Some(30_000 + estimate_history(&next[2..]))
789 );
790 assert_eq!(anchor.footprint(0, &base), Some(30_000));
792 }
793
794 #[test]
798 fn footprint_falls_back_to_the_estimate_without_an_anchor() {
799 let system = "s".repeat(2_500);
800 let history = vec![
801 msg("user", "task"),
802 msg("assistant", &"a".repeat(25_000)),
803 msg("user", "more"),
804 msg("assistant", &"b".repeat(25_000)),
805 ];
806 let plain = assessment_of(&system, &history);
807 assert_eq!(plain.anchored_tokens, None);
808 assert_eq!(plain.used_tokens(), plain.estimated_tokens());
809 assert_eq!(plain.anchored(None, &history).used_tokens(), plain.estimated_tokens());
810
811 let anchor = UsageAnchor {
814 provider_tokens: 5_000,
815 fixed_tokens: plain.fixed_tokens(),
816 messages: history.clone(),
817 };
818 let successor = vec![history[0].clone(), msg("user", "[host note]"), history[3].clone()];
819 let rolled = assessment_of(&system, &successor);
820 assert_eq!(
821 rolled.anchored(Some(&anchor), &successor).used_tokens(),
822 rolled.estimated_tokens()
823 );
824 }
825
826 #[test]
827 fn image_block_in_tool_result_charged_flat() {
828 let messages: Vec<SharedMessage> = vec![Arc::new(json!({
829 "role": "user",
830 "content": [{
831 "type": "tool_result",
832 "tool_use_id": "toolu_1",
833 "content": [
834 {"type": "text", "text": "Image: /tmp/x.png (1024x1536, image/png, 2292 KB)"},
835 image_block(3_000_000),
836 ]
837 }]
838 }))];
839 let tokens = history_tokens_of(&messages);
840 assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
841 assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
842 assert!(tokens < 10_000, "{tokens}");
843 }
844
845 #[test]
846 fn user_message_image_charged_flat() {
847 let messages: Vec<SharedMessage> = vec![Arc::new(json!({
848 "role": "user",
849 "content": [
850 {"type": "text", "text": "what is this?"},
851 image_block(3_000_000),
852 ]
853 }))];
854 let tokens = history_tokens_of(&messages);
855 assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
856 assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
857 }
858
859 #[test]
860 fn string_content_unchanged() {
861 let msg = json!({"role": "user", "content": "plain string content, no blocks here"});
862 let estimator = estimator_for_model("claude-sonnet-4-6");
863 assert_eq!(
864 estimate_message(&estimator, &msg),
865 estimate_serialized(&estimator, &msg)
866 );
867 }
868
869 #[test]
870 fn array_content_without_images_unchanged() {
871 let estimator = estimator_for_model("claude-sonnet-4-6");
872 for msg in [
873 json!({"role": "assistant", "content": [
874 {"type": "text", "text": "let me look"},
875 {"type": "tool_use", "id": "toolu_1", "name": "read", "input": {"path": "x"}}
876 ]}),
877 json!({"role": "user", "content": [
878 {"type": "tool_result", "tool_use_id": "toolu_1", "content": "1\tfoo\n2\tbar"}
879 ]}),
880 json!({"role": "user", "content": [
881 {"type": "tool_result", "tool_use_id": "toolu_1", "content": [
882 {"type": "text", "text": "nested text only"}
883 ]}
884 ]}),
885 ] {
886 assert_eq!(
887 estimate_message(&estimator, &msg),
888 estimate_serialized(&estimator, &msg),
889 "{msg}"
890 );
891 }
892 }
893
894 #[test]
895 fn non_image_blocks_still_charged_by_payload() {
896 let msg = json!({"role": "user", "content": [
898 {"type": "text", "text": "A".repeat(100_000)}
899 ]});
900 let estimator = estimator_for_model("claude-sonnet-4-6");
901 assert!(estimate_message(&estimator, &msg) > 10_000);
902 }
903
904 #[test]
905 fn ascii_is_charged_at_the_dense_bpe_rate() {
906 assert_eq!(conservative_token_estimate(&"a".repeat(25)), 10);
908 assert_eq!(conservative_token_estimate(""), 0);
909 }
910
911 #[test]
912 fn non_ascii_is_charged_per_utf8_byte() {
913 assert_eq!(conservative_token_estimate("你好"), 6);
915 assert_eq!(conservative_token_estimate("😀"), 4);
917 assert_eq!(conservative_token_estimate("да"), 4);
919 }
920
921 #[test]
922 fn tiny_window_never_underflows_and_fails_toward_compaction() {
923 let messages: Vec<SharedMessage> = (0..MIN_COMPACTION_MESSAGES)
924 .map(|i| Arc::new(json!({"role": "user", "content": format!("m{i}")})) as SharedMessage)
925 .collect();
926 let assessment = assess(&ContextBudgetInputs {
927 model: "claude-sonnet-4-6",
928 provider_window: 10,
929 system_prompt: None,
930 tools_schema: &[],
931 messages: &messages,
932 skill_contents: &[],
933 memory_contents: &[],
934 thinking_budget_tokens: 1_000,
935 next_tool_result_bytes: 0,
936 max_output_tokens: 64_000,
937 });
938 assert_eq!(assessment.budget_tokens(), 0);
939 assert_eq!(assessment.remaining_tokens(), 0);
940 assert!(assessment.should_compact());
941 }
942
943 #[test]
946 fn anthropic_wire_counts_thinking_exactly_once_inside_the_output_reserve() {
947 let messages: Vec<SharedMessage> =
948 vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
949 let window = 200_000;
950 let margin = window * SAFETY_MARGIN_PERCENT / 100;
951 let inputs = ContextBudgetInputs {
952 model: "claude-sonnet-4-6",
953 provider_window: window,
954 system_prompt: None,
955 tools_schema: &[],
956 messages: &messages,
957 skill_contents: &[],
958 memory_contents: &[],
959 thinking_budget_tokens: 8_000,
960 next_tool_result_bytes: 0,
961 max_output_tokens: 64_000,
962 };
963 let assessment = assess(&inputs);
964 assert_eq!(
965 assessment.reserves.thinking_accounting,
966 ThinkingAccounting::InsideOutputBudget
967 );
968 assert_eq!(
969 assessment.reserves.thinking_tokens, 0,
970 "thinking must not be reserved a second time on the Anthropic wire"
971 );
972 let envelope = 8_000 + EXPECTED_RESPONSE_TOKENS;
973 assert_eq!(assessment.reserves.output_tokens, envelope);
974 assert_eq!(
975 assessment.budget_tokens(),
976 window - (envelope + margin),
977 "budget must subtract the output/thinking envelope exactly once"
978 );
979
980 let big_thinking = ContextBudgetInputs {
983 thinking_budget_tokens: 100_000,
984 ..inputs
985 };
986 let assessment = assess(&big_thinking);
987 assert_eq!(assessment.reserves.output_tokens, 64_000);
988 assert_eq!(assessment.reserves.thinking_tokens, 0);
989
990 let adaptive = assess(&ContextBudgetInputs {
992 thinking_budget_tokens: 0,
993 ..inputs
994 });
995 assert_eq!(
996 adaptive.reserves.output_tokens,
997 ZERO_BUDGET_THINKING_RESERVE_TOKENS + EXPECTED_RESPONSE_TOKENS
998 );
999 }
1000
1001 #[test]
1006 fn the_output_reserve_is_a_realistic_need_not_the_max_tokens_ceiling() {
1007 let hard_line = |model: &str, window: u64, thinking: u64| {
1008 let (assessment, budget) = crate::runtime::stream::pre_request_context_budget(
1009 model, window, None, &[], &[], thinking, 30_000, None,
1010 );
1011 (assessment.reserves.output_tokens, window - budget.required_next_round_tokens)
1012 };
1013 for thinking in [0, 16_384] {
1015 for model in ["claude-opus-5-5", "claude-sonnet-4-6"] {
1016 assert_eq!(hard_line(model, 200_000, thinking), (32_768, 126_720), "{model}");
1018 assert_eq!(hard_line(model, 1_000_000, thinking), (32_768, 806_720), "{model}");
1019 }
1020 }
1021 assert_eq!(hard_line("claude-opus-5-5", 200_000, 4_096), (20_480, 139_008));
1023 assert_eq!(hard_line("claude-opus-5-5", 200_000, 32_768), (49_152, 110_336));
1025 assert_eq!(hard_line("claude-sonnet-4-6", 200_000, 100_000).0, 64_000);
1027 }
1028
1029 #[test]
1031 fn openai_wires_account_reasoning_inside_the_output_reserve() {
1032 for model in ["openai-codex/gpt-5.2-codex", "xai-auth/grok-4"] {
1033 let accounting = thinking_accounting_for_model(model);
1034 if model.starts_with("xai-auth") && accounting == ThinkingAccounting::SeparateFromOutput
1037 {
1038 continue;
1039 }
1040 assert_eq!(
1041 accounting,
1042 ThinkingAccounting::InsideOutputBudget,
1043 "{model}"
1044 );
1045 }
1046 }
1047
1048 #[test]
1051 fn gemini_and_unknown_models_reserve_thinking_separately() {
1052 let messages: Vec<SharedMessage> =
1053 vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
1054 let window = 200_000;
1055 let margin = window * SAFETY_MARGIN_PERCENT / 100;
1056 for model in ["google-gemini/gemini-2.5-pro", "totally/unroutable-model"] {
1057 let assessment = assess(&ContextBudgetInputs {
1058 model,
1059 provider_window: window,
1060 system_prompt: None,
1061 tools_schema: &[],
1062 messages: &messages,
1063 skill_contents: &[],
1064 memory_contents: &[],
1065 thinking_budget_tokens: 8_000,
1066 next_tool_result_bytes: 0,
1067 max_output_tokens: 64_000,
1068 });
1069 assert_eq!(
1070 assessment.reserves.thinking_accounting,
1071 ThinkingAccounting::SeparateFromOutput,
1072 "{model}"
1073 );
1074 assert_eq!(assessment.reserves.thinking_tokens, 8_000, "{model}");
1075 assert_eq!(
1076 assessment.reserves.output_tokens, EXPECTED_RESPONSE_TOKENS,
1077 "{model}"
1078 );
1079 assert_eq!(
1080 assessment.budget_tokens(),
1081 window - (8_000 + EXPECTED_RESPONSE_TOKENS + margin),
1082 "{model}: separate wires reserve both envelopes"
1083 );
1084 }
1085 }
1086
1087 #[tokio::test]
1090 async fn runtime_assessment_matches_engine_calculation() {
1091 let mut runtime = crate::Runtime::new_headless();
1092 runtime.set_system_prompt("shared system prompt for budget parity".into());
1093 runtime.set_context_window(Some(200_000));
1094
1095 let messages: Vec<SharedMessage> = vec![
1096 Arc::new(json!({"role": "user", "content": "hello 你好 😀"})),
1097 Arc::new(json!({"role": "assistant", "content": [
1098 {"type": "text", "text": "fn main() { println!(\"hi\"); }"}
1099 ]})),
1100 ];
1101
1102 let via_runtime = runtime.assess_context(&messages).await;
1103
1104 let system = runtime.effective_system_prompt().await;
1105 let schema = runtime.tools_shared().read().await.tools_schema();
1106 let direct = assess(&ContextBudgetInputs {
1107 model: runtime.model(),
1108 provider_window: runtime.context_window(),
1109 system_prompt: system.as_deref(),
1110 tools_schema: &schema,
1111 messages: &messages,
1112 skill_contents: &[],
1113 memory_contents: &[],
1114 thinking_budget_tokens: runtime.thinking_budget() as u64,
1115 next_tool_result_bytes: runtime.max_tool_output() as u64,
1116 max_output_tokens: crate::runtime::helpers::HelperMethods::max_tokens_for_model(
1117 runtime.model(),
1118 ),
1119 });
1120
1121 assert_eq!(
1122 via_runtime, direct,
1123 "Runtime surface must not fork the budget math"
1124 );
1125 assert!(
1126 via_runtime.reserves.output_tokens > 0,
1127 "output reserve must be model-derived"
1128 );
1129 assert!(
1130 via_runtime.breakdown.system_tokens > 0,
1131 "actual system segment must be accounted"
1132 );
1133 }
1134
1135 fn synthetic_memory_contribution(
1138 rendered: &str,
1139 ) -> crate::runtime::memory_context::MemoryContextContribution {
1140 use crate::runtime::memory_context as mc;
1141 use agent_core::BoundedText;
1142 mc::MemoryContextContribution {
1143 schema: mc::ContributionSchemaVersion::parse("contribution/1").expect("valid schema"),
1144 provider_id: mc::ContextProviderId::parse("axel-memory").expect("valid provider"),
1145 project_id: crate::runtime::memory_project_id(),
1146 records: vec![mc::MemoryContributionRecord {
1147 memory_id: mc::MemoryId::parse("mem-0001").expect("valid memory id"),
1148 source: mc::MemorySource::ChatHistory,
1149 timestamp: std::time::SystemTime::UNIX_EPOCH,
1150 rank_reason: vec![mc::RankReason::ExactTopic],
1151 sensitivity: agent_core::core::disclosure::DisclosureClass::ModelVisible,
1152 retention: mc::RetentionClass::Standard,
1153 content: BoundedText::new("the project uses session-scoped authorization", 2048),
1154 truncated: false,
1155 supersedes: None,
1156 }],
1157 rendered: BoundedText::new(rendered, 16 * 1024),
1158 accounting: mc::ContributionAccounting::default(),
1159 }
1160 }
1161
1162 #[tokio::test]
1171 async fn memory_contribution_charges_only_the_memory_lane_and_never_reserves() {
1172 let mut runtime = crate::Runtime::new_headless();
1173 runtime.set_system_prompt("system prompt for the memory budget test".into());
1174 runtime.set_context_window(Some(200_000));
1175
1176 let messages: Vec<SharedMessage> = vec![
1177 Arc::new(json!({"role": "user", "content": "what auth model do we use?"})),
1178 Arc::new(json!({"role": "assistant", "content": "let me check"})),
1179 ];
1180
1181 let without = runtime.assess_context(&messages).await;
1182 assert_eq!(without.breakdown.memory_tokens, 0);
1183
1184 let rendered = "[Axel memory — lower-authority project data; verify before relying]\n\
1185 1. mem-0001 — Decision — the project uses session-scoped \
1186 authorization rather than persisted grants.";
1187 runtime
1188 .hold_memory_contribution(synthetic_memory_contribution(rendered))
1189 .expect("in-bounds contribution for the runtime's own project is accepted");
1190 let with = runtime.assess_context(&messages).await;
1191
1192 let expected_memory = conservative_token_estimate(rendered);
1193 assert!(expected_memory > 0, "test contribution must be non-empty");
1194 assert_eq!(
1195 with.breakdown.memory_tokens, expected_memory,
1196 "memory lane must carry exactly the estimated rendered tokens"
1197 );
1198
1199 assert_eq!(
1202 with.reserves, without.reserves,
1203 "reserves must be COMPLETELY unaffected by memory content"
1204 );
1205 assert_eq!(
1206 with.budget_tokens(),
1207 without.budget_tokens(),
1208 "the admissible budget (window minus reserves) must not move"
1209 );
1210
1211 assert_eq!(
1213 with.breakdown.system_tokens,
1214 without.breakdown.system_tokens
1215 );
1216 assert_eq!(
1217 with.breakdown.tool_schema_tokens,
1218 without.breakdown.tool_schema_tokens
1219 );
1220 assert_eq!(
1221 with.breakdown.history_tokens,
1222 without.breakdown.history_tokens
1223 );
1224 assert_eq!(
1225 with.breakdown.framing_tokens,
1226 without.breakdown.framing_tokens
1227 );
1228 assert_eq!(with.breakdown.skill_tokens, without.breakdown.skill_tokens);
1229 assert_eq!(with.history_messages, without.history_messages);
1230
1231 assert_eq!(with.used_tokens(), without.used_tokens() + expected_memory);
1233 assert_eq!(
1234 with.remaining_tokens(),
1235 without.remaining_tokens() - expected_memory
1236 );
1237
1238 assert_eq!(
1242 with.should_compact(),
1243 with.history_messages >= MIN_COMPACTION_MESSAGES
1244 && with.used_tokens() >= with.budget_tokens()
1245 );
1246
1247 runtime.clear_memory_contribution();
1250 let cleared = runtime.assess_context(&messages).await;
1251 assert_eq!(
1252 cleared, without,
1253 "no held contribution ⇒ assessment identical to the empty lane"
1254 );
1255 }
1256
1257 #[tokio::test]
1261 async fn foreign_project_contribution_is_refused_and_never_budgeted() {
1262 use crate::runtime::memory_context as mc;
1263 let mut runtime = crate::Runtime::new_headless();
1264 runtime.set_context_window(Some(200_000));
1265
1266 let mut contribution = synthetic_memory_contribution("foreign project memory");
1267 contribution.project_id =
1268 mc::ProjectId::parse("project-not-this-host").expect("valid project id");
1269 assert_eq!(
1270 runtime.hold_memory_contribution(contribution),
1271 Err(mc::MemoryContextError::ContributionProjectMismatch)
1272 );
1273
1274 let messages: Vec<SharedMessage> =
1275 vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
1276 let assessment = runtime.assess_context(&messages).await;
1277 assert_eq!(
1278 assessment.breakdown.memory_tokens, 0,
1279 "a refused contribution must never be budgeted"
1280 );
1281 }
1282}