Skip to main content

ic_llm/
chat.rs

1use crate::tool::Tool;
2use candid::{CandidType, Principal};
3use serde::{Deserialize, Serialize};
4
5/// A message in a chat.
6#[derive(CandidType, Serialize, Deserialize, Debug, Clone, PartialEq)]
7pub enum ChatMessage {
8    #[serde(rename = "user")]
9    User { content: String },
10    #[serde(rename = "system")]
11    System { content: String },
12    #[serde(rename = "assistant")]
13    Assistant(AssistantMessage),
14    #[serde(rename = "tool")]
15    Tool {
16        content: String,
17        tool_call_id: String,
18    },
19}
20
21#[derive(CandidType, Clone, Deserialize, Serialize, Debug)]
22pub struct Response {
23    pub message: AssistantMessage,
24}
25
26#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq)]
27pub struct AssistantMessage {
28    pub content: Option<String>,
29    pub tool_calls: Vec<ToolCall>,
30}
31
32#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq)]
33pub struct ToolCall {
34    pub id: String,
35    pub function: FunctionCall,
36}
37
38#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq)]
39pub struct FunctionCall {
40    pub name: String,
41    pub arguments: Vec<ToolCallArgument>,
42}
43
44impl FunctionCall {
45    pub fn get(&self, argument: &str) -> Option<String> {
46        self.arguments
47            .iter()
48            .find(|arg| arg.name == argument)
49            .map(|arg| arg.value.clone())
50    }
51}
52
53/// An argument to be provided to a tool.
54#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq)]
55pub struct ToolCallArgument {
56    pub name: String,
57    pub value: String,
58}
59
60// Internal request type sent to the canister
61#[derive(CandidType, Serialize, Deserialize, Debug)]
62struct Request {
63    model: String,
64    messages: Vec<ChatMessage>,
65    tools: Option<Vec<Tool>>,
66}
67
68/// Cycles attached to every `v1_chat` call.
69///
70/// Paid models require a minimum of 100B cycles to accept a request. Free
71/// models charge nothing: they accept no cycles and the full amount is
72/// refunded. Paid models accept only what's needed to cover the request and
73/// refund the remainder, so attaching this amount unconditionally is safe.
74///
75/// Note: the calling canister must hold at least this many cycles when `send()`
76/// runs, otherwise the call traps.
77const CYCLES_PER_CHAT: u128 = 100_000_000_000;
78
79/// Builder for creating and sending chat requests to the LLM canister.
80#[derive(Debug)]
81pub struct ChatBuilder {
82    model: String,
83    messages: Vec<ChatMessage>,
84    tools: Vec<Tool>,
85    canister: Principal,
86}
87
88impl ChatBuilder {
89    /// Creates a new chat builder with a model.
90    ///
91    /// `model` is the canister's model identifier, e.g. `"llama3.1:8b"` (free)
92    /// or `"gemma3:27b"` (paid). See the README for the current list.
93    pub fn new(model: impl Into<String>) -> Self {
94        Self {
95            model: model.into(),
96            messages: Vec::new(),
97            tools: Vec::new(),
98            canister: crate::default_llm_canister(),
99        }
100    }
101
102    /// Sets the messages for the chat.
103    pub fn with_messages(mut self, messages: Vec<ChatMessage>) -> Self {
104        self.messages = messages;
105        self
106    }
107
108    /// Sets the tools for the chat.
109    pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
110        self.tools = tools;
111        self
112    }
113
114    /// Overrides the LLM canister to call.
115    ///
116    /// By default the SDK addresses the mainnet LLM canister
117    /// (`w36hm-eqaaa-aaaal-qr76a-cai`), unless `icp deploy` has auto-injected
118    /// `PUBLIC_CANISTER_ID:llm` on this canister — in which case that value is
119    /// used. Set this only when neither default is what you want (e.g. when
120    /// pointing at a fork, a mock, or a staging deployment under a different
121    /// name).
122    pub fn with_canister(mut self, canister: Principal) -> Self {
123        self.canister = canister;
124        self
125    }
126
127    /// Sends the chat request to the LLM canister.
128    ///
129    /// Attaches `CYCLES_PER_CHAT` cycles to pay for paid models. Free models
130    /// refund the full amount. The calling canister must hold at least that
131    /// many cycles or this call traps.
132    pub async fn send(self) -> Response {
133        let tools_option = if self.tools.is_empty() {
134            None
135        } else {
136            Some(self.tools)
137        };
138
139        ic_cdk::call::Call::bounded_wait(self.canister, "v1_chat")
140            .change_timeout(300)
141            .with_cycles(CYCLES_PER_CHAT)
142            .with_arg(Request {
143                model: self.model,
144                messages: self.messages,
145                tools: tools_option,
146            })
147            .await
148            .unwrap_or_else(|e| ic_cdk::trap(format!("LLM call failed: {e:?}")))
149            .candid()
150            .unwrap_or_else(|e| ic_cdk::trap(format!("failed to decode LLM response: {e:?}")))
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::tool::ToolBuilder;
158
159    #[test]
160    fn create_chat_builder() {
161        let builder = ChatBuilder::new("llama3.1:8b");
162        assert!(builder.messages.is_empty());
163        assert!(builder.tools.is_empty());
164    }
165
166    #[test]
167    fn chat_builder_with_messages() {
168        let messages = vec![
169            ChatMessage::System {
170                content: "You are a helpful assistant".to_string(),
171            },
172            ChatMessage::User {
173                content: "Hello".to_string(),
174            },
175        ];
176
177        let builder = ChatBuilder::new("llama3.1:8b").with_messages(messages.clone());
178
179        assert_eq!(builder.messages, messages);
180        assert!(builder.tools.is_empty());
181    }
182
183    #[test]
184    fn chat_builder_with_tools() {
185        let tool = ToolBuilder::new("test_tool")
186            .with_description("A test tool")
187            .build();
188
189        let builder = ChatBuilder::new("llama3.1:8b").with_tools(vec![tool.clone()]);
190
191        assert!(builder.messages.is_empty());
192        assert_eq!(builder.tools.len(), 1);
193        assert_eq!(builder.tools[0], tool);
194    }
195
196    #[test]
197    fn chat_builder_defaults_to_mainnet_llm_canister() {
198        let builder = ChatBuilder::new("llama3.1:8b");
199        assert_eq!(
200            builder.canister,
201            Principal::from_text(crate::MAINNET_LLM_CANISTER).unwrap(),
202        );
203    }
204
205    #[test]
206    fn chat_builder_with_canister() {
207        let canister = Principal::from_slice(&[1, 2, 3, 4]);
208        let builder = ChatBuilder::new("llama3.1:8b").with_canister(canister);
209        assert_eq!(builder.canister, canister);
210    }
211
212    #[test]
213    fn chat_builder_with_messages_and_tools() {
214        let messages = vec![ChatMessage::User {
215            content: "Hello".to_string(),
216        }];
217
218        let tool = ToolBuilder::new("test_tool").build();
219
220        let builder = ChatBuilder::new("llama3.1:8b")
221            .with_messages(messages.clone())
222            .with_tools(vec![tool.clone()]);
223
224        assert_eq!(builder.messages, messages);
225        assert_eq!(builder.tools.len(), 1);
226        assert_eq!(builder.tools[0], tool);
227    }
228
229    #[test]
230    fn function_call_get() {
231        let function_call = FunctionCall {
232            name: "test_function".to_string(),
233            arguments: vec![
234                ToolCallArgument {
235                    name: "arg1".to_string(),
236                    value: "value1".to_string(),
237                },
238                ToolCallArgument {
239                    name: "arg2".to_string(),
240                    value: "value2".to_string(),
241                },
242            ],
243        };
244
245        assert_eq!(function_call.get("arg1"), Some("value1".to_string()));
246        assert_eq!(function_call.get("arg2"), Some("value2".to_string()));
247        assert_eq!(function_call.get("arg3"), None);
248    }
249}