use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChatRequest {
pub model: String,
pub model_provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub messages: Vec<ChatMessage>,
pub tools: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub stream: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub tool_calls: Vec<Value>,
}
#[must_use]
pub fn build_chat_request(
model: impl Into<String>,
model_provider: impl Into<String>,
system_prompt: Option<String>,
messages: Vec<ChatMessage>,
tools: Vec<Value>,
reasoning_effort: Option<String>,
) -> ChatRequest {
ChatRequest {
model: model.into(),
model_provider: model_provider.into(),
system_prompt,
messages,
tools,
reasoning_effort,
stream: true,
}
}
#[must_use]
pub fn render_request_bytes(req: &ChatRequest) -> Vec<u8> {
serde_json::to_vec(req).expect("ChatRequest is serializable")
}
#[must_use]
pub fn byte_parity(a: &ChatRequest, b: &ChatRequest) -> Option<usize> {
let ab = render_request_bytes(a);
let bb = render_request_bytes(b);
if ab == bb {
None
} else {
ab.iter()
.zip(bb.iter())
.position(|(x, y)| x != y)
.or(Some(ab.len().min(bb.len())))
}
}
#[must_use]
pub fn preview_human(req: &ChatRequest) -> String {
let mut out = String::new();
out.push_str(&format!("model: {} ({})\n", req.model, req.model_provider));
if let Some(sp) = req.system_prompt.as_deref() {
out.push_str(&format!("system: {} chars\n", sp.len()));
}
out.push_str(&format!("messages: {}\n", req.messages.len()));
out.push_str(&format!("tools: {}\n", req.tools.len()));
if let Some(effort) = req.reasoning_effort.as_deref() {
out.push_str(&format!("reasoning_effort: {effort}\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn byte_identical_for_same_inputs() {
let msgs = vec![ChatMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: vec![],
}];
let a = build_chat_request(
"deepseek-v4-flash",
"deepseek",
Some("sys".into()),
msgs.clone(),
vec![json!({"name":"read"})],
Some("low".into()),
);
let b = build_chat_request(
"deepseek-v4-flash",
"deepseek",
Some("sys".into()),
msgs,
vec![json!({"name":"read"})],
Some("low".into()),
);
assert_eq!(byte_parity(&a, &b), None);
assert_eq!(render_request_bytes(&a), render_request_bytes(&b));
}
#[test]
fn dryrun_is_pure_inspection() {
let req = build_chat_request("m", "deepseek", None, vec![], vec![], None);
let preview = preview_human(&req);
assert!(preview.contains("model: m"));
let req2 = build_chat_request("m", "deepseek", None, vec![], vec![], None);
assert_eq!(render_request_bytes(&req), render_request_bytes(&req2));
}
}