1use 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 Text {
38 text: String,
39 },
40 Thinking {
42 text: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
47 signature: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 encrypted: Option<String>,
51 #[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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub cache_control: Vec<Option<serde_json::Value>>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub name: Option<String>,
97 #[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 #[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 None,
140 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 #[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 #[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 #[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 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 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}