swiftide_core/
token_estimation.rs1use std::borrow::Cow;
2
3use anyhow::Result;
4use async_trait::async_trait;
5
6use crate::{chat_completion::ChatMessage, prompt::Prompt};
7
8#[async_trait]
28pub trait EstimateTokens {
29 async fn estimate(&self, value: impl Estimatable) -> Result<usize>;
30}
31
32pub struct CharEstimator;
49
50#[async_trait]
51impl EstimateTokens for CharEstimator {
52 async fn estimate(&self, value: impl Estimatable) -> Result<usize> {
53 let s = value.for_estimate()?;
54 Ok(s.iter().map(|s| s.chars().count()).sum::<usize>() / 4 + value.additional_tokens())
55 }
56}
57
58pub trait Estimatable: Send + Sync {
82 fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>>;
88
89 fn additional_tokens(&self) -> usize {
91 0
92 }
93}
94
95impl Estimatable for &str {
96 fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>> {
97 Ok(vec![Cow::Borrowed(self)])
98 }
99}
100
101impl Estimatable for String {
102 fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>> {
103 Ok(vec![Cow::Borrowed(self.as_str())])
104 }
105}
106
107impl Estimatable for &Prompt {
108 fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>> {
109 let rendered = self.render()?;
110 Ok(vec![Cow::Owned(rendered)])
111 }
112}
113
114impl Estimatable for &ChatMessage {
115 fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>> {
116 Ok(match self {
117 ChatMessage::User(msg) | ChatMessage::Summary(msg) | ChatMessage::System(msg) => {
118 vec![Cow::Borrowed(msg)]
119 }
120 ChatMessage::UserWithParts(parts) => parts
121 .iter()
122 .filter_map(|part| match part {
123 crate::chat_completion::ChatMessageContentPart::Text { text } => {
124 Some(Cow::Borrowed(text.as_ref()))
125 }
126 crate::chat_completion::ChatMessageContentPart::Image { .. }
127 | crate::chat_completion::ChatMessageContentPart::Document { .. }
128 | crate::chat_completion::ChatMessageContentPart::Audio { .. }
129 | crate::chat_completion::ChatMessageContentPart::Video { .. } => None,
130 })
131 .collect(),
132 ChatMessage::Assistant(msg, vec) => {
133 let mut tool_calls = vec.as_ref().map(|vec| {
137 vec.iter()
138 .filter_map(|c| c.args().map(Cow::Borrowed))
139 .collect::<Vec<_>>()
140 });
141
142 if let Some(msg) = msg {
143 if let Some(tool_calls) = tool_calls.as_mut() {
144 let mut msg = vec![Cow::Borrowed(msg.as_ref())];
145 msg.append(tool_calls);
146 msg
147 } else {
148 vec![Cow::Borrowed(msg)]
149 }
150 } else if let Some(tool_calls) = tool_calls {
151 tool_calls
152 } else {
153 vec!["None".into()]
154 }
155 }
156 ChatMessage::ToolOutput(_tool_call, tool_output) => {
157 let tool_output_content = tool_output.content().unwrap_or_default();
158
159 vec![Cow::Borrowed(tool_output_content)]
160 }
161 ChatMessage::Reasoning(_reasoning_item) => vec![],
162 })
163 }
164
165 fn additional_tokens(&self) -> usize {
169 4
170 }
171}
172
173impl Estimatable for &[ChatMessage] {
174 fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>> {
175 let mut total = Vec::new();
176 for msg in *self {
177 let mut v = msg
178 .for_estimate()?
179 .into_iter()
180 .map(Cow::into_owned)
181 .map(Into::into)
182 .collect();
183 total.append(&mut v);
184 }
185
186 Ok(total)
187 }
188
189 fn additional_tokens(&self) -> usize {
191 self.iter().map(|m| m.additional_tokens()).sum::<usize>() + 3
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::chat_completion::ToolCall;
199
200 #[tokio::test]
201 async fn estimate_counts_characters_and_additional_tokens() {
202 let estimator = CharEstimator;
203 let tokens = estimator.estimate("abcd").await.unwrap();
204 assert_eq!(tokens, 1);
205 }
206
207 #[tokio::test]
208 async fn estimate_prompt_renders_before_counting() {
209 let estimator = CharEstimator;
210 let prompt = Prompt::from("hello {{name}}").with_context_value("name", "swiftide");
211 let tokens = estimator.estimate(&prompt).await.unwrap();
212 assert_eq!(tokens, "hello swiftide".chars().count() / 4);
213 }
214
215 #[tokio::test]
216 async fn estimate_chat_message_includes_role_tokens() {
217 let estimator = CharEstimator;
218 let message = ChatMessage::new_user("hello");
219 let tokens = estimator.estimate(&message).await.unwrap();
220 assert_eq!(tokens, "hello".chars().count() / 4 + 4);
221 }
222
223 #[tokio::test]
224 async fn estimate_slice_adds_reply_priming_tokens() {
225 let estimator = CharEstimator;
226 let messages = [
227 ChatMessage::new_user("hello"),
228 ChatMessage::new_system("world"),
229 ];
230 let tokens = estimator.estimate(&messages[..]).await.unwrap();
231 let content_tokens = "helloworld".chars().count() / 4;
232 let additional_tokens = 4 + 4 + 3;
233 assert_eq!(tokens, content_tokens + additional_tokens);
234 }
235
236 #[tokio::test]
237 async fn assistant_tool_calls_are_included_in_estimate() {
238 let estimator = CharEstimator;
239 let tool_call = ToolCall::builder()
240 .id("tool-1")
241 .name("search")
242 .args("{\"q\":\"swiftide\"}")
243 .build()
244 .unwrap();
245 let message = ChatMessage::new_assistant(None::<String>, Some(vec![tool_call]));
246 let tokens = estimator.estimate(&message).await.unwrap();
247 let content_tokens = "{\"q\":\"swiftide\"}".chars().count() / 4;
248 assert_eq!(tokens, content_tokens + 4);
249 }
250
251 #[tokio::test]
252 async fn assistant_without_content_or_tools_uses_none_marker() {
253 let message = ChatMessage::Assistant(None, None);
254 let message_ref = &message;
255 let content = message_ref.for_estimate().unwrap();
256 assert_eq!(content, vec![Cow::Borrowed("None")]);
257 }
258}