use std::time::Duration;
use chrono::DateTime;
use chrono::Utc;
use ferrin_message::Message;
use ferrin_spec::ApprovalId;
use ferrin_spec::CustomKind;
use ferrin_spec::FileData;
use ferrin_spec::FinishReason;
use ferrin_spec::Headers;
use ferrin_spec::JsonObject;
use ferrin_spec::JsonValue;
use ferrin_spec::MediaType;
use ferrin_spec::ModelId;
use ferrin_spec::ProviderMetadata;
use ferrin_spec::ToolCallId;
use ferrin_spec::ToolName;
use ferrin_spec::Usage;
use ferrin_spec::Warning;
use ferrin_spec::language_model::Source;
use ferrin_tool::ToolError;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::telemetry::ModelIdentity;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StepResult {
pub step_number: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_context: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools_context: Option<JsonValue>,
pub model: ModelIdentity,
pub content: Vec<StepContent>,
pub finish_reason: FinishReason,
pub usage: Usage,
pub warnings: Vec<Warning>,
pub request: StepRequest,
pub response: StepResponse,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
pub performance: StepPerformance,
}
impl StepResult {
#[must_use]
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(|part| match part {
StepContent::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect()
}
#[must_use]
pub fn reasoning_text(&self) -> Option<String> {
let mut found = false;
let text: String = self
.content
.iter()
.filter_map(|part| match part {
StepContent::Reasoning { text, .. } => {
found = true;
Some(text.as_str())
}
_ => None,
})
.collect();
found.then_some(text)
}
pub fn tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
self.content.iter().filter_map(|part| match part {
StepContent::ToolCall(call) => Some(call),
_ => None,
})
}
pub fn static_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
self.tool_calls().filter(|call| !call.dynamic)
}
pub fn dynamic_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
self.tool_calls().filter(|call| call.dynamic)
}
pub fn tool_results(&self) -> impl Iterator<Item = &ToolResult> + '_ {
self.content.iter().filter_map(|part| match part {
StepContent::ToolResult(result) => Some(result),
_ => None,
})
}
pub fn tool_errors(&self) -> impl Iterator<Item = &ToolExecutionError> + '_ {
self.content.iter().filter_map(|part| match part {
StepContent::ToolError(error) => Some(error),
_ => None,
})
}
pub fn tool_approval_requests(&self) -> impl Iterator<Item = &ToolApprovalRequestContent> + '_ {
self.content.iter().filter_map(|part| match part {
StepContent::ToolApprovalRequest(request) => Some(request),
_ => None,
})
}
pub fn files(&self) -> impl Iterator<Item = &GeneratedFile> + '_ {
self.content.iter().filter_map(|part| match part {
StepContent::File(file) => Some(file),
_ => None,
})
}
pub fn sources(&self) -> impl Iterator<Item = &Source> + '_ {
self.content.iter().filter_map(|part| match part {
StepContent::Source(source) => Some(source),
_ => None,
})
}
#[must_use]
pub fn response_messages(&self) -> Vec<Message> {
self.response.messages.clone()
}
pub fn tool_result_as<T: DeserializeOwned>(
&self,
tool_name: &str,
) -> Result<Option<T>, serde_json::Error> {
self.tool_results()
.find(|result| result.tool_name == tool_name)
.map(|result| serde_json::from_value(result.output.clone()))
.transpose()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StepRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub messages: Option<Vec<Message>>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StepResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<ModelId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<Headers>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<JsonValue>,
#[serde(default)]
pub messages: Vec<Message>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StepPerformance {
#[serde(default)]
pub step_time: Duration,
#[serde(default)]
pub response_time: Duration,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_to_first_output: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens_per_second: Option<f64>,
#[serde(default)]
pub effective_output_tokens_per_second: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens_per_second: Option<f64>,
#[serde(default)]
pub effective_total_tokens_per_second: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_between_output_chunks: Option<ChunkTimingStats>,
}
impl StepPerformance {
#[must_use]
pub fn tokens_per_second(tokens: Option<u64>, duration: Duration) -> f64 {
let seconds = duration.as_secs_f64();
if seconds <= 0.0 {
return 0.0;
}
#[allow(
clippy::cast_precision_loss,
reason = "token counts fit in f64 for rates"
)]
let rate = tokens.unwrap_or(0) as f64 / seconds;
if rate.is_finite() { rate } else { 0.0 }
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ChunkTimingStats {
pub min: Duration,
pub max: Duration,
pub mean: Duration,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub count: u64,
}
impl ChunkTimingStats {
#[must_use]
pub fn from_gaps(gaps: &[Duration]) -> Option<Self> {
if gaps.is_empty() {
return None;
}
let mut sorted = gaps.to_vec();
sorted.sort_unstable();
let total: Duration = sorted.iter().sum();
let count = sorted.len();
let percentile = |p: f64| {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "index arithmetic on a small vector"
)]
let index = ((p / 100.0) * (count as f64 - 1.0)).round() as usize;
sorted[index.min(count - 1)]
};
Some(Self {
min: sorted[0],
max: sorted[count - 1],
mean: total / u32::try_from(count).unwrap_or(u32::MAX),
p50: percentile(50.0),
p90: percentile(90.0),
p99: percentile(99.0),
count: count as u64,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
#[non_exhaustive]
pub enum StepContent {
Text {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_metadata: Option<ProviderMetadata>,
},
Reasoning {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_metadata: Option<ProviderMetadata>,
},
ReasoningFile(GeneratedFile),
File(GeneratedFile),
Custom {
kind: CustomKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_metadata: Option<ProviderMetadata>,
},
Source(Source),
ToolCall(ParsedToolCall),
ToolResult(ToolResult),
ToolError(ToolExecutionError),
ToolApprovalRequest(ToolApprovalRequestContent),
ToolApprovalResponse(ToolApprovalResponseContent),
ToolOutputDenied(ToolOutputDenied),
}
impl StepContent {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self::Text {
text: text.into(),
provider_metadata: None,
}
}
#[must_use]
pub fn kind_name(&self) -> &'static str {
match self {
Self::Text { .. } => "text",
Self::Reasoning { .. } => "reasoning",
Self::ReasoningFile(_) => "reasoning-file",
Self::File(_) => "file",
Self::Custom { .. } => "custom",
Self::Source(_) => "source",
Self::ToolCall(_) => "tool-call",
Self::ToolResult(_) => "tool-result",
Self::ToolError(_) => "tool-error",
Self::ToolApprovalRequest(_) => "tool-approval-request",
Self::ToolApprovalResponse(_) => "tool-approval-response",
Self::ToolOutputDenied(_) => "tool-output-denied",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GeneratedFile {
pub data: FileData,
pub media_type: MediaType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
}
impl GeneratedFile {
#[must_use]
pub fn bytes(&self) -> Option<&bytes::Bytes> {
self.data.as_bytes()
}
#[must_use]
pub fn base64(&self) -> Option<String> {
self.data.to_base64()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParsedToolCall {
pub tool_call_id: ToolCallId,
pub tool_name: ToolName,
pub input: JsonValue,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub provider_executed: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dynamic: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub invalid: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_metadata: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
}
impl ParsedToolCall {
#[must_use]
pub fn new(
tool_call_id: impl Into<ToolCallId>,
tool_name: impl Into<ToolName>,
input: JsonValue,
) -> Self {
Self {
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
input,
provider_executed: false,
dynamic: false,
invalid: false,
error: None,
title: None,
tool_metadata: None,
provider_metadata: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_call_id: ToolCallId,
pub tool_name: ToolName,
pub input: JsonValue,
pub output: JsonValue,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub provider_executed: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dynamic: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preliminary: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_metadata: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolExecutionError {
pub tool_call_id: ToolCallId,
pub tool_name: ToolName,
pub input: JsonValue,
pub error: ToolErrorInfo,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub provider_executed: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dynamic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_metadata: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
#[non_exhaustive]
pub enum ToolErrorInfo {
Text {
message: String,
},
Json {
value: JsonValue,
},
}
impl ToolErrorInfo {
#[must_use]
pub fn text(message: impl Into<String>) -> Self {
Self::Text {
message: message.into(),
}
}
#[must_use]
pub fn to_json_value(&self) -> JsonValue {
match self {
Self::Text { message } => JsonValue::String(message.clone()),
Self::Json { value } => value.clone(),
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
Self::Text { message } => message.clone(),
Self::Json { value } => ferrin_tool::model_output::error_message(value),
}
}
}
impl From<&ToolError> for ToolErrorInfo {
fn from(error: &ToolError) -> Self {
match error {
ToolError::Json { value } => Self::Json {
value: value.clone(),
},
other => Self::text(other.to_string()),
}
}
}
impl std::fmt::Display for ToolErrorInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolApprovalRequestContent {
pub approval_id: ApprovalId,
pub tool_call: ParsedToolCall,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_automatic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolApprovalResponseContent {
pub approval_id: ApprovalId,
pub tool_call: ParsedToolCall,
pub approved: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub provider_executed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolOutputDenied {
pub tool_call_id: ToolCallId,
pub tool_name: ToolName,
pub input: JsonValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub provider_executed: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dynamic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_metadata: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<ProviderMetadata>,
}