1use crate::message::{Content, Message, Role};
4use rustlavel_core::Json;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub struct Usage {
13 pub input_tokens: u32,
14 pub output_tokens: u32,
15}
16
17impl Usage {
18 pub fn new(input_tokens: u32, output_tokens: u32) -> Usage {
19 Usage { input_tokens, output_tokens }
20 }
21
22 pub fn total(&self) -> u32 {
23 self.input_tokens + self.output_tokens
24 }
25
26 pub fn add(&mut self, other: Usage) {
29 self.input_tokens += other.input_tokens;
30 self.output_tokens += other.output_tokens;
31 }
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct ToolCall {
37 pub id: String,
40 pub name: String,
41 pub arguments: Json,
42}
43
44impl ToolCall {
45 pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Json) -> ToolCall {
46 ToolCall { id: id.into(), name: name.into(), arguments }
47 }
48
49 pub fn argument(&self, path: &str) -> Option<&Json> {
51 self.arguments.get(path)
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StopReason {
58 EndTurn,
60 MaxTokens,
62 StopSequence,
64 ToolUse,
66 Other(String),
68}
69
70impl StopReason {
71 pub fn parse(text: &str) -> StopReason {
76 match text {
77 "end_turn" | "stop" | "stop_sequence_reached" => StopReason::EndTurn,
78 "max_tokens" | "length" => StopReason::MaxTokens,
79 "stop_sequence" => StopReason::StopSequence,
80 "tool_use" | "tool_calls" | "function_call" => StopReason::ToolUse,
81 other => StopReason::Other(other.to_string()),
82 }
83 }
84
85 pub fn as_str(&self) -> &str {
86 match self {
87 StopReason::EndTurn => "end_turn",
88 StopReason::MaxTokens => "max_tokens",
89 StopReason::StopSequence => "stop_sequence",
90 StopReason::ToolUse => "tool_use",
91 StopReason::Other(other) => other,
92 }
93 }
94
95 pub fn is_tool_use(&self) -> bool {
96 matches!(self, StopReason::ToolUse)
97 }
98}
99
100impl std::fmt::Display for StopReason {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.write_str(self.as_str())
103 }
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub struct Completion {
109 pub text: String,
110 pub tool_calls: Vec<ToolCall>,
111 pub stop_reason: StopReason,
112 pub usage: Usage,
113 pub model: String,
116}
117
118impl Default for Completion {
119 fn default() -> Completion {
120 Completion {
121 text: String::new(),
122 tool_calls: Vec::new(),
123 stop_reason: StopReason::EndTurn,
124 usage: Usage::default(),
125 model: String::new(),
126 }
127 }
128}
129
130impl Completion {
131 pub fn text(text: impl Into<String>) -> Completion {
133 Completion { text: text.into(), ..Completion::default() }
134 }
135
136 pub fn with_tool_call(mut self, call: ToolCall) -> Completion {
137 self.tool_calls.push(call);
138 self.stop_reason = StopReason::ToolUse;
139 self
140 }
141
142 pub fn with_usage(mut self, usage: Usage) -> Completion {
143 self.usage = usage;
144 self
145 }
146
147 pub fn with_model(mut self, model: impl Into<String>) -> Completion {
148 self.model = model.into();
149 self
150 }
151
152 pub fn wants_tools(&self) -> bool {
153 !self.tool_calls.is_empty()
154 }
155
156 pub fn to_message(&self) -> Message {
162 let mut content = Vec::new();
163 if !self.text.is_empty() {
164 content.push(Content::Text(self.text.clone()));
165 }
166 for call in &self.tool_calls {
167 content.push(Content::ToolUse {
168 id: call.id.clone(),
169 name: call.name.clone(),
170 input: call.arguments.clone(),
171 });
172 }
173 Message::new(Role::Assistant, content)
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn usage_adds_up_across_rounds() {
183 let mut usage = Usage::new(10, 5);
184 usage.add(Usage::new(3, 4));
185
186 assert_eq!(usage, Usage::new(13, 9));
187 assert_eq!(usage.total(), 22);
188 }
189
190 #[test]
191 fn stop_reasons_from_every_provider_map_onto_one_vocabulary() {
192 assert_eq!(StopReason::parse("end_turn"), StopReason::EndTurn);
193 assert_eq!(StopReason::parse("stop"), StopReason::EndTurn);
194 assert_eq!(StopReason::parse("length"), StopReason::MaxTokens);
195 assert_eq!(StopReason::parse("tool_calls"), StopReason::ToolUse);
196 assert!(StopReason::parse("tool_use").is_tool_use());
197 assert_eq!(StopReason::parse("weird"), StopReason::Other("weird".into()));
198 assert_eq!(StopReason::parse("weird").to_string(), "weird");
199 }
200
201 #[test]
202 fn a_completion_replays_as_an_assistant_turn_with_its_tool_requests() {
203 let completion = Completion::text("Checking the weather.").with_tool_call(ToolCall::new(
204 "toolu_1",
205 "get_weather",
206 Json::object([("city", "Oslo".into())]),
207 ));
208
209 assert!(completion.wants_tools());
210 assert_eq!(completion.stop_reason, StopReason::ToolUse);
211
212 let message = completion.to_message();
213 assert_eq!(message.role, Role::Assistant);
214 assert_eq!(message.content.len(), 2);
215 assert_eq!(message.text(), "Checking the weather.");
216 assert_eq!(message.tool_uses().count(), 1);
217 }
218
219 #[test]
220 fn an_empty_answer_replays_without_an_empty_text_part() {
221 let completion = Completion::default().with_tool_call(ToolCall::new("id", "t", Json::Null));
222
223 assert_eq!(completion.to_message().content.len(), 1);
224 }
225
226 #[test]
227 fn a_tool_call_reads_its_arguments_by_path() {
228 let call = ToolCall::new(
229 "call_1",
230 "search",
231 Json::object([("query", Json::object([("text", "rust".into())]))]),
232 );
233
234 assert_eq!(call.argument("query.text").unwrap().as_str(), Some("rust"));
235 assert!(call.argument("missing").is_none());
236 }
237}