Skip to main content

swiftide_core/
token_estimation.rs

1use std::borrow::Cow;
2
3use anyhow::Result;
4use async_trait::async_trait;
5
6use crate::{chat_completion::ChatMessage, prompt::Prompt};
7
8/// Estimate the number of tokens in a given value.
9///
10/// This trait is intentionally async so implementations can defer to remote or
11/// more expensive estimators without blocking.
12///
13/// # Examples
14///
15/// ```rust
16/// # use swiftide_core::token_estimation::{CharEstimator, EstimateTokens};
17/// # use swiftide_core::chat_completion::ChatMessage;
18/// # #[tokio::main]
19/// # async fn main() -> anyhow::Result<()> {
20/// let estimator = CharEstimator;
21/// let message = ChatMessage::new_user("Hello from Swiftide!");
22/// let tokens = estimator.estimate(&message).await?;
23/// assert!(tokens > 0);
24/// # Ok(())
25/// # }
26/// ```
27#[async_trait]
28pub trait EstimateTokens {
29    async fn estimate(&self, value: impl Estimatable) -> Result<usize>;
30}
31
32/// A rough estimator when speed matters more than accuracy.
33///
34/// Divides the number of characters by 4 as recommended by `OpenAI`.
35///
36/// # Examples
37///
38/// ```rust
39/// # use swiftide_core::token_estimation::{CharEstimator, EstimateTokens};
40/// # #[tokio::main]
41/// # async fn main() -> anyhow::Result<()> {
42/// let estimator = CharEstimator;
43/// let tokens = estimator.estimate("Roughly four chars per token.").await?;
44/// assert!(tokens > 0);
45/// # Ok(())
46/// # }
47/// ```
48pub 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
58/// A value that can be estimated for the number of tokens it contains.
59///
60/// # Errors
61///
62/// Errors if the value cannot be presented for estimation.
63///
64/// # Examples
65///
66/// ```rust
67/// # use std::borrow::Cow;
68/// # use anyhow::Result;
69/// # use swiftide_core::token_estimation::Estimatable;
70/// struct Snippet {
71///     title: String,
72///     body: String,
73/// }
74///
75/// impl Estimatable for Snippet {
76///     fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>> {
77///         Ok(vec![Cow::Borrowed(&self.title), Cow::Borrowed(&self.body)])
78///     }
79/// }
80/// ```
81pub trait Estimatable: Send + Sync {
82    /// A list of string slices used for estimation
83    ///
84    /// # Errors
85    ///
86    /// Some estimatable values may fail to render or prepare for estimation.
87    fn for_estimate(&self) -> Result<Vec<Cow<'_, str>>>;
88
89    /// Optionally return extra tokens that should be added to the estimate.
90    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                // Note that this is not super accurate.
134                //
135                // It's a bit verbose to avoid unnecessary allocations. Is what it is.
136                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    // 4 each for the role
166    //
167    // See https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
168    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    // Apparently every reply is primed with a <|start|>assistant<|message|>
190    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}