Skip to main content

llm_dialect/
items.rs

1//! Cross-dialect canonical representation.
2//!
3//! The central model is *content items* — a typed, ordered list per message.
4//! Anthropic Messages, OpenAI Responses, and Gemini all share this shape
5//! natively; OpenAI Chat/Completions is the only dialect that hoists tool
6//! calls into a side-array, and that asymmetry is normalized at the dialect
7//! adapter boundary.
8//!
9//! Invariants enforced here:
10//! * `ItemStreamMessage.items` preserves order verbatim from the wire.
11//! * `ToolCall.id` is non-empty; `ToolResult.tool_call_id` references a prior
12//!   `ToolCall` in the same conversation.
13//! * `Thinking.signature` is opaque and never fabricated; `None` propagates
14//!   as `None` through dialects that support unsigned thinking (all except
15//!   Anthropic-signed).
16//! * `Text("")` is legal in the model; dropping happens in dialect code
17//!   (Gemini rejects empty parts upstream).
18//!
19//! Lossy notes are documented per-dialect; the canonical model itself stays
20//! lossless.
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Role {
26    System,
27    User,
28    Assistant,
29    Tool,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(tag = "type", rename_all = "snake_case")]
34pub enum ContentItem {
35    /// Plain assistant/user text. Empty string is legal at the canonical
36    /// layer but dialects (Gemini) may drop it.
37    Text {
38        text: String,
39    },
40    /// Chain-of-thought block, signed by Anthropic or plaintext elsewhere.
41    Thinking {
42        text: String,
43        /// Opaque Anthropic signature. Never fabricated; preserved verbatim
44        /// through Anthropic→Anthropic round trips. `None` when the upstream
45        /// was unsigned (vLLM / reasoning_content).
46        #[serde(default, skip_serializing_if = "Option::is_none")]
47        signature: Option<String>,
48        /// OpenAI Responses-style encrypted thinking payload.
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        encrypted: Option<String>,
51        /// Anthropic `redacted_thinking` payload (the wire field is `data`).
52        /// Opaque; only the Anthropic dialects re-emit it.
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        redacted_data: Option<String>,
55    },
56    ToolCall {
57        id: String,
58        name: String,
59        arguments: serde_json::Value,
60    },
61    ToolResult {
62        tool_call_id: String,
63        content: serde_json::Value,
64        #[serde(default)]
65        is_error: bool,
66    },
67    Refusal {
68        text: String,
69    },
70}
71
72impl ContentItem {
73    /// Short diagnostic discriminant for error messages / logs.
74    /// Only exercised by tests today; keep the discriminant table here so
75    /// future error paths don't need to re-derive it.
76    #[cfg(test)]
77    pub fn kind(&self) -> &'static str {
78        match self {
79            Self::Text { .. } => "text",
80            Self::Thinking { .. } => "thinking",
81            Self::ToolCall { .. } => "tool_call",
82            Self::ToolResult { .. } => "tool_result",
83            Self::Refusal { .. } => "refusal",
84        }
85    }
86}
87
88#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89pub struct ItemMeta {
90    /// Per-block cache-control markers (Anthropic `cache_control` ephemeral
91    /// breakpoints). Parallel-indexed with the original content items.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub cache_control: Vec<Option<serde_json::Value>>,
94    /// OpenAI-only `name` field.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub name: Option<String>,
97    /// Anything the dialect layer couldn't express as a typed item.
98    /// Dropped by dialects that don't understand them.
99    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
100    pub extra: serde_json::Map<String, serde_json::Value>,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct ItemStreamMessage {
105    pub role: Role,
106    pub items: Vec<ContentItem>,
107    #[serde(default)]
108    pub metadata: ItemMeta,
109}
110
111impl ItemStreamMessage {
112    /// Concatenated visible text (Text items only; thinking is metadata). Test aid.
113    #[cfg(test)]
114    pub fn text(&self) -> String {
115        self.items
116            .iter()
117            .filter_map(|i| match i {
118                ContentItem::Text { text } => Some(text.as_str()),
119                _ => None,
120            })
121            .collect::<Vec<_>>()
122            .join("")
123    }
124}
125
126#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127pub struct Tool {
128    pub name: String,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub description: Option<String>,
131    pub input_schema: serde_json::Value,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135#[serde(tag = "type", rename_all = "snake_case")]
136pub enum ToolChoice {
137    Auto,
138    /// Alias for OpenAI `tool_choice: "none"`.
139    None,
140    /// Alias for OpenAI `tool_choice: "required"` (any tool) / Anthropic
141    /// `tool_choice: {type: "any"}`.
142    Required,
143    Tool {
144        name: String,
145    },
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
149pub struct ThinkingCfg {
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub budget_tokens: Option<u32>,
152    /// Cross-dialect effort hint: "low" | "medium" | "high" | "max"
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub effort: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[serde(tag = "type", rename_all = "snake_case")]
159pub enum ResponseFormat {
160    Text,
161    JsonObject,
162    JsonSchema {
163        name: String,
164        schema: serde_json::Value,
165        #[serde(default)]
166        strict: bool,
167    },
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ItemRequest {
172    pub model: String,
173    pub messages: Vec<ItemStreamMessage>,
174    #[serde(default)]
175    pub stream: bool,
176    /// `None` = client set no cap; each dialect then applies its own wire
177    /// default (Anthropic requires a value and substitutes 4096) instead of
178    /// silently clamping every request to an arbitrary ceiling.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub max_tokens: Option<u32>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub temperature: Option<f64>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub top_p: Option<f64>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub stop_sequences: Option<Vec<String>>,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub tools: Vec<Tool>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub tool_choice: Option<ToolChoice>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub thinking: Option<ThinkingCfg>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub response_format: Option<ResponseFormat>,
195    /// Catch-all for dialect-specific fields that don't fit the canonical
196    /// model (container / mcp_servers / service_tier etc.). Dropped by every
197    /// dialect that doesn't understand them.
198    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
199    pub extra: serde_json::Map<String, serde_json::Value>,
200}
201
202impl Default for ItemRequest {
203    fn default() -> Self {
204        Self {
205            model: String::new(),
206            messages: Vec::new(),
207            stream: false,
208            max_tokens: None,
209            temperature: None,
210            top_p: None,
211            stop_sequences: None,
212            tools: Vec::new(),
213            tool_choice: None,
214            thinking: None,
215            response_format: None,
216            extra: serde_json::Map::new(),
217        }
218    }
219}
220
221impl ItemRequest {
222    /// Validate the canonical request. Cheap, deterministic, no I/O.
223    /// The dialect adapters call this on entry so malformed requests get a
224    /// consistent 400 regardless of surface.
225    pub fn validate(&self) -> Result<(), String> {
226        if self.messages.is_empty() {
227            return Err("messages must be non-empty".into());
228        }
229        if self.max_tokens == Some(0) {
230            return Err("max_tokens must be >= 1".into());
231        }
232        for (i, m) in self.messages.iter().enumerate() {
233            if m.items.is_empty() && m.role != Role::System {
234                return Err(format!("messages[{i}]: items must be non-empty"));
235            }
236        }
237        // tool_result references must resolve
238        let mut known_ids: std::collections::HashSet<&str> = Default::default();
239        for m in &self.messages {
240            for item in &m.items {
241                match item {
242                    ContentItem::ToolCall { id, .. } => {
243                        known_ids.insert(id.as_str());
244                    }
245                    ContentItem::ToolResult { tool_call_id, .. }
246                        if !known_ids.contains(tool_call_id.as_str()) =>
247                    {
248                        return Err(format!(
249                            "tool_result references unknown tool_call id {tool_call_id:?}"
250                        ));
251                    }
252                    _ => {}
253                }
254            }
255        }
256        Ok(())
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn assistant(items: Vec<ContentItem>) -> ItemStreamMessage {
265        ItemStreamMessage {
266            role: Role::Assistant,
267            items,
268            metadata: ItemMeta::default(),
269        }
270    }
271
272    #[test]
273    fn visible_text_concatenates_in_order() {
274        let msg = assistant(vec![
275            ContentItem::Text { text: "a".into() },
276            ContentItem::Thinking {
277                text: "t".into(),
278                signature: None,
279                encrypted: None,
280                redacted_data: None,
281            },
282            ContentItem::Text { text: "b".into() },
283        ]);
284        assert_eq!(msg.text(), "ab");
285    }
286
287    #[test]
288    fn validate_rejects_empty_messages() {
289        let req = ItemRequest {
290            model: "m".into(),
291            messages: vec![],
292            max_tokens: Some(10),
293            ..Default::default()
294        };
295        assert!(req.validate().is_err());
296    }
297
298    #[test]
299    fn validate_rejects_unknown_tool_call_reference() {
300        let req = ItemRequest {
301            model: "m".into(),
302            max_tokens: Some(10),
303            messages: vec![ItemStreamMessage {
304                role: Role::User,
305                items: vec![ContentItem::ToolResult {
306                    tool_call_id: "missing".into(),
307                    content: serde_json::json!("x"),
308                    is_error: false,
309                }],
310                metadata: ItemMeta::default(),
311            }],
312            ..Default::default()
313        };
314        assert!(req.validate().is_err());
315    }
316
317    #[test]
318    fn validate_accepts_tool_linkage() {
319        let req = ItemRequest {
320            model: "m".into(),
321            max_tokens: Some(10),
322            messages: vec![
323                assistant(vec![ContentItem::ToolCall {
324                    id: "tc_1".into(),
325                    name: "bash".into(),
326                    arguments: serde_json::json!({"command":"ls"}),
327                }]),
328                ItemStreamMessage {
329                    role: Role::User,
330                    items: vec![ContentItem::ToolResult {
331                        tool_call_id: "tc_1".into(),
332                        content: serde_json::json!("file.txt"),
333                        is_error: false,
334                    }],
335                    metadata: ItemMeta::default(),
336                },
337            ],
338            ..Default::default()
339        };
340        assert!(req.validate().is_ok());
341    }
342
343    #[test]
344    fn item_request_round_trips_through_serde() {
345        let original = ItemRequest {
346            model: "m".into(),
347            messages: vec![assistant(vec![
348                ContentItem::Thinking {
349                    text: "plan".into(),
350                    signature: Some("sig".into()),
351                    encrypted: None,
352                    redacted_data: None,
353                },
354                ContentItem::Text {
355                    text: "hello".into(),
356                },
357            ])],
358            max_tokens: Some(100),
359            thinking: Some(ThinkingCfg {
360                budget_tokens: Some(2048),
361                effort: Some("max".into()),
362            }),
363            response_format: Some(ResponseFormat::JsonSchema {
364                name: "answer".into(),
365                schema: serde_json::json!({"type":"object"}),
366                strict: true,
367            }),
368            ..Default::default()
369        };
370        let s = serde_json::to_string(&original).unwrap();
371        let back: ItemRequest = serde_json::from_str(&s).unwrap();
372        assert_eq!(original, back);
373    }
374
375    #[test]
376    fn item_kind_labels_match_variants() {
377        assert_eq!(
378            ContentItem::Text {
379                text: String::new()
380            }
381            .kind(),
382            "text"
383        );
384        assert_eq!(
385            ContentItem::Thinking {
386                text: String::new(),
387                signature: None,
388                encrypted: None,
389                redacted_data: None
390            }
391            .kind(),
392            "thinking"
393        );
394        assert_eq!(
395            ContentItem::ToolCall {
396                id: "a".into(),
397                name: "b".into(),
398                arguments: serde_json::Value::Null
399            }
400            .kind(),
401            "tool_call"
402        );
403        assert_eq!(
404            ContentItem::ToolResult {
405                tool_call_id: "a".into(),
406                content: serde_json::Value::Null,
407                is_error: false
408            }
409            .kind(),
410            "tool_result"
411        );
412        assert_eq!(
413            ContentItem::Refusal {
414                text: String::new()
415            }
416            .kind(),
417            "refusal"
418        );
419    }
420
421    #[test]
422    fn tool_choice_serde_tags() {
423        let json = serde_json::to_value(ToolChoice::Tool {
424            name: "bash".into(),
425        })
426        .unwrap();
427        assert_eq!(json, serde_json::json!({"type":"tool","name":"bash"}));
428        assert_eq!(
429            serde_json::to_value(ToolChoice::Auto).unwrap(),
430            serde_json::json!({"type":"auto"})
431        );
432    }
433
434    #[test]
435    fn tool_choice_and_response_format_round_trip() {
436        let t = ToolChoice::Required;
437        assert_eq!(
438            serde_json::from_value::<ToolChoice>(serde_json::to_value(t.clone()).unwrap()).unwrap(),
439            t
440        );
441        let rf = ResponseFormat::JsonSchema {
442            name: "n".into(),
443            schema: serde_json::json!({}),
444            strict: false,
445        };
446        assert_eq!(
447            serde_json::from_value::<ResponseFormat>(serde_json::to_value(rf.clone()).unwrap())
448                .unwrap(),
449            rf
450        );
451    }
452
453    #[test]
454    fn signature_is_preserved_verbatim_when_signed() {
455        let original = ContentItem::Thinking {
456            text: "x".into(),
457            signature: Some("EqQBCgIYAhIM...".into()),
458            encrypted: None,
459            redacted_data: None,
460        };
461        let s = serde_json::to_string(&original).unwrap();
462        let back: ContentItem = serde_json::from_str(&s).unwrap();
463        match back {
464            ContentItem::Thinking { signature, .. } => {
465                assert_eq!(signature, Some("EqQBCgIYAhIM...".into()));
466            }
467            _ => panic!(),
468        }
469    }
470}