Skip to main content

nexo_core/agent/llm_remote/
wire.rs

1//! Wire types for `RemoteLlmClient`.
2//!
3//! Subprocess plugins exchange `ChatRequest` / `ChatResponse` /
4//! `StreamChunk` over stdio JSON-RPC. The in-tree types in
5//! `nexo-llm` aren't all `Serialize + Deserialize` (e.g.
6//! `PromptBlock.label: &'static str`), so we ship parallel
7//! wire-only types here with full serde derives + conversion
8//! fns. Wire fields drop information that's host-only telemetry
9//! (e.g. `PromptBlock.label`) — providers never see those.
10//!
11//! Wire-type drift policy: when `ChatRequest` adds a field, the
12//! conversion fn here MUST list it (compiler enforces). New
13//! fields surface as a deliberate decision: pass through to wire
14//! or drop at the boundary.
15
16use 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// ── Wire types ───────────────────────────────────────────────────
25
26#[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    /// `PromptBlock.label` (`&'static str`, telemetry-only) is
145    /// dropped at the wire boundary — providers never see it.
146    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
177// ── Conversions ──────────────────────────────────────────────────
178
179pub 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// ── Tests ────────────────────────────────────────────────────────
335
336#[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            // Re-serialize for equality check (no Eq on WireStreamChunk).
414            assert_eq!(serde_json::to_string(&back).unwrap(), s);
415        }
416    }
417}