1use std::collections::BTreeMap;
26use std::fmt;
27use std::time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use crate::ids::{CallId, RequestId};
32use crate::purpose::ModelPurpose;
33
34pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
36
37pub const MAX_METADATA_VALUE_LEN: usize = 64;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Role {
44 System,
47 User,
49 Assistant,
51 Tool,
53}
54
55impl Role {
56 #[must_use]
58 pub const fn as_str(self) -> &'static str {
59 match self {
60 Self::System => "system",
61 Self::User => "user",
62 Self::Assistant => "assistant",
63 Self::Tool => "tool",
64 }
65 }
66}
67
68impl fmt::Display for Role {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.write_str(self.as_str())
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum ImageSource {
79 Url {
81 url: String,
83 },
84 Base64 {
86 media_type: String,
88 data: String,
90 },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(tag = "kind", rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum DocumentSource {
104 Url {
106 url: String,
108 media_type: String,
111 },
112 Base64 {
114 media_type: String,
116 data: String,
118 },
119}
120
121impl DocumentSource {
122 #[must_use]
124 pub fn media_type(&self) -> &str {
125 match self {
126 Self::Url { media_type, .. } | Self::Base64 { media_type, .. } => media_type,
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ToolCall {
138 pub id: CallId,
140 pub name: String,
142 pub arguments: serde_json::Value,
144}
145
146impl ToolCall {
147 #[must_use]
149 pub fn new(
150 id: impl Into<CallId>,
151 name: impl Into<String>,
152 arguments: serde_json::Value,
153 ) -> Self {
154 Self {
155 id: id.into(),
156 name: name.into(),
157 arguments,
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct ToolResult {
166 pub call_id: CallId,
168 pub content: String,
171 #[serde(default)]
173 pub is_error: bool,
174}
175
176impl ToolResult {
177 #[must_use]
179 pub fn ok(call_id: impl Into<CallId>, content: impl Into<String>) -> Self {
180 Self {
181 call_id: call_id.into(),
182 content: content.into(),
183 is_error: false,
184 }
185 }
186
187 #[must_use]
189 pub fn error(call_id: impl Into<CallId>, content: impl Into<String>) -> Self {
190 Self {
191 call_id: call_id.into(),
192 content: content.into(),
193 is_error: true,
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(tag = "type", rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum ContentPart {
206 Text {
208 text: String,
210 },
211 Image {
214 source: ImageSource,
216 },
217 Document {
222 source: DocumentSource,
224 },
225 ToolCall(ToolCall),
227 ToolResult(ToolResult),
229}
230
231impl ContentPart {
232 #[must_use]
234 pub fn text(text: impl Into<String>) -> Self {
235 Self::Text { text: text.into() }
236 }
237
238 #[must_use]
240 pub fn image_url(url: impl Into<String>) -> Self {
241 Self::Image {
242 source: ImageSource::Url { url: url.into() },
243 }
244 }
245
246 #[must_use]
248 pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
249 Self::Image {
250 source: ImageSource::Base64 {
251 media_type: media_type.into(),
252 data: data.into(),
253 },
254 }
255 }
256
257 #[must_use]
271 pub fn inline_bytes(media_type: impl Into<String>, bytes: &[u8]) -> Self {
272 use base64::Engine as _;
273 let media_type = media_type.into();
274 let data = base64::engine::general_purpose::STANDARD.encode(bytes);
275 if media_type.starts_with("image/") {
276 Self::Image {
277 source: ImageSource::Base64 { media_type, data },
278 }
279 } else {
280 Self::Document {
281 source: DocumentSource::Base64 { media_type, data },
282 }
283 }
284 }
285
286 #[must_use]
288 pub fn document_url(url: impl Into<String>, media_type: impl Into<String>) -> Self {
289 Self::Document {
290 source: DocumentSource::Url {
291 url: url.into(),
292 media_type: media_type.into(),
293 },
294 }
295 }
296
297 #[must_use]
299 pub fn document_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
300 Self::Document {
301 source: DocumentSource::Base64 {
302 media_type: media_type.into(),
303 data: data.into(),
304 },
305 }
306 }
307
308 #[must_use]
310 pub fn as_text(&self) -> Option<&str> {
311 match self {
312 Self::Text { text } => Some(text),
313 _ => None,
314 }
315 }
316
317 #[must_use]
319 pub fn as_tool_call(&self) -> Option<&ToolCall> {
320 match self {
321 Self::ToolCall(call) => Some(call),
322 _ => None,
323 }
324 }
325
326 #[must_use]
328 pub fn as_document(&self) -> Option<&DocumentSource> {
329 match self {
330 Self::Document { source } => Some(source),
331 _ => None,
332 }
333 }
334
335 #[must_use]
337 pub const fn kind(&self) -> &'static str {
338 match self {
339 Self::Text { .. } => "text",
340 Self::Image { .. } => "image",
341 Self::Document { .. } => "document",
342 Self::ToolCall(_) => "tool_call",
343 Self::ToolResult(_) => "tool_result",
344 }
345 }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct Message {
352 pub role: Role,
354 pub content: Vec<ContentPart>,
356}
357
358impl Message {
359 #[must_use]
361 pub fn new(role: Role, content: Vec<ContentPart>) -> Self {
362 Self { role, content }
363 }
364
365 #[must_use]
367 pub fn user(text: impl Into<String>) -> Self {
368 Self::new(Role::User, vec![ContentPart::text(text)])
369 }
370
371 #[must_use]
373 pub fn assistant(text: impl Into<String>) -> Self {
374 Self::new(Role::Assistant, vec![ContentPart::text(text)])
375 }
376
377 #[must_use]
379 pub fn system(text: impl Into<String>) -> Self {
380 Self::new(Role::System, vec![ContentPart::text(text)])
381 }
382
383 #[must_use]
385 pub fn tool_result(result: ToolResult) -> Self {
386 Self::new(Role::Tool, vec![ContentPart::ToolResult(result)])
387 }
388
389 #[must_use]
391 pub fn with_part(mut self, part: ContentPart) -> Self {
392 self.content.push(part);
393 self
394 }
395
396 #[must_use]
398 pub fn text(&self) -> String {
399 let mut out = String::new();
400 for part in &self.content {
401 if let Some(text) = part.as_text() {
402 out.push_str(text);
403 }
404 }
405 out
406 }
407
408 #[must_use]
410 pub fn has_image(&self) -> bool {
411 self.content
412 .iter()
413 .any(|part| matches!(part, ContentPart::Image { .. }))
414 }
415
416 #[must_use]
418 pub fn has_document(&self) -> bool {
419 self.content
420 .iter()
421 .any(|part| matches!(part, ContentPart::Document { .. }))
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(tag = "kind", rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum OutputSpec {
438 FreeText,
440 Json {
442 schema: serde_json::Value,
446 name: String,
448 strict: bool,
453 },
454 ToolCalls,
456}
457
458impl OutputSpec {
459 #[must_use]
461 pub fn json(name: impl Into<String>, schema: serde_json::Value) -> Self {
462 Self::Json {
463 schema,
464 name: name.into(),
465 strict: true,
466 }
467 }
468
469 #[must_use]
471 pub fn schema(&self) -> Option<&serde_json::Value> {
472 match self {
473 Self::Json { schema, .. } => Some(schema),
474 _ => None,
475 }
476 }
477
478 #[must_use]
480 pub const fn is_structured(&self) -> bool {
481 matches!(self, Self::Json { .. })
482 }
483
484 #[must_use]
486 pub const fn as_str(&self) -> &'static str {
487 match self {
488 Self::FreeText => "free_text",
489 Self::Json { .. } => "json",
490 Self::ToolCalls => "tool_calls",
491 }
492 }
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(deny_unknown_fields)]
502pub struct ToolSpec {
503 pub name: String,
505 pub description: String,
507 pub parameters: serde_json::Value,
509}
510
511impl ToolSpec {
512 #[must_use]
514 pub fn new(
515 name: impl Into<String>,
516 description: impl Into<String>,
517 parameters: serde_json::Value,
518 ) -> Self {
519 Self {
520 name: name.into(),
521 description: description.into(),
522 parameters,
523 }
524 }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
529#[serde(tag = "kind", rename_all = "snake_case")]
530#[non_exhaustive]
531pub enum ToolChoice {
532 #[default]
534 Auto,
535 None,
537 Required,
539 Named {
542 name: String,
544 },
545}
546
547impl ToolChoice {
548 #[must_use]
550 pub fn named(name: impl Into<String>) -> Self {
551 Self::Named { name: name.into() }
552 }
553
554 #[must_use]
556 pub const fn as_str(&self) -> &'static str {
557 match self {
558 Self::Auto => "auto",
559 Self::None => "none",
560 Self::Required => "required",
561 Self::Named { .. } => "named",
562 }
563 }
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573#[non_exhaustive]
574pub enum ReasoningEffort {
575 Minimal,
577 Low,
579 Medium,
581 High,
583}
584
585impl ReasoningEffort {
586 #[must_use]
588 pub const fn as_str(self) -> &'static str {
589 match self {
590 Self::Minimal => "minimal",
591 Self::Low => "low",
592 Self::Medium => "medium",
593 Self::High => "high",
594 }
595 }
596}
597
598#[derive(Debug, Clone, PartialEq)]
603#[non_exhaustive]
604pub struct Sampling {
605 pub temperature: Option<f32>,
607 pub seed: Option<u64>,
609 pub reasoning_effort: Option<ReasoningEffort>,
611 pub dropped: Vec<&'static str>,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
622#[serde(tag = "kind", rename_all = "snake_case")]
623#[non_exhaustive]
624pub enum CacheHint {
625 #[default]
627 None,
628 System,
630 Prefix {
632 messages: usize,
634 },
635}
636
637#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
644#[serde(transparent)]
645pub struct RequestMetadata(BTreeMap<String, String>);
646
647impl<'de> Deserialize<'de> for RequestMetadata {
648 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
652 let raw = BTreeMap::<String, String>::deserialize(deserializer)?;
653 let mut metadata = Self::new();
654 for (key, value) in raw {
655 metadata
656 .insert(key, value)
657 .map_err(serde::de::Error::custom)?;
658 }
659 Ok(metadata)
660 }
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
665#[non_exhaustive]
666pub enum MetadataError {
667 #[error("metadata key is empty")]
669 EmptyKey,
670 #[error("metadata value for key {key} is {len} bytes, over the label limit")]
672 ValueTooLong {
673 key: String,
675 len: usize,
677 },
678 #[error("metadata value for key {key} is not a label")]
681 ValueNotALabel {
682 key: String,
684 },
685}
686
687impl RequestMetadata {
688 #[must_use]
690 pub fn new() -> Self {
691 Self::default()
692 }
693
694 pub fn insert(
705 &mut self,
706 key: impl Into<String>,
707 value: impl Into<String>,
708 ) -> Result<(), MetadataError> {
709 let key = key.into();
710 let value = value.into();
711 if key.is_empty() {
712 return Err(MetadataError::EmptyKey);
713 }
714 if value.len() > MAX_METADATA_VALUE_LEN {
715 return Err(MetadataError::ValueTooLong {
716 key,
717 len: value.len(),
718 });
719 }
720 if value
721 .chars()
722 .any(|ch| ch.is_whitespace() || ch.is_control())
723 {
724 return Err(MetadataError::ValueNotALabel { key });
725 }
726 self.0.insert(key, value);
727 Ok(())
728 }
729
730 #[must_use]
732 pub fn get(&self, key: &str) -> Option<&str> {
733 self.0.get(key).map(String::as_str)
734 }
735
736 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
738 self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
739 }
740
741 #[must_use]
743 pub fn len(&self) -> usize {
744 self.0.len()
745 }
746
747 #[must_use]
749 pub fn is_empty(&self) -> bool {
750 self.0.is_empty()
751 }
752}
753
754#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
759#[serde(deny_unknown_fields)]
760pub struct ModelRequest {
761 pub request_id: RequestId,
764 pub purpose: ModelPurpose,
767 pub messages: Vec<Message>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub system: Option<String>,
773 pub output: OutputSpec,
775 #[serde(default, skip_serializing_if = "Vec::is_empty")]
777 pub tools: Vec<ToolSpec>,
778 #[serde(default)]
780 pub tool_choice: ToolChoice,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub max_output_tokens: Option<u32>,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub temperature: Option<f32>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub reasoning_effort: Option<ReasoningEffort>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub seed: Option<u64>,
793 #[serde(default, skip_serializing_if = "Vec::is_empty")]
795 pub stop: Vec<String>,
796 #[serde(default, skip_serializing_if = "RequestMetadata::is_empty")]
798 pub metadata: RequestMetadata,
799 pub timeout: Duration,
801 #[serde(default)]
803 pub cache_hint: CacheHint,
804}
805
806impl ModelRequest {
807 #[must_use]
810 pub fn new(purpose: ModelPurpose) -> Self {
811 Self {
812 request_id: RequestId::new(),
813 purpose,
814 messages: Vec::new(),
815 system: None,
816 output: OutputSpec::FreeText,
817 tools: Vec::new(),
818 tool_choice: ToolChoice::Auto,
819 max_output_tokens: None,
820 temperature: None,
821 reasoning_effort: None,
822 seed: None,
823 stop: Vec::new(),
824 metadata: RequestMetadata::new(),
825 timeout: DEFAULT_TIMEOUT,
826 cache_hint: CacheHint::None,
827 }
828 }
829
830 #[must_use]
832 pub fn with_request_id(mut self, request_id: RequestId) -> Self {
833 self.request_id = request_id;
834 self
835 }
836
837 #[must_use]
839 pub fn with_system(mut self, system: impl Into<String>) -> Self {
840 self.system = Some(system.into());
841 self
842 }
843
844 #[must_use]
846 pub fn with_message(mut self, message: Message) -> Self {
847 self.messages.push(message);
848 self
849 }
850
851 #[must_use]
853 pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
854 self.messages = messages;
855 self
856 }
857
858 #[must_use]
860 pub fn with_output(mut self, output: OutputSpec) -> Self {
861 self.output = output;
862 self
863 }
864
865 #[must_use]
867 pub fn with_tools(mut self, tools: Vec<ToolSpec>) -> Self {
868 self.tools = tools;
869 self
870 }
871
872 #[must_use]
874 pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
875 self.tool_choice = tool_choice;
876 self
877 }
878
879 #[must_use]
881 pub fn with_max_output_tokens(mut self, tokens: u32) -> Self {
882 self.max_output_tokens = Some(tokens);
883 self
884 }
885
886 #[must_use]
888 pub fn with_temperature(mut self, temperature: f32) -> Self {
889 self.temperature = Some(temperature);
890 self
891 }
892
893 #[must_use]
895 pub fn sampling_for(
896 &self,
897 capabilities: &crate::capabilities::ProviderCapabilities,
898 ) -> Sampling {
899 let mut dropped = Vec::new();
900 let mut accept = |accepted: bool, name: &'static str| {
901 if !accepted {
902 dropped.push(name);
903 }
904 accepted
905 };
906 let temperature = self
907 .temperature
908 .filter(|_| accept(capabilities.temperature, "temperature"));
909 let seed = self.seed.filter(|_| accept(capabilities.seed, "seed"));
910 let reasoning_effort = self
911 .reasoning_effort
912 .filter(|_| accept(capabilities.reasoning_controls, "reasoning_effort"));
913 Sampling {
914 temperature,
915 seed,
916 reasoning_effort,
917 dropped,
918 }
919 }
920
921 #[must_use]
923 pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
924 self.reasoning_effort = Some(effort);
925 self
926 }
927
928 #[must_use]
930 pub fn with_seed(mut self, seed: u64) -> Self {
931 self.seed = Some(seed);
932 self
933 }
934
935 #[must_use]
937 pub fn with_stop(mut self, stop: Vec<String>) -> Self {
938 self.stop = stop;
939 self
940 }
941
942 #[must_use]
944 pub fn with_timeout(mut self, timeout: Duration) -> Self {
945 self.timeout = timeout;
946 self
947 }
948
949 #[must_use]
951 pub fn with_cache_hint(mut self, cache_hint: CacheHint) -> Self {
952 self.cache_hint = cache_hint;
953 self
954 }
955
956 pub fn with_metadata(
962 mut self,
963 key: impl Into<String>,
964 value: impl Into<String>,
965 ) -> Result<Self, MetadataError> {
966 self.metadata.insert(key, value)?;
967 Ok(self)
968 }
969
970 #[must_use]
978 pub fn requirements(&self) -> crate::capabilities::CapabilityRequirements {
979 let mut requirements = self.purpose.requirements();
980 if !self.tools.is_empty() || matches!(self.output, OutputSpec::ToolCalls) {
981 requirements.needs_tools = true;
982 }
983 if self.messages.iter().any(Message::has_image) {
984 requirements.needs_vision = true;
985 }
986 if self.messages.iter().any(Message::has_document) {
987 requirements.needs_documents = true;
988 }
989 requirements
990 }
991}
992
993#[cfg(test)]
994mod tests {
995 #[test]
996 fn sampling_keeps_what_the_profile_takes_and_names_the_rest() {
997 use crate::capabilities::ProviderCapabilities;
998 let request = ModelRequest::new(ModelPurpose::Extract)
999 .with_temperature(0.0)
1000 .with_seed(7)
1001 .with_reasoning_effort(ReasoningEffort::Minimal);
1002 let reasoning_model = ProviderCapabilities::minimal().with_reasoning_controls(true);
1003 let sampling = request.sampling_for(&reasoning_model);
1004 assert_eq!(sampling.temperature, None);
1005 assert_eq!(sampling.seed, None);
1006 assert_eq!(sampling.reasoning_effort, Some(ReasoningEffort::Minimal));
1007 assert_eq!(sampling.dropped, vec!["temperature", "seed"]);
1008
1009 let chat_model = ProviderCapabilities::minimal()
1010 .with_temperature(true)
1011 .with_seed(true);
1012 let sampling = request.sampling_for(&chat_model);
1013 assert_eq!(sampling.temperature, Some(0.0));
1014 assert_eq!(sampling.seed, Some(7));
1015 assert_eq!(sampling.dropped, vec!["reasoning_effort"]);
1016
1017 let nothing_set = ModelRequest::new(ModelPurpose::Extract).sampling_for(&chat_model);
1018 assert!(
1019 nothing_set.dropped.is_empty(),
1020 "an unset parameter is never dropped"
1021 );
1022 }
1023
1024 use super::*;
1025 use serde_json::json;
1026
1027 #[test]
1028 fn builder_defaults_are_conservative() {
1029 let request = ModelRequest::new(ModelPurpose::Extract);
1030 assert_eq!(request.timeout, DEFAULT_TIMEOUT);
1031 assert_eq!(request.tool_choice, ToolChoice::Auto);
1032 assert_eq!(request.cache_hint, CacheHint::None);
1033 assert!(request.temperature.is_none());
1034 assert!(request.metadata.is_empty());
1035 assert_eq!(request.output.as_str(), "free_text");
1036 }
1037
1038 #[test]
1039 fn request_id_is_stable_when_pinned() {
1040 let id = RequestId::nil();
1041 let request = ModelRequest::new(ModelPurpose::Acknowledge).with_request_id(id);
1042 let retried = request.clone().with_temperature(0.7);
1043 assert_eq!(request.request_id, retried.request_id);
1044 }
1045
1046 #[test]
1047 fn metadata_rejects_anything_that_is_not_a_label() {
1048 let mut metadata = RequestMetadata::new();
1049 metadata.insert("turn", "0192f0aa-1b2c-7def").unwrap();
1050 assert_eq!(metadata.get("turn"), Some("0192f0aa-1b2c-7def"));
1051 assert_eq!(metadata.len(), 1);
1052
1053 assert!(matches!(
1054 metadata.insert("", "x"),
1055 Err(MetadataError::EmptyKey)
1056 ));
1057 assert!(matches!(
1058 metadata.insert("note", "hello world"),
1059 Err(MetadataError::ValueNotALabel { .. })
1060 ));
1061 assert!(matches!(
1062 metadata.insert("note", "x".repeat(MAX_METADATA_VALUE_LEN + 1)),
1063 Err(MetadataError::ValueTooLong { .. })
1064 ));
1065 assert!(matches!(
1066 metadata.insert("note", "line\nbreak"),
1067 Err(MetadataError::ValueNotALabel { .. })
1068 ));
1069 assert_eq!(metadata.len(), 1);
1071 let collected: Vec<_> = metadata.iter().collect();
1072 assert_eq!(collected, vec![("turn", "0192f0aa-1b2c-7def")]);
1073 }
1074
1075 #[test]
1076 fn requirements_follow_the_content_of_the_request() {
1077 let plain = ModelRequest::new(ModelPurpose::Acknowledge);
1078 let requirements = plain.requirements();
1079 assert!(!requirements.needs_tools);
1080 assert!(!requirements.needs_vision);
1081
1082 let with_tools = ModelRequest::new(ModelPurpose::Investigate)
1083 .with_tools(vec![ToolSpec::new("case.get", "load a case", json!({}))])
1084 .with_message(
1085 Message::user("look at this").with_part(ContentPart::image_url("https://x.test/a")),
1086 );
1087 let requirements = with_tools.requirements();
1088 assert!(requirements.needs_tools);
1089 assert!(requirements.needs_vision);
1090 assert!(!requirements.structured_output.is_empty());
1091 }
1092
1093 #[test]
1094 fn messages_flatten_their_text_parts() {
1095 let message = Message::user("hello ").with_part(ContentPart::text("world"));
1096 assert_eq!(message.text(), "hello world");
1097 assert!(!message.has_image());
1098 assert_eq!(message.role, Role::User);
1099 assert_eq!(message.content[0].kind(), "text");
1100 }
1101
1102 #[test]
1103 fn a_document_states_its_media_type_on_both_forms() {
1104 let inline = ContentPart::document_base64("application/pdf", "JVBERi0=");
1105 let referenced = ContentPart::document_url("https://x.test/a", "application/pdf");
1106 for part in [&inline, &referenced] {
1107 assert_eq!(part.kind(), "document");
1108 assert_eq!(
1109 part.as_document().map(DocumentSource::media_type),
1110 Some("application/pdf"),
1111 "a document never leaves its media type to be guessed"
1112 );
1113 assert!(part.as_text().is_none());
1114 }
1115
1116 let message = Message::new(Role::User, vec![inline]);
1117 assert!(message.has_document());
1118 assert!(!message.has_image(), "a document is not an image");
1119
1120 let request = ModelRequest::new(ModelPurpose::Acknowledge).with_message(message);
1123 let requirements = request.requirements();
1124 assert!(requirements.needs_documents);
1125 assert!(!requirements.needs_vision);
1126 }
1127
1128 #[test]
1129 fn output_spec_exposes_its_schema() {
1130 let spec = OutputSpec::json("user_turn_plan", json!({"type": "object"}));
1131 assert!(spec.is_structured());
1132 assert_eq!(spec.schema(), Some(&json!({"type": "object"})));
1133 assert!(matches!(spec, OutputSpec::Json { strict: true, .. }));
1134 assert_eq!(OutputSpec::FreeText.schema(), None);
1135 }
1136
1137 #[test]
1138 fn requests_round_trip_through_serde() {
1139 let request = ModelRequest::new(ModelPurpose::Extract)
1140 .with_request_id(RequestId::nil())
1141 .with_system("be precise")
1142 .with_message(Message::user("ciao"))
1143 .with_message(Message::tool_result(ToolResult::ok("call_1", "{}")))
1144 .with_output(OutputSpec::json("plan", json!({"type": "object"})))
1145 .with_tools(vec![ToolSpec::new("case.get", "load", json!({}))])
1146 .with_tool_choice(ToolChoice::named("case.get"))
1147 .with_cache_hint(CacheHint::Prefix { messages: 1 })
1148 .with_metadata("workflow", "trip")
1149 .unwrap();
1150 let json = serde_json::to_string(&request).unwrap();
1151 let back: ModelRequest = serde_json::from_str(&json).unwrap();
1152 assert_eq!(back, request);
1153 assert!(!json.contains("\"stop\""), "empty vectors are skipped");
1154 }
1155
1156 #[test]
1157 fn unknown_fields_are_rejected() {
1158 let json = json!({
1159 "request_id": "00000000-0000-0000-0000-000000000000",
1160 "purpose": "acknowledge",
1161 "messages": [],
1162 "output": {"kind": "free_text"},
1163 "timeout": {"secs": 1, "nanos": 0},
1164 "surprise": true
1165 });
1166 assert!(serde_json::from_value::<ModelRequest>(json).is_err());
1167 }
1168
1169 #[test]
1170 fn tool_calls_and_results_pair_by_id() {
1171 let call = ToolCall::new("call_7", "case.get", json!({"id": "c1"}));
1172 let result = ToolResult::ok(call.id.clone(), "{\"ok\":true}");
1173 assert_eq!(result.call_id, call.id);
1174 assert!(!result.is_error);
1175 assert!(ToolResult::error("call_7", "boom").is_error);
1176 let part = ContentPart::ToolCall(call);
1177 assert_eq!(part.kind(), "tool_call");
1178 assert!(part.as_tool_call().is_some());
1179 assert!(part.as_text().is_none());
1180 }
1181}