1use std::collections::BTreeMap;
8
9use crate::capabilities::{ToolCall, ToolSpec};
10use crate::records::JsonValue;
11use base64::prelude::{BASE64_STANDARD, Engine as _};
12
13const TOOL_SHAPE_HINT: &str = "each tool must be {type: \"function\", function: {name}}";
14const TOOLS_ARRAY_HINT: &str = "tools must be an array of function tools";
15const NO_MESSAGES_HINT: &str = "chat payload must carry a messages array or a prompt";
16
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum ChatWireError {
20 #[error("{0}")]
22 PayloadInvalid(String),
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum ChatRole {
28 System,
30 User,
32 Assistant,
34 Tool,
36}
37
38impl ChatRole {
39 pub fn as_str(self) -> &'static str {
41 match self {
42 ChatRole::System => "system",
43 ChatRole::User => "user",
44 ChatRole::Assistant => "assistant",
45 ChatRole::Tool => "tool",
46 }
47 }
48
49 pub fn from_value(value: &str) -> Option<Self> {
51 match value {
52 "system" => Some(ChatRole::System),
53 "user" => Some(ChatRole::User),
54 "assistant" => Some(ChatRole::Assistant),
55 "tool" => Some(ChatRole::Tool),
56 _ => None,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum AttachmentKind {
64 Image,
66 Document,
68}
69
70#[derive(Debug, Clone, PartialEq)]
73pub struct ChatAttachment {
74 pub kind: AttachmentKind,
76 pub data: Vec<u8>,
78 pub mime_type: String,
80 pub name: Option<String>,
82}
83
84impl ChatAttachment {
85 pub fn inline_block(&self) -> Option<String> {
88 if self.kind != AttachmentKind::Document {
89 return None;
90 }
91 let text = String::from_utf8_lossy(&self.data);
92 let open = match &self.name {
93 Some(name) => format!("<attached-file name=\"{name}\">"),
94 None => "<attached-file>".to_owned(),
95 };
96 Some(format!("{open}\n{text}\n</attached-file>"))
97 }
98}
99
100#[derive(Debug, Clone, PartialEq)]
103pub struct ChatMessage {
104 pub role: ChatRole,
106 pub content: String,
108 pub tool_calls: Vec<ToolCall>,
110 pub tool_call_id: Option<String>,
112 pub tool_name: Option<String>,
114 pub attachments: Vec<ChatAttachment>,
116 pub attachment_refs: Vec<String>,
118}
119
120impl ChatMessage {
121 pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
123 Self {
124 role,
125 content: content.into(),
126 tool_calls: Vec::new(),
127 tool_call_id: None,
128 tool_name: None,
129 attachments: Vec::new(),
130 attachment_refs: Vec::new(),
131 }
132 }
133
134 pub fn payload_value(&self) -> JsonValue {
138 let mut parts: Vec<String> = self
139 .attachments
140 .iter()
141 .filter_map(ChatAttachment::inline_block)
142 .collect();
143 parts.push(self.content.clone());
144 let body = parts
145 .into_iter()
146 .filter(|part| !part.is_empty())
147 .collect::<Vec<_>>()
148 .join("\n\n");
149
150 let mut object = BTreeMap::new();
151 object.insert(
152 "role".to_owned(),
153 JsonValue::String(self.role.as_str().to_owned()),
154 );
155 object.insert("content".to_owned(), JsonValue::String(body));
156 if !self.tool_calls.is_empty() {
157 object.insert(
158 "tool_calls".to_owned(),
159 JsonValue::Array(
160 self.tool_calls
161 .iter()
162 .map(ToolCall::payload_value)
163 .collect(),
164 ),
165 );
166 }
167 if let Some(id) = &self.tool_call_id {
168 object.insert("tool_call_id".to_owned(), JsonValue::String(id.clone()));
169 }
170 if let Some(name) = &self.tool_name {
171 object.insert("tool_name".to_owned(), JsonValue::String(name.clone()));
172 }
173 let images: Vec<JsonValue> = self
174 .attachments
175 .iter()
176 .filter(|attachment| attachment.kind == AttachmentKind::Image)
177 .map(|attachment| JsonValue::String(BASE64_STANDARD.encode(&attachment.data)))
178 .collect();
179 if !images.is_empty() {
180 object.insert("images".to_owned(), JsonValue::Array(images));
181 }
182 JsonValue::Object(object)
183 }
184
185 pub fn from_payload(value: &JsonValue) -> Option<Self> {
188 let fields = value.as_object()?;
189 let role = ChatRole::from_value(fields.get("role")?.as_str()?)?;
190 let content = fields
191 .get("content")
192 .and_then(JsonValue::as_str)
193 .unwrap_or("");
194 let mut message = ChatMessage::new(role, content);
195 message.apply_tool_routing(fields);
196 Some(message)
197 }
198
199 pub fn parse_strict(value: &JsonValue, index: usize) -> Result<Self, ChatWireError> {
202 let Some(fields) = value.as_object() else {
203 return Err(ChatWireError::PayloadInvalid(format!(
204 "message at index {index} is not an object"
205 )));
206 };
207 let role = fields
208 .get("role")
209 .and_then(JsonValue::as_str)
210 .and_then(ChatRole::from_value)
211 .ok_or_else(|| {
212 ChatWireError::PayloadInvalid(format!(
213 "message at index {index} has a missing or unknown role"
214 ))
215 })?;
216 let content = match fields.get("content") {
217 None | Some(JsonValue::Null) => "",
218 Some(JsonValue::String(text)) => text,
219 Some(_) => {
220 return Err(ChatWireError::PayloadInvalid(format!(
221 "message at index {index} has non-string content"
222 )));
223 }
224 };
225 let mut message = ChatMessage::new(role, content);
226 message.apply_tool_routing(fields);
227 Ok(message)
228 }
229
230 fn apply_tool_routing(&mut self, fields: &BTreeMap<String, JsonValue>) {
233 self.tool_calls = parse_tool_calls(fields.get("tool_calls"));
234 self.tool_call_id = fields
235 .get("tool_call_id")
236 .and_then(JsonValue::as_str)
237 .map(str::to_owned);
238 self.tool_name = fields
239 .get("tool_name")
240 .and_then(JsonValue::as_str)
241 .map(str::to_owned);
242 }
243
244 pub fn parse_all(payload: &JsonValue) -> Result<Vec<Self>, ChatWireError> {
247 let fields = payload
248 .as_object()
249 .ok_or_else(|| ChatWireError::PayloadInvalid(NO_MESSAGES_HINT.to_owned()))?;
250 if let Some(JsonValue::Array(messages)) = fields.get("messages") {
251 return messages
252 .iter()
253 .enumerate()
254 .map(|(index, value)| Self::parse_strict(value, index))
255 .collect();
256 }
257 if let Some(JsonValue::String(prompt)) = fields.get("prompt") {
258 return Ok(vec![ChatMessage::new(ChatRole::User, prompt.clone())]);
259 }
260 Err(ChatWireError::PayloadInvalid(NO_MESSAGES_HINT.to_owned()))
261 }
262
263 pub fn inlined_tool_transcript(&self) -> ChatMessage {
268 if self.role != ChatRole::Assistant || self.tool_calls.is_empty() {
269 return self.clone();
270 }
271 let mut parts = vec![self.content.clone()];
272 for call in &self.tool_calls {
273 let mut object = BTreeMap::new();
274 object.insert("name".to_owned(), JsonValue::String(call.name.clone()));
275 object.insert("arguments".to_owned(), call.arguments.clone());
276 parts.push(format!(
277 "<tool_call>{}</tool_call>",
278 json_string(&JsonValue::Object(object))
279 ));
280 }
281 let joined = parts
282 .into_iter()
283 .filter(|part| !part.is_empty())
284 .collect::<Vec<_>>()
285 .join("\n");
286 ChatMessage::new(ChatRole::Assistant, joined)
287 }
288}
289
290pub fn decode_tool_specs(value: Option<&JsonValue>) -> Result<Vec<ToolSpec>, ChatWireError> {
294 let Some(value) = value else {
295 return Ok(Vec::new());
296 };
297 let JsonValue::Array(entries) = value else {
298 return Err(ChatWireError::PayloadInvalid(TOOLS_ARRAY_HINT.to_owned()));
299 };
300 if entries.iter().any(|entry| entry.as_object().is_none()) {
303 return Err(ChatWireError::PayloadInvalid(TOOLS_ARRAY_HINT.to_owned()));
304 }
305 let mut specs = Vec::with_capacity(entries.len());
306 for entry in entries {
307 let object = entry.as_object();
308 let kind = object
309 .and_then(|fields| fields.get("type"))
310 .and_then(JsonValue::as_str)
311 .unwrap_or("function");
312 let function = object
313 .and_then(|fields| fields.get("function"))
314 .and_then(JsonValue::as_object);
315 let name = function
316 .and_then(|fields| fields.get("name"))
317 .and_then(JsonValue::as_str)
318 .filter(|name| !name.is_empty());
319 let (Some(function), Some(name)) = (function, name) else {
320 return Err(ChatWireError::PayloadInvalid(TOOL_SHAPE_HINT.to_owned()));
321 };
322 if kind != "function" {
323 return Err(ChatWireError::PayloadInvalid(TOOL_SHAPE_HINT.to_owned()));
324 }
325 let description = function
326 .get("description")
327 .and_then(JsonValue::as_str)
328 .unwrap_or("")
329 .to_owned();
330 let parameters = match function.get("parameters") {
331 Some(JsonValue::Object(fields)) => JsonValue::Object(fields.clone()),
332 _ => JsonValue::Object(BTreeMap::new()),
333 };
334 specs.push(ToolSpec::new(name, description, parameters));
335 }
336 Ok(specs)
337}
338
339pub struct ChatMlPrompt;
341
342impl ChatMlPrompt {
343 pub const NO_TEMPLATE_NOTICE: &'static str =
345 "this model has no chat template — using a generic format";
346
347 pub fn render(messages: &[ChatMessage]) -> String {
349 let mut prompt = String::new();
350 for message in messages {
351 prompt.push_str("<|im_start|>");
352 prompt.push_str(message.role.as_str());
353 prompt.push('\n');
354 prompt.push_str(&message.content);
355 prompt.push_str("<|im_end|>\n");
356 }
357 prompt.push_str("<|im_start|>assistant\n");
358 prompt
359 }
360}
361
362fn parse_tool_calls(value: Option<&JsonValue>) -> Vec<ToolCall> {
363 match value {
364 Some(JsonValue::Array(calls)) => calls.iter().filter_map(ToolCall::from_payload).collect(),
365 _ => Vec::new(),
366 }
367}
368
369fn json_string(value: &JsonValue) -> String {
372 serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned())
373}