use std::sync::OnceLock;
use serde_json::Value;
use tiktoken_rs::CoreBPE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolSize {
pub name: String,
pub schema_tokens: usize,
pub name_tokens: usize,
pub total_tokens: usize,
}
static VERBOSE_TABLE: OnceLock<Vec<ToolSize>> = OnceLock::new();
static TRIMMED_TABLE: OnceLock<Vec<ToolSize>> = OnceLock::new();
pub fn tool_sizes() -> &'static [ToolSize] {
VERBOSE_TABLE
.get_or_init(|| compute_table(false))
.as_slice()
}
pub fn trimmed_tool_sizes() -> &'static [ToolSize] {
TRIMMED_TABLE.get_or_init(|| compute_table(true)).as_slice()
}
pub fn tool_sizes_under_ci_gate() -> usize {
tool_sizes()
.iter()
.map(|t| t.total_tokens)
.max()
.unwrap_or(0)
}
pub fn full_profile_total_tokens() -> usize {
tool_sizes().iter().map(|t| t.total_tokens).sum()
}
pub fn trimmed_full_profile_total_tokens() -> usize {
trimmed_tool_sizes().iter().map(|t| t.total_tokens).sum()
}
pub fn tool_size(name: &str) -> Option<&'static ToolSize> {
tool_sizes().iter().find(|t| t.name == name)
}
fn compute_table(trimmed: bool) -> Vec<ToolSize> {
let bpe = bpe();
let defs = if trimmed {
crate::mcp::tool_definitions_for_profile(&crate::profile::Profile::full())
} else {
crate::mcp::tool_definitions()
};
let tools = defs
.get("tools")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
tools
.into_iter()
.filter_map(|tool| size_one_tool(&bpe, &tool))
.collect()
}
fn size_one_tool(bpe: &CoreBPE, tool: &Value) -> Option<ToolSize> {
let name = tool.get("name").and_then(Value::as_str)?.to_string();
let schema_json = serde_json::to_string(tool).ok()?;
let schema_tokens = bpe.encode_with_special_tokens(&schema_json).len();
let name_tokens = bpe.encode_with_special_tokens(&name).len();
Some(ToolSize {
name,
schema_tokens,
name_tokens,
total_tokens: schema_tokens,
})
}
fn bpe() -> CoreBPE {
tiktoken_rs::cl100k_base().expect("cl100k_base BPE table embedded in tiktoken-rs")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_tool_exceeds_1500_tokens() {
let max = tool_sizes_under_ci_gate();
assert!(
max <= 1500,
"v0.6.4-005 CI gate: largest tool schema is {max} tokens (limit: 1500). \
Inspect `cargo run -- doctor --tokens --raw-table` to find the offender."
);
}
#[test]
fn table_entry_count_matches_full_profile() {
let n = tool_sizes().len();
let expected = crate::profile::Profile::full().expected_tool_count();
assert_eq!(
n, expected,
"tool_sizes() must hold exactly {expected} tools (the full-profile \
SSOT count); got {n}. If these diverge, a tool was added to one \
surface but not the other."
);
}
#[test]
fn every_tool_has_nonzero_cost() {
for t in tool_sizes() {
assert!(t.schema_tokens > 0, "tool {} schema_tokens = 0", t.name);
assert!(t.name_tokens > 0, "tool {} name_tokens = 0", t.name);
}
}
#[test]
fn full_profile_total_in_honest_measured_range() {
let total = full_profile_total_tokens();
assert!(
(5_000..=17_000).contains(&total),
"full-profile total {total} tokens is outside the measured \
cl100k_base range (5K-17K, post-#987 D1.6). If the schema \
grew intentionally, update `tests/token_budget_guard.rs::\
VERBOSE_FULL_PROFILE_CEILING_TOKENS` AND this bound together."
);
}
#[test]
fn tool_size_resolves_memory_store() {
let t = tool_size("memory_store").expect("memory_store should exist");
assert!(t.total_tokens > 0);
assert!(t.total_tokens < 1500);
}
#[test]
fn tool_size_returns_none_for_unknown() {
assert!(tool_size("memory_does_not_exist_42").is_none());
}
#[test]
fn trimmed_full_profile_total_under_post_859_ceiling() {
let trimmed = trimmed_full_profile_total_tokens();
let verbose = full_profile_total_tokens();
assert!(
trimmed < verbose,
"trimmed total ({trimmed}) must be strictly smaller than verbose ({verbose})"
);
let saved_pct = (verbose - trimmed) as f64 / verbose as f64 * 100.0;
assert!(
saved_pct >= 25.0,
"trim should save >=25% of full-profile tokens; got {saved_pct:.1}% \
(verbose={verbose}, trimmed={trimmed}). Audit `strip_docs_from_tools` and \
`wire_compact_descriptions` — if those broke the trim itself regressed."
);
assert!(
trimmed <= 11_000,
"post-#987 D1.6 trimmed full-profile total {trimmed} > 11000-token ceiling. \
The #859 fix preserves every property entry on the wire. The post-D1.6 \
ceiling rose from 5000 to 11000 because schemars-derived schemas carry \
additional metadata (`additionalProperties: false`, `default: null`, \
`$schema`, `title`, request-struct `description`) that the legacy \
hand-coded `tool_definitions()` macro did not emit. If trimmed grew \
beyond 11000, audit per-property `description` prose (must be stripped \
by `strip_docs_from_tools`) and consider routing the new tool to \
`family=power` instead of the always-on core."
);
}
#[test]
fn trimmed_table_strictly_smaller_per_tool_where_optionals_existed() {
let verbose: std::collections::HashMap<&str, usize> = tool_sizes()
.iter()
.map(|t| (t.name.as_str(), t.total_tokens))
.collect();
let mut at_least_one_smaller = false;
for trimmed_tool in trimmed_tool_sizes() {
let v = verbose
.get(trimmed_tool.name.as_str())
.copied()
.unwrap_or(0);
assert!(
trimmed_tool.total_tokens <= v,
"{} grew under trim ({} > {})",
trimmed_tool.name,
trimmed_tool.total_tokens,
v
);
if trimmed_tool.total_tokens < v {
at_least_one_smaller = true;
}
}
assert!(
at_least_one_smaller,
"trim should shrink at least one tool; none did — wiring is broken"
);
}
}