Skip to main content

rustlavel_ai/
request.rs

1//! What gets asked: one request shape every provider is translated from.
2
3use crate::message::{Conversation, Message, Role};
4use crate::tool::Tool;
5
6/// A single call to a model.
7#[derive(Debug, Clone, Default, PartialEq)]
8pub struct Request {
9    pub model: String,
10    /// Instructions that frame the conversation. Kept apart from the messages
11    /// because Anthropic wants it as a top-level field, and because a system
12    /// prompt is configuration rather than dialogue.
13    pub system: Option<String>,
14    pub messages: Vec<Message>,
15    pub temperature: Option<f64>,
16    pub max_tokens: Option<u32>,
17    pub tools: Vec<Tool>,
18    pub stop_sequences: Vec<String>,
19}
20
21impl Request {
22    pub fn new(model: impl Into<String>) -> Request {
23        Request { model: model.into(), ..Request::default() }
24    }
25
26    pub fn model(mut self, model: impl Into<String>) -> Request {
27        self.model = model.into();
28        self
29    }
30
31    pub fn system(mut self, system: impl Into<String>) -> Request {
32        self.system = Some(system.into());
33        self
34    }
35
36    pub fn message(mut self, message: Message) -> Request {
37        self.messages.push(message);
38        self
39    }
40
41    pub fn messages(mut self, messages: impl IntoIterator<Item = Message>) -> Request {
42        self.messages.extend(messages);
43        self
44    }
45
46    pub fn conversation(self, conversation: Conversation) -> Request {
47        self.messages(conversation.into_messages())
48    }
49
50    pub fn user(self, text: impl Into<String>) -> Request {
51        self.message(Message::user(text))
52    }
53
54    pub fn assistant(self, text: impl Into<String>) -> Request {
55        self.message(Message::assistant(text))
56    }
57
58    /// How adventurous the model may be, from 0.0 to 1.0.
59    pub fn temperature(mut self, temperature: f64) -> Request {
60        self.temperature = Some(temperature);
61        self
62    }
63
64    pub fn max_tokens(mut self, max_tokens: u32) -> Request {
65        self.max_tokens = Some(max_tokens);
66        self
67    }
68
69    pub fn tool(mut self, tool: Tool) -> Request {
70        self.tools.push(tool);
71        self
72    }
73
74    pub fn tools(mut self, tools: impl IntoIterator<Item = Tool>) -> Request {
75        self.tools.extend(tools);
76        self
77    }
78
79    pub fn stop(mut self, sequence: impl Into<String>) -> Request {
80        self.stop_sequences.push(sequence.into());
81        self
82    }
83
84    /// The system prompt as the provider should see it.
85    ///
86    /// A caller can set `system` directly *or* push `Role::System` messages
87    /// into the conversation; both are honoured, joined in order, so no
88    /// instruction is silently dropped just because it arrived the other way.
89    pub fn effective_system(&self) -> Option<String> {
90        let mut parts: Vec<String> = Vec::new();
91        if let Some(system) = &self.system {
92            parts.push(system.clone());
93        }
94        for message in &self.messages {
95            if message.role == Role::System {
96                parts.push(message.text());
97            }
98        }
99        if parts.is_empty() {
100            return None;
101        }
102        Some(parts.join("\n\n"))
103    }
104
105    /// The messages minus the system ones, which every provider carries
106    /// separately or not at all.
107    pub fn dialogue(&self) -> impl Iterator<Item = &Message> {
108        self.messages.iter().filter(|message| message.role != Role::System)
109    }
110
111    /// The last thing the user said, for logs and fakes that match on it.
112    pub fn last_user_text(&self) -> Option<String> {
113        self.messages
114            .iter()
115            .rev()
116            .find(|message| message.role == Role::User)
117            .map(Message::text)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::tool::Tool;
125
126    #[test]
127    fn a_request_is_built_in_one_expression() {
128        let request = Request::new("claude-sonnet-5")
129            .system("Be brief.")
130            .user("Why is the sky blue?")
131            .temperature(0.2)
132            .max_tokens(1024)
133            .stop("END")
134            .tool(Tool::new("search", "Search the web"));
135
136        assert_eq!(request.model, "claude-sonnet-5");
137        assert_eq!(request.temperature, Some(0.2));
138        assert_eq!(request.max_tokens, Some(1024));
139        assert_eq!(request.stop_sequences, vec!["END".to_string()]);
140        assert_eq!(request.tools.len(), 1);
141        assert_eq!(request.last_user_text().as_deref(), Some("Why is the sky blue?"));
142    }
143
144    #[test]
145    fn system_messages_and_the_system_field_are_both_honoured() {
146        let request = Request::new("claude-sonnet-5")
147            .system("Be brief.")
148            .message(Message::system("Answer in English."))
149            .user("Hello");
150
151        assert_eq!(request.effective_system().unwrap(), "Be brief.\n\nAnswer in English.");
152        // The dialogue the provider sends is the conversation without them.
153        assert_eq!(request.dialogue().count(), 1);
154    }
155
156    #[test]
157    fn a_request_without_instructions_has_no_system_prompt() {
158        assert!(Request::new("claude-sonnet-5").user("Hi").effective_system().is_none());
159    }
160
161    #[test]
162    fn a_conversation_can_be_handed_over_whole() {
163        let conversation = Conversation::new().user("One").assistant("Two").user("Three");
164        let request = Request::new("claude-sonnet-5").conversation(conversation);
165
166        assert_eq!(request.messages.len(), 3);
167        assert_eq!(request.last_user_text().as_deref(), Some("Three"));
168    }
169}