use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub(crate) struct ChatCompletionRequest {
pub model: String,
pub messages: Vec<WireMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<WireTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct WireTool {
#[serde(rename = "type")]
pub kind: String,
pub function: WireToolFunction,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct WireToolFunction {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WireMessage {
pub role: String,
pub content: String,
}
impl WireMessage {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".into(),
content: content.into(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".into(),
content: content.into(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".into(),
content: content.into(),
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct ResponseFormat {
#[serde(rename = "type")]
pub format_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_schema: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChatCompletionResponse {
pub id: String,
pub choices: Vec<ChatChoice>,
pub usage: Option<WireUsage>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChatChoice {
pub message: WireResponseMessage,
#[allow(dead_code)]
#[serde(default)]
pub finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireResponseMessage {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub tool_calls: Vec<WireToolCall>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireToolCall {
pub id: String,
pub function: WireToolCallFunction,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireToolCallFunction {
pub name: String,
pub arguments: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireUsage {
pub prompt_tokens: Option<u64>,
pub completion_tokens: Option<u64>,
#[allow(dead_code)]
pub total_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChatCompletionChunk {
pub id: String,
pub choices: Vec<ChunkChoice>,
#[serde(default)]
pub usage: Option<WireUsage>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkChoice {
pub index: u32,
pub delta: ChunkDelta,
#[serde(default)]
pub finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkDelta {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub tool_calls: Vec<WireChunkToolCall>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireChunkToolCall {
pub index: usize,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub function: Option<WireChunkToolCallFunction>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireChunkToolCallFunction {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub arguments: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_non_streaming_response() {
let json = r#"{
"id": "chatcmpl-abc",
"choices": [{
"message": {"role": "assistant", "content": "Hello"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
}"#;
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, "chatcmpl-abc");
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello"));
assert_eq!(resp.choices[0].finish_reason.as_deref(), Some("stop"));
assert_eq!(resp.usage.as_ref().unwrap().prompt_tokens, Some(10));
}
#[test]
fn parse_streaming_chunk() {
let json = r#"{
"id": "chatcmpl-abc",
"choices": [{"index": 0, "delta": {"content": "Hi"}, "finish_reason": null}]
}"#;
let chunk: ChatCompletionChunk = serde_json::from_str(json).unwrap();
assert_eq!(chunk.id, "chatcmpl-abc");
assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("Hi"));
assert!(chunk.choices[0].finish_reason.is_none());
}
#[test]
fn parse_streaming_chunk_finish() {
let json = r#"{
"id": "chatcmpl-abc",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
}"#;
let chunk: ChatCompletionChunk = serde_json::from_str(json).unwrap();
assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
assert!(chunk.usage.is_some());
}
#[test]
fn request_serialization() {
let req = ChatCompletionRequest {
model: "glm-5".into(),
messages: vec![WireMessage::user("hello")],
max_tokens: Some(100),
temperature: None,
stream: None,
response_format: None,
tools: vec![],
tool_choice: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["model"], "glm-5");
assert_eq!(json["messages"][0]["role"], "user");
assert!(json.get("temperature").is_none());
assert!(json.get("stream").is_none());
}
#[test]
fn request_with_response_format() {
let req = ChatCompletionRequest {
model: "glm-5".into(),
messages: vec![],
max_tokens: None,
temperature: None,
stream: None,
response_format: Some(ResponseFormat {
format_type: "json_schema".into(),
json_schema: Some(serde_json::json!({"type": "object"})),
}),
tools: vec![],
tool_choice: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["response_format"]["type"], "json_schema");
assert_eq!(json["response_format"]["json_schema"]["type"], "object");
}
#[test]
fn wire_message_constructors() {
let s = WireMessage::system("sys");
assert_eq!(s.role, "system");
let u = WireMessage::user("usr");
assert_eq!(u.role, "user");
let a = WireMessage::assistant("ast");
assert_eq!(a.role, "assistant");
}
#[test]
fn request_with_tools_serializes() {
let req = ChatCompletionRequest {
model: "glm-5".into(),
messages: vec![WireMessage::user("hello")],
max_tokens: None,
temperature: None,
stream: None,
response_format: None,
tools: vec![WireTool {
kind: "function".into(),
function: WireToolFunction {
name: "greet".into(),
description: "say hello".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
}],
tool_choice: Some(serde_json::json!("required")),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["tools"][0]["type"], "function");
assert_eq!(json["tools"][0]["function"]["name"], "greet");
assert_eq!(json["tool_choice"], "required");
}
#[test]
fn parse_response_with_tool_calls() {
let json = r#"{
"id": "chatcmpl-abc",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_xyz",
"type": "function",
"function": {"name": "submit", "arguments": "{\"x\": 1}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": null
}"#;
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
let msg = &resp.choices[0].message;
assert!(msg.content.is_none());
assert_eq!(msg.tool_calls.len(), 1);
assert_eq!(msg.tool_calls[0].id, "call_xyz");
assert_eq!(msg.tool_calls[0].function.name, "submit");
assert_eq!(msg.tool_calls[0].function.arguments, r#"{"x": 1}"#);
}
#[test]
fn parse_chunk_with_tool_call_delta() {
let json = r#"{
"id": "cmpl-1",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_abc",
"function": {"name": "submit", "arguments": "{\""}
}]
},
"finish_reason": null
}]
}"#;
let chunk: ChatCompletionChunk = serde_json::from_str(json).unwrap();
let tc = &chunk.choices[0].delta.tool_calls[0];
assert_eq!(tc.index, 0);
assert_eq!(tc.id.as_deref(), Some("call_abc"));
assert_eq!(
tc.function.as_ref().unwrap().name.as_deref(),
Some("submit")
);
}
}