Skip to main content

ferrin_core/generate_text/
step.rs

1//! Step results and the core content enum.
2
3use std::time::Duration;
4
5use chrono::DateTime;
6use chrono::Utc;
7use ferrin_message::Message;
8use ferrin_spec::ApprovalId;
9use ferrin_spec::CustomKind;
10use ferrin_spec::FileData;
11use ferrin_spec::FinishReason;
12use ferrin_spec::Headers;
13use ferrin_spec::JsonValue;
14use ferrin_spec::MediaType;
15use ferrin_spec::ModelId;
16use ferrin_spec::ProviderMetadata;
17use ferrin_spec::ToolCallId;
18use ferrin_spec::ToolName;
19use ferrin_spec::Usage;
20use ferrin_spec::Warning;
21use ferrin_spec::language_model::Source;
22use ferrin_tool::ToolError;
23use serde::Deserialize;
24use serde::Serialize;
25use serde::de::DeserializeOwned;
26
27use crate::telemetry::ModelIdentity;
28
29/// The result of one step: a model call plus the tool executions it caused.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct StepResult {
32    /// Zero-based step index.
33    pub step_number: u32,
34    /// Model that produced the step.
35    pub model: ModelIdentity,
36    /// Content parts in order.
37    pub content: Vec<StepContent>,
38    /// Why the model stopped.
39    pub finish_reason: FinishReason,
40    /// Token usage of the model call.
41    pub usage: Usage,
42    /// Warnings produced by the adapter.
43    pub warnings: Vec<Warning>,
44    /// Request metadata (body and messages when included).
45    pub request: StepRequest,
46    /// Response metadata and the messages to append to the history.
47    pub response: StepResponse,
48    /// Provider-specific metadata.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub provider_metadata: Option<ProviderMetadata>,
51    /// Timing and throughput.
52    pub performance: StepPerformance,
53}
54
55impl StepResult {
56    /// Concatenates all text parts.
57    #[must_use]
58    pub fn text(&self) -> String {
59        self.content
60            .iter()
61            .filter_map(|part| match part {
62                StepContent::Text { text, .. } => Some(text.as_str()),
63                _ => None,
64            })
65            .collect()
66    }
67
68    /// Concatenates all reasoning parts, or `None` when there is none.
69    #[must_use]
70    pub fn reasoning_text(&self) -> Option<String> {
71        let mut found = false;
72        let text: String = self
73            .content
74            .iter()
75            .filter_map(|part| match part {
76                StepContent::Reasoning { text, .. } => {
77                    found = true;
78                    Some(text.as_str())
79                }
80                _ => None,
81            })
82            .collect();
83        found.then_some(text)
84    }
85
86    /// Iterates over the tool calls of the step.
87    pub fn tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
88        self.content.iter().filter_map(|part| match part {
89            StepContent::ToolCall(call) => Some(call),
90            _ => None,
91        })
92    }
93
94    /// Tool calls to tools of the static tool set.
95    pub fn static_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
96        self.tool_calls().filter(|call| !call.dynamic)
97    }
98
99    /// Tool calls to dynamic tools (including invalid calls).
100    pub fn dynamic_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
101        self.tool_calls().filter(|call| call.dynamic)
102    }
103
104    /// Iterates over the (final) tool results of the step.
105    pub fn tool_results(&self) -> impl Iterator<Item = &ToolResult> + '_ {
106        self.content.iter().filter_map(|part| match part {
107            StepContent::ToolResult(result) => Some(result),
108            _ => None,
109        })
110    }
111
112    /// Iterates over the tool errors of the step.
113    pub fn tool_errors(&self) -> impl Iterator<Item = &ToolExecutionError> + '_ {
114        self.content.iter().filter_map(|part| match part {
115            StepContent::ToolError(error) => Some(error),
116            _ => None,
117        })
118    }
119
120    /// Iterates over the approval requests of the step.
121    pub fn tool_approval_requests(&self) -> impl Iterator<Item = &ToolApprovalRequestContent> + '_ {
122        self.content.iter().filter_map(|part| match part {
123            StepContent::ToolApprovalRequest(request) => Some(request),
124            _ => None,
125        })
126    }
127
128    /// Iterates over generated files.
129    pub fn files(&self) -> impl Iterator<Item = &GeneratedFile> + '_ {
130        self.content.iter().filter_map(|part| match part {
131            StepContent::File(file) => Some(file),
132            _ => None,
133        })
134    }
135
136    /// Iterates over citation sources.
137    pub fn sources(&self) -> impl Iterator<Item = &Source> + '_ {
138        self.content.iter().filter_map(|part| match part {
139            StepContent::Source(source) => Some(source),
140            _ => None,
141        })
142    }
143
144    /// The messages to append to the conversation history.
145    #[must_use]
146    pub fn response_messages(&self) -> Vec<Message> {
147        self.response.messages.clone()
148    }
149
150    /// Deserializes the output of the first final result of `tool_name`.
151    ///
152    /// # Errors
153    ///
154    /// Returns the deserialization error; `Ok(None)` when no such result
155    /// exists.
156    pub fn tool_result_as<T: DeserializeOwned>(
157        &self,
158        tool_name: &str,
159    ) -> Result<Option<T>, serde_json::Error> {
160        self.tool_results()
161            .find(|result| result.tool_name == tool_name)
162            .map(|result| serde_json::from_value(result.output.clone()))
163            .transpose()
164    }
165}
166
167/// Request metadata of a step.
168#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
169pub struct StepRequest {
170    /// The JSON request body (only when `Include::request_body`).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub body: Option<JsonValue>,
173    /// The messages sent to the model (only when `Include::request_messages`).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub messages: Option<Vec<Message>>,
176}
177
178/// Response metadata of a step.
179#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
180pub struct StepResponse {
181    /// Provider response id.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub id: Option<String>,
184    /// Response timestamp.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub timestamp: Option<DateTime<Utc>>,
187    /// Model that produced the response.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub model_id: Option<ModelId>,
190    /// Response headers.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub headers: Option<Headers>,
193    /// Raw response body (only when `Include::response_body`).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub body: Option<JsonValue>,
196    /// Messages produced by the step (assistant and tool messages).
197    #[serde(default)]
198    pub messages: Vec<Message>,
199}
200
201/// Timing and throughput of a step.
202#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
203pub struct StepPerformance {
204    /// Wall time of the whole step (model call and tool executions).
205    #[serde(default)]
206    pub step_time: Duration,
207    /// Time from the request until the response (or stream end).
208    #[serde(default)]
209    pub response_time: Duration,
210    /// Streaming: time until the first content part.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub time_to_first_output: Option<Duration>,
213    /// Streaming: output tokens per second after the first output.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub output_tokens_per_second: Option<f64>,
216    /// Output tokens divided by the response time.
217    #[serde(default)]
218    pub effective_output_tokens_per_second: f64,
219    /// Streaming: input tokens divided by the time to first output.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub input_tokens_per_second: Option<f64>,
222    /// Input plus output tokens divided by the response time.
223    #[serde(default)]
224    pub effective_total_tokens_per_second: f64,
225    /// Streaming: statistics of the gaps between content parts.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub time_between_output_chunks: Option<ChunkTimingStats>,
228}
229
230impl StepPerformance {
231    /// Tokens per second, `0.0` when the duration is zero.
232    #[must_use]
233    pub fn tokens_per_second(tokens: Option<u64>, duration: Duration) -> f64 {
234        let seconds = duration.as_secs_f64();
235        if seconds <= 0.0 {
236            return 0.0;
237        }
238        #[allow(
239            clippy::cast_precision_loss,
240            reason = "token counts fit in f64 for rates"
241        )]
242        let rate = tokens.unwrap_or(0) as f64 / seconds;
243        if rate.is_finite() { rate } else { 0.0 }
244    }
245}
246
247/// Statistics over the gaps between consecutive content parts.
248#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
249pub struct ChunkTimingStats {
250    /// Smallest gap.
251    pub min: Duration,
252    /// Largest gap.
253    pub max: Duration,
254    /// Mean gap.
255    pub mean: Duration,
256    /// Median gap.
257    pub p50: Duration,
258    /// 90th percentile.
259    pub p90: Duration,
260    /// 99th percentile.
261    pub p99: Duration,
262    /// Number of gaps.
263    pub count: u64,
264}
265
266impl ChunkTimingStats {
267    /// Computes the statistics of `gaps`; `None` when empty.
268    #[must_use]
269    pub fn from_gaps(gaps: &[Duration]) -> Option<Self> {
270        if gaps.is_empty() {
271            return None;
272        }
273        let mut sorted = gaps.to_vec();
274        sorted.sort_unstable();
275        let total: Duration = sorted.iter().sum();
276        let count = sorted.len();
277        let percentile = |p: f64| {
278            #[allow(
279                clippy::cast_possible_truncation,
280                clippy::cast_sign_loss,
281                clippy::cast_precision_loss,
282                reason = "index arithmetic on a small vector"
283            )]
284            let index = ((p / 100.0) * (count as f64 - 1.0)).round() as usize;
285            sorted[index.min(count - 1)]
286        };
287        Some(Self {
288            min: sorted[0],
289            max: sorted[count - 1],
290            mean: total / u32::try_from(count).unwrap_or(u32::MAX),
291            p50: percentile(50.0),
292            p90: percentile(90.0),
293            p99: percentile(99.0),
294            count: count as u64,
295        })
296    }
297}
298
299/// A content part of a step.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(tag = "type", rename_all = "kebab-case")]
302#[non_exhaustive]
303pub enum StepContent {
304    /// Generated text.
305    Text {
306        /// The text.
307        text: String,
308        /// Provider-specific metadata.
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        provider_metadata: Option<ProviderMetadata>,
311    },
312    /// Reasoning text.
313    Reasoning {
314        /// The text.
315        text: String,
316        /// Provider-specific metadata.
317        #[serde(default, skip_serializing_if = "Option::is_none")]
318        provider_metadata: Option<ProviderMetadata>,
319    },
320    /// A reasoning artifact stored as a file.
321    ReasoningFile(GeneratedFile),
322    /// A generated file.
323    File(GeneratedFile),
324    /// Provider-specific content.
325    Custom {
326        /// Kind of the content.
327        kind: CustomKind,
328        /// Provider-specific metadata.
329        #[serde(default, skip_serializing_if = "Option::is_none")]
330        provider_metadata: Option<ProviderMetadata>,
331    },
332    /// A citation source.
333    Source(Source),
334    /// A parsed tool call.
335    ToolCall(ParsedToolCall),
336    /// A tool result (client- or provider-executed).
337    ToolResult(ToolResult),
338    /// A tool error (client- or provider-executed).
339    ToolError(ToolExecutionError),
340    /// A request to approve a tool call.
341    ToolApprovalRequest(ToolApprovalRequestContent),
342    /// An automatic approval decision made by the approval policy.
343    ToolApprovalResponse(ToolApprovalResponseContent),
344    /// A tool call whose execution was denied.
345    ToolOutputDenied(ToolOutputDenied),
346}
347
348impl StepContent {
349    /// Creates a text part.
350    #[must_use]
351    pub fn text(text: impl Into<String>) -> Self {
352        Self::Text {
353            text: text.into(),
354            provider_metadata: None,
355        }
356    }
357
358    /// Returns the wire name of the variant.
359    #[must_use]
360    pub fn kind_name(&self) -> &'static str {
361        match self {
362            Self::Text { .. } => "text",
363            Self::Reasoning { .. } => "reasoning",
364            Self::ReasoningFile(_) => "reasoning-file",
365            Self::File(_) => "file",
366            Self::Custom { .. } => "custom",
367            Self::Source(_) => "source",
368            Self::ToolCall(_) => "tool-call",
369            Self::ToolResult(_) => "tool-result",
370            Self::ToolError(_) => "tool-error",
371            Self::ToolApprovalRequest(_) => "tool-approval-request",
372            Self::ToolApprovalResponse(_) => "tool-approval-response",
373            Self::ToolOutputDenied(_) => "tool-output-denied",
374        }
375    }
376}
377
378/// A generated file.
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct GeneratedFile {
381    /// File payload (inline bytes or URL).
382    pub data: FileData,
383    /// Media type.
384    pub media_type: MediaType,
385    /// Optional file name.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub filename: Option<String>,
388    /// Provider-specific metadata.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub provider_metadata: Option<ProviderMetadata>,
391}
392
393impl GeneratedFile {
394    /// Returns the inline bytes when the payload is inline.
395    #[must_use]
396    pub fn bytes(&self) -> Option<&bytes::Bytes> {
397        self.data.as_bytes()
398    }
399
400    /// Returns the payload as base64 when it is inline.
401    #[must_use]
402    pub fn base64(&self) -> Option<String> {
403        self.data.to_base64()
404    }
405}
406
407/// A tool call after parsing and validation.
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409pub struct ParsedToolCall {
410    /// Identifier of the tool call.
411    pub tool_call_id: ToolCallId,
412    /// Name of the tool.
413    pub tool_name: ToolName,
414    /// Parsed input (the raw text when it was not valid JSON).
415    pub input: JsonValue,
416    /// Whether the provider executes the tool itself.
417    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
418    pub provider_executed: bool,
419    /// Whether the call targets a dynamic tool (or could not be validated).
420    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
421    pub dynamic: bool,
422    /// Whether parsing or validation failed.
423    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
424    pub invalid: bool,
425    /// The parse or validation error message for invalid calls.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub error: Option<String>,
428    /// Display title of the tool, if defined.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub title: Option<String>,
431    /// Provider-specific metadata.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub provider_metadata: Option<ProviderMetadata>,
434}
435
436impl ParsedToolCall {
437    /// Creates a valid, client-executed, static call.
438    #[must_use]
439    pub fn new(
440        tool_call_id: impl Into<ToolCallId>,
441        tool_name: impl Into<ToolName>,
442        input: JsonValue,
443    ) -> Self {
444        Self {
445            tool_call_id: tool_call_id.into(),
446            tool_name: tool_name.into(),
447            input,
448            provider_executed: false,
449            dynamic: false,
450            invalid: false,
451            error: None,
452            title: None,
453            provider_metadata: None,
454        }
455    }
456}
457
458/// A tool result.
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460pub struct ToolResult {
461    /// Identifier of the tool call.
462    pub tool_call_id: ToolCallId,
463    /// Name of the tool.
464    pub tool_name: ToolName,
465    /// The validated input.
466    pub input: JsonValue,
467    /// The output value.
468    pub output: JsonValue,
469    /// Whether the provider executed the tool.
470    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
471    pub provider_executed: bool,
472    /// Whether the tool is dynamic.
473    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474    pub dynamic: bool,
475    /// Whether this result will be superseded by a final one.
476    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
477    pub preliminary: bool,
478    /// Execution time in milliseconds (client-executed tools).
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub execution_ms: Option<u64>,
481    /// Provider-specific metadata.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub provider_metadata: Option<ProviderMetadata>,
484}
485
486/// A failed tool execution (non-fatal: reported back to the model).
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct ToolExecutionError {
489    /// Identifier of the tool call.
490    pub tool_call_id: ToolCallId,
491    /// Name of the tool.
492    pub tool_name: ToolName,
493    /// The input.
494    pub input: JsonValue,
495    /// The error.
496    pub error: ToolErrorInfo,
497    /// Whether the provider executed the tool.
498    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
499    pub provider_executed: bool,
500    /// Whether the tool is dynamic.
501    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
502    pub dynamic: bool,
503    /// Provider-specific metadata.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub provider_metadata: Option<ProviderMetadata>,
506}
507
508/// Serializable description of a tool error.
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510#[serde(tag = "type", rename_all = "kebab-case")]
511#[non_exhaustive]
512pub enum ToolErrorInfo {
513    /// A textual error.
514    Text {
515        /// The message.
516        message: String,
517    },
518    /// A structured error payload.
519    Json {
520        /// The payload.
521        value: JsonValue,
522    },
523}
524
525impl ToolErrorInfo {
526    /// Creates a textual error.
527    #[must_use]
528    pub fn text(message: impl Into<String>) -> Self {
529        Self::Text {
530            message: message.into(),
531        }
532    }
533
534    /// The error as a JSON value (text becomes a JSON string).
535    #[must_use]
536    pub fn to_json_value(&self) -> JsonValue {
537        match self {
538            Self::Text { message } => JsonValue::String(message.clone()),
539            Self::Json { value } => value.clone(),
540        }
541    }
542
543    /// A human-readable message.
544    #[must_use]
545    pub fn message(&self) -> String {
546        match self {
547            Self::Text { message } => message.clone(),
548            Self::Json { value } => ferrin_tool::model_output::error_message(value),
549        }
550    }
551}
552
553impl From<&ToolError> for ToolErrorInfo {
554    fn from(error: &ToolError) -> Self {
555        match error {
556            ToolError::Json { value } => Self::Json {
557                value: value.clone(),
558            },
559            other => Self::text(other.to_string()),
560        }
561    }
562}
563
564impl std::fmt::Display for ToolErrorInfo {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        f.write_str(&self.message())
567    }
568}
569
570/// A request to approve a tool call before it executes.
571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
572pub struct ToolApprovalRequestContent {
573    /// Identifier of the approval request.
574    pub approval_id: ApprovalId,
575    /// The tool call awaiting approval.
576    pub tool_call: ParsedToolCall,
577    /// Reason shown to the approver.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub reason: Option<String>,
580    /// Whether the decision was made automatically by the approval policy.
581    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
582    pub is_automatic: bool,
583    /// HMAC signature over the request (when a secret is configured).
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub signature: Option<String>,
586    /// Provider-specific metadata (provider-issued requests).
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub provider_metadata: Option<ProviderMetadata>,
589}
590
591/// An approval decision made automatically by the approval policy.
592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct ToolApprovalResponseContent {
594    /// Identifier of the approval request.
595    pub approval_id: ApprovalId,
596    /// The tool call the decision concerns.
597    pub tool_call: ParsedToolCall,
598    /// Whether execution was approved.
599    pub approved: bool,
600    /// Reason given by the policy.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub reason: Option<String>,
603    /// Whether the provider executes the tool.
604    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
605    pub provider_executed: bool,
606}
607
608/// A tool call whose execution was denied.
609#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
610pub struct ToolOutputDenied {
611    /// Identifier of the tool call.
612    pub tool_call_id: ToolCallId,
613    /// Name of the tool.
614    pub tool_name: ToolName,
615    /// The input.
616    pub input: JsonValue,
617    /// Why execution was denied.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub reason: Option<String>,
620    /// Whether the provider would have executed the tool.
621    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
622    pub provider_executed: bool,
623    /// Whether the tool is dynamic.
624    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
625    pub dynamic: bool,
626}