use std::fmt;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::ids::{CallId, ModelKey, ModelRef, ProviderKey, RequestId};
use crate::request::{ContentPart, ToolCall};
use crate::structured::StructuredOutputError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FinishReason {
Stop,
MaxTokens,
ToolCalls,
ContentFilter,
Refusal,
Other,
}
impl FinishReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stop => "stop",
Self::MaxTokens => "max_tokens",
Self::ToolCalls => "tool_calls",
Self::ContentFilter => "content_filter",
Self::Refusal => "refusal",
Self::Other => "other",
}
}
#[must_use]
pub const fn is_complete(self) -> bool {
matches!(self, Self::Stop | Self::ToolCalls)
}
}
impl fmt::Display for FinishReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
#[serde(default)]
pub cached_input: u64,
#[serde(default)]
pub reasoning: u64,
}
impl TokenUsage {
#[must_use]
pub const fn new(input: u64, output: u64) -> Self {
Self {
input,
output,
cached_input: 0,
reasoning: 0,
}
}
#[must_use]
pub const fn none() -> Self {
Self::new(0, 0)
}
#[must_use]
pub const fn with_cached_input(mut self, cached: u64) -> Self {
self.cached_input = cached;
self
}
#[must_use]
pub const fn with_reasoning(mut self, reasoning: u64) -> Self {
self.reasoning = reasoning;
self
}
#[must_use]
pub const fn total(self) -> u64 {
self.input.saturating_add(self.output)
}
#[must_use]
pub const fn is_unreported(self) -> bool {
self.input == 0 && self.output == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseWarning {
SynthesizedCallIds,
UnknownFinishReason {
reported: String,
},
UsageUnreported,
FeatureDropped {
feature: String,
},
Reconstructed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelResponse {
pub request_id: RequestId,
pub provider: ProviderKey,
pub model: ModelKey,
pub content: Vec<ContentPart>,
pub finish: FinishReason,
#[serde(default)]
pub usage: TokenUsage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_id: Option<String>,
pub latency: Duration,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ResponseWarning>,
}
impl ModelResponse {
#[must_use]
pub fn new(
request_id: RequestId,
provider: impl Into<ProviderKey>,
model: impl Into<ModelKey>,
) -> Self {
Self {
request_id,
provider: provider.into(),
model: model.into(),
content: Vec::new(),
finish: FinishReason::Stop,
usage: TokenUsage::none(),
raw_id: None,
latency: Duration::ZERO,
warnings: Vec::new(),
}
}
#[must_use]
pub fn with_part(mut self, part: ContentPart) -> Self {
self.content.push(part);
self
}
#[must_use]
pub fn with_text(self, text: impl Into<String>) -> Self {
self.with_part(ContentPart::text(text))
}
#[must_use]
pub fn with_tool_call(self, call: ToolCall) -> Self {
self.with_part(ContentPart::ToolCall(call))
}
#[must_use]
pub const fn with_finish(mut self, finish: FinishReason) -> Self {
self.finish = finish;
self
}
#[must_use]
pub const fn with_usage(mut self, usage: TokenUsage) -> Self {
self.usage = usage;
self
}
#[must_use]
pub fn with_raw_id(mut self, raw_id: impl Into<String>) -> Self {
self.raw_id = Some(raw_id.into());
self
}
#[must_use]
pub const fn with_latency(mut self, latency: Duration) -> Self {
self.latency = latency;
self
}
#[must_use]
pub fn with_warning(mut self, warning: ResponseWarning) -> Self {
self.warnings.push(warning);
self
}
#[must_use]
pub fn reference(&self) -> ModelRef {
ModelRef {
provider: self.provider.clone(),
model: self.model.clone(),
}
}
#[must_use]
pub fn text(&self) -> String {
let mut out = String::new();
for part in &self.content {
if let Some(text) = part.as_text() {
out.push_str(text);
}
}
out
}
#[must_use]
pub fn tool_calls(&self) -> Vec<&ToolCall> {
self.content
.iter()
.filter_map(ContentPart::as_tool_call)
.collect()
}
#[must_use]
pub fn tool_call(&self, id: &CallId) -> Option<&ToolCall> {
self.tool_calls().into_iter().find(|call| &call.id == id)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.tool_calls().is_empty() && self.text().trim().is_empty()
}
pub fn single_json(&self) -> Result<serde_json::Value, StructuredOutputError> {
if self.finish == FinishReason::Refusal {
return Err(StructuredOutputError::Refusal);
}
let calls = self.tool_calls();
if calls.len() > 1 {
return Err(StructuredOutputError::MultipleCandidates {
candidates: calls.len(),
});
}
if !self.finish.is_complete() {
return Err(StructuredOutputError::NoOutput);
}
if let Some(call) = calls.first() {
return Ok(call.arguments.clone());
}
let text = self.text();
let payload = strip_code_fence(text.trim());
if payload.is_empty() {
return Err(StructuredOutputError::NoOutput);
}
serde_json::from_str(payload).map_err(StructuredOutputError::not_json)
}
}
fn strip_code_fence(text: &str) -> &str {
let Some(rest) = text.strip_prefix("```") else {
return text;
};
let Some(body_start) = rest.find('\n') else {
return text;
};
if rest[..body_start]
.chars()
.any(|ch| !ch.is_ascii_alphanumeric())
{
return text;
}
let body = &rest[body_start + 1..];
match body.trim_end().strip_suffix("```") {
Some(inner) => inner.trim(),
None => text,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn response() -> ModelResponse {
ModelResponse::new(RequestId::nil(), "openai", "gpt-4o")
}
#[test]
fn helpers_read_text_and_calls_in_order() {
let built = response()
.with_text("first ")
.with_tool_call(ToolCall::new("call_a", "plan", json!({"n": 1})))
.with_text("second")
.with_tool_call(ToolCall::new("call_b", "plan", json!({"n": 2})));
assert_eq!(built.text(), "first second");
let calls = built.tool_calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id.as_str(), "call_a");
assert_eq!(calls[1].id.as_str(), "call_b");
assert!(built.tool_call(&CallId::from("call_b")).is_some());
assert!(built.tool_call(&CallId::from("call_z")).is_none());
assert!(!built.is_empty());
assert_eq!(built.reference().to_string(), "openai/gpt-4o");
}
#[test]
fn single_json_takes_the_only_tool_call_and_ignores_the_preamble() {
let built = response()
.with_text("Certo, ecco il piano.")
.with_tool_call(ToolCall::new("call_a", "plan", json!({"acts": []})))
.with_finish(FinishReason::ToolCalls);
assert_eq!(built.single_json().unwrap(), json!({"acts": []}));
}
#[test]
fn single_json_refuses_to_choose_between_two_calls() {
let built = response()
.with_tool_call(ToolCall::new("a", "plan", json!({"n": 1})))
.with_tool_call(ToolCall::new("b", "plan", json!({"n": 2})))
.with_finish(FinishReason::ToolCalls);
assert!(matches!(
built.single_json(),
Err(StructuredOutputError::MultipleCandidates { candidates: 2 })
));
}
#[test]
fn single_json_parses_text_and_strips_one_fence() {
let plain = response().with_text(" {\"a\": 1} ");
assert_eq!(plain.single_json().unwrap(), json!({"a": 1}));
let fenced = response().with_text("```json\n{\"a\": 1}\n```");
assert_eq!(fenced.single_json().unwrap(), json!({"a": 1}));
let bare_fence = response().with_text("```\n{\"a\": 1}\n```");
assert_eq!(bare_fence.single_json().unwrap(), json!({"a": 1}));
let not_a_fence = response().with_text("```json {\"a\": 1}");
assert!(matches!(
not_a_fence.single_json(),
Err(StructuredOutputError::NotJson { .. })
));
}
#[test]
fn single_json_never_parses_an_incomplete_answer() {
let truncated = response()
.with_text("{\"a\": 1")
.with_finish(FinishReason::MaxTokens);
assert!(matches!(
truncated.single_json(),
Err(StructuredOutputError::NoOutput)
));
let filtered = response()
.with_text("{\"a\": 1}")
.with_finish(FinishReason::ContentFilter);
assert!(matches!(
filtered.single_json(),
Err(StructuredOutputError::NoOutput)
));
let refused = response()
.with_text("I cannot help with that.")
.with_finish(FinishReason::Refusal);
assert!(matches!(
refused.single_json(),
Err(StructuredOutputError::Refusal)
));
let empty = response();
assert!(empty.is_empty());
assert!(matches!(
empty.single_json(),
Err(StructuredOutputError::NoOutput)
));
}
#[test]
fn finish_reasons_say_whether_output_is_complete() {
assert!(FinishReason::Stop.is_complete());
assert!(FinishReason::ToolCalls.is_complete());
for incomplete in [
FinishReason::MaxTokens,
FinishReason::ContentFilter,
FinishReason::Refusal,
FinishReason::Other,
] {
assert!(!incomplete.is_complete(), "{incomplete}");
}
}
#[test]
fn usage_totals_and_reports_absence() {
assert!(TokenUsage::none().is_unreported());
let usage = TokenUsage::new(100, 20)
.with_cached_input(80)
.with_reasoning(5);
assert_eq!(usage.total(), 120);
assert_eq!(usage.cached_input, 80);
assert_eq!(usage.reasoning, 5);
assert!(!usage.is_unreported());
}
#[test]
fn responses_round_trip_through_serde() {
let built = response()
.with_text("hi")
.with_tool_call(ToolCall::new("c1", "plan", json!({})))
.with_finish(FinishReason::ToolCalls)
.with_usage(TokenUsage::new(10, 3))
.with_raw_id("resp_123")
.with_latency(Duration::from_millis(420))
.with_warning(ResponseWarning::Reconstructed);
let json = serde_json::to_string(&built).unwrap();
let back: ModelResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back, built);
}
}