1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct StepResult {
32 pub step_number: u32,
34 pub model: ModelIdentity,
36 pub content: Vec<StepContent>,
38 pub finish_reason: FinishReason,
40 pub usage: Usage,
42 pub warnings: Vec<Warning>,
44 pub request: StepRequest,
46 pub response: StepResponse,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub provider_metadata: Option<ProviderMetadata>,
51 pub performance: StepPerformance,
53}
54
55impl StepResult {
56 #[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 #[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 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 pub fn static_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
96 self.tool_calls().filter(|call| !call.dynamic)
97 }
98
99 pub fn dynamic_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
101 self.tool_calls().filter(|call| call.dynamic)
102 }
103
104 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 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 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 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 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 #[must_use]
146 pub fn response_messages(&self) -> Vec<Message> {
147 self.response.messages.clone()
148 }
149
150 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
169pub struct StepRequest {
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub body: Option<JsonValue>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub messages: Option<Vec<Message>>,
176}
177
178#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
180pub struct StepResponse {
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub id: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub timestamp: Option<DateTime<Utc>>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub model_id: Option<ModelId>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub headers: Option<Headers>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub body: Option<JsonValue>,
196 #[serde(default)]
198 pub messages: Vec<Message>,
199}
200
201#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
203pub struct StepPerformance {
204 #[serde(default)]
206 pub step_time: Duration,
207 #[serde(default)]
209 pub response_time: Duration,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub time_to_first_output: Option<Duration>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub output_tokens_per_second: Option<f64>,
216 #[serde(default)]
218 pub effective_output_tokens_per_second: f64,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub input_tokens_per_second: Option<f64>,
222 #[serde(default)]
224 pub effective_total_tokens_per_second: f64,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub time_between_output_chunks: Option<ChunkTimingStats>,
228}
229
230impl StepPerformance {
231 #[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
249pub struct ChunkTimingStats {
250 pub min: Duration,
252 pub max: Duration,
254 pub mean: Duration,
256 pub p50: Duration,
258 pub p90: Duration,
260 pub p99: Duration,
262 pub count: u64,
264}
265
266impl ChunkTimingStats {
267 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(tag = "type", rename_all = "kebab-case")]
302#[non_exhaustive]
303pub enum StepContent {
304 Text {
306 text: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 provider_metadata: Option<ProviderMetadata>,
311 },
312 Reasoning {
314 text: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 provider_metadata: Option<ProviderMetadata>,
319 },
320 ReasoningFile(GeneratedFile),
322 File(GeneratedFile),
324 Custom {
326 kind: CustomKind,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
330 provider_metadata: Option<ProviderMetadata>,
331 },
332 Source(Source),
334 ToolCall(ParsedToolCall),
336 ToolResult(ToolResult),
338 ToolError(ToolExecutionError),
340 ToolApprovalRequest(ToolApprovalRequestContent),
342 ToolApprovalResponse(ToolApprovalResponseContent),
344 ToolOutputDenied(ToolOutputDenied),
346}
347
348impl StepContent {
349 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct GeneratedFile {
381 pub data: FileData,
383 pub media_type: MediaType,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub filename: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub provider_metadata: Option<ProviderMetadata>,
391}
392
393impl GeneratedFile {
394 #[must_use]
396 pub fn bytes(&self) -> Option<&bytes::Bytes> {
397 self.data.as_bytes()
398 }
399
400 #[must_use]
402 pub fn base64(&self) -> Option<String> {
403 self.data.to_base64()
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409pub struct ParsedToolCall {
410 pub tool_call_id: ToolCallId,
412 pub tool_name: ToolName,
414 pub input: JsonValue,
416 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
418 pub provider_executed: bool,
419 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
421 pub dynamic: bool,
422 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
424 pub invalid: bool,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub error: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub title: Option<String>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub provider_metadata: Option<ProviderMetadata>,
434}
435
436impl ParsedToolCall {
437 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460pub struct ToolResult {
461 pub tool_call_id: ToolCallId,
463 pub tool_name: ToolName,
465 pub input: JsonValue,
467 pub output: JsonValue,
469 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
471 pub provider_executed: bool,
472 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474 pub dynamic: bool,
475 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
477 pub preliminary: bool,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub execution_ms: Option<u64>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub provider_metadata: Option<ProviderMetadata>,
484}
485
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct ToolExecutionError {
489 pub tool_call_id: ToolCallId,
491 pub tool_name: ToolName,
493 pub input: JsonValue,
495 pub error: ToolErrorInfo,
497 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
499 pub provider_executed: bool,
500 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
502 pub dynamic: bool,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub provider_metadata: Option<ProviderMetadata>,
506}
507
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510#[serde(tag = "type", rename_all = "kebab-case")]
511#[non_exhaustive]
512pub enum ToolErrorInfo {
513 Text {
515 message: String,
517 },
518 Json {
520 value: JsonValue,
522 },
523}
524
525impl ToolErrorInfo {
526 #[must_use]
528 pub fn text(message: impl Into<String>) -> Self {
529 Self::Text {
530 message: message.into(),
531 }
532 }
533
534 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
572pub struct ToolApprovalRequestContent {
573 pub approval_id: ApprovalId,
575 pub tool_call: ParsedToolCall,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub reason: Option<String>,
580 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
582 pub is_automatic: bool,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub signature: Option<String>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub provider_metadata: Option<ProviderMetadata>,
589}
590
591#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct ToolApprovalResponseContent {
594 pub approval_id: ApprovalId,
596 pub tool_call: ParsedToolCall,
598 pub approved: bool,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub reason: Option<String>,
603 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
605 pub provider_executed: bool,
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
610pub struct ToolOutputDenied {
611 pub tool_call_id: ToolCallId,
613 pub tool_name: ToolName,
615 pub input: JsonValue,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub reason: Option<String>,
620 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
622 pub provider_executed: bool,
623 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
625 pub dynamic: bool,
626}