use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::LowLevelClient;
#[derive(Debug, Clone)]
pub struct CompletionRequest {
pub messages: Vec<MessageContent>,
pub system_prompt: Option<String>,
pub parameters: HashMap<String, Value>,
pub stream: bool,
}
impl CompletionRequest {
pub fn new(messages: Vec<MessageContent>) -> Self {
Self {
messages,
system_prompt: None,
parameters: HashMap::new(),
stream: false,
}
}
pub fn text(role: &str, content: &str) -> Self {
Self::new(vec![MessageContent::Text {
role: role.to_string(),
content: content.to_string(),
}])
}
pub fn multimodal(role: &str, content_blocks: Vec<ContentBlock>) -> Self {
Self::new(vec![MessageContent::Multimodal {
role: role.to_string(),
content: content_blocks,
}])
}
pub fn with_system_prompt(mut self, system_prompt: String) -> Self {
self.system_prompt = Some(system_prompt);
self
}
pub fn with_parameter<T: Into<Value>>(mut self, key: &str, value: T) -> Self {
self.parameters.insert(key.to_string(), value.into());
self
}
pub fn with_streaming(mut self, stream: bool) -> Self {
self.stream = stream;
self
}
}
#[derive(Debug, Clone)]
pub enum MessageContent {
Text {
role: String,
content: String
},
Multimodal {
role: String,
content: Vec<ContentBlock>
},
}
impl MessageContent {
pub fn role(&self) -> &str {
match self {
MessageContent::Text { role, .. } => role,
MessageContent::Multimodal { role, .. } => role,
}
}
}
#[derive(Debug, Clone)]
pub enum ContentBlock {
Text(String),
Binary {
data: Vec<u8>,
mime_type: String,
filename: Option<String>,
},
Url {
url: String,
mime_type: Option<String>,
},
}
impl ContentBlock {
pub fn text(content: &str) -> Self {
Self::Text(content.to_string())
}
pub fn image(data: Vec<u8>, format: ImageFormat) -> Self {
let mime_type = match format {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::Gif => "image/gif",
ImageFormat::Webp => "image/webp",
};
Self::Binary {
data,
mime_type: mime_type.to_string(),
filename: None,
}
}
pub fn audio(data: Vec<u8>, format: AudioFormat, filename: Option<String>) -> Self {
let mime_type = match format {
AudioFormat::Wav => "audio/wav",
AudioFormat::Mp3 => "audio/mp3",
AudioFormat::M4a => "audio/m4a",
AudioFormat::Flac => "audio/flac",
AudioFormat::Ogg => "audio/ogg",
};
Self::Binary {
data,
mime_type: mime_type.to_string(),
filename,
}
}
pub fn document(data: Vec<u8>, format: DocumentFormat, filename: Option<String>) -> Self {
let mime_type = match format {
DocumentFormat::Pdf => "application/pdf",
DocumentFormat::Txt => "text/plain",
DocumentFormat::Csv => "text/csv",
DocumentFormat::Doc => "application/msword",
DocumentFormat::Docx => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};
Self::Binary {
data,
mime_type: mime_type.to_string(),
filename,
}
}
pub fn image_url(url: &str) -> Self {
Self::Url {
url: url.to_string(),
mime_type: Some("image/*".to_string()),
}
}
pub fn binary(data: Vec<u8>, mime_type: &str, filename: Option<String>) -> Self {
Self::Binary {
data,
mime_type: mime_type.to_string(),
filename,
}
}
pub fn url(url: &str, mime_type: Option<&str>) -> Self {
Self::Url {
url: url.to_string(),
mime_type: mime_type.map(|s| s.to_string()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Jpeg,
Png,
Gif,
Webp,
}
impl ImageFormat {
pub fn mime_type(&self) -> &'static str {
match self {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::Gif => "image/gif",
ImageFormat::Webp => "image/webp",
}
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
"png" => Some(ImageFormat::Png),
"gif" => Some(ImageFormat::Gif),
"webp" => Some(ImageFormat::Webp),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioFormat {
Wav,
Mp3,
M4a,
Flac,
Ogg,
}
impl AudioFormat {
pub fn mime_type(&self) -> &'static str {
match self {
AudioFormat::Wav => "audio/wav",
AudioFormat::Mp3 => "audio/mp3",
AudioFormat::M4a => "audio/m4a",
AudioFormat::Flac => "audio/flac",
AudioFormat::Ogg => "audio/ogg",
}
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_lowercase().as_str() {
"wav" => Some(AudioFormat::Wav),
"mp3" => Some(AudioFormat::Mp3),
"m4a" => Some(AudioFormat::M4a),
"flac" => Some(AudioFormat::Flac),
"ogg" => Some(AudioFormat::Ogg),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentFormat {
Pdf,
Txt,
Csv,
Doc,
Docx,
}
impl DocumentFormat {
pub fn mime_type(&self) -> &'static str {
match self {
DocumentFormat::Pdf => "application/pdf",
DocumentFormat::Txt => "text/plain",
DocumentFormat::Csv => "text/csv",
DocumentFormat::Doc => "application/msword",
DocumentFormat::Docx => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_lowercase().as_str() {
"pdf" => Some(DocumentFormat::Pdf),
"txt" => Some(DocumentFormat::Txt),
"csv" => Some(DocumentFormat::Csv),
"doc" => Some(DocumentFormat::Doc),
"docx" => Some(DocumentFormat::Docx),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct MessageList {
messages: Vec<Message>,
}
impl MessageList {
pub fn new() -> Self {
Self {
messages: Vec::new(),
}
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
messages: Vec::with_capacity(capacity),
}
}
pub fn from_vec(messages: Vec<Message>) -> Self {
Self { messages }
}
pub fn push(&mut self, message: Message) {
self.messages.push(message);
}
pub fn get(&self, index: usize) -> Option<&Message> {
self.messages.get(index)
}
pub fn get_mut(&mut self, index: usize) -> Option<&mut Message> {
self.messages.get_mut(index)
}
pub fn set(&mut self, index: usize, message: Message) -> Option<Message> {
if index < self.messages.len() {
Some(std::mem::replace(&mut self.messages[index], message))
} else {
None
}
}
pub fn remove(&mut self, index: usize) -> Option<Message> {
if index < self.messages.len() {
Some(self.messages.remove(index))
} else {
None
}
}
pub fn insert(&mut self, index: usize, message: Message) {
self.messages.insert(index, message);
}
pub fn clear(&mut self) -> Vec<Message> {
std::mem::take(&mut self.messages)
}
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Message> {
self.messages.iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Message> {
self.messages.iter_mut()
}
pub fn as_slice(&self) -> &[Message] {
&self.messages
}
pub fn into_vec(self) -> Vec<Message> {
self.messages
}
pub fn to_vec(&self) -> Vec<Message> {
self.messages.clone()
}
pub fn extend<I: IntoIterator<Item = Message>>(&mut self, iter: I) {
self.messages.extend(iter);
}
pub fn retain<F>(&mut self, f: F)
where
F: FnMut(&Message) -> bool,
{
self.messages.retain(f);
}
}
impl From<Vec<Message>> for MessageList {
fn from(messages: Vec<Message>) -> Self {
Self { messages }
}
}
impl From<MessageList> for Vec<Message> {
fn from(list: MessageList) -> Self {
list.messages
}
}
impl IntoIterator for MessageList {
type Item = Message;
type IntoIter = std::vec::IntoIter<Message>;
fn into_iter(self) -> Self::IntoIter {
self.messages.into_iter()
}
}
impl<'a> IntoIterator for &'a MessageList {
type Item = &'a Message;
type IntoIter = std::slice::Iter<'a, Message>;
fn into_iter(self) -> Self::IntoIter {
self.messages.iter()
}
}
impl std::ops::Index<usize> for MessageList {
type Output = Message;
fn index(&self, index: usize) -> &Self::Output {
&self.messages[index]
}
}
impl std::ops::IndexMut<usize> for MessageList {
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.messages[index]
}
}
#[derive(Debug, Clone)]
pub struct Message {
role: String,
content: Vec<ContentBlock>,
}
impl Message {
pub fn user(text: &str) -> Self {
Self {
role: "user".to_string(),
content: vec![ContentBlock::text(text)],
}
}
pub fn assistant(text: &str) -> Self {
Self {
role: "assistant".to_string(),
content: vec![ContentBlock::text(text)],
}
}
pub fn system(text: &str) -> Self {
Self {
role: "system".to_string(),
content: vec![ContentBlock::text(text)],
}
}
pub fn custom(role: &str) -> Self {
Self {
role: role.to_string(),
content: vec![],
}
}
pub fn user_multimodal(content: Vec<ContentBlock>) -> Self {
Self {
role: "user".to_string(),
content,
}
}
pub fn add_text(mut self, text: &str) -> Self {
self.content.push(ContentBlock::text(text));
self
}
pub fn add_image(mut self, data: Vec<u8>, format: ImageFormat) -> Self {
self.content.push(ContentBlock::image(data, format));
self
}
pub fn add_image_url(mut self, url: &str) -> Self {
self.content.push(ContentBlock::image_url(url));
self
}
pub fn add_audio(mut self, data: Vec<u8>, format: AudioFormat, filename: Option<String>) -> Self {
self.content.push(ContentBlock::audio(data, format, filename));
self
}
pub fn add_document(mut self, data: Vec<u8>, format: DocumentFormat, filename: Option<String>) -> Self {
self.content.push(ContentBlock::document(data, format, filename));
self
}
pub fn add_content(mut self, block: ContentBlock) -> Self {
self.content.push(block);
self
}
pub fn content(mut self, blocks: Vec<ContentBlock>) -> Self {
self.content = blocks;
self
}
pub(crate) fn into_message_content(self) -> MessageContent {
if self.content.len() == 1 {
if let ContentBlock::Text(text) = &self.content[0] {
return MessageContent::Text {
role: self.role,
content: text.clone(),
};
}
}
MessageContent::Multimodal {
role: self.role,
content: self.content,
}
}
pub fn role(&self) -> &str {
&self.role
}
}
#[derive(Debug, Clone)]
pub struct CompletionResponse {
pub content: String,
pub role: Option<String>,
pub finish_reason: Option<String>,
pub usage: Option<Usage>,
pub raw_response: Value,
}
impl CompletionResponse {
pub fn text(&self) -> &str {
&self.content
}
pub fn cost(&self) -> Option<f64> {
self.usage.as_ref().and({
None
})
}
}
pub struct RequestBuilder {
pub(crate) model_id: String,
pub(crate) request: CompletionRequest,
pub(crate) config_provider: Arc<dyn crate::client::ConfigProvider + Send + Sync>,
}
impl RequestBuilder {
pub(crate) fn new(
model_id: String,
config_provider: Arc<dyn crate::client::ConfigProvider + Send + Sync>
) -> crate::error::Result<Self> {
Ok(Self {
model_id,
request: CompletionRequest::new(vec![]),
config_provider,
})
}
pub fn model_id(&self) -> &str {
&self.model_id
}
pub fn system(mut self, prompt: &str) -> Self {
self.request.system_prompt = Some(prompt.to_string());
self
}
pub fn temperature(mut self, temp: f64) -> Self {
self.request.parameters.insert("temperature".to_string(), Value::Number(
serde_json::Number::from_f64(temp).unwrap_or_else(|| serde_json::Number::from(0))
));
self
}
pub fn max_tokens(mut self, tokens: u32) -> Self {
self.request.parameters.insert("max_tokens".to_string(), Value::Number(
serde_json::Number::from(tokens)
));
self
}
pub fn top_p(mut self, top_p: f64) -> Self {
self.request.parameters.insert("top_p".to_string(), Value::Number(
serde_json::Number::from_f64(top_p).unwrap_or_else(|| serde_json::Number::from(0))
));
self
}
pub fn parameter<T: Into<Value>>(mut self, key: &str, value: T) -> Self {
self.request.parameters.insert(key.to_string(), value.into());
self
}
pub fn prompt(mut self, content: &str) -> Self {
self.request.messages = vec![MessageContent::Text {
role: "user".to_string(),
content: content.to_string(),
}];
self
}
pub fn append_message(mut self, message: Message) -> Self {
self.request.messages.push(message.into_message_content());
self
}
pub fn messages<T: Into<Vec<Message>>>(mut self, messages: T) -> Self {
self.request.messages = messages
.into()
.into_iter()
.map(|m| m.into_message_content())
.collect();
self
}
pub async fn send(self) -> crate::error::Result<CompletionResponse> {
if self.request.messages.is_empty() {
return Err(crate::error::Error::ValidationError(
"No messages set. Use .prompt(text) or .send_text(text) instead.".to_string()
));
}
let client = crate::client::HttpClient::from_model_id(&*self.config_provider, &self.model_id)?;
client.complete(&self.request).await
}
pub async fn send_text(mut self, content: &str) -> crate::error::Result<CompletionResponse> {
let message = MessageContent::Text {
role: "user".to_string(),
content: content.to_string(),
};
self.request.messages = vec![message];
let client = crate::client::HttpClient::from_model_id(&*self.config_provider, &self.model_id)?;
client.complete(&self.request).await
}
pub async fn send_multimodal(mut self, content_blocks: Vec<ContentBlock>) -> crate::error::Result<CompletionResponse> {
let message = MessageContent::Multimodal {
role: "user".to_string(),
content: content_blocks,
};
self.request.messages = vec![message];
let client = crate::client::HttpClient::from_model_id(&*self.config_provider, &self.model_id)?;
client.complete(&self.request).await
}
pub async fn stream(mut self) -> crate::error::Result<crate::streaming::Stream> {
if self.request.messages.is_empty() {
return Err(crate::error::Error::ValidationError(
"No messages set. Use .prompt(text) or .stream_text(text) instead.".to_string()
));
}
self.request.stream = true;
let client = crate::client::HttpClient::from_model_id(&*self.config_provider, &self.model_id)?;
client.complete_stream(&self.request).await
}
pub async fn stream_text(mut self, content: &str) -> crate::error::Result<crate::streaming::Stream> {
let message = MessageContent::Text {
role: "user".to_string(),
content: content.to_string(),
};
self.request.messages = vec![message];
self.request.stream = true;
let client = crate::client::HttpClient::from_model_id(&*self.config_provider, &self.model_id)?;
client.complete_stream(&self.request).await
}
pub fn chat(self, chatter_id: crate::chat::ChatterId) -> crate::chat::ChatBuilder {
crate::chat::ChatBuilder::new(self, chatter_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Usage {
pub input_tokens: u32,
pub output_tokens: u32,
pub total_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_tokens: Option<u32>,
}
impl Usage {
pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
Self {
input_tokens,
output_tokens,
total_tokens: Some(input_tokens + output_tokens),
cache_read_tokens: None,
cache_creation_tokens: None,
}
}
pub fn with_cache(
input_tokens: u32,
output_tokens: u32,
cache_read_tokens: Option<u32>,
cache_creation_tokens: Option<u32>,
) -> Self {
Self {
input_tokens,
output_tokens,
total_tokens: Some(input_tokens + output_tokens),
cache_read_tokens,
cache_creation_tokens,
}
}
pub fn calculate_cost(&self, input_cost_per_1k: f64, output_cost_per_1k: f64) -> f64 {
let input_cost = (self.input_tokens as f64 / 1000.0) * input_cost_per_1k;
let output_cost = (self.output_tokens as f64 / 1000.0) * output_cost_per_1k;
input_cost + output_cost
}
pub fn calculate_cost_with_cache(
&self,
input_cost_per_1k: f64,
output_cost_per_1k: f64,
cache_read_cost_per_1k: f64,
cache_creation_cost_per_1k: f64,
) -> f64 {
let input_cost = (self.input_tokens as f64 / 1000.0) * input_cost_per_1k;
let output_cost = (self.output_tokens as f64 / 1000.0) * output_cost_per_1k;
let cache_read_cost = self.cache_read_tokens
.map(|t| (t as f64 / 1000.0) * cache_read_cost_per_1k)
.unwrap_or(0.0);
let cache_creation_cost = self.cache_creation_tokens
.map(|t| (t as f64 / 1000.0) * cache_creation_cost_per_1k)
.unwrap_or(0.0);
input_cost + output_cost + cache_read_cost + cache_creation_cost
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct UsagePathConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_tokens: Option<String>,
}
impl UsagePathConfig {
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> UsagePathBuilder {
UsagePathBuilder::new()
}
pub fn openai() -> Self {
Self::builder()
.input_tokens("usage.prompt_tokens")
.output_tokens("usage.completion_tokens")
.total_tokens("usage.total_tokens")
.build()
}
pub fn anthropic() -> Self {
Self::builder()
.input_tokens("usage.input_tokens")
.output_tokens("usage.output_tokens")
.cache_read_tokens("usage.cache_read_input_tokens")
.cache_creation_tokens("usage.cache_creation_input_tokens")
.build()
}
pub fn google() -> Self {
Self::builder()
.input_tokens("usageMetadata.promptTokenCount")
.output_tokens("usageMetadata.candidatesTokenCount")
.total_tokens("usageMetadata.totalTokenCount")
.build()
}
pub fn extract(&self, value: &Value) -> Usage {
let input_tokens = self.input_tokens.as_ref()
.and_then(|path| extract_json_path(path, value))
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(0);
let output_tokens = self.output_tokens.as_ref()
.and_then(|path| extract_json_path(path, value))
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(0);
let total_tokens = self.total_tokens.as_ref()
.and_then(|path| extract_json_path(path, value))
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let cache_read_tokens = self.cache_read_tokens.as_ref()
.and_then(|path| extract_json_path(path, value))
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let cache_creation_tokens = self.cache_creation_tokens.as_ref()
.and_then(|path| extract_json_path(path, value))
.and_then(|v| v.as_u64())
.map(|v| v as u32);
Usage {
input_tokens,
output_tokens,
total_tokens,
cache_read_tokens,
cache_creation_tokens,
}
}
pub fn is_empty(&self) -> bool {
self.input_tokens.is_none()
&& self.output_tokens.is_none()
&& self.total_tokens.is_none()
&& self.cache_read_tokens.is_none()
&& self.cache_creation_tokens.is_none()
}
}
#[derive(Debug, Clone, Default)]
pub struct UsagePathBuilder {
input_tokens: Option<String>,
output_tokens: Option<String>,
total_tokens: Option<String>,
cache_read_tokens: Option<String>,
cache_creation_tokens: Option<String>,
}
impl UsagePathBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn input_tokens(mut self, path: impl Into<String>) -> Self {
self.input_tokens = Some(path.into());
self
}
pub fn output_tokens(mut self, path: impl Into<String>) -> Self {
self.output_tokens = Some(path.into());
self
}
pub fn total_tokens(mut self, path: impl Into<String>) -> Self {
self.total_tokens = Some(path.into());
self
}
pub fn cache_read_tokens(mut self, path: impl Into<String>) -> Self {
self.cache_read_tokens = Some(path.into());
self
}
pub fn cache_creation_tokens(mut self, path: impl Into<String>) -> Self {
self.cache_creation_tokens = Some(path.into());
self
}
pub fn build(self) -> UsagePathConfig {
UsagePathConfig {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
total_tokens: self.total_tokens,
cache_read_tokens: self.cache_read_tokens,
cache_creation_tokens: self.cache_creation_tokens,
}
}
}
fn extract_json_path(path: &str, json: &Value) -> Option<Value> {
let mut current = json;
let mut i = 0;
let chars: Vec<char> = path.chars().collect();
while i < chars.len() {
let mut part_end = i;
while part_end < chars.len() && chars[part_end] != '.' && chars[part_end] != '[' {
part_end += 1;
}
if part_end > i {
let key: String = chars[i..part_end].iter().collect();
current = current.get(&key)?;
i = part_end;
}
if i < chars.len() && chars[i] == '[' {
i += 1; let mut index_end = i;
while index_end < chars.len() && chars[index_end] != ']' {
index_end += 1;
}
if index_end >= chars.len() {
return None; }
let index_str: String = chars[i..index_end].iter().collect();
let index: usize = index_str.parse().ok()?;
current = current.get(index)?;
i = index_end + 1; }
if i < chars.len() && chars[i] == '.' {
i += 1;
}
}
Some(current.clone())
}
pub trait FromFile {
fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, std::io::Error>
where
Self: Sized;
}
impl FromFile for ContentBlock {
fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, std::io::Error> {
let path = path.as_ref();
let data = std::fs::read(path)?;
let filename = path.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if let Some(image_format) = ImageFormat::from_extension(ext) {
return Ok(Self::image(data, image_format));
}
if let Some(audio_format) = AudioFormat::from_extension(ext) {
return Ok(Self::audio(data, audio_format, filename));
}
if let Some(doc_format) = DocumentFormat::from_extension(ext) {
return Ok(Self::document(data, doc_format, filename));
}
}
Ok(Self::binary(data, "application/octet-stream", filename))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_image_format_detection() {
assert_eq!(ImageFormat::from_extension("jpg"), Some(ImageFormat::Jpeg));
assert_eq!(ImageFormat::from_extension("PNG"), Some(ImageFormat::Png));
assert_eq!(ImageFormat::from_extension("unknown"), None);
}
#[test]
fn test_content_block_builders() {
let text_block = ContentBlock::text("Hello world");
match text_block {
ContentBlock::Text(content) => assert_eq!(content, "Hello world"),
_ => panic!("Expected text block"),
}
let image_data = vec![0xFF, 0xD8, 0xFF]; let image_block = ContentBlock::image(image_data.clone(), ImageFormat::Jpeg);
match image_block {
ContentBlock::Binary { data, mime_type, .. } => {
assert_eq!(data, image_data);
assert_eq!(mime_type, "image/jpeg");
},
_ => panic!("Expected binary block"),
}
let url_block = ContentBlock::image_url("https://example.com/image.jpg");
match url_block {
ContentBlock::Url { url, mime_type } => {
assert_eq!(url, "https://example.com/image.jpg");
assert_eq!(mime_type, Some("image/*".to_string()));
},
_ => panic!("Expected URL block"),
}
}
#[test]
fn test_completion_request_builders() {
let request = CompletionRequest::text("user", "Hello")
.with_system_prompt("You are helpful".to_string())
.with_parameter("temperature", 0.7)
.with_streaming(true);
assert_eq!(request.system_prompt, Some("You are helpful".to_string()));
assert!(request.stream);
let multimodal_request = CompletionRequest::multimodal("user", vec![
ContentBlock::text("What's this?"),
ContentBlock::image(vec![1, 2, 3], ImageFormat::Png),
]);
match &multimodal_request.messages[0] {
MessageContent::Multimodal { role, content } => {
assert_eq!(role, "user");
assert_eq!(content.len(), 2);
},
_ => panic!("Expected multimodal message"),
}
}
#[test]
fn test_usage_calculations() {
let usage = Usage::new(1000, 500);
assert_eq!(usage.total_tokens, Some(1500));
let cost = usage.calculate_cost(0.01, 0.03); assert_eq!(cost, 0.025); }
#[test]
fn test_usage_path_builder_basic() {
let config = UsagePathBuilder::new()
.input_tokens("usage.prompt_tokens")
.output_tokens("usage.completion_tokens")
.build();
assert_eq!(config.input_tokens, Some("usage.prompt_tokens".to_string()));
assert_eq!(config.output_tokens, Some("usage.completion_tokens".to_string()));
assert_eq!(config.total_tokens, None);
assert_eq!(config.cache_read_tokens, None);
assert_eq!(config.cache_creation_tokens, None);
}
#[test]
fn test_usage_path_builder_full() {
let config = UsagePathBuilder::new()
.input_tokens("usage.input_tokens")
.output_tokens("usage.output_tokens")
.total_tokens("usage.total_tokens")
.cache_read_tokens("usage.cache_read")
.cache_creation_tokens("usage.cache_creation")
.build();
assert_eq!(config.input_tokens, Some("usage.input_tokens".to_string()));
assert_eq!(config.output_tokens, Some("usage.output_tokens".to_string()));
assert_eq!(config.total_tokens, Some("usage.total_tokens".to_string()));
assert_eq!(config.cache_read_tokens, Some("usage.cache_read".to_string()));
assert_eq!(config.cache_creation_tokens, Some("usage.cache_creation".to_string()));
}
#[test]
fn test_usage_path_config_openai_preset() {
let config = UsagePathConfig::openai();
assert_eq!(config.input_tokens, Some("usage.prompt_tokens".to_string()));
assert_eq!(config.output_tokens, Some("usage.completion_tokens".to_string()));
assert_eq!(config.total_tokens, Some("usage.total_tokens".to_string()));
assert_eq!(config.cache_read_tokens, None);
}
#[test]
fn test_usage_path_config_anthropic_preset() {
let config = UsagePathConfig::anthropic();
assert_eq!(config.input_tokens, Some("usage.input_tokens".to_string()));
assert_eq!(config.output_tokens, Some("usage.output_tokens".to_string()));
assert_eq!(config.total_tokens, None);
assert_eq!(config.cache_read_tokens, Some("usage.cache_read_input_tokens".to_string()));
assert_eq!(config.cache_creation_tokens, Some("usage.cache_creation_input_tokens".to_string()));
}
#[test]
fn test_usage_path_config_google_preset() {
let config = UsagePathConfig::google();
assert_eq!(config.input_tokens, Some("usageMetadata.promptTokenCount".to_string()));
assert_eq!(config.output_tokens, Some("usageMetadata.candidatesTokenCount".to_string()));
assert_eq!(config.total_tokens, Some("usageMetadata.totalTokenCount".to_string()));
}
#[test]
fn test_usage_extract_openai_response() {
let config = UsagePathConfig::openai();
let response = json!({
"id": "chatcmpl-123",
"choices": [{
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150
}
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.total_tokens, Some(150));
assert_eq!(usage.cache_read_tokens, None);
assert_eq!(usage.cache_creation_tokens, None);
}
#[test]
fn test_usage_extract_anthropic_response() {
let config = UsagePathConfig::anthropic();
let response = json!({
"id": "msg_123",
"role": "assistant",
"content": [{"type": "text", "text": "Hello!"}],
"usage": {
"input_tokens": 200,
"output_tokens": 75,
"cache_read_input_tokens": 50,
"cache_creation_input_tokens": 25
}
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.output_tokens, 75);
assert_eq!(usage.total_tokens, None);
assert_eq!(usage.cache_read_tokens, Some(50));
assert_eq!(usage.cache_creation_tokens, Some(25));
}
#[test]
fn test_usage_extract_google_response() {
let config = UsagePathConfig::google();
let response = json!({
"candidates": [{
"content": {"parts": [{"text": "Hello!"}], "role": "model"}
}],
"usageMetadata": {
"promptTokenCount": 150,
"candidatesTokenCount": 80,
"totalTokenCount": 230
}
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 150);
assert_eq!(usage.output_tokens, 80);
assert_eq!(usage.total_tokens, Some(230));
}
#[test]
fn test_usage_extract_with_array_index() {
let config = UsagePathBuilder::new()
.input_tokens("responses[0].usage.input")
.output_tokens("responses[0].usage.output")
.build();
let response = json!({
"responses": [{
"usage": {
"input": 300,
"output": 125
}
}]
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 300);
assert_eq!(usage.output_tokens, 125);
}
#[test]
fn test_usage_extract_nested_array() {
let config = UsagePathBuilder::new()
.input_tokens("data[0].results[1].tokens.input")
.output_tokens("data[0].results[1].tokens.output")
.build();
let response = json!({
"data": [{
"results": [
{"tokens": {"input": 10, "output": 5}},
{"tokens": {"input": 400, "output": 200}}
]
}]
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 400);
assert_eq!(usage.output_tokens, 200);
}
#[test]
fn test_usage_extract_missing_path_returns_zero() {
let config = UsagePathConfig::openai();
let response = json!({
"id": "chatcmpl-123",
"choices": [{"message": {"content": "Hello!"}}]
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.total_tokens, None);
}
#[test]
fn test_usage_extract_partial_response() {
let config = UsagePathConfig::openai();
let response = json!({
"usage": {
"prompt_tokens": 100
}
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.total_tokens, None);
}
#[test]
fn test_usage_extract_empty_config() {
let config = UsagePathConfig::new();
let response = json!({
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50
}
});
let usage = config.extract(&response);
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.total_tokens, None);
}
#[test]
fn test_usage_path_config_is_empty() {
let empty = UsagePathConfig::new();
assert!(empty.is_empty());
let with_input = UsagePathBuilder::new()
.input_tokens("usage.input")
.build();
assert!(!with_input.is_empty());
let full = UsagePathConfig::openai();
assert!(!full.is_empty());
}
#[test]
fn test_usage_path_config_serialization() {
let config = UsagePathConfig::openai();
let serialized = serde_json::to_string(&config).unwrap();
let deserialized: UsagePathConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_usage_path_config_yaml_deserialization() {
let yaml = r#"
input_tokens: usage.prompt_tokens
output_tokens: usage.completion_tokens
total_tokens: usage.total_tokens
"#;
let config: UsagePathConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.input_tokens, Some("usage.prompt_tokens".to_string()));
assert_eq!(config.output_tokens, Some("usage.completion_tokens".to_string()));
assert_eq!(config.total_tokens, Some("usage.total_tokens".to_string()));
}
#[test]
fn test_extract_json_path_simple() {
let json = json!({"usage": {"tokens": 100}});
let result = extract_json_path("usage.tokens", &json);
assert_eq!(result, Some(json!(100)));
}
#[test]
fn test_extract_json_path_array() {
let json = json!({"items": [{"value": 1}, {"value": 2}]});
let result = extract_json_path("items[1].value", &json);
assert_eq!(result, Some(json!(2)));
}
#[test]
fn test_extract_json_path_deeply_nested() {
let json = json!({
"level1": {
"level2": {
"level3": {
"value": "deep"
}
}
}
});
let result = extract_json_path("level1.level2.level3.value", &json);
assert_eq!(result, Some(json!("deep")));
}
#[test]
fn test_extract_json_path_invalid() {
let json = json!({"usage": {"tokens": 100}});
let result = extract_json_path("nonexistent.path", &json);
assert_eq!(result, None);
let result = extract_json_path("usage[0]", &json);
assert_eq!(result, None);
}
#[test]
fn test_extract_json_path_unclosed_bracket() {
let json = json!({"items": [{"value": 1}]});
let result = extract_json_path("items[0", &json);
assert_eq!(result, None);
}
#[test]
fn test_usage_with_cache_constructor() {
let usage = Usage::with_cache(1000, 500, Some(200), Some(100));
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.total_tokens, Some(1500));
assert_eq!(usage.cache_read_tokens, Some(200));
assert_eq!(usage.cache_creation_tokens, Some(100));
}
#[test]
fn test_usage_calculate_cost_with_cache() {
let usage = Usage::with_cache(1000, 500, Some(1000), Some(500));
let cost = usage.calculate_cost_with_cache(0.01, 0.03, 0.005, 0.02);
assert!((cost - 0.04).abs() < 0.0001);
}
#[test]
fn test_usage_calculate_cost_with_cache_no_cache_used() {
let usage = Usage::new(1000, 500);
let cost = usage.calculate_cost_with_cache(0.01, 0.03, 0.005, 0.02);
assert!((cost - 0.025).abs() < 0.0001);
}
#[test]
fn test_streaming_usage_path_extraction() {
let config = UsagePathBuilder::new()
.input_tokens("usage.prompt_tokens")
.output_tokens("usage.completion_tokens")
.total_tokens("usage.total_tokens")
.build();
let streaming_chunk = json!({
"id": "chatcmpl-123",
"choices": [],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 25,
"total_tokens": 75
}
});
let usage = config.extract(&streaming_chunk);
assert_eq!(usage.input_tokens, 50);
assert_eq!(usage.output_tokens, 25);
assert_eq!(usage.total_tokens, Some(75));
}
#[test]
fn test_anthropic_streaming_usage_paths() {
let message_start_config = UsagePathBuilder::new()
.input_tokens("message.usage.input_tokens")
.cache_read_tokens("message.usage.cache_read_input_tokens")
.build();
let message_start = json!({
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_read_input_tokens": 50
}
}
});
let usage = message_start_config.extract(&message_start);
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.cache_read_tokens, Some(50));
let message_delta_config = UsagePathBuilder::new()
.output_tokens("usage.output_tokens")
.build();
let message_delta = json!({
"type": "message_delta",
"usage": {
"output_tokens": 75
}
});
let delta_usage = message_delta_config.extract(&message_delta);
assert_eq!(delta_usage.output_tokens, 75);
}
#[test]
fn test_usage_default() {
let usage = Usage::default();
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.total_tokens, None);
assert_eq!(usage.cache_read_tokens, None);
assert_eq!(usage.cache_creation_tokens, None);
}
#[test]
fn test_usage_path_from_builder_via_config() {
let config = UsagePathConfig::builder()
.input_tokens("a.b.c")
.output_tokens("x.y.z")
.build();
assert_eq!(config.input_tokens, Some("a.b.c".to_string()));
assert_eq!(config.output_tokens, Some("x.y.z".to_string()));
}
}