Skip to main content

aither_core/llm/
event.rs

1//! LLM response events.
2//!
3//! The [`Event`] enum represents all possible events emitted by a language model
4//! during response generation. This provides a low-level, provider-agnostic interface
5//! for streaming LLM responses.
6//!
7//! # Event Types
8//!
9//! - [`Event::Text`] - Visible text output
10//! - [`Event::Reasoning`] - Internal reasoning/thinking (for reasoning models)
11//! - [`Event::ToolCall`] - Request to execute a tool (NOT auto-executed)
12//! - [`Event::BuiltInToolResult`] - Result from provider's built-in tool (e.g., Google Search)
13//! - [`Event::Usage`] - Token usage and cost information
14//!
15//! # Design
16//!
17//! The core crate only emits events - it does NOT execute tool calls.
18//! Tool execution is the responsibility of higher-level abstractions like `aither-agent`.
19//! This separation allows:
20//! - Full control over tool execution (hooks, compression, error handling)
21//! - Clean separation between LLM communication and agent logic
22//! - Proper context management between tool calls
23
24use crate::llm::reasoning::ReasoningState;
25use alloc::string::{String, ToString};
26use serde_json::Value;
27
28/// Token usage information from a model response.
29///
30/// Providers should emit this at the end of each response stream.
31/// Token counts and costs are optional since not all providers report them.
32#[derive(Debug, Clone, Default, PartialEq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub struct Usage {
35    /// Number of tokens in the prompt/input.
36    pub prompt_tokens: Option<u32>,
37    /// Number of tokens in the completion/output.
38    pub completion_tokens: Option<u32>,
39    /// Total tokens (prompt + completion).
40    pub total_tokens: Option<u32>,
41    /// Tokens used for reasoning/thinking (for reasoning models).
42    pub reasoning_tokens: Option<u32>,
43    /// Tokens read from cache (for providers with prompt caching).
44    pub cache_read_tokens: Option<u32>,
45    /// Tokens written to cache.
46    pub cache_write_tokens: Option<u32>,
47    /// Estimated cost in USD for this request.
48    pub cost_usd: Option<f64>,
49    /// Provider-specific reason the generation stopped (e.g. `stop`, `length`, `tool_use`).
50    pub stop_reason: Option<String>,
51}
52
53impl Usage {
54    /// Creates a new usage with basic token counts.
55    #[must_use]
56    pub const fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
57        Self {
58            prompt_tokens: Some(prompt_tokens),
59            completion_tokens: Some(completion_tokens),
60            total_tokens: Some(prompt_tokens + completion_tokens),
61            reasoning_tokens: None,
62            cache_read_tokens: None,
63            cache_write_tokens: None,
64            cost_usd: None,
65            stop_reason: None,
66        }
67    }
68
69    /// Adds reasoning token count.
70    #[must_use]
71    pub const fn with_reasoning_tokens(mut self, tokens: u32) -> Self {
72        self.reasoning_tokens = Some(tokens);
73        self
74    }
75
76    /// Adds cache token counts.
77    #[must_use]
78    pub const fn with_cache_tokens(mut self, read: u32, write: u32) -> Self {
79        self.cache_read_tokens = Some(read);
80        self.cache_write_tokens = Some(write);
81        self
82    }
83
84    /// Adds estimated cost.
85    #[must_use]
86    pub const fn with_cost(mut self, cost_usd: f64) -> Self {
87        self.cost_usd = Some(cost_usd);
88        self
89    }
90
91    /// Adds provider stop reason metadata.
92    #[must_use]
93    pub fn with_stop_reason(mut self, reason: impl Into<String>) -> Self {
94        self.stop_reason = Some(reason.into());
95        self
96    }
97
98    /// Accumulates usage from another instance.
99    pub fn accumulate(&mut self, other: &Self) {
100        if let Some(v) = other.prompt_tokens {
101            *self.prompt_tokens.get_or_insert(0) += v;
102        }
103        if let Some(v) = other.completion_tokens {
104            *self.completion_tokens.get_or_insert(0) += v;
105        }
106        if let Some(v) = other.total_tokens {
107            *self.total_tokens.get_or_insert(0) += v;
108        }
109        if let Some(v) = other.reasoning_tokens {
110            *self.reasoning_tokens.get_or_insert(0) += v;
111        }
112        if let Some(v) = other.cache_read_tokens {
113            *self.cache_read_tokens.get_or_insert(0) += v;
114        }
115        if let Some(v) = other.cache_write_tokens {
116            *self.cache_write_tokens.get_or_insert(0) += v;
117        }
118        if let Some(v) = other.cost_usd {
119            *self.cost_usd.get_or_insert(0.0) += v;
120        }
121        if self.stop_reason.is_none() {
122            self.stop_reason.clone_from(&other.stop_reason);
123        }
124    }
125}
126
127/// Events emitted by a language model during response generation.
128///
129/// This is the primary output type from [`LanguageModel::respond`].
130/// Consumers should handle each event type appropriately.
131///
132/// # Example
133///
134/// ```rust,ignore
135/// use futures_lite::StreamExt;
136///
137/// let mut stream = model.respond(request);
138/// while let Some(event) = stream.next().await {
139///     match event? {
140///         Event::Text(text) => print!("{}", text),
141///         Event::Reasoning(thought) => eprintln!("[thinking] {}", thought),
142///         Event::ToolCall(call) => {
143///             // Execute tool and continue conversation
144///             let result = execute_tool(&call).await;
145///             // ... add result to messages and continue
146///         }
147///         Event::BuiltInToolResult { tool, result } => {
148///             println!("[{}] {}", tool, result);
149///         }
150///         Event::Usage(usage) => {
151///             println!("Tokens used: {:?}", usage.total_tokens);
152///         }
153///     }
154/// }
155/// ```
156#[derive(Debug, Clone)]
157pub enum Event {
158    /// Visible text chunk from the model.
159    ///
160    /// These chunks should be concatenated to form the complete response.
161    Text(String),
162
163    /// Internal reasoning or thinking from reasoning models.
164    ///
165    /// Not all models emit reasoning. For models like Claude with extended thinking
166    /// or `OpenAI`'s o1, this contains the model's internal thought process.
167    /// This is for observability only - it's not part of the conversation.
168    Reasoning(String),
169
170    /// Incremental tool call assembly progress.
171    ///
172    /// Emitted as the model streams a tool call's name and arguments.
173    /// Consumers can use this to show early UI feedback (e.g., tool name
174    /// and partial description) before the full arguments are available.
175    ///
176    /// A final [`Event::ToolCall`] is always emitted once the tool call
177    /// is fully assembled; consumers that don't need incremental progress
178    /// can ignore `ToolCallDelta` entirely.
179    ToolCallDelta {
180        /// Tool call identifier (available from the first delta).
181        id: String,
182        /// Tool name (available from the first delta for Claude;
183        /// may arrive incrementally for `OpenAI`).
184        name: String,
185        /// Partial JSON arguments accumulated so far.
186        arguments_fragment: String,
187    },
188
189    /// Request to execute a tool.
190    ///
191    /// **Important**: The core crate does NOT execute tool calls.
192    /// This event indicates the model wants to use a tool. The consumer
193    /// (typically an agent) should:
194    /// 1. Execute the tool
195    /// 2. Add the result to the conversation
196    /// 3. Continue the conversation with the model
197    ToolCall(ToolCall),
198
199    /// Opaque reasoning state that must be replayed to the provider.
200    ///
201    /// Distinct from [`Event::Reasoning`], which is display text: state carries
202    /// no meaning for the reader and text carries none for the model. They are
203    /// emitted independently, and a provider may emit either alone — Claude
204    /// with `display: "omitted"` produces state with no text at all.
205    ///
206    /// Consumers assembling the next request must collect these into the
207    /// assistant message they build; dropping them degrades multi-turn tool use.
208    ReasoningState(ReasoningState),
209
210    /// Result from a provider's built-in tool.
211    ///
212    /// Some providers have native tools that are executed server-side:
213    /// - Gemini: Google Search grounding
214    /// - `OpenAI`: Code interpreter, file search
215    /// - Claude: (future built-in tools)
216    ///
217    /// These are already executed - this event contains the result.
218    BuiltInToolResult {
219        /// Name of the built-in tool that was executed.
220        tool: String,
221        /// Result from the tool execution.
222        result: String,
223    },
224
225    /// Token usage and cost information.
226    ///
227    /// Emitted at the end of a response stream with usage statistics.
228    /// Use this to track token consumption and costs across requests.
229    Usage(Usage),
230}
231
232impl Event {
233    /// Creates a text event.
234    #[must_use]
235    pub fn text(text: impl Into<String>) -> Self {
236        Self::Text(text.into())
237    }
238
239    /// Creates a reasoning event.
240    #[must_use]
241    pub fn reasoning(thought: impl Into<String>) -> Self {
242        Self::Reasoning(thought.into())
243    }
244
245    /// Creates a tool call delta event for incremental streaming.
246    #[must_use]
247    pub fn tool_call_delta(
248        id: impl Into<String>,
249        name: impl Into<String>,
250        arguments_fragment: impl Into<String>,
251    ) -> Self {
252        Self::ToolCallDelta {
253            id: id.into(),
254            name: name.into(),
255            arguments_fragment: arguments_fragment.into(),
256        }
257    }
258
259    /// Creates a tool call event.
260    #[must_use]
261    pub fn tool_call(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
262        Self::ToolCall(ToolCall {
263            id: id.into(),
264            name: name.into(),
265            arguments,
266            reasoning_state: None,
267        })
268    }
269
270    /// Creates a built-in tool result event.
271    #[must_use]
272    pub fn builtin_result(tool: impl Into<String>, result: impl Into<String>) -> Self {
273        Self::BuiltInToolResult {
274            tool: tool.into(),
275            result: result.into(),
276        }
277    }
278
279    /// Creates a usage event.
280    #[must_use]
281    pub const fn usage(usage: Usage) -> Self {
282        Self::Usage(usage)
283    }
284
285    /// Returns the text content if this is a Text event.
286    #[must_use]
287    pub fn as_text(&self) -> Option<&str> {
288        match self {
289            Self::Text(s) => Some(s),
290            _ => None,
291        }
292    }
293
294    /// Returns the reasoning content if this is a Reasoning event.
295    #[must_use]
296    pub fn as_reasoning(&self) -> Option<&str> {
297        match self {
298            Self::Reasoning(s) => Some(s),
299            _ => None,
300        }
301    }
302
303    /// Returns the tool call if this is a `ToolCall` event.
304    #[must_use]
305    pub const fn as_tool_call(&self) -> Option<&ToolCall> {
306        match self {
307            Self::ToolCall(call) => Some(call),
308            _ => None,
309        }
310    }
311
312    /// Returns true if this is a text event.
313    #[must_use]
314    pub const fn is_text(&self) -> bool {
315        matches!(self, Self::Text(_))
316    }
317
318    /// Returns true if this is a tool call event.
319    #[must_use]
320    pub const fn is_tool_call(&self) -> bool {
321        matches!(self, Self::ToolCall(_))
322    }
323
324    /// Returns the usage info if this is a Usage event.
325    #[must_use]
326    pub const fn as_usage(&self) -> Option<&Usage> {
327        match self {
328            Self::Usage(u) => Some(u),
329            _ => None,
330        }
331    }
332
333    /// Returns true if this is a usage event.
334    #[must_use]
335    pub const fn is_usage(&self) -> bool {
336        matches!(self, Self::Usage(_))
337    }
338}
339
340/// A request from the model to execute a tool.
341///
342/// This represents an "intent" to call a tool - the tool has NOT been executed.
343/// The consumer is responsible for:
344/// 1. Looking up the tool by name
345/// 2. Parsing and validating arguments
346/// 3. Executing the tool
347/// 4. Returning results to the model
348#[derive(Debug, Clone, PartialEq, Eq)]
349#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
350pub struct ToolCall {
351    /// Unique identifier for this tool call.
352    ///
353    /// Used to correlate tool results with their requests when
354    /// continuing the conversation.
355    pub id: String,
356
357    /// Name of the tool to execute.
358    pub name: String,
359
360    /// Arguments to pass to the tool, as a JSON value.
361    ///
362    /// The structure depends on the tool's schema.
363    pub arguments: Value,
364
365    /// Provider reasoning state bound to this specific call.
366    ///
367    /// Gemini attaches a thought signature to each function call rather than to
368    /// the turn, so it lives here; providers that scope reasoning to the whole
369    /// turn use [`Message::assistant_with_reasoning`] instead.
370    ///
371    /// [`Message::assistant_with_reasoning`]: crate::llm::Message::assistant_with_reasoning
372    #[cfg_attr(
373        feature = "serde",
374        serde(default, skip_serializing_if = "Option::is_none")
375    )]
376    pub reasoning_state: Option<ReasoningState>,
377}
378
379impl ToolCall {
380    /// Creates a new tool call.
381    #[must_use]
382    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
383        Self {
384            id: id.into(),
385            name: name.into(),
386            arguments,
387            reasoning_state: None,
388        }
389    }
390
391    /// Binds provider reasoning state to this call.
392    ///
393    /// Used by providers that sign each function call individually, so the
394    /// signature travels with the call it belongs to instead of the turn.
395    #[must_use]
396    pub fn with_reasoning_state(mut self, state: ReasoningState) -> Self {
397        self.reasoning_state = Some(state);
398        self
399    }
400
401    /// Returns the arguments as a JSON string.
402    #[must_use]
403    pub fn arguments_json(&self) -> String {
404        self.arguments.to_string()
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_event_constructors() {
414        let text = Event::text("hello");
415        assert!(text.is_text());
416        assert_eq!(text.as_text(), Some("hello"));
417
418        let reasoning = Event::reasoning("thinking...");
419        assert_eq!(reasoning.as_reasoning(), Some("thinking..."));
420
421        let tool = Event::tool_call("call_1", "search", serde_json::json!({"query": "rust"}));
422        assert!(tool.is_tool_call());
423        let call = tool.as_tool_call().unwrap();
424        assert_eq!(call.name, "search");
425        assert_eq!(call.id, "call_1");
426    }
427
428    #[test]
429    fn test_tool_call_arguments() {
430        let call = ToolCall::new("id", "test", serde_json::json!({"key": "value"}));
431        let json = call.arguments_json();
432        assert!(json.contains("key"));
433        assert!(json.contains("value"));
434    }
435}