1use std::error::Error;
14use std::fmt;
15use std::pin::Pin;
16
17use async_trait::async_trait;
18use serde::{Deserialize, Serialize};
19use tokio_stream::Stream;
20
21use crate::turn::ConversationTurnId;
22
23#[derive(Debug, Clone)]
29#[must_use]
30pub struct RunnerError {
31 pub kind: ErrorKind,
33 pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ErrorKind {
40 Internal,
42 ExternalService,
44 Timeout,
46 BinaryNotFound,
48 AuthFailure,
50 Config,
52 Guardrail,
54 ContextLength,
61 ModelUnavailable,
67 RateLimit,
81}
82
83impl ErrorKind {
84 #[must_use]
95 pub const fn is_transient(self) -> bool {
96 matches!(self, Self::Timeout | Self::ExternalService)
97 }
98}
99
100impl RunnerError {
101 pub fn internal(message: impl Into<String>) -> Self {
103 Self {
104 kind: ErrorKind::Internal,
105 message: message.into(),
106 }
107 }
108
109 pub fn external_service(service: impl Into<String>, message: impl Into<String>) -> Self {
111 Self {
112 kind: ErrorKind::ExternalService,
113 message: format!("{}: {}", service.into(), message.into()),
114 }
115 }
116
117 pub fn binary_not_found(binary: impl Into<String>) -> Self {
119 Self {
120 kind: ErrorKind::BinaryNotFound,
121 message: format!("Binary not found: {}", binary.into()),
122 }
123 }
124
125 pub fn auth_failure(message: impl Into<String>) -> Self {
127 Self {
128 kind: ErrorKind::AuthFailure,
129 message: message.into(),
130 }
131 }
132
133 pub fn config(message: impl Into<String>) -> Self {
135 Self {
136 kind: ErrorKind::Config,
137 message: message.into(),
138 }
139 }
140
141 pub fn timeout(message: impl Into<String>) -> Self {
143 Self {
144 kind: ErrorKind::Timeout,
145 message: message.into(),
146 }
147 }
148
149 pub fn guardrail(message: impl Into<String>) -> Self {
151 Self {
152 kind: ErrorKind::Guardrail,
153 message: message.into(),
154 }
155 }
156
157 pub fn context_length(message: impl Into<String>) -> Self {
159 Self {
160 kind: ErrorKind::ContextLength,
161 message: message.into(),
162 }
163 }
164
165 pub fn model_unavailable(model: impl Into<String>) -> Self {
167 let model = model.into();
168 Self {
169 kind: ErrorKind::ModelUnavailable,
170 message: format!("Model {model:?} is not available"),
171 }
172 }
173
174 pub fn rate_limit(service: impl Into<String>, message: impl Into<String>) -> Self {
180 let service = service.into();
181 let message = message.into();
182 Self {
183 kind: ErrorKind::RateLimit,
184 message: format!("{service}: {message}"),
185 }
186 }
187}
188
189impl fmt::Display for RunnerError {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 write!(f, "{:?}: {}", self.kind, self.message)
192 }
193}
194
195impl Error for RunnerError {}
196
197bitflags::bitflags! {
202 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207 pub struct LlmCapabilities: u16 {
208 const STREAMING = 0b0000_0000_0001;
210 const FUNCTION_CALLING = 0b0000_0000_0010;
212 const VISION = 0b0000_0000_0100;
214 const JSON_MODE = 0b0000_0000_1000;
216 const SYSTEM_MESSAGES = 0b0000_0001_0000;
218 const SDK_TOOL_CALLING = 0b0000_0010_0000;
220 const TEMPERATURE = 0b0000_0100_0000;
222 const MAX_TOKENS = 0b0000_1000_0000;
224 const TOP_P = 0b0001_0000_0000;
226 const STOP_SEQUENCES = 0b0010_0000_0000;
228 const RESPONSE_FORMAT = 0b0100_0000_0000;
230 }
231}
232
233impl LlmCapabilities {
234 #[must_use]
236 pub const fn text_only() -> Self {
237 Self::STREAMING.union(Self::SYSTEM_MESSAGES)
238 }
239
240 #[must_use]
242 pub const fn full_featured() -> Self {
243 Self::STREAMING
244 .union(Self::FUNCTION_CALLING)
245 .union(Self::VISION)
246 .union(Self::JSON_MODE)
247 .union(Self::SYSTEM_MESSAGES)
248 }
249
250 #[must_use]
252 pub const fn supports_streaming(&self) -> bool {
253 self.contains(Self::STREAMING)
254 }
255
256 #[must_use]
258 pub const fn supports_function_calling(&self) -> bool {
259 self.contains(Self::FUNCTION_CALLING)
260 }
261
262 #[must_use]
264 pub const fn supports_vision(&self) -> bool {
265 self.contains(Self::VISION)
266 }
267
268 #[must_use]
270 pub const fn supports_json_mode(&self) -> bool {
271 self.contains(Self::JSON_MODE)
272 }
273
274 #[must_use]
276 pub const fn supports_system_messages(&self) -> bool {
277 self.contains(Self::SYSTEM_MESSAGES)
278 }
279
280 #[must_use]
282 pub const fn supports_sdk_tool_calling(&self) -> bool {
283 self.contains(Self::SDK_TOOL_CALLING)
284 }
285
286 #[must_use]
288 pub const fn supports_temperature(&self) -> bool {
289 self.contains(Self::TEMPERATURE)
290 }
291
292 #[must_use]
294 pub const fn supports_max_tokens(&self) -> bool {
295 self.contains(Self::MAX_TOKENS)
296 }
297
298 #[must_use]
300 pub const fn supports_top_p(&self) -> bool {
301 self.contains(Self::TOP_P)
302 }
303
304 #[must_use]
306 pub const fn supports_stop_sequences(&self) -> bool {
307 self.contains(Self::STOP_SEQUENCES)
308 }
309
310 #[must_use]
312 pub const fn supports_response_format(&self) -> bool {
313 self.contains(Self::RESPONSE_FORMAT)
314 }
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "lowercase")]
324pub enum MessageRole {
325 System,
327 User,
329 Assistant,
331 Tool,
333}
334
335impl MessageRole {
336 #[must_use]
338 pub const fn as_str(&self) -> &'static str {
339 match self {
340 Self::System => "system",
341 Self::User => "user",
342 Self::Assistant => "assistant",
343 Self::Tool => "tool",
344 }
345 }
346}
347
348const VALID_IMAGE_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif"];
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
353pub struct ImagePart {
354 pub data: String,
356 pub mime_type: String,
358}
359
360impl ImagePart {
361 pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Result<Self, RunnerError> {
369 let mime_type = mime_type.into();
370 if !VALID_IMAGE_MIME_TYPES.contains(&mime_type.as_str()) {
371 return Err(RunnerError::config(format!(
372 "Unsupported image MIME type '{mime_type}'; expected one of: {}",
373 VALID_IMAGE_MIME_TYPES.join(", ")
374 )));
375 }
376 Ok(Self {
377 data: data.into(),
378 mime_type,
379 })
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct ChatMessage {
386 pub role: MessageRole,
388 pub content: String,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub images: Option<Vec<ImagePart>>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub tool_calls: Option<Vec<ToolCallRequest>>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub tool_call_id: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub name: Option<String>,
402}
403
404impl ChatMessage {
405 #[must_use]
407 pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
408 Self {
409 role,
410 content: content.into(),
411 images: None,
412 tool_calls: None,
413 tool_call_id: None,
414 name: None,
415 }
416 }
417
418 #[must_use]
420 pub fn system(content: impl Into<String>) -> Self {
421 Self::new(MessageRole::System, content)
422 }
423
424 #[must_use]
426 pub fn user(content: impl Into<String>) -> Self {
427 Self::new(MessageRole::User, content)
428 }
429
430 #[must_use]
432 pub fn user_with_images(content: impl Into<String>, images: Vec<ImagePart>) -> Self {
433 Self {
434 role: MessageRole::User,
435 content: content.into(),
436 images: Some(images),
437 tool_calls: None,
438 tool_call_id: None,
439 name: None,
440 }
441 }
442
443 #[must_use]
445 pub fn assistant(content: impl Into<String>) -> Self {
446 Self::new(MessageRole::Assistant, content)
447 }
448
449 #[must_use]
451 pub fn tool(
452 name: impl Into<String>,
453 tool_call_id: impl Into<String>,
454 content: impl Into<String>,
455 ) -> Self {
456 Self {
457 role: MessageRole::Tool,
458 content: content.into(),
459 images: None,
460 tool_calls: None,
461 tool_call_id: Some(tool_call_id.into()),
462 name: Some(name.into()),
463 }
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct ToolCallRequest {
474 pub id: String,
476 pub function_name: String,
478 pub arguments: serde_json::Value,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct ToolDefinition {
485 pub name: String,
487 pub description: String,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub parameters: Option<serde_json::Value>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
496pub enum ToolChoice {
497 Auto,
499 None,
501 Required,
503 Specific {
505 name: String,
507 },
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
512pub enum ResponseFormat {
513 Text,
515 JsonObject,
517 JsonSchema {
519 name: String,
521 schema: serde_json::Value,
523 },
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532pub struct McpHeader {
533 pub name: String,
535 pub value: String,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
545pub enum McpTransport {
546 Http {
548 url: String,
550 headers: Vec<McpHeader>,
552 },
553 Sse {
555 url: String,
557 headers: Vec<McpHeader>,
559 },
560 Stdio {
562 command: String,
564 args: Vec<String>,
566 env: Vec<McpHeader>,
568 },
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
577pub struct McpServerConfig {
578 pub name: String,
580 pub transport: McpTransport,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct ChatRequest {
587 pub messages: Vec<ChatMessage>,
589 pub model: Option<String>,
591 pub temperature: Option<f32>,
597 pub max_tokens: Option<u32>,
603 pub stream: bool,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub tools: Option<Vec<ToolDefinition>>,
608 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub tool_choice: Option<ToolChoice>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub top_p: Option<f32>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub stop: Option<Vec<String>>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub response_format: Option<ResponseFormat>,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub turn_id: Option<ConversationTurnId>,
628 #[serde(default, skip_serializing_if = "Vec::is_empty")]
634 pub mcp_servers: Vec<McpServerConfig>,
635}
636
637impl ChatRequest {
638 #[must_use]
640 pub const fn new(messages: Vec<ChatMessage>) -> Self {
641 Self {
642 messages,
643 model: None,
644 temperature: None,
645 max_tokens: None,
646 stream: false,
647 tools: None,
648 tool_choice: None,
649 top_p: None,
650 stop: None,
651 response_format: None,
652 turn_id: None,
653 mcp_servers: Vec::new(),
654 }
655 }
656
657 #[must_use]
659 pub fn with_mcp_servers(mut self, mcp_servers: Vec<McpServerConfig>) -> Self {
660 self.mcp_servers = mcp_servers;
661 self
662 }
663
664 #[must_use]
666 pub fn with_model(mut self, model: impl Into<String>) -> Self {
667 self.model = Some(model.into());
668 self
669 }
670
671 #[must_use]
673 pub const fn with_temperature(mut self, temperature: f32) -> Self {
674 self.temperature = Some(temperature);
675 self
676 }
677
678 #[must_use]
680 pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
681 self.max_tokens = Some(max_tokens);
682 self
683 }
684
685 #[must_use]
687 pub const fn with_streaming(mut self) -> Self {
688 self.stream = true;
689 self
690 }
691
692 #[must_use]
694 pub fn with_tools(mut self, tools: Vec<ToolDefinition>) -> Self {
695 self.tools = Some(tools);
696 self
697 }
698
699 #[must_use]
701 pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
702 self.tool_choice = Some(tool_choice);
703 self
704 }
705
706 #[must_use]
708 pub const fn with_top_p(mut self, top_p: f32) -> Self {
709 self.top_p = Some(top_p);
710 self
711 }
712
713 #[must_use]
715 pub fn with_stop(mut self, stop: Vec<String>) -> Self {
716 self.stop = Some(stop);
717 self
718 }
719
720 #[must_use]
722 pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
723 self.response_format = Some(response_format);
724 self
725 }
726
727 #[must_use]
733 pub const fn with_turn_id(mut self, turn_id: ConversationTurnId) -> Self {
734 self.turn_id = Some(turn_id);
735 self
736 }
737
738 #[must_use]
740 pub fn has_images(&self) -> bool {
741 self.messages
742 .iter()
743 .any(|m| m.images.as_ref().is_some_and(|imgs| !imgs.is_empty()))
744 }
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct ChatResponse {
750 pub content: String,
752 pub model: String,
754 pub usage: Option<TokenUsage>,
756 pub finish_reason: Option<String>,
758 #[serde(skip_serializing_if = "Option::is_none")]
760 pub warnings: Option<Vec<String>>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub tool_calls: Option<Vec<ToolCallRequest>>,
764}
765
766#[derive(Debug, Clone, Default, Serialize, Deserialize)]
777#[non_exhaustive]
778pub struct TokenUsage {
779 pub prompt_tokens: u32,
781 pub completion_tokens: u32,
783 pub total_tokens: u32,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub cached_read_tokens: Option<u32>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub cached_write_tokens: Option<u32>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub reasoning_tokens: Option<u32>,
803}
804
805impl TokenUsage {
806 #[must_use]
809 pub const fn new(prompt_tokens: u32, completion_tokens: u32, total_tokens: u32) -> Self {
810 Self {
811 prompt_tokens,
812 completion_tokens,
813 total_tokens,
814 cached_read_tokens: None,
815 cached_write_tokens: None,
816 reasoning_tokens: None,
817 }
818 }
819
820 #[must_use]
822 pub const fn with_cache(mut self, read: Option<u32>, write: Option<u32>) -> Self {
823 self.cached_read_tokens = read;
824 self.cached_write_tokens = write;
825 self
826 }
827
828 #[must_use]
830 pub const fn with_reasoning(mut self, reasoning: Option<u32>) -> Self {
831 self.reasoning_tokens = reasoning;
832 self
833 }
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
838pub struct StreamChunk {
839 pub delta: String,
841 pub is_final: bool,
843 pub finish_reason: Option<String>,
845}
846
847pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamChunk, RunnerError>> + Send>>;
849
850#[async_trait]
860pub trait LlmProvider: Send + Sync {
861 fn name(&self) -> &'static str;
863
864 fn display_name(&self) -> &str;
866
867 fn capabilities(&self) -> LlmCapabilities;
869
870 fn default_model(&self) -> &str;
872
873 fn available_models(&self) -> &[String];
875
876 async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError>;
878
879 async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError>;
884
885 async fn health_check(&self) -> Result<bool, RunnerError>;
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892 use serde_json::json;
893
894 #[test]
895 fn is_transient_classification() {
896 assert!(ErrorKind::Timeout.is_transient());
897 assert!(ErrorKind::ExternalService.is_transient());
898 assert!(!ErrorKind::Internal.is_transient());
899 assert!(!ErrorKind::BinaryNotFound.is_transient());
900 assert!(!ErrorKind::AuthFailure.is_transient());
901 assert!(!ErrorKind::Config.is_transient());
902 assert!(!ErrorKind::Guardrail.is_transient());
903 assert!(!ErrorKind::ContextLength.is_transient());
904 assert!(!ErrorKind::ModelUnavailable.is_transient());
905 }
906
907 #[test]
908 fn model_unavailable_constructor() {
909 let err = RunnerError::model_unavailable("claude-opus-4.6-fast");
910 assert_eq!(err.kind, ErrorKind::ModelUnavailable);
911 assert!(err.message.contains("claude-opus-4.6-fast"));
912 }
913
914 #[test]
915 fn tool_call_request_serde_round_trip() {
916 let tc = ToolCallRequest {
917 id: "call_1".to_owned(),
918 function_name: "get_weather".to_owned(),
919 arguments: json!({"city": "Paris"}),
920 };
921 let json = serde_json::to_string(&tc).unwrap(); let deserialized: ToolCallRequest = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.id, "call_1");
924 assert_eq!(deserialized.function_name, "get_weather");
925 assert_eq!(deserialized.arguments["city"], "Paris");
926 }
927
928 #[test]
929 fn tool_definition_serde_round_trip() {
930 let td = ToolDefinition {
931 name: "search".to_owned(),
932 description: "Search the web".to_owned(),
933 parameters: Some(json!({"type": "object", "properties": {"q": {"type": "string"}}})),
934 };
935 let json = serde_json::to_string(&td).unwrap(); let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.name, "search");
938 assert!(deserialized.parameters.is_some());
939 }
940
941 #[test]
942 fn tool_definition_without_parameters() {
943 let td = ToolDefinition {
944 name: "ping".to_owned(),
945 description: "Check connectivity".to_owned(),
946 parameters: None,
947 };
948 let json = serde_json::to_string(&td).unwrap(); assert!(!json.contains("parameters"));
950 let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap(); assert!(deserialized.parameters.is_none());
952 }
953
954 #[test]
955 fn tool_choice_serde_variants() {
956 let auto = ToolChoice::Auto;
957 let json = serde_json::to_string(&auto).unwrap(); let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, ToolChoice::Auto));
960
961 let none = ToolChoice::None;
962 let json = serde_json::to_string(&none).unwrap(); let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, ToolChoice::None));
965
966 let required = ToolChoice::Required;
967 let json = serde_json::to_string(&required).unwrap(); let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, ToolChoice::Required));
970
971 let specific = ToolChoice::Specific {
972 name: "get_weather".to_owned(),
973 };
974 let json = serde_json::to_string(&specific).unwrap(); let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, ToolChoice::Specific { name } if name == "get_weather"));
977 }
978
979 #[test]
980 fn response_format_serde_variants() {
981 let text = ResponseFormat::Text;
982 let json = serde_json::to_string(&text).unwrap(); let deserialized: ResponseFormat = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, ResponseFormat::Text));
985
986 let json_obj = ResponseFormat::JsonObject;
987 let json = serde_json::to_string(&json_obj).unwrap(); let deserialized: ResponseFormat = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, ResponseFormat::JsonObject));
990
991 let json_schema = ResponseFormat::JsonSchema {
992 name: "person".to_owned(),
993 schema: json!({"type": "object", "properties": {"name": {"type": "string"}}}),
994 };
995 let json = serde_json::to_string(&json_schema).unwrap(); let deserialized: ResponseFormat = serde_json::from_str(&json).unwrap(); assert!(
998 matches!(deserialized, ResponseFormat::JsonSchema { name, .. } if name == "person")
999 );
1000 }
1001
1002 #[test]
1003 fn chat_message_tool_constructor() {
1004 let msg = ChatMessage::tool("get_weather", "call_1", r#"{"temp": 72}"#);
1005 assert_eq!(msg.role, MessageRole::Tool);
1006 assert_eq!(msg.content, r#"{"temp": 72}"#);
1007 assert_eq!(msg.tool_call_id.as_deref(), Some("call_1"));
1008 assert_eq!(msg.name.as_deref(), Some("get_weather"));
1009 assert!(msg.tool_calls.is_none());
1010 }
1011
1012 #[test]
1013 fn chat_message_regular_constructors_have_none_tool_fields() {
1014 let user = ChatMessage::user("hello");
1015 assert!(user.tool_calls.is_none());
1016 assert!(user.tool_call_id.is_none());
1017 assert!(user.name.is_none());
1018 assert!(user.images.is_none());
1019 }
1020
1021 #[test]
1022 fn image_part_valid_mime_types() {
1023 for mime in &["image/png", "image/jpeg", "image/webp", "image/gif"] {
1024 let part = ImagePart::new("base64data", *mime);
1025 assert!(part.is_ok(), "Expected {mime} to be valid");
1026 }
1027 }
1028
1029 #[test]
1030 fn image_part_invalid_mime_type() {
1031 let err = ImagePart::new("data", "image/bmp").unwrap_err();
1032 assert_eq!(err.kind, ErrorKind::Config);
1033 assert!(err.message.contains("image/bmp"));
1034 }
1035
1036 #[test]
1037 fn user_with_images_constructor() {
1038 let img = ImagePart::new("aGVsbG8=", "image/png").unwrap(); let msg = ChatMessage::user_with_images("describe this", vec![img]);
1040 assert_eq!(msg.role, MessageRole::User);
1041 assert_eq!(msg.content, "describe this");
1042 let images = msg.images.as_ref().unwrap(); assert_eq!(images.len(), 1);
1044 assert_eq!(images[0].mime_type, "image/png");
1045 }
1046
1047 #[test]
1048 fn chat_request_has_images() {
1049 let img = ImagePart::new("data", "image/jpeg").unwrap(); let with = ChatRequest::new(vec![ChatMessage::user_with_images("x", vec![img])]);
1051 assert!(with.has_images());
1052
1053 let without = ChatRequest::new(vec![ChatMessage::user("text only")]);
1054 assert!(!without.has_images());
1055 }
1056
1057 #[test]
1058 fn chat_request_has_images_empty_vec() {
1059 let msg = ChatMessage::user_with_images("x", vec![]);
1060 let req = ChatRequest::new(vec![msg]);
1061 assert!(!req.has_images());
1062 }
1063
1064 #[test]
1065 fn image_part_serde_round_trip() {
1066 let img = ImagePart::new("aGVsbG8=", "image/png").unwrap(); let json = serde_json::to_string(&img).unwrap(); let deserialized: ImagePart = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized, img);
1070 }
1071
1072 #[test]
1073 fn chat_message_with_images_serde_round_trip() {
1074 let img = ImagePart::new("data", "image/jpeg").unwrap(); let msg = ChatMessage::user_with_images("describe", vec![img]);
1076 let json = serde_json::to_string(&msg).unwrap(); let deserialized: ChatMessage = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.images.as_ref().unwrap().len(), 1); assert_eq!(deserialized.images.unwrap()[0].mime_type, "image/jpeg"); }
1081
1082 #[test]
1083 fn chat_message_without_images_backward_compat() {
1084 let json = r#"{"role":"user","content":"hello"}"#;
1085 let msg: ChatMessage = serde_json::from_str(json).unwrap(); assert!(msg.images.is_none());
1087 assert_eq!(msg.content, "hello");
1088 }
1089
1090 #[test]
1091 fn chat_message_images_not_serialized_when_none() {
1092 let msg = ChatMessage::user("hello");
1093 let json = serde_json::to_string(&msg).unwrap(); assert!(!json.contains("images"));
1095 }
1096
1097 #[test]
1098 fn chat_request_builder_methods() {
1099 let req = ChatRequest::new(vec![ChatMessage::user("hi")])
1100 .with_tools(vec![ToolDefinition {
1101 name: "test".to_owned(),
1102 description: "test fn".to_owned(),
1103 parameters: None,
1104 }])
1105 .with_tool_choice(ToolChoice::Required)
1106 .with_top_p(0.9)
1107 .with_stop(vec!["END".to_owned()])
1108 .with_response_format(ResponseFormat::JsonObject);
1109
1110 assert!(req.tools.is_some());
1111 assert!(matches!(req.tool_choice, Some(ToolChoice::Required)));
1112 assert_eq!(req.top_p, Some(0.9));
1113 assert_eq!(req.stop.as_ref().unwrap()[0], "END"); assert!(matches!(
1115 req.response_format,
1116 Some(ResponseFormat::JsonObject)
1117 ));
1118 }
1119
1120 #[test]
1121 fn message_role_tool_as_str() {
1122 assert_eq!(MessageRole::Tool.as_str(), "tool");
1123 }
1124
1125 #[test]
1126 fn capability_flags_new_fields() {
1127 let caps = LlmCapabilities::TOP_P
1128 | LlmCapabilities::STOP_SEQUENCES
1129 | LlmCapabilities::RESPONSE_FORMAT;
1130 assert!(caps.supports_top_p());
1131 assert!(caps.supports_stop_sequences());
1132 assert!(caps.supports_response_format());
1133
1134 let empty = LlmCapabilities::empty();
1135 assert!(!empty.supports_top_p());
1136 assert!(!empty.supports_stop_sequences());
1137 assert!(!empty.supports_response_format());
1138 }
1139}