use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::config::ThinkingMode;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub(crate) struct ChatRequest {
pub model: String,
pub messages: Vec<Value>,
#[serde(flatten)]
pub rest: Map<String, Value>,
}
const SUPPORTED_ROLES: [&str; 6] = [
"system",
"user",
"assistant",
"tool",
"function",
"developer",
];
impl ChatRequest {
const RESERVED: [&'static str; 2] = ["model", "messages"];
pub(crate) fn validate(&self) -> Result<(), &'static str> {
if self.model.trim().is_empty() {
return Err("model must not be empty");
}
if self.messages.is_empty() {
return Err("messages must not be empty");
}
for message in &self.messages {
validate_message(message)?;
}
if Self::RESERVED
.iter()
.any(|key| self.rest.contains_key(*key))
{
return Err("rest must not contain a reserved key (model, messages)");
}
Ok(())
}
}
fn validate_message(message: &Value) -> Result<(), &'static str> {
let object = message
.as_object()
.ok_or("each message must be a JSON object")?;
let role = object
.get("role")
.and_then(Value::as_str)
.ok_or("each message must have a string role")?;
if !SUPPORTED_ROLES.contains(&role) {
return Err("each message role is not supported");
}
let has_content = object.contains_key("content");
let has_call = object.contains_key("tool_calls") || object.contains_key("function_call");
if !has_content && !has_call {
return Err("each message must carry content or a tool/function call");
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub(crate) struct ChatResponse {
pub model: String,
pub choices: Vec<Value>,
#[serde(flatten)]
pub rest: Map<String, Value>,
}
impl ChatResponse {
const RESERVED: [&'static str; 2] = ["model", "choices"];
pub(crate) fn validate(&self) -> Result<(), &'static str> {
for choice in &self.choices {
validate_choice(choice)?;
}
if Self::RESERVED
.iter()
.any(|key| self.rest.contains_key(*key))
{
return Err("rest must not contain a reserved key (model, choices)");
}
Ok(())
}
}
fn validate_choice(choice: &Value) -> Result<(), &'static str> {
let object = choice
.as_object()
.ok_or("upstream returned a non-object choice")?;
if !object.contains_key("index") {
return Err("upstream choice is missing index");
}
let has_payload = object.contains_key("message")
|| object.contains_key("delta")
|| object.contains_key("text");
if !has_payload {
return Err("upstream choice is missing message/delta/text");
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub(crate) struct ModelsResponse {
pub object: &'static str,
pub data: Vec<ModelInfo>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub(crate) struct ModelInfo {
pub id: String,
pub object: &'static str,
pub description: String,
pub context: u32,
pub thinking: ThinkingMode,
pub tool_dialect: String,
pub tools_mode: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn request(model: &str, messages: Vec<Value>) -> ChatRequest {
ChatRequest {
model: model.to_owned(),
messages,
rest: Map::new(),
}
}
#[test]
fn accepts_object_messages() {
let req = request(
"m",
vec![serde_json::json!({ "role": "user", "content": "hi" })],
);
assert!(req.validate().is_ok());
}
#[test]
fn rejects_empty_model_and_non_object_messages() {
assert!(request(" ", vec![]).validate().is_err());
assert!(
request("m", vec![serde_json::json!("not-an-object")])
.validate()
.is_err()
);
}
#[test]
fn request_rejects_reserved_keys_in_rest() {
let mut req = request(
"m",
vec![serde_json::json!({ "role": "user", "content": "hi" })],
);
req.rest
.insert("messages".to_owned(), serde_json::json!(["x"]));
assert!(req.validate().is_err());
}
#[test]
fn rejects_empty_messages_array() {
assert!(request("m", vec![]).validate().is_err());
}
#[test]
fn rejects_message_without_role_or_content() {
assert!(
request("m", vec![serde_json::json!({ "content": "hi" })])
.validate()
.is_err()
);
assert!(
request("m", vec![serde_json::json!({ "role": "user" })])
.validate()
.is_err()
);
assert!(
request(
"m",
vec![serde_json::json!({ "role": "spork", "content": "x" })]
)
.validate()
.is_err()
);
}
#[test]
fn accepts_assistant_tool_call_without_content() {
let req = request(
"m",
vec![serde_json::json!({
"role": "assistant",
"tool_calls": [{ "id": "1", "type": "function" }]
})],
);
assert!(req.validate().is_ok());
}
#[test]
fn response_rejects_choice_missing_index_or_payload() {
let missing_index = ChatResponse {
model: "m".to_owned(),
choices: vec![serde_json::json!({ "message": { "role": "assistant" } })],
rest: Map::new(),
};
assert!(missing_index.validate().is_err());
let missing_payload = ChatResponse {
model: "m".to_owned(),
choices: vec![serde_json::json!({ "index": 0 })],
rest: Map::new(),
};
assert!(missing_payload.validate().is_err());
}
#[test]
fn response_accepts_minimally_shaped_choice() {
let response = ChatResponse {
model: "m".to_owned(),
choices: vec![serde_json::json!({
"index": 0,
"message": { "role": "assistant", "content": "hi" },
"finish_reason": "stop"
})],
rest: Map::new(),
};
assert!(response.validate().is_ok());
}
#[test]
fn response_rejects_reserved_keys_in_rest() {
let mut response = ChatResponse {
model: "m".to_owned(),
choices: vec![],
rest: Map::new(),
};
response
.rest
.insert("choices".to_owned(), serde_json::json!([]));
assert!(response.validate().is_err());
}
#[test]
fn response_rejects_non_object_choice() {
let response = ChatResponse {
model: "m".to_owned(),
choices: vec![serde_json::json!(42)],
rest: Map::new(),
};
assert!(response.validate().is_err());
}
#[test]
fn request_round_trips_through_json() {
let json = serde_json::json!({
"model": "m",
"messages": [{ "role": "user", "content": "hi" }],
"temperature": 0.5,
"stream": false,
});
let req: ChatRequest = serde_json::from_value(json.clone()).expect("parse request");
assert!(req.rest.contains_key("temperature"));
assert!(req.rest.contains_key("stream"));
assert!(!req.rest.contains_key("model"));
assert!(!req.rest.contains_key("messages"));
let reparsed: ChatRequest =
serde_json::from_value(serde_json::to_value(&req).expect("serialize"))
.expect("reparse");
assert_eq!(req, reparsed);
}
#[test]
fn response_round_trips_and_preserves_unknown_fields() {
let json = serde_json::json!({
"model": "backend",
"choices": [{ "index": 0 }],
"usage": { "total_tokens": 7 },
});
let resp: ChatResponse = serde_json::from_value(json).expect("parse response");
assert!(resp.rest.contains_key("usage"));
let reparsed: ChatResponse =
serde_json::from_value(serde_json::to_value(&resp).expect("serialize"))
.expect("reparse");
assert_eq!(resp, reparsed);
}
}