1use nexo_llm::prompt_block::{CachePolicy, PromptBlock};
17use nexo_llm::stream::StreamChunk;
18use nexo_llm::types::{
19 Attachment, AttachmentData, CacheUsage, ChatMessage, ChatRequest, ChatResponse, ChatRole,
20 FinishReason, ResponseContent, TokenUsage, ToolCall, ToolChoice, ToolDef,
21};
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct WireChatRequest {
28 pub model: String,
29 pub messages: Vec<WireChatMessage>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub tools: Vec<WireToolDef>,
32 pub max_tokens: u32,
33 pub temperature: f32,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub system_prompt: Option<String>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub stop_sequences: Vec<String>,
38 #[serde(default)]
39 pub tool_choice: WireToolChoice,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub system_blocks: Vec<WirePromptBlock>,
42 #[serde(default)]
43 pub cache_tools: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct WireChatResponse {
48 pub content: WireResponseContent,
49 pub usage: WireTokenUsage,
50 pub finish_reason: WireFinishReason,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub cache_usage: Option<WireCacheUsage>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct WireChatMessage {
57 pub role: WireChatRole,
58 pub content: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub tool_call_id: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub name: Option<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub tool_calls: Vec<WireToolCall>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub attachments: Vec<WireAttachment>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "lowercase")]
71pub enum WireChatRole {
72 System,
73 User,
74 Assistant,
75 Tool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct WireToolDef {
80 pub name: String,
81 pub description: String,
82 pub parameters: serde_json::Value,
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum WireToolChoice {
88 #[default]
89 Auto,
90 Any,
91 None,
92 Specific {
93 name: String,
94 },
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct WireToolCall {
99 pub id: String,
100 pub name: String,
101 pub arguments: serde_json::Value,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum WireResponseContent {
107 Text { text: String },
108 ToolCalls { tool_calls: Vec<WireToolCall> },
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct WireTokenUsage {
113 pub prompt_tokens: u32,
114 pub completion_tokens: u32,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct WireCacheUsage {
119 pub cache_read_input_tokens: u32,
120 pub cache_creation_input_tokens: u32,
121 pub input_tokens: u32,
122 pub output_tokens: u32,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(tag = "kind", rename_all = "snake_case")]
127pub enum WireFinishReason {
128 Stop,
129 ToolUse,
130 Length,
131 Other { reason: String },
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(rename_all = "snake_case")]
136pub enum WireCachePolicy {
137 None,
138 Ephemeral5m,
139 Ephemeral1h,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct WirePromptBlock {
144 pub text: String,
147 pub cache: WireCachePolicy,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct WireAttachment {
152 pub kind: String,
153 pub mime_type: String,
154 #[serde(flatten)]
155 pub data: WireAttachmentData,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(tag = "data_kind", rename_all = "snake_case")]
160pub enum WireAttachmentData {
161 Base64 { base64: String },
162 Url { url: String },
163 Path { path: String },
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(tag = "type", rename_all = "snake_case")]
168pub enum WireStreamChunk {
169 TextDelta { delta: String },
170 ToolCallStart { id: String, name: String },
171 ToolCallArgsDelta { id: String, delta: String },
172 ToolCallEnd { id: String },
173 Usage { usage: WireTokenUsage },
174 End { finish_reason: WireFinishReason },
175}
176
177pub fn request_to_wire(req: &ChatRequest) -> WireChatRequest {
180 WireChatRequest {
181 model: req.model.clone(),
182 messages: req.messages.iter().map(message_to_wire).collect(),
183 tools: req.tools.iter().map(tool_def_to_wire).collect(),
184 max_tokens: req.max_tokens,
185 temperature: req.temperature,
186 system_prompt: req.system_prompt.clone(),
187 stop_sequences: req.stop_sequences.clone(),
188 tool_choice: tool_choice_to_wire(&req.tool_choice),
189 system_blocks: req.system_blocks.iter().map(prompt_block_to_wire).collect(),
190 cache_tools: req.cache_tools,
191 }
192}
193
194pub fn wire_to_response(w: WireChatResponse) -> ChatResponse {
195 ChatResponse {
196 content: wire_to_content(w.content),
197 usage: wire_to_token_usage(w.usage),
198 finish_reason: wire_to_finish_reason(w.finish_reason),
199 cache_usage: w.cache_usage.map(wire_to_cache_usage),
200 }
201}
202
203pub fn wire_to_chunk(w: WireStreamChunk) -> StreamChunk {
204 match w {
205 WireStreamChunk::TextDelta { delta } => StreamChunk::TextDelta { delta },
206 WireStreamChunk::ToolCallStart { id, name } => StreamChunk::ToolCallStart { id, name },
207 WireStreamChunk::ToolCallArgsDelta { id, delta } => {
208 StreamChunk::ToolCallArgsDelta { id, delta }
209 }
210 WireStreamChunk::ToolCallEnd { id } => StreamChunk::ToolCallEnd { id },
211 WireStreamChunk::Usage { usage } => StreamChunk::Usage(wire_to_token_usage(usage)),
212 WireStreamChunk::End { finish_reason } => StreamChunk::End {
213 finish_reason: wire_to_finish_reason(finish_reason),
214 },
215 }
216}
217
218fn message_to_wire(m: &ChatMessage) -> WireChatMessage {
219 WireChatMessage {
220 role: chat_role_to_wire(&m.role),
221 content: m.content.clone(),
222 tool_call_id: m.tool_call_id.clone(),
223 name: m.name.clone(),
224 tool_calls: m.tool_calls.iter().map(tool_call_to_wire).collect(),
225 attachments: m.attachments.iter().map(attachment_to_wire).collect(),
226 }
227}
228
229fn chat_role_to_wire(r: &ChatRole) -> WireChatRole {
230 match r {
231 ChatRole::System => WireChatRole::System,
232 ChatRole::User => WireChatRole::User,
233 ChatRole::Assistant => WireChatRole::Assistant,
234 ChatRole::Tool => WireChatRole::Tool,
235 }
236}
237
238fn tool_def_to_wire(t: &ToolDef) -> WireToolDef {
239 WireToolDef {
240 name: t.name.clone(),
241 description: t.description.clone(),
242 parameters: t.parameters.clone(),
243 }
244}
245
246fn tool_choice_to_wire(c: &ToolChoice) -> WireToolChoice {
247 match c {
248 ToolChoice::Auto => WireToolChoice::Auto,
249 ToolChoice::Any => WireToolChoice::Any,
250 ToolChoice::None => WireToolChoice::None,
251 ToolChoice::Specific(name) => WireToolChoice::Specific { name: name.clone() },
252 }
253}
254
255fn tool_call_to_wire(c: &ToolCall) -> WireToolCall {
256 WireToolCall {
257 id: c.id.clone(),
258 name: c.name.clone(),
259 arguments: c.arguments.clone(),
260 }
261}
262
263fn prompt_block_to_wire(b: &PromptBlock) -> WirePromptBlock {
264 WirePromptBlock {
265 text: b.text.clone(),
266 cache: cache_policy_to_wire(&b.cache),
267 }
268}
269
270fn cache_policy_to_wire(p: &CachePolicy) -> WireCachePolicy {
271 match p {
272 CachePolicy::None => WireCachePolicy::None,
273 CachePolicy::Ephemeral5m => WireCachePolicy::Ephemeral5m,
274 CachePolicy::Ephemeral1h => WireCachePolicy::Ephemeral1h,
275 }
276}
277
278fn attachment_to_wire(a: &Attachment) -> WireAttachment {
279 WireAttachment {
280 kind: a.kind.clone(),
281 mime_type: a.mime_type.clone(),
282 data: match &a.data {
283 AttachmentData::Base64 { base64 } => WireAttachmentData::Base64 {
284 base64: base64.clone(),
285 },
286 AttachmentData::Url { url } => WireAttachmentData::Url { url: url.clone() },
287 AttachmentData::Path { path } => WireAttachmentData::Path { path: path.clone() },
288 },
289 }
290}
291
292fn wire_to_content(c: WireResponseContent) -> ResponseContent {
293 match c {
294 WireResponseContent::Text { text } => ResponseContent::Text(text),
295 WireResponseContent::ToolCalls { tool_calls } => {
296 ResponseContent::ToolCalls(tool_calls.into_iter().map(wire_to_tool_call).collect())
297 }
298 }
299}
300
301fn wire_to_tool_call(c: WireToolCall) -> ToolCall {
302 ToolCall {
303 id: c.id,
304 name: c.name,
305 arguments: c.arguments,
306 }
307}
308
309fn wire_to_token_usage(u: WireTokenUsage) -> TokenUsage {
310 TokenUsage {
311 prompt_tokens: u.prompt_tokens,
312 completion_tokens: u.completion_tokens,
313 }
314}
315
316fn wire_to_cache_usage(u: WireCacheUsage) -> CacheUsage {
317 CacheUsage {
318 cache_read_input_tokens: u.cache_read_input_tokens,
319 cache_creation_input_tokens: u.cache_creation_input_tokens,
320 input_tokens: u.input_tokens,
321 output_tokens: u.output_tokens,
322 }
323}
324
325fn wire_to_finish_reason(f: WireFinishReason) -> FinishReason {
326 match f {
327 WireFinishReason::Stop => FinishReason::Stop,
328 WireFinishReason::ToolUse => FinishReason::ToolUse,
329 WireFinishReason::Length => FinishReason::Length,
330 WireFinishReason::Other { reason } => FinishReason::Other(reason),
331 }
332}
333
334#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn chat_request_round_trip() {
342 let req = ChatRequest::new(
343 "command-r",
344 vec![ChatMessage {
345 role: ChatRole::User,
346 content: "hi".into(),
347 tool_call_id: None,
348 name: None,
349 tool_calls: Vec::new(),
350 attachments: Vec::new(),
351 }],
352 );
353 let wire = request_to_wire(&req);
354 let s = serde_json::to_string(&wire).unwrap();
355 let back: WireChatRequest = serde_json::from_str(&s).unwrap();
356 assert_eq!(back.model, "command-r");
357 assert_eq!(back.messages.len(), 1);
358 assert_eq!(back.messages[0].role, WireChatRole::User);
359 assert_eq!(back.max_tokens, 4096);
360 }
361
362 #[test]
363 fn chat_response_round_trip() {
364 let wire = WireChatResponse {
365 content: WireResponseContent::Text {
366 text: "hello".into(),
367 },
368 usage: WireTokenUsage {
369 prompt_tokens: 5,
370 completion_tokens: 1,
371 },
372 finish_reason: WireFinishReason::Stop,
373 cache_usage: None,
374 };
375 let s = serde_json::to_string(&wire).unwrap();
376 let back: WireChatResponse = serde_json::from_str(&s).unwrap();
377 let resp = wire_to_response(back);
378 match resp.content {
379 ResponseContent::Text(t) => assert_eq!(t, "hello"),
380 other => panic!("expected Text, got {other:?}"),
381 }
382 assert_eq!(resp.finish_reason, FinishReason::Stop);
383 }
384
385 #[test]
386 fn stream_chunk_serializes_per_variant() {
387 let cases: Vec<WireStreamChunk> = vec![
388 WireStreamChunk::TextDelta {
389 delta: "hello".into(),
390 },
391 WireStreamChunk::ToolCallStart {
392 id: "1".into(),
393 name: "fetch".into(),
394 },
395 WireStreamChunk::ToolCallArgsDelta {
396 id: "1".into(),
397 delta: "{\"x\":".into(),
398 },
399 WireStreamChunk::ToolCallEnd { id: "1".into() },
400 WireStreamChunk::Usage {
401 usage: WireTokenUsage {
402 prompt_tokens: 10,
403 completion_tokens: 2,
404 },
405 },
406 WireStreamChunk::End {
407 finish_reason: WireFinishReason::Stop,
408 },
409 ];
410 for w in cases {
411 let s = serde_json::to_string(&w).unwrap();
412 let back: WireStreamChunk = serde_json::from_str(&s).unwrap();
413 assert_eq!(serde_json::to_string(&back).unwrap(), s);
415 }
416 }
417}