Skip to main content

rustlavel_ai/
completion.rs

1//! What a model gives back: text, tool calls, why it stopped, what it cost.
2
3use crate::message::{Content, Message, Role};
4use rustlavel_core::Json;
5
6/// How many tokens a call consumed.
7///
8/// Providers disagree about the names (`input_tokens`, `prompt_tokens`,
9/// `prompt_eval_count`) but not the meaning, so they are normalised here —
10/// this is what Telescope charts and what a bill is made of.
11#[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    /// Add another call's usage, for a multi-round tool loop that should be
27    /// billed as one operation.
28    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/// One tool the model wants run, with the arguments it chose.
35#[derive(Debug, Clone, PartialEq)]
36pub struct ToolCall {
37    /// The provider's id for this call. It has to be echoed back with the
38    /// result so the model can match answer to question.
39    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    /// One named argument, by dotted path.
50    pub fn argument(&self, path: &str) -> Option<&Json> {
51        self.arguments.get(path)
52    }
53}
54
55/// Why the model stopped generating.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StopReason {
58    /// It finished what it had to say.
59    EndTurn,
60    /// It ran into `max_tokens` — the text is probably truncated.
61    MaxTokens,
62    /// It hit one of the caller's stop sequences.
63    StopSequence,
64    /// It is waiting for tool results.
65    ToolUse,
66    /// Something provider-specific; kept verbatim rather than guessed at.
67    Other(String),
68}
69
70impl StopReason {
71    /// Map a provider's word onto ours.
72    ///
73    /// The vocabularies genuinely overlap (`end_turn`/`stop`, `max_tokens`/
74    /// `length`), so one table serves all three providers.
75    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/// One answer from a model.
107#[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    /// The model that actually answered, which is not always the one asked
114    /// for — providers alias and pin.
115    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    /// A plain text answer, mostly for fakes and tests.
132    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    /// Replay this answer as an assistant turn.
157    ///
158    /// The tool loop has to put the assistant's own words *and* its tool
159    /// requests back into the conversation, or the provider rejects the
160    /// tool results as answers to a question nobody asked.
161    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}