use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self::text(Role::System, content)
}
pub fn user(content: impl Into<String>) -> Self {
Self::text(Role::User, content)
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::text(Role::Assistant, content)
}
pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Message {
role: Role::Tool,
content: Some(content.into()),
tool_calls: Vec::new(),
tool_call_id: Some(tool_call_id.into()),
name: None,
}
}
fn text(role: Role, content: impl Into<String>) -> Self {
Message {
role,
content: Some(content.into()),
tool_calls: Vec::new(),
tool_call_id: None,
name: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
impl Serialize for ToolCall {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let arguments =
serde_json::to_string(&self.arguments).map_err(serde::ser::Error::custom)?;
let wire = serde_json::json!({
"id": self.id,
"type": "function",
"function": { "name": self.name, "arguments": arguments },
});
wire.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ToolCall {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
id: String,
#[serde(default)]
function: RawFunction,
}
#[derive(Deserialize, Default)]
struct RawFunction {
#[serde(default)]
name: String,
#[serde(default)]
arguments: Value,
}
let raw = Raw::deserialize(deserializer)?;
Ok(ToolCall {
id: raw.id,
name: raw.function.name,
arguments: normalize_arguments(raw.function.arguments),
})
}
}
fn normalize_arguments(v: Value) -> Value {
match v {
Value::String(s) => {
if s.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&s).unwrap_or(Value::String(s))
}
}
Value::Null => Value::Object(serde_json::Map::new()),
other => other,
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Tool {
#[serde(rename = "type")]
pub kind: String,
pub function: ToolFunction,
}
impl Tool {
pub fn function(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
) -> Self {
Tool {
kind: "function".to_string(),
function: ToolFunction {
name: name.into(),
description: description.into(),
parameters,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ToolFunction {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_template_kwargs: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
impl ChatRequest {
pub fn new(messages: Vec<Message>) -> Self {
ChatRequest {
model: String::new(),
messages,
tools: Vec::new(),
tool_choice: None,
temperature: None,
max_completion_tokens: None,
top_p: None,
top_k: None,
chat_template_kwargs: None,
reasoning_effort: None,
}
}
pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
self.tools = tools;
self
}
pub fn with_tool_choice(mut self, choice: Value) -> Self {
self.tool_choice = Some(choice);
self
}
pub fn with_bench_sampling(
mut self,
temperature: f32,
top_p: f32,
max_completion_tokens: u32,
reasoning_effort: Option<String>,
qwen: bool,
) -> Self {
self.temperature = Some(temperature);
self.top_p = Some(top_p);
self.max_completion_tokens = Some(max_completion_tokens);
self.reasoning_effort = reasoning_effort;
if qwen {
self.top_k = Some(20);
self.chat_template_kwargs = Some(serde_json::json!({ "enable_thinking": false }));
}
self
}
pub fn with_explore_sampling(mut self, temperature: f32, max_tokens: u32, think: bool) -> Self {
self.temperature = Some(temperature);
self.max_completion_tokens = Some(max_tokens);
self.top_p = None;
self.top_k = None;
self.reasoning_effort = None;
self.chat_template_kwargs = Some(serde_json::json!({ "enable_thinking": think }));
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatResponse {
#[serde(default)]
pub choices: Vec<Choice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
impl ChatResponse {
pub fn first_message(&self) -> Option<&Message> {
self.choices.first().map(|c| &c.message)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub total_tokens: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Choice {
pub message: Message,
#[serde(default)]
pub finish_reason: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chat_request_serializes_lean() {
let req = ChatRequest::new(vec![
Message::system("be helpful"),
Message::user("hello"),
]);
let v: Value = serde_json::to_value(&req).unwrap();
assert_eq!(v["messages"][0]["role"], "system");
assert_eq!(v["messages"][1]["role"], "user");
assert_eq!(v["messages"][1]["content"], "hello");
assert!(v.get("tools").is_none(), "empty tools omitted");
assert!(v.get("tool_choice").is_none(), "unset tool_choice omitted");
assert!(v.get("temperature").is_none(), "unset temperature omitted");
assert!(v["messages"][1].get("tool_call_id").is_none());
assert!(v["messages"][1].get("tool_calls").is_none());
}
#[test]
fn request_with_tools_and_choice_serializes() {
let tool = Tool::function(
"search",
"search the code",
serde_json::json!({"type": "object", "properties": {"q": {"type": "string"}}}),
);
let req = ChatRequest::new(vec![Message::user("find foo")])
.with_tools(vec![tool])
.with_tool_choice(serde_json::json!("auto"));
let v: Value = serde_json::to_value(&req).unwrap();
assert_eq!(v["tools"][0]["type"], "function");
assert_eq!(v["tools"][0]["function"]["name"], "search");
assert_eq!(v["tools"][0]["function"]["parameters"]["type"], "object");
assert_eq!(v["tool_choice"], "auto");
}
#[test]
fn tool_message_carries_call_id() {
let m = Message::tool("call_42", "{\"result\": 1}");
let v: Value = serde_json::to_value(&m).unwrap();
assert_eq!(v["role"], "tool");
assert_eq!(v["tool_call_id"], "call_42");
assert_eq!(v["content"], "{\"result\": 1}");
}
const LLAMACPP_RESPONSE: &str = r#"{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "search", "arguments": {"q": "foo", "n": 3} }
}]
}
}]
}"#;
const OLLAMA_RESPONSE: &str = r#"{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "search", "arguments": "{\"q\": \"foo\", \"n\": 3}" }
}]
}
}]
}"#;
#[test]
fn provider_dialects_normalize_to_identical_tool_calls() {
let a: ChatResponse = serde_json::from_str(LLAMACPP_RESPONSE).unwrap();
let b: ChatResponse = serde_json::from_str(OLLAMA_RESPONSE).unwrap();
let ta = &a.first_message().unwrap().tool_calls[0];
let tb = &b.first_message().unwrap().tool_calls[0];
assert_eq!(ta, tb, "object-args and string-args normalize identically");
assert_eq!(ta.id, "call_1");
assert_eq!(ta.name, "search");
assert_eq!(ta.arguments["q"], "foo");
assert_eq!(ta.arguments["n"], 3);
}
#[test]
fn empty_and_null_arguments_normalize_to_object() {
assert_eq!(normalize_arguments(Value::Null), serde_json::json!({}));
assert_eq!(normalize_arguments(Value::String(String::new())), serde_json::json!({}));
assert_eq!(normalize_arguments(Value::String(" ".into())), serde_json::json!({}));
}
#[test]
fn unparseable_string_arguments_degrade_gracefully() {
let got = normalize_arguments(Value::String("not json".into()));
assert_eq!(got, Value::String("not json".into()));
}
#[test]
fn tool_call_round_trips_through_canonical_shape() {
let tc = ToolCall {
id: "call_9".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "bar"}),
};
let v: Value = serde_json::to_value(&tc).unwrap();
assert_eq!(v["type"], "function");
assert_eq!(v["function"]["name"], "search");
assert!(v["function"]["arguments"].is_string());
let back: ToolCall = serde_json::from_value(v).unwrap();
assert_eq!(back, tc);
}
#[test]
fn usage_is_parsed_when_present_and_absent() {
let with = r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}],
"usage":{"prompt_tokens":120,"completion_tokens":8,"total_tokens":128}}"#;
let resp: ChatResponse = serde_json::from_str(with).unwrap();
let u = resp.usage.expect("usage present");
assert_eq!(u.prompt_tokens, 120);
assert_eq!(u.completion_tokens, 8);
assert_eq!(u.total_tokens, 128);
let without = r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}]}"#;
let resp: ChatResponse = serde_json::from_str(without).unwrap();
assert!(resp.usage.is_none());
let partial = r#"{"choices":[],"usage":{"prompt_tokens":5}}"#;
let resp: ChatResponse = serde_json::from_str(partial).unwrap();
let u = resp.usage.unwrap();
assert_eq!(u.prompt_tokens, 5);
assert_eq!(u.completion_tokens, 0);
}
#[test]
fn response_without_tool_calls_is_plain_text() {
let json = r#"{"choices":[{"message":{"role":"assistant","content":"hi there"}}]}"#;
let resp: ChatResponse = serde_json::from_str(json).unwrap();
let m = resp.first_message().unwrap();
assert_eq!(m.content.as_deref(), Some("hi there"));
assert!(m.tool_calls.is_empty());
}
}