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::JsonObject;
14use ferrin_spec::JsonValue;
15use ferrin_spec::MediaType;
16use ferrin_spec::ModelId;
17use ferrin_spec::ProviderMetadata;
18use ferrin_spec::ToolCallId;
19use ferrin_spec::ToolName;
20use ferrin_spec::Usage;
21use ferrin_spec::Warning;
22use ferrin_spec::language_model::Source;
23use ferrin_tool::ToolError;
24use serde::Deserialize;
25use serde::Serialize;
26use serde::de::DeserializeOwned;
27
28use crate::telemetry::ModelIdentity;
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct StepResult {
33 pub step_number: u32,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub runtime_context: Option<JsonValue>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub tools_context: Option<JsonValue>,
41 pub model: ModelIdentity,
43 pub content: Vec<StepContent>,
45 pub finish_reason: FinishReason,
47 pub usage: Usage,
49 pub warnings: Vec<Warning>,
51 pub request: StepRequest,
53 pub response: StepResponse,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub provider_metadata: Option<ProviderMetadata>,
58 pub performance: StepPerformance,
60}
61
62impl StepResult {
63 #[must_use]
65 pub fn text(&self) -> String {
66 self.content
67 .iter()
68 .filter_map(|part| match part {
69 StepContent::Text { text, .. } => Some(text.as_str()),
70 _ => None,
71 })
72 .collect()
73 }
74
75 #[must_use]
77 pub fn reasoning_text(&self) -> Option<String> {
78 let mut found = false;
79 let text: String = self
80 .content
81 .iter()
82 .filter_map(|part| match part {
83 StepContent::Reasoning { text, .. } => {
84 found = true;
85 Some(text.as_str())
86 }
87 _ => None,
88 })
89 .collect();
90 found.then_some(text)
91 }
92
93 pub fn tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
95 self.content.iter().filter_map(|part| match part {
96 StepContent::ToolCall(call) => Some(call),
97 _ => None,
98 })
99 }
100
101 pub fn static_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
103 self.tool_calls().filter(|call| !call.dynamic)
104 }
105
106 pub fn dynamic_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
108 self.tool_calls().filter(|call| call.dynamic)
109 }
110
111 pub fn tool_results(&self) -> impl Iterator<Item = &ToolResult> + '_ {
113 self.content.iter().filter_map(|part| match part {
114 StepContent::ToolResult(result) => Some(result),
115 _ => None,
116 })
117 }
118
119 pub fn tool_errors(&self) -> impl Iterator<Item = &ToolExecutionError> + '_ {
121 self.content.iter().filter_map(|part| match part {
122 StepContent::ToolError(error) => Some(error),
123 _ => None,
124 })
125 }
126
127 pub fn tool_approval_requests(&self) -> impl Iterator<Item = &ToolApprovalRequestContent> + '_ {
129 self.content.iter().filter_map(|part| match part {
130 StepContent::ToolApprovalRequest(request) => Some(request),
131 _ => None,
132 })
133 }
134
135 pub fn files(&self) -> impl Iterator<Item = &GeneratedFile> + '_ {
137 self.content.iter().filter_map(|part| match part {
138 StepContent::File(file) => Some(file),
139 _ => None,
140 })
141 }
142
143 pub fn sources(&self) -> impl Iterator<Item = &Source> + '_ {
145 self.content.iter().filter_map(|part| match part {
146 StepContent::Source(source) => Some(source),
147 _ => None,
148 })
149 }
150
151 #[must_use]
153 pub fn response_messages(&self) -> Vec<Message> {
154 self.response.messages.clone()
155 }
156
157 pub fn tool_result_as<T: DeserializeOwned>(
164 &self,
165 tool_name: &str,
166 ) -> Result<Option<T>, serde_json::Error> {
167 self.tool_results()
168 .find(|result| result.tool_name == tool_name)
169 .map(|result| serde_json::from_value(result.output.clone()))
170 .transpose()
171 }
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
176pub struct StepRequest {
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub body: Option<JsonValue>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub messages: Option<Vec<Message>>,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
187pub struct StepResponse {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub id: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub timestamp: Option<DateTime<Utc>>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub model_id: Option<ModelId>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub headers: Option<Headers>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub body: Option<JsonValue>,
203 #[serde(default)]
205 pub messages: Vec<Message>,
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
210pub struct StepPerformance {
211 #[serde(default)]
213 pub step_time: Duration,
214 #[serde(default)]
216 pub response_time: Duration,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub time_to_first_output: Option<Duration>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub output_tokens_per_second: Option<f64>,
223 #[serde(default)]
225 pub effective_output_tokens_per_second: f64,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub input_tokens_per_second: Option<f64>,
229 #[serde(default)]
231 pub effective_total_tokens_per_second: f64,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub time_between_output_chunks: Option<ChunkTimingStats>,
235}
236
237impl StepPerformance {
238 #[must_use]
240 pub fn tokens_per_second(tokens: Option<u64>, duration: Duration) -> f64 {
241 let seconds = duration.as_secs_f64();
242 if seconds <= 0.0 {
243 return 0.0;
244 }
245 #[allow(
246 clippy::cast_precision_loss,
247 reason = "token counts fit in f64 for rates"
248 )]
249 let rate = tokens.unwrap_or(0) as f64 / seconds;
250 if rate.is_finite() { rate } else { 0.0 }
251 }
252}
253
254#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
256pub struct ChunkTimingStats {
257 pub min: Duration,
259 pub max: Duration,
261 pub mean: Duration,
263 pub p50: Duration,
265 pub p90: Duration,
267 pub p99: Duration,
269 pub count: u64,
271}
272
273impl ChunkTimingStats {
274 #[must_use]
276 pub fn from_gaps(gaps: &[Duration]) -> Option<Self> {
277 if gaps.is_empty() {
278 return None;
279 }
280 let mut sorted = gaps.to_vec();
281 sorted.sort_unstable();
282 let total: Duration = sorted.iter().sum();
283 let count = sorted.len();
284 let percentile = |p: f64| {
285 #[allow(
286 clippy::cast_possible_truncation,
287 clippy::cast_sign_loss,
288 clippy::cast_precision_loss,
289 reason = "index arithmetic on a small vector"
290 )]
291 let index = ((p / 100.0) * (count as f64 - 1.0)).round() as usize;
292 sorted[index.min(count - 1)]
293 };
294 Some(Self {
295 min: sorted[0],
296 max: sorted[count - 1],
297 mean: total / u32::try_from(count).unwrap_or(u32::MAX),
298 p50: percentile(50.0),
299 p90: percentile(90.0),
300 p99: percentile(99.0),
301 count: count as u64,
302 })
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308#[serde(tag = "type", rename_all = "kebab-case")]
309#[non_exhaustive]
310pub enum StepContent {
311 Text {
313 text: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 provider_metadata: Option<ProviderMetadata>,
318 },
319 Reasoning {
321 text: String,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 provider_metadata: Option<ProviderMetadata>,
326 },
327 ReasoningFile(GeneratedFile),
329 File(GeneratedFile),
331 Custom {
333 kind: CustomKind,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 provider_metadata: Option<ProviderMetadata>,
338 },
339 Source(Source),
341 ToolCall(ParsedToolCall),
343 ToolResult(ToolResult),
345 ToolError(ToolExecutionError),
347 ToolApprovalRequest(ToolApprovalRequestContent),
349 ToolApprovalResponse(ToolApprovalResponseContent),
351 ToolOutputDenied(ToolOutputDenied),
353}
354
355impl StepContent {
356 #[must_use]
358 pub fn text(text: impl Into<String>) -> Self {
359 Self::Text {
360 text: text.into(),
361 provider_metadata: None,
362 }
363 }
364
365 #[must_use]
367 pub fn kind_name(&self) -> &'static str {
368 match self {
369 Self::Text { .. } => "text",
370 Self::Reasoning { .. } => "reasoning",
371 Self::ReasoningFile(_) => "reasoning-file",
372 Self::File(_) => "file",
373 Self::Custom { .. } => "custom",
374 Self::Source(_) => "source",
375 Self::ToolCall(_) => "tool-call",
376 Self::ToolResult(_) => "tool-result",
377 Self::ToolError(_) => "tool-error",
378 Self::ToolApprovalRequest(_) => "tool-approval-request",
379 Self::ToolApprovalResponse(_) => "tool-approval-response",
380 Self::ToolOutputDenied(_) => "tool-output-denied",
381 }
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct GeneratedFile {
388 pub data: FileData,
390 pub media_type: MediaType,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub filename: Option<String>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub provider_metadata: Option<ProviderMetadata>,
398}
399
400impl GeneratedFile {
401 #[must_use]
403 pub fn bytes(&self) -> Option<&bytes::Bytes> {
404 self.data.as_bytes()
405 }
406
407 #[must_use]
409 pub fn base64(&self) -> Option<String> {
410 self.data.to_base64()
411 }
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct ParsedToolCall {
417 pub tool_call_id: ToolCallId,
419 pub tool_name: ToolName,
421 pub input: JsonValue,
423 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
425 pub provider_executed: bool,
426 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
428 pub dynamic: bool,
429 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
431 pub invalid: bool,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub error: Option<String>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub title: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub tool_metadata: Option<JsonObject>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub provider_metadata: Option<ProviderMetadata>,
444}
445
446impl ParsedToolCall {
447 #[must_use]
449 pub fn new(
450 tool_call_id: impl Into<ToolCallId>,
451 tool_name: impl Into<ToolName>,
452 input: JsonValue,
453 ) -> Self {
454 Self {
455 tool_call_id: tool_call_id.into(),
456 tool_name: tool_name.into(),
457 input,
458 provider_executed: false,
459 dynamic: false,
460 invalid: false,
461 error: None,
462 title: None,
463 tool_metadata: None,
464 provider_metadata: None,
465 }
466 }
467}
468
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471pub struct ToolResult {
472 pub tool_call_id: ToolCallId,
474 pub tool_name: ToolName,
476 pub input: JsonValue,
478 pub output: JsonValue,
480 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
482 pub provider_executed: bool,
483 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
485 pub dynamic: bool,
486 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
488 pub preliminary: bool,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub execution_ms: Option<u64>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub tool_metadata: Option<JsonObject>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub provider_metadata: Option<ProviderMetadata>,
498}
499
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502pub struct ToolExecutionError {
503 pub tool_call_id: ToolCallId,
505 pub tool_name: ToolName,
507 pub input: JsonValue,
509 pub error: ToolErrorInfo,
511 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
513 pub provider_executed: bool,
514 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
516 pub dynamic: bool,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub tool_metadata: Option<JsonObject>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub provider_metadata: Option<ProviderMetadata>,
523}
524
525#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
527#[serde(tag = "type", rename_all = "kebab-case")]
528#[non_exhaustive]
529pub enum ToolErrorInfo {
530 Text {
532 message: String,
534 },
535 Json {
537 value: JsonValue,
539 },
540}
541
542impl ToolErrorInfo {
543 #[must_use]
545 pub fn text(message: impl Into<String>) -> Self {
546 Self::Text {
547 message: message.into(),
548 }
549 }
550
551 #[must_use]
553 pub fn to_json_value(&self) -> JsonValue {
554 match self {
555 Self::Text { message } => JsonValue::String(message.clone()),
556 Self::Json { value } => value.clone(),
557 }
558 }
559
560 #[must_use]
562 pub fn message(&self) -> String {
563 match self {
564 Self::Text { message } => message.clone(),
565 Self::Json { value } => ferrin_tool::model_output::error_message(value),
566 }
567 }
568}
569
570impl From<&ToolError> for ToolErrorInfo {
571 fn from(error: &ToolError) -> Self {
572 match error {
573 ToolError::Json { value } => Self::Json {
574 value: value.clone(),
575 },
576 other => Self::text(other.to_string()),
577 }
578 }
579}
580
581impl std::fmt::Display for ToolErrorInfo {
582 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 f.write_str(&self.message())
584 }
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589pub struct ToolApprovalRequestContent {
590 pub approval_id: ApprovalId,
592 pub tool_call: ParsedToolCall,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub reason: Option<String>,
597 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
599 pub is_automatic: bool,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub signature: Option<String>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub provider_metadata: Option<ProviderMetadata>,
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
610pub struct ToolApprovalResponseContent {
611 pub approval_id: ApprovalId,
613 pub tool_call: ParsedToolCall,
615 pub approved: bool,
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}
624
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
627pub struct ToolOutputDenied {
628 pub tool_call_id: ToolCallId,
630 pub tool_name: ToolName,
632 pub input: JsonValue,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub reason: Option<String>,
637 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
639 pub provider_executed: bool,
640 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
642 pub dynamic: bool,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub tool_metadata: Option<JsonObject>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub provider_metadata: Option<ProviderMetadata>,
649}