use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::types::UsageMetadata;
pub const DEFAULT_MODEL: &str = "gpt-5-nano";
pub const MINI_MODEL: &str = "gpt-5-mini";
pub const PRO_MODEL: &str = "gpt-5-pro";
#[derive(Debug, Clone, Serialize, Default)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tools: Vec<ToolDef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct StreamOptions {
pub include_usage: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Message {
pub role: Role,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub content: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tool_calls: Vec<ToolCall>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub function: FunctionCall,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FunctionCall {
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ToolDef {
#[serde(rename = "type")]
pub kind: String,
pub function: FunctionDef,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct FunctionDef {
pub name: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolChoice {
Auto,
Required,
None,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ChatResponse {
#[serde(default)]
pub id: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub choices: Vec<Choice>,
#[serde(default)]
pub usage: Option<WireUsage>,
}
impl ChatResponse {
pub fn text(&self) -> String {
self.choices
.first()
.and_then(|c| c.message.as_ref())
.and_then(|m| m.content.clone())
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Choice {
#[serde(default)]
pub index: u32,
#[serde(default)]
pub message: Option<Message>,
#[serde(default)]
pub finish_reason: Option<FinishReason>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
Stop,
Length,
ToolCalls,
ContentFilter,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
pub struct WireUsage {
#[serde(default)]
pub prompt_tokens: Option<i32>,
#[serde(default)]
pub completion_tokens: Option<i32>,
#[serde(default)]
pub total_tokens: Option<i32>,
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
pub struct PromptTokensDetails {
#[serde(default)]
pub cached_tokens: Option<i32>,
}
impl From<WireUsage> for UsageMetadata {
fn from(w: WireUsage) -> Self {
let total = w.total_tokens.or(match (w.prompt_tokens, w.completion_tokens) {
(Some(p), Some(c)) => Some(p + c),
(Some(p), None) => Some(p),
(None, Some(c)) => Some(c),
(None, None) => None,
});
UsageMetadata {
prompt_token_count: w.prompt_tokens,
cached_content_token_count: w
.prompt_tokens_details
.and_then(|d| d.cached_tokens),
candidates_token_count: w.completion_tokens,
thoughts_token_count: None,
total_token_count: total,
}
}
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct ChatChunk {
#[serde(default)]
pub id: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub choices: Vec<ChunkChoice>,
#[serde(default)]
pub usage: Option<WireUsage>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct ChunkChoice {
#[serde(default)]
pub index: u32,
#[serde(default)]
pub delta: Delta,
#[serde(default)]
pub finish_reason: Option<FinishReason>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct Delta {
#[serde(default)]
pub role: Option<Role>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub tool_calls: Vec<ToolCallDelta>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct ToolCallDelta {
#[serde(default)]
pub index: u32,
#[serde(default)]
pub id: Option<String>,
#[serde(rename = "type", default)]
pub kind: Option<String>,
#[serde(default)]
pub function: Option<FunctionDelta>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct FunctionDelta {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub arguments: Option<String>,
}
impl Message {
pub fn system(text: impl Into<String>) -> Self {
Self {
role: Role::System,
content: Some(text.into()),
tool_calls: Vec::new(),
tool_call_id: None,
}
}
pub fn user_text(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: Some(text.into()),
tool_calls: Vec::new(),
tool_call_id: None,
}
}
pub fn assistant_text(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: Some(text.into()),
tool_calls: Vec::new(),
tool_call_id: None,
}
}
pub fn tool_result(id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: Role::Tool,
content: Some(content.into()),
tool_calls: Vec::new(),
tool_call_id: Some(id.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn request_serializes_messages_and_tool_schema() {
let req = ChatRequest {
model: DEFAULT_MODEL.to_string(),
messages: vec![
Message::system("be terse"),
Message::user_text("hi"),
],
tools: vec![ToolDef {
kind: "function".into(),
function: FunctionDef {
name: "view_file".into(),
description: "read a file".into(),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
},
}],
tool_choice: Some(ToolChoice::Auto),
stream: true,
stream_options: Some(StreamOptions { include_usage: true }),
temperature: Some(0.2),
max_completion_tokens: Some(256),
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["model"], DEFAULT_MODEL);
assert_eq!(v["stream"], true);
assert_eq!(v["stream_options"]["include_usage"], true);
assert_eq!(v["messages"][0]["role"], "system");
assert_eq!(v["messages"][0]["content"], "be terse");
assert_eq!(v["messages"][1]["role"], "user");
assert_eq!(v["tools"][0]["type"], "function");
assert_eq!(v["tools"][0]["function"]["name"], "view_file");
assert_eq!(
v["tools"][0]["function"]["parameters"]["properties"]["path"]["type"],
"string"
);
assert_eq!(v["tool_choice"], "auto");
assert!((v["temperature"].as_f64().unwrap() - 0.2).abs() < 1e-6);
assert_eq!(v["max_completion_tokens"], 256);
}
#[test]
fn message_shapes_serialize_per_role() {
let req = ChatRequest {
model: "m".into(),
messages: vec![
Message {
role: Role::Assistant,
content: None,
tool_calls: vec![ToolCall {
id: "call_1".into(),
kind: "function".into(),
function: FunctionCall {
name: "view_file".into(),
arguments: r#"{"path":"a.rs"}"#.into(),
},
}],
tool_call_id: None,
},
Message::tool_result("call_1", r#"{"contents":"fn main(){}"}"#),
],
..Default::default()
};
let v = serde_json::to_value(&req).unwrap();
assert!(v.get("tools").is_none(), "empty tools omitted");
assert!(v["messages"][0].get("content").is_none());
assert_eq!(v["messages"][0]["tool_calls"][0]["id"], "call_1");
assert_eq!(v["messages"][0]["tool_calls"][0]["type"], "function");
assert_eq!(
v["messages"][0]["tool_calls"][0]["function"]["arguments"],
r#"{"path":"a.rs"}"#
);
assert_eq!(v["messages"][1]["role"], "tool");
assert_eq!(v["messages"][1]["tool_call_id"], "call_1");
}
#[test]
fn deserialize_full_response_text_and_tool_call() {
let json = r#"{
"id": "chatcmpl-1",
"model": "gpt-5-nano",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Let me read it.",
"tool_calls": [{
"id": "call_abc",
"type": "function",
"function": {"name": "view_file", "arguments": "{\"path\":\"main.rs\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 42, "completion_tokens": 17, "total_tokens": 59,
"prompt_tokens_details": {"cached_tokens": 8}}
}"#;
let resp: ChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.text(), "Let me read it.");
let choice = &resp.choices[0];
assert_eq!(choice.finish_reason, Some(FinishReason::ToolCalls));
let call = &choice.message.as_ref().unwrap().tool_calls[0];
assert_eq!(call.id, "call_abc");
assert_eq!(call.function.name, "view_file");
let parsed: Value = serde_json::from_str(&call.function.arguments).unwrap();
assert_eq!(parsed["path"], "main.rs");
let usage: UsageMetadata = resp.usage.unwrap().into();
assert_eq!(usage.prompt_token_count, Some(42));
assert_eq!(usage.candidates_token_count, Some(17));
assert_eq!(usage.cached_content_token_count, Some(8));
assert_eq!(usage.total_token_count, Some(59));
}
#[test]
fn deserialize_stream_chunk_tool_call_fragment() {
let c: ChatChunk = serde_json::from_str(
r#"{"id":"x","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"view_file","arguments":"{\"pa"}}]}}]}"#,
)
.unwrap();
let d = &c.choices[0].delta.tool_calls[0];
assert_eq!(d.index, 0);
assert_eq!(d.id.as_deref(), Some("call_1"));
assert_eq!(d.function.as_ref().unwrap().name.as_deref(), Some("view_file"));
assert_eq!(d.function.as_ref().unwrap().arguments.as_deref(), Some("{\"pa"));
let t: ChatChunk = serde_json::from_str(
r#"{"choices":[{"index":0,"delta":{"content":"hello"}}]}"#,
)
.unwrap();
assert_eq!(t.choices[0].delta.content.as_deref(), Some("hello"));
let f: ChatChunk = serde_json::from_str(
r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}}"#,
)
.unwrap();
assert_eq!(f.choices[0].finish_reason, Some(FinishReason::Stop));
assert_eq!(f.usage.unwrap().completion_tokens, Some(7));
}
}