use serde::{Deserialize, Deserializer, Serialize};
fn deserialize_nullable_content<'de, D>(deserializer: D) -> Result<MessageContent, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<MessageContent>::deserialize(deserializer)?;
Ok(opt.unwrap_or_else(|| MessageContent::Text(String::new())))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
impl MessageContent {
pub fn from_text(s: impl Into<String>) -> Self {
Self::Text(s.into())
}
pub fn text(&self) -> &str {
match self {
Self::Text(s) => s,
Self::Blocks(blocks) => blocks
.iter()
.find_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.unwrap_or(""),
}
}
pub fn text_all(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Blocks(blocks) => {
let texts: Vec<&str> = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
texts.join("\n")
}
}
}
pub fn image_count(&self) -> usize {
match self {
Self::Text(_) => 0,
Self::Blocks(blocks) => blocks
.iter()
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
.count(),
}
}
pub fn has_images(&self) -> bool {
match self {
Self::Text(_) => false,
Self::Blocks(blocks) => blocks
.iter()
.any(|b| matches!(b, ContentBlock::ImageUrl { .. })),
}
}
pub fn len(&self) -> usize {
self.text().len()
}
pub fn is_empty(&self) -> bool {
self.text().is_empty()
}
pub fn contains(&self, pat: &str) -> bool {
self.text().contains(pat)
}
pub fn chars(&self) -> std::str::Chars<'_> {
self.text().chars()
}
pub fn strip_images(&self) -> Self {
match self {
Self::Text(_) => self.clone(),
Self::Blocks(blocks) => {
let text_blocks: Vec<ContentBlock> = blocks
.iter()
.filter(|b| matches!(b, ContentBlock::Text { .. }))
.cloned()
.collect();
if text_blocks.len() == 1 {
if let ContentBlock::Text { text } = &text_blocks[0] {
return Self::Text(text.clone());
}
}
Self::Blocks(text_blocks)
}
}
}
pub fn with_image(self, base64_png: &str) -> Self {
let mut blocks = match self {
Self::Text(s) => vec![ContentBlock::Text { text: s }],
Self::Blocks(b) => b,
};
blocks.push(ContentBlock::ImageUrl {
image_url: ImageUrl {
url: format!("data:image/png;base64,{}", base64_png),
detail: None,
},
});
Self::Blocks(blocks)
}
}
impl Default for MessageContent {
fn default() -> Self {
Self::Text(String::new())
}
}
impl PartialEq for MessageContent {
fn eq(&self, other: &Self) -> bool {
self.text() == other.text()
}
}
impl Eq for MessageContent {}
impl std::fmt::Display for MessageContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.text())
}
}
impl From<String> for MessageContent {
fn from(s: String) -> Self {
Self::Text(s)
}
}
impl From<&str> for MessageContent {
fn from(s: &str) -> Self {
Self::Text(s.to_string())
}
}
impl PartialEq<str> for MessageContent {
fn eq(&self, other: &str) -> bool {
self.text() == other
}
}
impl PartialEq<&str> for MessageContent {
fn eq(&self, other: &&str) -> bool {
self.text() == *other
}
}
impl PartialEq<String> for MessageContent {
fn eq(&self, other: &String) -> bool {
self.text() == other
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrl },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
#[serde(default, deserialize_with = "deserialize_nullable_content")]
pub content: MessageContent,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "reasoning")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".to_string(),
content: MessageContent::Text(content.into()),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: MessageContent::Text(content.into()),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".to_string(),
content: MessageContent::Text(content.into()),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn assistant_with_reasoning(
content: impl Into<String>,
reasoning: impl Into<String>,
) -> Self {
Self {
role: "assistant".to_string(),
content: MessageContent::Text(content.into()),
reasoning_content: Some(reasoning.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn tool(content: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
Self {
role: "tool".to_string(),
content: MessageContent::Text(content.into()),
reasoning_content: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
name: None,
}
}
pub fn strip_images(&self) -> Self {
Self {
role: self.role.clone(),
content: self.content.strip_images(),
reasoning_content: self.reasoning_content.clone(),
tool_calls: self.tool_calls.clone(),
tool_call_id: self.tool_call_id.clone(),
name: self.name.clone(),
}
}
pub fn user_multimodal(content: MessageContent) -> Self {
Self {
role: "user".to_string(),
content,
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: ToolFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub arguments: String,
}
impl ToolCall {
pub fn validate_structure(&self) -> anyhow::Result<()> {
if self.id.trim().is_empty() {
anyhow::bail!("Tool call is missing a valid 'id'");
}
if self.call_type != "function" {
anyhow::bail!(
"Tool call has unsupported type '{}', expected 'function'",
self.call_type
);
}
if self.function.name.trim().is_empty() {
anyhow::bail!("Tool call is missing a valid function name");
}
if let Err(e) = serde_json::from_str::<serde_json::Value>(&self.function.arguments) {
anyhow::bail!(
"Tool call arguments for '{}' are not valid JSON: {}",
self.function.name,
e
);
}
Ok(())
}
}
impl Usage {
pub fn validate(&self) -> anyhow::Result<()> {
let expected_total = self.prompt_tokens.saturating_add(self.completion_tokens);
if self.total_tokens != expected_total {
anyhow::bail!(
"Token usage mismatch: prompt_tokens ({}) + completion_tokens ({}) = {}, but total_tokens is {}",
self.prompt_tokens,
self.completion_tokens,
expected_total,
self.total_tokens
);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub def_type: String,
pub function: FunctionDefinition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDefinition {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<Choice>,
#[serde(default)]
pub usage: Usage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
pub index: usize,
pub message: Message,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "reasoning")]
pub reasoning_content: Option<String>,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Usage {
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub total_tokens: usize,
#[serde(default)]
pub cost: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct ChatMetadata {
pub request_body: serde_json::Value,
pub elapsed_ms: u64,
pub finish_reason: Option<String>,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub total_tokens: Option<u32>,
pub cost: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatResponseChunk {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ChoiceDelta>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChoiceDelta {
pub index: usize,
pub delta: MessageDelta,
pub finish_reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct MessageDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "reasoning")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallDelta>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ToolCallDelta {
pub index: usize,
pub id: Option<String>,
#[serde(rename = "type")]
pub call_type: Option<String>,
pub function: Option<FunctionDelta>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FunctionDelta {
pub name: Option<String>,
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionRequest {
pub model: String,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<CompletionChoice>,
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionChoice {
pub text: String,
pub index: usize,
pub finish_reason: Option<String>,
}
#[cfg(test)]
#[path = "../../tests/unit/api/types/types_test.rs"]
mod tests;