dwara_core/ai/
token_estimator.rs

1//! Local prompt-token estimation (PERF-01): a lightweight, dependency-
2//! free heuristic that estimates the prompt token count of a canonical
3//! [`ChatRequest`] BEFORE the upstream provider is contacted. The
4//! estimate feeds a budget pre-check that rejects requests exceeding
5//! the remaining token-budget window with 429 `ai_budget_exceeded`
6//! BEFORE the provider call, so a holder near the limit cannot overrun
7//! by a large prompt.
8//!
9//! # Design: character-based heuristic, not a tokenizer
10//!
11//! The issue proposed `tiktoken-rs` or a BPE approximation. This module
12//! implements a CHARACTER-BASED heuristic instead — no new dependency,
13//! no license review, no vendored BPE merge tables. The trade-off is
14//! accuracy: the heuristic approximates ~4 characters per token for
15//! English text (the widely-cited rule of thumb from the OpenAI
16//! cookbook), with per-message structural overhead. The estimate is
17//! CONSERVATIVE (it rounds UP and adds per-message overhead) so the
18//! pre-check errs on the side of rejecting rather than undercounting —
19//! the actual spend is still provider-reported ([`crate::ai::budget`]
20//! records the provider's `usage.total_tokens` after the call).
21//!
22//! # What is estimated
23//!
24//! - Each message's text content (role + content parts).
25//! - Each tool call's name + arguments (assistant messages).
26//! - Each tool definition's name + description + JSON-Schema parameters
27//!   (serialized to a compact JSON string for the character count).
28//! - Per-message structural overhead (role markers, formatting): a
29//!   fixed 4-token allowance per message.
30//! - Image parts: a conservative fixed estimate (85 tokens per image,
31//!   the OpenAI `detail: low` overhead — the gateway cannot decode the
32//!   image to measure its tile count, so the low-detail cost is the
33//!   honest floor).
34//!
35//! `max_tokens` (the requested output cap) is NOT included here; the
36//! caller adds it to the prompt estimate for the total-request
37//! pre-check (the budget window covers total tokens, not just the
38//! prompt).
39//!
40//! # Dependency direction
41//!
42//! `ai` depends on `config` only; this module reads
43//! [`crate::ai::types::ChatRequest`] (same level). No new dependencies.
44
45use crate::ai::types::{ChatRequest, ContentPart};
46use serde_json::Value;
47
48/// Characters per token (the English-text rule of thumb; conservative
49/// for code-heavy or whitespace-heavy prompts, which tokenize denser).
50const CHARS_PER_TOKEN: u64 = 4;
51
52/// Per-message structural overhead (role marker, formatting): 4 tokens.
53/// This matches the OpenAI cookbook's per-message overhead for the
54/// `gpt-3.5/4` family; it is a fixed allowance, not a measured value.
55const PER_MESSAGE_OVERHEAD_TOKENS: u64 = 4;
56
57/// Conservative image-part estimate: 85 tokens per image (the OpenAI
58/// `detail: low` tile cost). The gateway cannot decode the image to
59/// count tiles, so the low-detail floor is the honest conservative
60/// estimate. A `detail: high` image costs more (up to 1700 tokens),
61/// but overestimating every image as 1700 would reject valid requests
62/// near the limit; the low-detail floor is the documented balance.
63const IMAGE_PART_TOKENS: u64 = 85;
64
65/// A small fixed overhead for the tool-definitions block (the
66/// `tools` array wrapper and the function-call protocol framing).
67const TOOLS_BLOCK_OVERHEAD_TOKENS: u64 = 16;
68
69/// Estimate the prompt token count for a canonical [`ChatRequest`]
70/// (PERF-01). The estimate is CONSERVATIVE (rounds up, adds per-message
71/// overhead) so the budget pre-check errs on the side of rejecting
72/// rather than undercounting. The actual spend is still provider-
73/// reported ([`crate::ai::budget`] records the provider's
74/// `usage.total_tokens` after the call).
75///
76/// The estimate covers message content, tool calls, tool definitions,
77/// and structural overhead. It does NOT include `max_tokens` (the
78/// requested output cap); the caller adds that for the total-request
79/// pre-check.
80pub fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
81    let mut chars: u64 = 0;
82
83    for msg in &req.messages {
84        chars += PER_MESSAGE_OVERHEAD_TOKENS * CHARS_PER_TOKEN;
85        // Role name ("system", "user", "assistant", "tool").
86        chars += msg.role.as_str().len() as u64;
87        // Optional name field.
88        if let Some(name) = &msg.name {
89            chars += name.len() as u64;
90        }
91        // Content parts.
92        for part in &msg.content {
93            match part {
94                ContentPart::Text { text } => chars += text.len() as u64,
95                ContentPart::Image { .. } => {
96                    // Images are counted as a fixed token estimate,
97                    // not characters (the base64 payload is not text
98                    // the provider tokenizes character-by-character).
99                    chars += IMAGE_PART_TOKENS * CHARS_PER_TOKEN;
100                }
101            }
102        }
103        // Tool calls issued by an assistant message.
104        for tc in &msg.tool_calls {
105            chars += tc.id.len() as u64;
106            chars += tc.name.len() as u64;
107            chars += tc.arguments.len() as u64;
108        }
109        // Tool-call-id for Tool-role messages.
110        if let Some(id) = &msg.tool_call_id {
111            chars += id.len() as u64;
112        }
113    }
114
115    // Tool definitions: serialize each tool's JSON-Schema parameters
116    // to a compact string and count characters (the schema is the bulk
117    // of a tool definition's token cost).
118    if !req.tools.is_empty() {
119        chars += TOOLS_BLOCK_OVERHEAD_TOKENS * CHARS_PER_TOKEN;
120        for tool in &req.tools {
121            chars += tool.name.len() as u64;
122            if let Some(desc) = &tool.description {
123                chars += desc.len() as u64;
124            }
125            if let Some(params) = &tool.parameters {
126                chars += compact_json_len(params);
127            }
128        }
129    }
130
131    // Convert characters to tokens, rounding UP (conservative).
132    chars.div_ceil(CHARS_PER_TOKEN)
133}
134
135/// Serialize a JSON value to a compact string and return its length.
136/// Falls back to 0 if serialization fails (a non-serializable schema
137/// is a config error, not a token-estimation concern).
138fn compact_json_len(v: &Value) -> u64 {
139    serde_json::to_string(v)
140        .map(|s| s.len() as u64)
141        .unwrap_or(0)
142}