use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Serialize)]
pub struct ChatRequest<'a> {
pub model: &'a str,
pub messages: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<&'a [ToolDef]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<Value>,
}
#[derive(Serialize, Clone)]
pub struct ToolDef {
#[serde(rename = "type")]
pub kind: &'static str, pub function: ToolFn,
}
#[derive(Serialize, Clone)]
pub struct ToolFn {
pub name: &'static str,
pub description: &'static str,
pub parameters: Value, }
#[derive(Deserialize, Clone, Serialize)]
pub struct ChatResponse {
pub choices: Vec<Choice>,
}
#[derive(Deserialize, Clone, Serialize)]
pub struct Choice {
pub message: AssistantMessage,
#[serde(default)]
pub finish_reason: String,
}
#[derive(Deserialize, Clone, Serialize)]
pub struct AssistantMessage {
#[serde(default = "default_assistant_role")]
pub role: String,
#[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>,
}
fn default_assistant_role() -> String {
"assistant".to_string()
}
#[derive(Deserialize, Clone, Serialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub function: ToolCallFn,
}
#[derive(Deserialize, Clone, Serialize)]
pub struct ToolCallFn {
pub name: String,
pub arguments: String,
}
pub async fn chat_with_tools(
api_key: &str,
request: ChatRequest<'_>,
) -> anyhow::Result<ChatResponse> {
let client = reqwest::Client::new();
let response = client
.post("https://api.openai.com/v1/chat/completions")
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("OpenAI API error {status}: {text}");
}
let parsed: ChatResponse = response.json().await?;
Ok(parsed)
}
pub async fn chat_json(
api_key: &str,
model: &str,
system_prompt: &str,
messages: &[Value],
) -> anyhow::Result<String> {
let mut all = Vec::with_capacity(messages.len() + 1);
all.push(serde_json::json!({"role": "system", "content": system_prompt}));
all.extend(messages.iter().cloned());
let request = ChatRequest {
model,
messages: all,
tools: None,
tool_choice: None,
response_format: Some(serde_json::json!({"type": "json_object"})),
};
let response = chat_with_tools(api_key, request).await?;
let content = response
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_default();
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_def_serializes_with_function_type() {
let tool = ToolDef {
kind: "function",
function: ToolFn {
name: "validate_card",
description: "Validate a card",
parameters: serde_json::json!({"type": "object"}),
},
};
let v = serde_json::to_value(&tool).unwrap();
assert_eq!(v["type"], "function");
assert_eq!(v["function"]["name"], "validate_card");
}
#[test]
fn assistant_message_parses_tool_calls() {
let raw = serde_json::json!({
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "validate_card",
"arguments": "{\"card\":{}}"
}
}
]
});
let parsed: AssistantMessage = serde_json::from_value(raw).unwrap();
assert!(parsed.content.is_none());
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.tool_calls[0].function.name, "validate_card");
}
}