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}