pub mod anthropic;
pub mod openai;
use serde::{Deserialize, Serialize};
use serde_json::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 trait LlmProvider: Send + Sync {
fn chat_with_tools(
&self,
messages: Vec<Value>,
tools: &[ToolDef],
tool_choice: Option<&str>,
) -> impl std::future::Future<Output = anyhow::Result<ChatResponse>> + Send;
}
pub enum LlmBackend {
OpenAi(openai::OpenAiProvider),
Anthropic(anthropic::AnthropicProvider),
}
impl LlmProvider for LlmBackend {
async fn chat_with_tools(
&self,
messages: Vec<Value>,
tools: &[ToolDef],
tool_choice: Option<&str>,
) -> anyhow::Result<ChatResponse> {
match self {
Self::OpenAi(p) => p.chat_with_tools(messages, tools, tool_choice).await,
Self::Anthropic(p) => p.chat_with_tools(messages, tools, tool_choice).await,
}
}
}
pub async fn legacy_chat_json(
provider: &LlmBackend,
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 response = provider.chat_with_tools(all, &[], None).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: "test_tool",
description: "A test",
parameters: serde_json::json!({"type": "object"}),
},
};
let v = serde_json::to_value(&tool).unwrap();
assert_eq!(v["type"], "function");
assert_eq!(v["function"]["name"], "test_tool");
}
#[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": "{}" }
}]
});
let parsed: AssistantMessage = serde_json::from_value(raw).unwrap();
assert!(parsed.content.is_none());
assert_eq!(parsed.tool_calls.len(), 1);
}
#[test]
fn assistant_message_parses_text_only() {
let raw = serde_json::json!({
"role": "assistant",
"content": "Hello"
});
let parsed: AssistantMessage = serde_json::from_value(raw).unwrap();
assert_eq!(parsed.content.as_deref(), Some("Hello"));
assert!(parsed.tool_calls.is_empty());
}
}