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    /// `arguments_fragment` is an increment: it carries only the argument
177    /// bytes produced since the previous delta for this call, so
178    /// concatenating a call's fragments reconstructs its full arguments.
179    ///
180    /// A final [`Event::ToolCall`] is always emitted once the tool call
181    /// is fully assembled; consumers that don't need incremental progress
182    /// can ignore `ToolCallDelta` entirely.
183    ToolCallDelta {
184        /// Tool call identifier (available from the first delta).
185        id: String,
186        /// Tool name (available from the first delta for Claude;
187        /// may arrive incrementally for `OpenAI`).
188        name: String,
189        /// Incremental JSON arguments fragment for this delta.
190        arguments_fragment: String,
191    },
192
193    /// Request to execute a tool.
194    ///
195    /// **Important**: The core crate does NOT execute tool calls.
196    /// This event indicates the model wants to use a tool. The consumer
197    /// (typically an agent) should:
198    /// 1. Execute the tool
199    /// 2. Add the result to the conversation
200    /// 3. Continue the conversation with the model
201    ToolCall(ToolCall),
202
203    /// Opaque reasoning state that must be replayed to the provider.
204    ///
205    /// Distinct from [`Event::Reasoning`], which is display text: state carries
206    /// no meaning for the reader and text carries none for the model. They are
207    /// emitted independently, and a provider may emit either alone — Claude
208    /// with `display: "omitted"` produces state with no text at all.
209    ///
210    /// Consumers assembling the next request must collect these into the
211    /// assistant message they build; dropping them degrades multi-turn tool use.
212    ReasoningState(ReasoningState),
213
214    /// A provider's built-in tool was invoked.
215    ///
216    /// Some providers have native tools that are executed server-side:
217    /// - `OpenAI`: `web_search`, file search, code interpreter
218    /// - Claude: `web_search`, `web_fetch`, code execution
219    /// - Gemini: code execution
220    ///
221    /// Unlike [`Event::ToolCall`], the consumer executes nothing — the
222    /// provider runs the call itself and a [`Event::BuiltInToolResult`]
223    /// follows with the outcome.
224    BuiltInToolCall {
225        /// Provider-assigned call identifier (may be empty).
226        id: String,
227        /// Name of the built-in tool (e.g. `web_search`).
228        tool: String,
229        /// Invocation detail available at emit time — the search query, the
230        /// URL, or the code — empty when the wire has not revealed it yet.
231        detail: String,
232    },
233
234    /// Result from a provider's built-in tool.
235    ///
236    /// Some providers have native tools that are executed server-side:
237    /// - Gemini: Google Search grounding, code execution
238    /// - `OpenAI`: Code interpreter, file search, web search
239    /// - Claude: server tools (`web_search`, `web_fetch`, code execution)
240    ///
241    /// These are already executed - this event contains the result.
242    BuiltInToolResult {
243        /// Provider-assigned identifier of the call this answers; empty when
244        /// the provider does not carry one (e.g. Gemini parts).
245        id: String,
246        /// Name of the built-in tool that was executed.
247        tool: String,
248        /// Result from the tool execution.
249        result: String,
250    },
251
252    /// Token usage and cost information.
253    ///
254    /// Emitted at the end of a response stream with usage statistics.
255    /// Use this to track token consumption and costs across requests.
256    Usage(Usage),
257}
258
259impl Event {
260    /// Creates a text event.
261    #[must_use]
262    pub fn text(text: impl Into<String>) -> Self {
263        Self::Text(text.into())
264    }
265
266    /// Creates a reasoning event.
267    #[must_use]
268    pub fn reasoning(thought: impl Into<String>) -> Self {
269        Self::Reasoning(thought.into())
270    }
271
272    /// Creates a tool call delta event for incremental streaming.
273    #[must_use]
274    pub fn tool_call_delta(
275        id: impl Into<String>,
276        name: impl Into<String>,
277        arguments_fragment: impl Into<String>,
278    ) -> Self {
279        Self::ToolCallDelta {
280            id: id.into(),
281            name: name.into(),
282            arguments_fragment: arguments_fragment.into(),
283        }
284    }
285
286    /// Creates a tool call event.
287    #[must_use]
288    pub fn tool_call(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
289        Self::ToolCall(ToolCall {
290            id: id.into(),
291            name: name.into(),
292            arguments,
293            reasoning_state: None,
294        })
295    }
296
297    /// Creates a built-in tool call event.
298    #[must_use]
299    pub fn builtin_call(
300        id: impl Into<String>,
301        tool: impl Into<String>,
302        detail: impl Into<String>,
303    ) -> Self {
304        Self::BuiltInToolCall {
305            id: id.into(),
306            tool: tool.into(),
307            detail: detail.into(),
308        }
309    }
310
311    /// Creates a built-in tool result event.
312    #[must_use]
313    pub fn builtin_result(
314        id: impl Into<String>,
315        tool: impl Into<String>,
316        result: impl Into<String>,
317    ) -> Self {
318        Self::BuiltInToolResult {
319            id: id.into(),
320            tool: tool.into(),
321            result: result.into(),
322        }
323    }
324
325    /// Creates a usage event.
326    #[must_use]
327    pub const fn usage(usage: Usage) -> Self {
328        Self::Usage(usage)
329    }
330
331    /// Returns the text content if this is a Text event.
332    #[must_use]
333    pub fn as_text(&self) -> Option<&str> {
334        match self {
335            Self::Text(s) => Some(s),
336            _ => None,
337        }
338    }
339
340    /// Returns the reasoning content if this is a Reasoning event.
341    #[must_use]
342    pub fn as_reasoning(&self) -> Option<&str> {
343        match self {
344            Self::Reasoning(s) => Some(s),
345            _ => None,
346        }
347    }
348
349    /// Returns the tool call if this is a `ToolCall` event.
350    #[must_use]
351    pub const fn as_tool_call(&self) -> Option<&ToolCall> {
352        match self {
353            Self::ToolCall(call) => Some(call),
354            _ => None,
355        }
356    }
357
358    /// Returns true if this is a text event.
359    #[must_use]
360    pub const fn is_text(&self) -> bool {
361        matches!(self, Self::Text(_))
362    }
363
364    /// Returns true if this is a tool call event.
365    #[must_use]
366    pub const fn is_tool_call(&self) -> bool {
367        matches!(self, Self::ToolCall(_))
368    }
369
370    /// Returns the usage info if this is a Usage event.
371    #[must_use]
372    pub const fn as_usage(&self) -> Option<&Usage> {
373        match self {
374            Self::Usage(u) => Some(u),
375            _ => None,
376        }
377    }
378
379    /// Returns true if this is a usage event.
380    #[must_use]
381    pub const fn is_usage(&self) -> bool {
382        matches!(self, Self::Usage(_))
383    }
384}
385
386/// A request from the model to execute a tool.
387///
388/// This represents an "intent" to call a tool - the tool has NOT been executed.
389/// The consumer is responsible for:
390/// 1. Looking up the tool by name
391/// 2. Parsing and validating arguments
392/// 3. Executing the tool
393/// 4. Returning results to the model
394#[derive(Debug, Clone, PartialEq, Eq)]
395#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
396pub struct ToolCall {
397    /// Unique identifier for this tool call.
398    ///
399    /// Used to correlate tool results with their requests when
400    /// continuing the conversation.
401    pub id: String,
402
403    /// Name of the tool to execute.
404    pub name: String,
405
406    /// Arguments to pass to the tool, as a JSON value.
407    ///
408    /// The structure depends on the tool's schema.
409    pub arguments: Value,
410
411    /// Provider reasoning state bound to this specific call.
412    ///
413    /// Gemini attaches a thought signature to each function call rather than to
414    /// the turn, so it lives here; providers that scope reasoning to the whole
415    /// turn use [`Message::assistant_with_reasoning`] instead.
416    ///
417    /// [`Message::assistant_with_reasoning`]: crate::llm::Message::assistant_with_reasoning
418    #[cfg_attr(
419        feature = "serde",
420        serde(default, skip_serializing_if = "Option::is_none")
421    )]
422    pub reasoning_state: Option<ReasoningState>,
423}
424
425impl ToolCall {
426    /// Creates a new tool call.
427    #[must_use]
428    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
429        Self {
430            id: id.into(),
431            name: name.into(),
432            arguments,
433            reasoning_state: None,
434        }
435    }
436
437    /// Binds provider reasoning state to this call.
438    ///
439    /// Used by providers that sign each function call individually, so the
440    /// signature travels with the call it belongs to instead of the turn.
441    #[must_use]
442    pub fn with_reasoning_state(mut self, state: ReasoningState) -> Self {
443        self.reasoning_state = Some(state);
444        self
445    }
446
447    /// Returns the arguments as a JSON string.
448    #[must_use]
449    pub fn arguments_json(&self) -> String {
450        self.arguments.to_string()
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_event_constructors() {
460        let text = Event::text("hello");
461        assert!(text.is_text());
462        assert_eq!(text.as_text(), Some("hello"));
463
464        let reasoning = Event::reasoning("thinking...");
465        assert_eq!(reasoning.as_reasoning(), Some("thinking..."));
466
467        let tool = Event::tool_call("call_1", "search", serde_json::json!({"query": "rust"}));
468        assert!(tool.is_tool_call());
469        let call = tool.as_tool_call().unwrap();
470        assert_eq!(call.name, "search");
471        assert_eq!(call.id, "call_1");
472    }
473
474    #[test]
475    fn test_tool_call_arguments() {
476        let call = ToolCall::new("id", "test", serde_json::json!({"key": "value"}));
477        let json = call.arguments_json();
478        assert!(json.contains("key"));
479        assert!(json.contains("value"));
480    }
481}