1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct CompletionRequest {
18 pub model: String,
20 pub system: Option<String>,
22 pub messages: Vec<Message>,
24 pub tools: Vec<ToolSpec>,
26 pub tool_choice: ToolChoice,
28 pub response_format: Option<JsonSchema>,
30 pub max_tokens: Option<u32>,
32 pub temperature: Option<f32>,
34 pub stop: Vec<String>,
36 pub web_search: bool,
46}
47
48impl CompletionRequest {
49 #[must_use]
53 pub fn new(model: impl Into<String>) -> Self {
54 Self {
55 model: model.into(),
56 system: None,
57 messages: Vec::new(),
58 tools: Vec::new(),
59 tool_choice: ToolChoice::Auto,
60 response_format: None,
61 max_tokens: None,
62 temperature: None,
63 stop: Vec::new(),
64 web_search: false,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Message {
74 pub role: Role,
76 pub content: Vec<Content>,
78}
79
80impl Message {
81 #[must_use]
83 pub fn user(text: impl Into<String>) -> Self {
84 Self {
85 role: Role::User,
86 content: vec![Content::Text(text.into())],
87 }
88 }
89
90 #[must_use]
92 pub fn assistant(text: impl Into<String>) -> Self {
93 Self {
94 role: Role::Assistant,
95 content: vec![Content::Text(text.into())],
96 }
97 }
98
99 #[must_use]
101 pub fn system(text: impl Into<String>) -> Self {
102 Self {
103 role: Role::System,
104 content: vec![Content::Text(text.into())],
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum Role {
116 User,
118 Assistant,
120 System,
122 Tool,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131#[non_exhaustive]
132pub enum Content {
133 Text(String),
135 ToolUse(ToolCall),
137 ToolResult(ToolResult),
139 Image(ImageRef),
141}
142
143impl Content {
144 #[must_use]
146 pub fn text(s: impl Into<String>) -> Self {
147 Self::Text(s.into())
148 }
149
150 #[must_use]
152 pub fn tool_use(
153 id: impl Into<String>,
154 name: impl Into<String>,
155 args_json: impl Into<String>,
156 ) -> Self {
157 Self::ToolUse(ToolCall {
158 id: id.into(),
159 name: name.into(),
160 args_json: args_json.into(),
161 signature: None,
162 })
163 }
164
165 #[must_use]
170 pub fn tool_use_signed(
171 id: impl Into<String>,
172 name: impl Into<String>,
173 args_json: impl Into<String>,
174 signature: Option<String>,
175 ) -> Self {
176 Self::ToolUse(ToolCall {
177 id: id.into(),
178 name: name.into(),
179 args_json: args_json.into(),
180 signature,
181 })
182 }
183
184 #[must_use]
186 pub fn tool_result(
187 tool_call_id: impl Into<String>,
188 result_json: impl Into<String>,
189 is_error: bool,
190 ) -> Self {
191 Self::ToolResult(ToolResult {
192 tool_call_id: tool_call_id.into(),
193 result_json: result_json.into(),
194 is_error,
195 })
196 }
197
198 #[must_use]
200 pub fn image(url: impl Into<String>, mime_type: Option<String>) -> Self {
201 Self::Image(ImageRef {
202 url: url.into(),
203 mime_type,
204 })
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ToolCall {
216 pub id: String,
218 pub name: String,
220 pub args_json: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub signature: Option<String>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ToolResult {
235 pub tool_call_id: String,
237 pub result_json: String,
239 pub is_error: bool,
241}
242
243#[derive(Debug, Clone, Default, Serialize, Deserialize)]
247pub struct ImageRef {
248 pub url: String,
250 pub mime_type: Option<String>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct ToolSpec {
259 pub name: String,
261 pub description: String,
263 pub schema_json: serde_json::Value,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub title: Option<String>,
273 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
288 pub needs_approval: bool,
289}
290
291#[must_use]
299pub fn humanize_tool_name(name: &str) -> String {
300 let words: Vec<&str> = name
301 .split(|c: char| c == '_' || c == '-' || c.is_whitespace())
302 .filter(|w| !w.is_empty())
303 .collect();
304 if words.is_empty() {
305 return String::new();
306 }
307 let mut out = String::new();
308 for (i, word) in words.iter().enumerate() {
309 if i > 0 {
310 out.push(' ');
311 }
312 let lower = word.to_lowercase();
313 if i == 0 {
314 let mut chars = lower.chars();
315 if let Some(first) = chars.next() {
316 out.extend(first.to_uppercase());
317 out.push_str(chars.as_str());
318 }
319 } else {
320 out.push_str(&lower);
321 }
322 }
323 out
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "snake_case")]
331#[non_exhaustive]
332pub enum ToolChoice {
333 Auto,
335 None,
337 Required,
339 Named(String),
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(transparent)]
351pub struct JsonSchema(
352 pub serde_json::Value,
354);
355
356#[cfg(test)]
359mod tests {
360 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
361
362 use serde_json::{Value, json};
363
364 use super::*;
365
366 #[test]
367 fn new_sets_model_and_defaults() {
368 let req = CompletionRequest::new("fast-2");
369 assert_eq!(req.model, "fast-2");
370 assert!(req.messages.is_empty());
371 assert!(req.tools.is_empty());
372 assert!(req.stop.is_empty());
373 assert!(req.system.is_none());
374 assert!(req.max_tokens.is_none());
375 assert!(req.temperature.is_none());
376 assert!(req.response_format.is_none());
377 assert_eq!(req.tool_choice, ToolChoice::Auto);
378 }
379
380 #[test]
381 fn role_serializes_to_snake_case() {
382 assert_eq!(serde_json::to_string(&Role::User).unwrap(), r#""user""#);
383 assert_eq!(
384 serde_json::to_string(&Role::Assistant).unwrap(),
385 r#""assistant""#
386 );
387 assert_eq!(serde_json::to_string(&Role::System).unwrap(), r#""system""#);
388 assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), r#""tool""#);
389 }
390
391 #[test]
392 fn role_round_trips() {
393 for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
394 let json = serde_json::to_string(&role).unwrap();
395 let back: Role = serde_json::from_str(&json).unwrap();
396 assert_eq!(back, role);
397 }
398 }
399
400 #[test]
401 fn tool_choice_unit_variants_serialize_as_strings() {
402 assert_eq!(
403 serde_json::to_string(&ToolChoice::Auto).unwrap(),
404 r#""auto""#
405 );
406 assert_eq!(
407 serde_json::to_string(&ToolChoice::None).unwrap(),
408 r#""none""#
409 );
410 assert_eq!(
411 serde_json::to_string(&ToolChoice::Required).unwrap(),
412 r#""required""#
413 );
414 }
415
416 #[test]
417 fn tool_choice_named_serializes_as_object() {
418 let tc = ToolChoice::Named("my_tool".to_owned());
419 let v: Value = serde_json::to_value(&tc).unwrap();
420 assert_eq!(v, json!({"named": "my_tool"}));
421 }
422
423 #[test]
424 fn tool_choice_round_trips() {
425 for tc in [
426 ToolChoice::Auto,
427 ToolChoice::None,
428 ToolChoice::Required,
429 ToolChoice::Named("search".to_owned()),
430 ] {
431 let json = serde_json::to_string(&tc).unwrap();
432 let back: ToolChoice = serde_json::from_str(&json).unwrap();
433 assert_eq!(back, tc);
434 }
435 }
436
437 #[test]
438 fn content_text_constructor() {
439 let c = Content::text("hello");
440 assert!(matches!(c, Content::Text(s) if s == "hello"));
441 }
442
443 #[test]
444 fn content_tool_use_constructor() {
445 let c = Content::tool_use("call-1", "search", r#"{"q":"rust"}"#);
446 match c {
447 Content::ToolUse(tu) => {
448 assert_eq!(tu.id, "call-1");
449 assert_eq!(tu.name, "search");
450 assert_eq!(tu.args_json, r#"{"q":"rust"}"#);
451 }
452 _ => panic!("wrong variant"),
453 }
454 }
455
456 #[test]
457 fn content_tool_result_constructor() {
458 let c = Content::tool_result("call-1", r#"{"result":"ok"}"#, false);
459 match c {
460 Content::ToolResult(tr) => {
461 assert_eq!(tr.tool_call_id, "call-1");
462 assert_eq!(tr.result_json, r#"{"result":"ok"}"#);
463 assert!(!tr.is_error);
464 }
465 _ => panic!("wrong variant"),
466 }
467 }
468
469 #[test]
470 fn content_image_constructor() {
471 let c = Content::image("https://example.com/img.png", Some("image/png".to_owned()));
472 match c {
473 Content::Image(img) => {
474 assert_eq!(img.url, "https://example.com/img.png");
475 assert_eq!(img.mime_type.as_deref(), Some("image/png"));
476 }
477 _ => panic!("wrong variant"),
478 }
479 }
480
481 #[test]
482 fn message_user_constructor() {
483 let m = Message::user("hi");
484 assert_eq!(m.role, Role::User);
485 assert_eq!(m.content.len(), 1);
486 assert!(matches!(&m.content[0], Content::Text(s) if s == "hi"));
487 }
488
489 #[test]
490 fn message_assistant_constructor() {
491 let m = Message::assistant("hello back");
492 assert_eq!(m.role, Role::Assistant);
493 assert_eq!(m.content.len(), 1);
494 assert!(matches!(&m.content[0], Content::Text(s) if s == "hello back"));
495 }
496
497 #[test]
498 fn message_system_constructor() {
499 let m = Message::system("You are helpful.");
500 assert_eq!(m.role, Role::System);
501 assert_eq!(m.content.len(), 1);
502 assert!(matches!(&m.content[0], Content::Text(_)));
503 }
504
505 #[test]
506 fn tool_use_args_json_preserved_as_opaque_string() {
507 let original = r#"{"nested":{"key":42},"arr":[1,2,3]}"#;
508 let c = Content::tool_use("id-42", "complex_tool", original);
509 let serialized = serde_json::to_string(&c).unwrap();
510 let back: Content = serde_json::from_str(&serialized).unwrap();
511 match back {
512 Content::ToolUse(tu) => assert_eq!(tu.args_json, original),
513 _ => panic!("wrong variant"),
514 }
515 }
516
517 #[test]
518 fn completion_request_round_trips_all_content_variants() {
519 let mut req = CompletionRequest::new("test-model");
520 req.system = Some("Be concise.".to_owned());
521 req.max_tokens = Some(256);
522 req.temperature = Some(0.7);
523 req.stop = vec!["<end>".to_owned()];
524 req.tool_choice = ToolChoice::Named("calculator".to_owned());
525 req.response_format = Some(JsonSchema(json!({"type": "object"})));
526 req.tools = vec![ToolSpec {
527 name: "calculator".to_owned(),
528 description: "Evaluates math expressions.".to_owned(),
529 schema_json: json!({"type": "object", "properties": {"expr": {"type": "string"}}}),
530 title: None,
531 needs_approval: false,
532 }];
533 req.messages = vec![
534 Message::user("Compute 2+2"),
535 Message {
536 role: Role::Assistant,
537 content: vec![Content::tool_use(
538 "call-1",
539 "calculator",
540 r#"{"expr":"2+2"}"#,
541 )],
542 },
543 Message {
544 role: Role::Tool,
545 content: vec![Content::tool_result("call-1", r#"{"value":4}"#, false)],
546 },
547 Message {
548 role: Role::User,
549 content: vec![Content::image(
550 "https://example.com/chart.png",
551 Some("image/png".to_owned()),
552 )],
553 },
554 ];
555
556 let json_str = serde_json::to_string(&req).unwrap();
557 let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
558
559 assert_eq!(back.model, "test-model");
560 assert_eq!(back.system.as_deref(), Some("Be concise."));
561 assert_eq!(back.max_tokens, Some(256));
562 assert_eq!(back.messages.len(), 4);
563 assert_eq!(back.tools.len(), 1);
564 assert_eq!(back.tool_choice, ToolChoice::Named("calculator".to_owned()));
565 }
566
567 #[test]
568 fn json_schema_serializes_transparently() {
569 let schema = JsonSchema(json!({"type": "object", "required": ["name"]}));
570 let v: Value = serde_json::to_value(&schema).unwrap();
571 assert_eq!(v["type"], "object");
572 assert_eq!(v["required"][0], "name");
573 }
574
575 #[test]
576 fn json_schema_round_trips() {
577 let inner = json!({"type": "string", "maxLength": 100});
578 let schema = JsonSchema(inner.clone());
579 let json_str = serde_json::to_string(&schema).unwrap();
580 let back: JsonSchema = serde_json::from_str(&json_str).unwrap();
581 assert_eq!(back.0, inner);
582 }
583
584 #[test]
585 fn image_ref_default_is_sensible() {
586 let img = ImageRef::default();
587 assert!(img.url.is_empty());
588 assert!(img.mime_type.is_none());
589 }
590
591 #[test]
592 fn humanize_snake_case() {
593 assert_eq!(humanize_tool_name("paid_fetch"), "Paid fetch");
594 assert_eq!(humanize_tool_name("delete_file"), "Delete file");
595 }
596
597 #[test]
598 fn humanize_kebab_case() {
599 assert_eq!(humanize_tool_name("delete-file"), "Delete file");
600 }
601
602 #[test]
603 fn humanize_single_word() {
604 assert_eq!(humanize_tool_name("calculator"), "Calculator");
605 }
606
607 #[test]
608 fn humanize_empty() {
609 assert_eq!(humanize_tool_name(""), "");
610 }
611
612 #[test]
613 fn humanize_already_spaced_passes_through() {
614 assert_eq!(humanize_tool_name("Pay for a page"), "Pay for a page");
615 assert_eq!(humanize_tool_name("delete file"), "Delete file");
616 }
617
618 #[test]
619 fn tool_spec_carries_optional_title() {
620 let spec = ToolSpec {
621 name: "paid_fetch".to_owned(),
622 description: "d".to_owned(),
623 schema_json: json!({}),
624 title: Some("Pay for & fetch a web page".to_owned()),
625 needs_approval: false,
626 };
627 assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
628 }
629
630 #[test]
631 fn tool_spec_carries_needs_approval_flag() {
632 let spec = ToolSpec {
633 name: "delete_file".to_owned(),
634 description: "d".to_owned(),
635 schema_json: json!({}),
636 title: None,
637 needs_approval: true,
638 };
639 assert!(spec.needs_approval);
640 }
641
642 #[test]
646 fn tool_spec_needs_approval_defaults_false_on_deserialize() {
647 let payload = json!({
648 "name": "calculator",
649 "description": "math",
650 "schema_json": {"type": "object"}
651 });
652 let spec: ToolSpec = serde_json::from_value(payload).unwrap();
653 assert!(
654 !spec.needs_approval,
655 "omitted needs_approval must default to false"
656 );
657 }
658}