use crate::message::{Content, Message, Role};
use rustlavel_core::Json;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Usage {
pub input_tokens: u32,
pub output_tokens: u32,
}
impl Usage {
pub fn new(input_tokens: u32, output_tokens: u32) -> Usage {
Usage { input_tokens, output_tokens }
}
pub fn total(&self) -> u32 {
self.input_tokens + self.output_tokens
}
pub fn add(&mut self, other: Usage) {
self.input_tokens += other.input_tokens;
self.output_tokens += other.output_tokens;
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Json,
}
impl ToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Json) -> ToolCall {
ToolCall { id: id.into(), name: name.into(), arguments }
}
pub fn argument(&self, path: &str) -> Option<&Json> {
self.arguments.get(path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StopReason {
EndTurn,
MaxTokens,
StopSequence,
ToolUse,
Other(String),
}
impl StopReason {
pub fn parse(text: &str) -> StopReason {
match text {
"end_turn" | "stop" | "stop_sequence_reached" => StopReason::EndTurn,
"max_tokens" | "length" => StopReason::MaxTokens,
"stop_sequence" => StopReason::StopSequence,
"tool_use" | "tool_calls" | "function_call" => StopReason::ToolUse,
other => StopReason::Other(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
StopReason::EndTurn => "end_turn",
StopReason::MaxTokens => "max_tokens",
StopReason::StopSequence => "stop_sequence",
StopReason::ToolUse => "tool_use",
StopReason::Other(other) => other,
}
}
pub fn is_tool_use(&self) -> bool {
matches!(self, StopReason::ToolUse)
}
}
impl std::fmt::Display for StopReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Completion {
pub text: String,
pub tool_calls: Vec<ToolCall>,
pub stop_reason: StopReason,
pub usage: Usage,
pub model: String,
}
impl Default for Completion {
fn default() -> Completion {
Completion {
text: String::new(),
tool_calls: Vec::new(),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
model: String::new(),
}
}
}
impl Completion {
pub fn text(text: impl Into<String>) -> Completion {
Completion { text: text.into(), ..Completion::default() }
}
pub fn with_tool_call(mut self, call: ToolCall) -> Completion {
self.tool_calls.push(call);
self.stop_reason = StopReason::ToolUse;
self
}
pub fn with_usage(mut self, usage: Usage) -> Completion {
self.usage = usage;
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Completion {
self.model = model.into();
self
}
pub fn wants_tools(&self) -> bool {
!self.tool_calls.is_empty()
}
pub fn to_message(&self) -> Message {
let mut content = Vec::new();
if !self.text.is_empty() {
content.push(Content::Text(self.text.clone()));
}
for call in &self.tool_calls {
content.push(Content::ToolUse {
id: call.id.clone(),
name: call.name.clone(),
input: call.arguments.clone(),
});
}
Message::new(Role::Assistant, content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_adds_up_across_rounds() {
let mut usage = Usage::new(10, 5);
usage.add(Usage::new(3, 4));
assert_eq!(usage, Usage::new(13, 9));
assert_eq!(usage.total(), 22);
}
#[test]
fn stop_reasons_from_every_provider_map_onto_one_vocabulary() {
assert_eq!(StopReason::parse("end_turn"), StopReason::EndTurn);
assert_eq!(StopReason::parse("stop"), StopReason::EndTurn);
assert_eq!(StopReason::parse("length"), StopReason::MaxTokens);
assert_eq!(StopReason::parse("tool_calls"), StopReason::ToolUse);
assert!(StopReason::parse("tool_use").is_tool_use());
assert_eq!(StopReason::parse("weird"), StopReason::Other("weird".into()));
assert_eq!(StopReason::parse("weird").to_string(), "weird");
}
#[test]
fn a_completion_replays_as_an_assistant_turn_with_its_tool_requests() {
let completion = Completion::text("Checking the weather.").with_tool_call(ToolCall::new(
"toolu_1",
"get_weather",
Json::object([("city", "Oslo".into())]),
));
assert!(completion.wants_tools());
assert_eq!(completion.stop_reason, StopReason::ToolUse);
let message = completion.to_message();
assert_eq!(message.role, Role::Assistant);
assert_eq!(message.content.len(), 2);
assert_eq!(message.text(), "Checking the weather.");
assert_eq!(message.tool_uses().count(), 1);
}
#[test]
fn an_empty_answer_replays_without_an_empty_text_part() {
let completion = Completion::default().with_tool_call(ToolCall::new("id", "t", Json::Null));
assert_eq!(completion.to_message().content.len(), 1);
}
#[test]
fn a_tool_call_reads_its_arguments_by_path() {
let call = ToolCall::new(
"call_1",
"search",
Json::object([("query", Json::object([("text", "rust".into())]))]),
);
assert_eq!(call.argument("query.text").unwrap().as_str(), Some("rust"));
assert!(call.argument("missing").is_none());
}
}