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}