use serde::{Deserialize, Serialize};
use super::tool::ToolCall;
use super::usage::{Usage, UsageBlock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StopReason {
Stop,
ToolCalls,
Length,
ContentFilter,
Other(String),
}
impl StopReason {
pub fn from_wire(reason: &str) -> Self {
match reason {
"stop" => Self::Stop,
"tool_calls" => Self::ToolCalls,
"length" => Self::Length,
"content_filter" => Self::ContentFilter,
other => Self::Other(other.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistantMessage {
pub content: Option<String>,
#[serde(default)]
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatChoice {
pub message: AssistantMessage,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub id: String,
#[serde(default)]
pub model: String,
pub choices: Vec<ChatChoice>,
#[serde(default)]
pub usage: UsageBlock,
}
impl ChatResponse {
pub fn first_text(&self) -> Option<String> {
self.choices.first()?.message.content.clone()
}
pub fn first_tool_calls(&self) -> &[ToolCall] {
self.choices
.first()
.map(|c| c.message.tool_calls.as_slice())
.unwrap_or(&[])
}
pub fn stop_reason(&self) -> Option<StopReason> {
self.choices
.first()?
.finish_reason
.as_deref()
.map(StopReason::from_wire)
}
pub fn usage(&self) -> Usage {
self.usage.clone().into_usage()
}
pub fn resolved_model<'a>(&'a self, requested: &'a str) -> &'a str {
if self.model.is_empty() {
requested
} else {
&self.model
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialises_text_fixture() {
let fixture = r#"{
"id": "gen-abc",
"choices": [{"message": {"role": "assistant", "content": "Hello", "tool_calls": []},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14}
}"#;
let resp: ChatResponse = serde_json::from_str(fixture).expect("deserialise");
assert_eq!(resp.id, "gen-abc");
assert_eq!(resp.first_text().as_deref(), Some("Hello"));
assert_eq!(resp.stop_reason(), Some(StopReason::Stop));
assert!(resp.first_tool_calls().is_empty());
}
#[test]
fn deserialises_tool_call() {
let fixture = r#"{
"id": "gen-xyz",
"choices": [{"message": {"role": "assistant", "content": null,
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"loc\":\"SEA\"}"}}]},
"finish_reason": "tool_calls"}],
"usage": {}
}"#;
let resp: ChatResponse = serde_json::from_str(fixture).expect("deserialise");
assert!(resp.first_text().is_none());
let calls = resp.first_tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(resp.stop_reason(), Some(StopReason::ToolCalls));
}
#[test]
fn stop_reason_maps_variants() {
assert_eq!(StopReason::from_wire("stop"), StopReason::Stop);
assert_eq!(StopReason::from_wire("tool_calls"), StopReason::ToolCalls);
assert_eq!(StopReason::from_wire("length"), StopReason::Length);
assert_eq!(
StopReason::from_wire("content_filter"),
StopReason::ContentFilter
);
assert_eq!(
StopReason::from_wire("weird"),
StopReason::Other("weird".into())
);
}
#[test]
fn usage_flows_through() {
let fixture = r#"{
"id": "z",
"choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 8, "completion_tokens": 3,
"cache_read_input_tokens": 5, "cache_creation_input_tokens": 2}
}"#;
let resp: ChatResponse = serde_json::from_str(fixture).expect("deserialise");
let u = resp.usage();
assert_eq!(u.prompt_tokens, 8);
assert_eq!(u.cache_read_tokens, 5);
assert_eq!(u.cache_creation_tokens, 2);
}
#[test]
fn first_text_empty_choices() {
let resp: ChatResponse =
serde_json::from_str(r#"{"id":"x","choices":[],"usage":{}}"#).expect("deserialise");
assert!(resp.first_text().is_none());
assert!(resp.stop_reason().is_none());
assert!(resp.first_tool_calls().is_empty());
}
#[test]
fn resolved_model_prefers_response_then_requested() {
let with: ChatResponse =
serde_json::from_str(r#"{"id":"x","model":"served/model","choices":[],"usage":{}}"#)
.expect("deserialise");
assert_eq!(with.resolved_model("asked/model"), "served/model");
let without: ChatResponse =
serde_json::from_str(r#"{"id":"x","choices":[],"usage":{}}"#).expect("deserialise");
assert_eq!(without.resolved_model("asked/model"), "asked/model");
}
}