1#![deny(missing_docs)]
3
4use std::fmt;
5use std::pin::Pin;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum MessageRole {
13 System,
15 User,
17 Assistant,
19 Tool,
21}
22
23impl fmt::Display for MessageRole {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 Self::System => write!(f, "system"),
27 Self::User => write!(f, "user"),
28 Self::Assistant => write!(f, "assistant"),
29 Self::Tool => write!(f, "tool"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum ContentPart {
42 Text {
44 text: String,
46 },
47 ImageUrl {
49 url: String,
51 },
52 ImageBase64 {
54 media_type: String,
56 data: String,
58 },
59}
60
61impl ContentPart {
62 pub fn text(s: impl Into<String>) -> Self {
64 Self::Text { text: s.into() }
65 }
66
67 pub fn image_url(url: impl Into<String>) -> Self {
69 Self::ImageUrl { url: url.into() }
70 }
71
72 pub fn as_text(&self) -> Option<&str> {
74 match self {
75 Self::Text { text } => Some(text),
76 _ => None,
77 }
78 }
79}
80
81mod content_vec_serde {
84 use super::ContentPart;
85 use serde::{Deserialize, Deserializer, Serialize, Serializer};
86
87 pub fn serialize<S: Serializer>(parts: &[ContentPart], s: S) -> Result<S::Ok, S::Error> {
88 if parts.len() == 1
89 && let ContentPart::Text { text } = &parts[0]
90 {
91 return s.serialize_str(text);
92 }
93 parts.serialize(s)
94 }
95
96 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<ContentPart>, D::Error> {
97 #[derive(Deserialize)]
98 #[serde(untagged)]
99 enum StringOrParts {
100 S(String),
101 P(Vec<ContentPart>),
102 }
103 match StringOrParts::deserialize(d)? {
104 StringOrParts::S(s) => Ok(vec![ContentPart::text(s)]),
105 StringOrParts::P(v) => Ok(v),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ChatMessage {
117 pub role: MessageRole,
119 #[serde(with = "content_vec_serde")]
122 pub content: Vec<ContentPart>,
123}
124
125impl Default for ChatMessage {
126 fn default() -> Self {
127 Self {
128 role: MessageRole::User,
129 content: Vec::new(),
130 }
131 }
132}
133
134impl ChatMessage {
135 pub fn system(content: impl Into<String>) -> Self {
137 Self {
138 role: MessageRole::System,
139 content: vec![ContentPart::text(content)],
140 }
141 }
142
143 pub fn user(content: impl Into<String>) -> Self {
145 Self {
146 role: MessageRole::User,
147 content: vec![ContentPart::text(content)],
148 }
149 }
150
151 pub fn assistant(content: impl Into<String>) -> Self {
153 Self {
154 role: MessageRole::Assistant,
155 content: vec![ContentPart::text(content)],
156 }
157 }
158
159 pub fn tool(content: impl Into<String>) -> Self {
161 Self {
162 role: MessageRole::Tool,
163 content: vec![ContentPart::text(content)],
164 }
165 }
166
167 pub fn user_multimodal(parts: Vec<ContentPart>) -> Self {
169 Self {
170 role: MessageRole::User,
171 content: parts,
172 }
173 }
174
175 pub fn text_content(&self) -> String {
177 self.content
178 .iter()
179 .filter_map(|p| p.as_text())
180 .collect::<Vec<_>>()
181 .join("")
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ModelConfig {
188 pub provider: String,
190 pub model: String,
192 pub api_key_env: String,
194 pub base_url: Option<String>,
196 pub temperature: f32,
198 pub max_tokens: Option<u32>,
200}
201
202impl Default for ModelConfig {
203 fn default() -> Self {
204 Self {
205 provider: "openai".into(),
206 model: "gpt-4o".into(),
207 api_key_env: "OPENAI_API_KEY".into(),
208 base_url: None,
209 temperature: 0.7,
210 max_tokens: Some(4096),
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(tag = "type", rename_all = "snake_case")]
218pub enum ResponseFormat {
219 Text,
221 Json,
223 JsonSchema {
225 schema: serde_json::Value,
227 },
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum ReasoningEffort {
239 None,
241 Minimal,
243 Low,
245 Medium,
247 High,
249 XHigh,
251 Max,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum Verbosity {
262 Low,
264 Medium,
266 High,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "lowercase")]
277pub enum ReasoningSummary {
278 Auto,
280 Concise,
282 Detailed,
284}
285
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct ReasoningConfig {
309 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub enabled: Option<bool>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub effort: Option<ReasoningEffort>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub summary: Option<ReasoningSummary>,
319}
320
321impl ReasoningConfig {
322 pub fn disabled() -> Self {
325 Self {
326 enabled: Some(false),
327 effort: None,
328 summary: None,
329 }
330 }
331
332 pub fn effort(effort: ReasoningEffort) -> Self {
334 Self {
335 enabled: None,
336 effort: Some(effort),
337 summary: None,
338 }
339 }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct LLMRequest {
361 pub system: Option<String>,
363 pub messages: Vec<ChatMessage>,
365 pub temperature: f32,
367 pub max_tokens: Option<u32>,
369 pub model: Option<String>,
371 #[serde(skip_serializing_if = "Option::is_none")]
379 pub response_format: Option<ResponseFormat>,
380 #[serde(skip_serializing_if = "Option::is_none")]
386 pub tools: Option<Vec<crate::llm::ToolDefinition>>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub reasoning: Option<ReasoningConfig>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub verbosity: Option<Verbosity>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub observability: Option<ObservabilityContext>,
416}
417
418#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
433pub struct ObservabilityContext {
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub traceparent: Option<String>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub session_id: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub name: Option<String>,
443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
445 pub tags: Vec<String>,
446 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
448 pub metadata: std::collections::BTreeMap<String, String>,
449}
450
451impl Default for LLMRequest {
452 fn default() -> Self {
453 Self {
454 system: None,
455 messages: Vec::new(),
456 temperature: 0.7,
459 max_tokens: None,
460 model: None,
461 response_format: None,
462 tools: None,
463 reasoning: None,
464 verbosity: None,
465 extra_body: None,
466 observability: None,
467 }
468 }
469}
470
471impl LLMRequest {
472 pub fn builder() -> LLMRequestBuilder {
474 LLMRequestBuilder::default()
475 }
476
477 pub(crate) fn into_openai_messages(self) -> Vec<(String, String)> {
481 let mut out = Vec::with_capacity(self.messages.len() + 1);
482 if let Some(system) = self.system {
483 out.push(("system".into(), system));
484 }
485 for msg in self.messages {
486 out.push((msg.role.to_string(), msg.text_content()));
487 }
488 out
489 }
490
491 pub(crate) fn into_anthropic_messages(self) -> Vec<(String, String)> {
495 self.messages
496 .into_iter()
497 .map(|m| (m.role.to_string(), m.text_content()))
498 .collect()
499 }
500}
501
502#[derive(Debug, Clone, Default)]
516pub struct LLMRequestBuilder {
517 system: Option<String>,
518 messages: Vec<ChatMessage>,
519 temperature: Option<f32>,
520 max_tokens: Option<u32>,
521 model: Option<String>,
522 response_format: Option<ResponseFormat>,
523 tools: Option<Vec<crate::llm::ToolDefinition>>,
524 reasoning: Option<ReasoningConfig>,
525 verbosity: Option<Verbosity>,
526 extra_body: Option<serde_json::Map<String, serde_json::Value>>,
527 observability: Option<ObservabilityContext>,
528}
529
530impl LLMRequestBuilder {
531 pub fn system(mut self, prompt: impl Into<String>) -> Self {
533 self.system = Some(prompt.into());
534 self
535 }
536
537 pub fn user_message(mut self, content: impl Into<String>) -> Self {
539 self.messages.push(ChatMessage::user(content));
540 self
541 }
542
543 pub fn assistant_message(mut self, content: impl Into<String>) -> Self {
545 self.messages.push(ChatMessage::assistant(content));
546 self
547 }
548
549 pub fn message(mut self, msg: ChatMessage) -> Self {
551 self.messages.push(msg);
552 self
553 }
554
555 pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
560 self.messages = messages;
561 self
562 }
563
564 pub fn temperature(mut self, temp: f32) -> Self {
566 self.temperature = Some(temp);
567 self
568 }
569
570 pub fn max_tokens(mut self, tokens: u32) -> Self {
572 self.max_tokens = Some(tokens);
573 self
574 }
575
576 pub fn maybe_max_tokens(mut self, tokens: Option<u32>) -> Self {
581 self.max_tokens = tokens;
582 self
583 }
584
585 pub fn model(mut self, model: impl Into<String>) -> Self {
587 self.model = Some(model.into());
588 self
589 }
590
591 pub fn response_format(mut self, format: ResponseFormat) -> Self {
593 self.response_format = Some(format);
594 self
595 }
596
597 pub fn tools(mut self, tools: Vec<crate::llm::ToolDefinition>) -> Self {
599 self.tools = Some(tools);
600 self
601 }
602
603 pub fn reasoning(mut self, cfg: ReasoningConfig) -> Self {
605 self.reasoning = Some(cfg);
606 self
607 }
608
609 pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
611 self.verbosity = Some(verbosity);
612 self
613 }
614
615 pub fn extra_body(mut self, extra: serde_json::Map<String, serde_json::Value>) -> Self {
618 self.extra_body = Some(extra);
619 self
620 }
621
622 pub fn observability(mut self, context: ObservabilityContext) -> Self {
624 self.observability = Some(context);
625 self
626 }
627
628 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
631 let context = self.observability.get_or_insert_with(Default::default);
632 context.session_id = Some(session_id.into());
633 self
634 }
635
636 pub fn build(self) -> LLMRequest {
638 LLMRequest {
639 system: self.system,
640 messages: self.messages,
641 temperature: self.temperature.unwrap_or(0.7),
642 max_tokens: self.max_tokens,
643 model: self.model,
644 response_format: self.response_format,
645 tools: self.tools,
646 reasoning: self.reasoning,
647 verbosity: self.verbosity,
648 extra_body: self.extra_body,
649 observability: self.observability,
650 }
651 }
652}
653
654#[derive(Debug, Clone, Default, Serialize, Deserialize)]
659pub struct LLMResponse {
660 pub content: String,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub reasoning: Option<String>,
670 pub model: String,
672 pub usage: TokenUsage,
674 #[serde(default, skip_serializing_if = "Vec::is_empty")]
680 pub tool_calls: Vec<crate::llm::ToolCall>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub finish_reason: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub id: Option<String>,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub created: Option<u64>,
690}
691
692#[derive(Debug, Clone, Default, Serialize, Deserialize)]
694pub struct TokenUsage {
695 pub prompt_tokens: u32,
697 pub completion_tokens: u32,
699 pub total_tokens: u32,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub reasoning_tokens: Option<u32>,
705}
706
707#[derive(Debug, Clone)]
714#[non_exhaustive]
715pub enum StreamEvent {
716 Delta {
718 content: String,
720 },
721 ReasoningDelta {
734 content: String,
736 },
737 Usage(TokenUsage),
739 Done,
741}
742
743#[cfg(feature = "client-async")]
745pub type LLMStream =
746 Pin<Box<dyn futures_core::Stream<Item = crate::error::Result<StreamEvent>> + Send>>;
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 #[test]
753 fn message_role_display() {
754 assert_eq!(MessageRole::System.to_string(), "system");
755 assert_eq!(MessageRole::User.to_string(), "user");
756 assert_eq!(MessageRole::Assistant.to_string(), "assistant");
757 assert_eq!(MessageRole::Tool.to_string(), "tool");
758 }
759
760 #[test]
761 fn message_role_serde_roundtrip() {
762 let json = serde_json::to_string(&MessageRole::User).unwrap();
763 assert_eq!(json, "\"user\"");
764 let back: MessageRole = serde_json::from_str(&json).unwrap();
765 assert_eq!(back, MessageRole::User);
766 }
767
768 #[test]
769 fn chat_message_constructors() {
770 let sys = ChatMessage::system("instructions");
771 assert_eq!(sys.role, MessageRole::System);
772
773 let user = ChatMessage::user("hello");
774 assert_eq!(user.role, MessageRole::User);
775
776 let asst = ChatMessage::assistant("hi there");
777 assert_eq!(asst.role, MessageRole::Assistant);
778
779 let tool = ChatMessage::tool("result");
780 assert_eq!(tool.role, MessageRole::Tool);
781 }
782
783 #[test]
784 fn single_text_serializes_as_string() {
785 let msg = ChatMessage::user("hello");
786 let json = serde_json::to_string(&msg).unwrap();
787 assert!(json.contains("\"content\":\"hello\""), "got: {json}");
788 }
789
790 #[test]
791 fn multipart_serializes_as_array() {
792 let msg = ChatMessage::user_multimodal(vec![
793 ContentPart::text("describe this"),
794 ContentPart::image_url("https://example.com/img.png"),
795 ]);
796 let json = serde_json::to_string(&msg).unwrap();
797 assert!(
798 json.contains("\"content\":["),
799 "expected array serialization, got: {json}"
800 );
801 }
802
803 #[test]
804 fn single_text_deserialize_from_string() {
805 let json = r#"{"role":"user","content":"hello"}"#;
806 let msg: ChatMessage = serde_json::from_str(json).unwrap();
807 assert_eq!(msg.role, MessageRole::User);
808 assert_eq!(msg.content.len(), 1);
809 assert_eq!(msg.text_content(), "hello");
810 }
811
812 #[test]
813 fn multipart_deserialize_from_array() {
814 let json = r#"{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","url":"https://x.com/img.png"}]}"#;
815 let msg: ChatMessage = serde_json::from_str(json).unwrap();
816 assert_eq!(msg.content.len(), 2);
817 }
818
819 #[test]
820 fn content_part_text_helper() {
821 let p = ContentPart::text("hello");
822 assert_eq!(p.as_text(), Some("hello"));
823 }
824
825 #[test]
826 fn response_format_json_serialization() {
827 let fmt = ResponseFormat::Json;
828 let json = serde_json::to_string(&fmt).unwrap();
829 assert!(json.contains("\"type\":\"json\""), "got: {json}");
830 }
831
832 #[test]
833 fn response_format_text_serialization() {
834 let fmt = ResponseFormat::Text;
835 let json = serde_json::to_string(&fmt).unwrap();
836 assert!(json.contains("\"type\":\"text\""), "got: {json}");
837 }
838
839 #[test]
840 fn response_format_json_schema() {
841 let fmt = ResponseFormat::JsonSchema {
842 schema: serde_json::json!({"type": "object"}),
843 };
844 let json = serde_json::to_string(&fmt).unwrap();
845 assert!(json.contains("json_schema"), "got: {json}");
846 }
847
848 #[test]
849 fn builder_basic() {
850 let req = LLMRequest::builder()
851 .system("you are helpful")
852 .user_message("hello")
853 .temperature(0.5)
854 .build();
855 assert_eq!(req.system.as_deref(), Some("you are helpful"));
856 assert_eq!(req.messages.len(), 1);
857 assert_eq!(req.temperature, 0.5);
858 }
859
860 #[test]
861 fn builder_with_model_and_format() {
862 let req = LLMRequest::builder()
863 .user_message("test")
864 .model("gpt-4o-mini")
865 .response_format(ResponseFormat::Json)
866 .max_tokens(100)
867 .build();
868 assert_eq!(req.model.as_deref(), Some("gpt-4o-mini"));
869 assert!(matches!(req.response_format, Some(ResponseFormat::Json)));
870 assert_eq!(req.max_tokens, Some(100));
871 }
872
873 #[test]
874 fn builder_with_tools() {
875 use crate::llm::ToolDefinition;
876 let req = LLMRequest::builder()
877 .user_message("what's the weather?")
878 .tools(vec![ToolDefinition {
879 name: "get_weather".into(),
880 description: "Get weather".into(),
881 input_schema: serde_json::json!({"type": "object"}),
882 }])
883 .build();
884 assert!(req.tools.is_some());
885 assert_eq!(req.tools.unwrap().len(), 1);
886 }
887
888 #[test]
893 fn default_matches_builder_default() {
894 let from_default = LLMRequest::default();
895 let from_builder = LLMRequest::builder().build();
896 assert_eq!(from_default.temperature, from_builder.temperature);
897 assert_eq!(from_default.temperature, 0.7);
898 assert!(from_default.system.is_none());
899 assert!(from_default.messages.is_empty());
900 assert!(from_default.max_tokens.is_none());
901 assert!(from_default.model.is_none());
902 assert!(from_default.response_format.is_none());
903 assert!(from_default.tools.is_none());
904 assert!(from_default.reasoning.is_none());
905 assert!(from_default.verbosity.is_none());
906 assert!(from_default.extra_body.is_none());
907 }
908
909 #[test]
910 fn reasoning_config_disabled_serializes_enabled_only() {
911 let json = serde_json::to_value(ReasoningConfig::disabled()).unwrap();
912 assert_eq!(json, serde_json::json!({"enabled": false}));
913 }
914
915 #[test]
916 fn reasoning_config_effort_serializes_effort_only() {
917 let json = serde_json::to_value(ReasoningConfig::effort(ReasoningEffort::Medium)).unwrap();
918 assert_eq!(json, serde_json::json!({"effort": "medium"}));
919 assert_eq!(
921 serde_json::to_value(ReasoningConfig::default()).unwrap(),
922 serde_json::json!({})
923 );
924 }
925
926 #[test]
927 fn observability_context_roundtrip() {
928 let mut metadata = std::collections::BTreeMap::new();
929 metadata.insert("source".to_string(), "router".to_string());
930 let req = LLMRequest::builder()
931 .user_message("hi")
932 .observability(ObservabilityContext {
933 traceparent: Some(
934 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
935 ),
936 session_id: Some("s-1".to_string()),
937 name: Some("generate-reply".to_string()),
938 tags: vec!["prod".to_string()],
939 metadata,
940 })
941 .build();
942 let ctx = req.observability.as_ref().unwrap();
943 assert_eq!(ctx.name.as_deref(), Some("generate-reply"));
944 assert_eq!(
945 ctx.metadata.get("source").map(String::as_str),
946 Some("router")
947 );
948
949 let json = serde_json::to_value(&req).unwrap();
951 assert_eq!(json["observability"]["session_id"], "s-1");
952 let back: LLMRequest = serde_json::from_value(json).unwrap();
953 assert_eq!(back.observability, req.observability);
954 let legacy = serde_json::json!({"messages": [], "temperature": 0.5});
955 let plain: LLMRequest = serde_json::from_value(legacy).unwrap();
956 assert!(plain.observability.is_none());
957 }
958
959 #[test]
960 fn with_session_preserves_other_context_fields() {
961 let req = LLMRequest::builder()
962 .observability(ObservabilityContext {
963 name: Some("generate-reply".to_string()),
964 ..Default::default()
965 })
966 .with_session("s-2")
967 .build();
968 let ctx = req.observability.as_ref().unwrap();
969 assert_eq!(ctx.session_id.as_deref(), Some("s-2"));
970 assert_eq!(ctx.name.as_deref(), Some("generate-reply"));
971 let fresh = LLMRequest::builder().with_session("s-3").build();
973 assert_eq!(
974 fresh.observability.unwrap().session_id.as_deref(),
975 Some("s-3")
976 );
977 }
978
979 #[test]
980 fn builder_reasoning_roundtrip() {
981 let req = LLMRequest::builder()
982 .user_message("hi")
983 .reasoning(ReasoningConfig::disabled())
984 .build();
985 assert_eq!(req.reasoning.as_ref().unwrap().enabled, Some(false));
986
987 let with = serde_json::to_value(&req).unwrap();
989 assert_eq!(with["reasoning"]["enabled"], false);
990 let without = serde_json::to_value(LLMRequest::default()).unwrap();
991 assert!(without.get("reasoning").is_none());
992 }
993
994 #[test]
995 fn builder_verbosity_roundtrip() {
996 let req = LLMRequest::builder()
997 .user_message("hi")
998 .verbosity(Verbosity::Low)
999 .build();
1000 assert_eq!(req.verbosity, Some(Verbosity::Low));
1001 let json = serde_json::to_value(&req).unwrap();
1002 assert_eq!(json["verbosity"], "low");
1003 assert!(
1005 serde_json::to_value(LLMRequest::default())
1006 .unwrap()
1007 .get("verbosity")
1008 .is_none()
1009 );
1010 }
1011
1012 #[test]
1013 fn builder_extra_body_roundtrip() {
1014 let mut extra = serde_json::Map::new();
1015 extra.insert("seed".into(), 42.into());
1016 extra.insert("stop".into(), ["\n\nUser:"].into());
1017 let req = LLMRequest::builder()
1018 .user_message("hi")
1019 .extra_body(extra)
1020 .build();
1021 let json = serde_json::to_value(&req).unwrap();
1022 assert_eq!(json["extra_body"]["seed"], 42);
1023 assert!(
1024 serde_json::to_value(LLMRequest::default())
1025 .unwrap()
1026 .get("extra_body")
1027 .is_none()
1028 );
1029 }
1030
1031 #[test]
1032 fn llm_request_deserializes_without_reasoning_field() {
1033 let json =
1035 r#"{"system":null,"messages":[],"temperature":0.7,"max_tokens":null,"model":null}"#;
1036 let req: LLMRequest = serde_json::from_str(json).unwrap();
1037 assert!(req.reasoning.is_none());
1038 assert!(req.verbosity.is_none());
1039 }
1040
1041 #[test]
1042 fn builder_messages_setter_replaces_list() {
1043 let conv = vec![ChatMessage::user("first"), ChatMessage::assistant("second")];
1044 let req = LLMRequest::builder().messages(conv).build();
1045 assert_eq!(req.messages.len(), 2);
1046 assert_eq!(req.messages[0].role, MessageRole::User);
1047 assert_eq!(req.messages[1].role, MessageRole::Assistant);
1048 }
1049
1050 #[test]
1051 fn builder_maybe_max_tokens_accepts_option() {
1052 let req = LLMRequest::builder().maybe_max_tokens(Some(512)).build();
1054 assert_eq!(req.max_tokens, Some(512));
1055 let req = LLMRequest::builder().maybe_max_tokens(None).build();
1057 assert_eq!(req.max_tokens, None);
1058 }
1059
1060 #[test]
1061 fn into_openai_messages_with_system() {
1062 let req = LLMRequest::builder()
1063 .system("be helpful")
1064 .user_message("hi")
1065 .assistant_message("hello")
1066 .build();
1067 let msgs = req.into_openai_messages();
1068 assert_eq!(msgs.len(), 3);
1069 assert_eq!(msgs[0].0, "system");
1070 assert_eq!(msgs[1].0, "user");
1071 assert_eq!(msgs[2].0, "assistant");
1072 }
1073
1074 #[test]
1075 fn into_anthropic_messages_excludes_system() {
1076 let req = LLMRequest::builder()
1077 .system("be helpful")
1078 .user_message("hi")
1079 .build();
1080 let msgs = req.into_anthropic_messages();
1081 assert_eq!(msgs.len(), 1);
1082 assert_eq!(msgs[0].0, "user");
1083 }
1084
1085 #[test]
1086 fn text_content_extracts_text() {
1087 let msg = ChatMessage::user_multimodal(vec![
1088 ContentPart::text("hello "),
1089 ContentPart::image_url("http://x.com/i.png"),
1090 ContentPart::text("world"),
1091 ]);
1092 assert_eq!(msg.text_content(), "hello world");
1093 }
1094
1095 #[test]
1096 fn llm_response_back_compat_without_reasoning_field() {
1097 let json = r#"{"content":"hi","model":"m","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
1099 let r: LLMResponse = serde_json::from_str(json).unwrap();
1100 assert_eq!(r.content, "hi");
1101 assert!(r.reasoning.is_none());
1102 }
1103
1104 #[test]
1105 fn token_usage_back_compat_without_reasoning_tokens() {
1106 let json = r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#;
1107 let u: TokenUsage = serde_json::from_str(json).unwrap();
1108 assert_eq!(u.total_tokens, 3);
1109 assert!(u.reasoning_tokens.is_none());
1110 }
1111}