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,
246 Minimal,
252 Low,
254 Medium,
256 High,
258 XHigh,
260 Max,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270pub enum Verbosity {
271 Low,
273 Medium,
275 High,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "lowercase")]
286pub enum ReasoningSummary {
287 Auto,
289 Concise,
291 Detailed,
293}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318pub struct ReasoningConfig {
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub enabled: Option<bool>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub effort: Option<ReasoningEffort>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub summary: Option<ReasoningSummary>,
329}
330
331impl ReasoningConfig {
332 pub fn disabled() -> Self {
335 Self {
336 enabled: Some(false),
337 effort: None,
338 summary: None,
339 }
340 }
341
342 pub fn effort(effort: ReasoningEffort) -> Self {
344 Self {
345 enabled: None,
346 effort: Some(effort),
347 summary: None,
348 }
349 }
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct LLMRequest {
371 pub system: Option<String>,
373 pub messages: Vec<ChatMessage>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub temperature: Option<f32>,
386 pub max_tokens: Option<u32>,
388 pub model: Option<String>,
390 #[serde(skip_serializing_if = "Option::is_none")]
398 pub response_format: Option<ResponseFormat>,
399 #[serde(skip_serializing_if = "Option::is_none")]
405 pub tools: Option<Vec<crate::llm::ToolDefinition>>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub reasoning: Option<ReasoningConfig>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub verbosity: Option<Verbosity>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub observability: Option<ObservabilityContext>,
436}
437
438#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
453pub struct ObservabilityContext {
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub traceparent: Option<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub session_id: Option<String>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub name: Option<String>,
463 #[serde(default, skip_serializing_if = "Vec::is_empty")]
465 pub tags: Vec<String>,
466 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
468 pub metadata: std::collections::BTreeMap<String, String>,
469}
470
471impl Default for LLMRequest {
472 fn default() -> Self {
473 Self {
474 system: None,
475 messages: Vec::new(),
476 temperature: Some(0.7),
479 max_tokens: None,
480 model: None,
481 response_format: None,
482 tools: None,
483 reasoning: None,
484 verbosity: None,
485 extra_body: None,
486 observability: None,
487 }
488 }
489}
490
491impl LLMRequest {
492 pub fn builder() -> LLMRequestBuilder {
494 LLMRequestBuilder::default()
495 }
496
497 pub(crate) fn into_openai_messages(self) -> Vec<(String, String)> {
501 let mut out = Vec::with_capacity(self.messages.len() + 1);
502 if let Some(system) = self.system {
503 out.push(("system".into(), system));
504 }
505 for msg in self.messages {
506 out.push((msg.role.to_string(), msg.text_content()));
507 }
508 out
509 }
510
511 pub(crate) fn into_anthropic_messages(self) -> Vec<(String, String)> {
515 self.messages
516 .into_iter()
517 .map(|m| (m.role.to_string(), m.text_content()))
518 .collect()
519 }
520}
521
522#[derive(Debug, Clone, Default)]
536pub struct LLMRequestBuilder {
537 system: Option<String>,
538 messages: Vec<ChatMessage>,
539 temperature: Option<Option<f32>>,
543 max_tokens: Option<u32>,
544 model: Option<String>,
545 response_format: Option<ResponseFormat>,
546 tools: Option<Vec<crate::llm::ToolDefinition>>,
547 reasoning: Option<ReasoningConfig>,
548 verbosity: Option<Verbosity>,
549 extra_body: Option<serde_json::Map<String, serde_json::Value>>,
550 observability: Option<ObservabilityContext>,
551}
552
553impl LLMRequestBuilder {
554 pub fn system(mut self, prompt: impl Into<String>) -> Self {
556 self.system = Some(prompt.into());
557 self
558 }
559
560 pub fn user_message(mut self, content: impl Into<String>) -> Self {
562 self.messages.push(ChatMessage::user(content));
563 self
564 }
565
566 pub fn assistant_message(mut self, content: impl Into<String>) -> Self {
568 self.messages.push(ChatMessage::assistant(content));
569 self
570 }
571
572 pub fn message(mut self, msg: ChatMessage) -> Self {
574 self.messages.push(msg);
575 self
576 }
577
578 pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
583 self.messages = messages;
584 self
585 }
586
587 pub fn temperature(mut self, temp: f32) -> Self {
589 self.temperature = Some(Some(temp));
590 self
591 }
592
593 pub fn maybe_temperature(mut self, temp: Option<f32>) -> Self {
597 self.temperature = Some(temp);
598 self
599 }
600
601 pub fn max_tokens(mut self, tokens: u32) -> Self {
603 self.max_tokens = Some(tokens);
604 self
605 }
606
607 pub fn maybe_max_tokens(mut self, tokens: Option<u32>) -> Self {
612 self.max_tokens = tokens;
613 self
614 }
615
616 pub fn model(mut self, model: impl Into<String>) -> Self {
618 self.model = Some(model.into());
619 self
620 }
621
622 pub fn response_format(mut self, format: ResponseFormat) -> Self {
624 self.response_format = Some(format);
625 self
626 }
627
628 pub fn tools(mut self, tools: Vec<crate::llm::ToolDefinition>) -> Self {
630 self.tools = Some(tools);
631 self
632 }
633
634 pub fn reasoning(mut self, cfg: ReasoningConfig) -> Self {
636 self.reasoning = Some(cfg);
637 self
638 }
639
640 pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
642 self.verbosity = Some(verbosity);
643 self
644 }
645
646 pub fn extra_body(mut self, extra: serde_json::Map<String, serde_json::Value>) -> Self {
649 self.extra_body = Some(extra);
650 self
651 }
652
653 pub fn observability(mut self, context: ObservabilityContext) -> Self {
655 self.observability = Some(context);
656 self
657 }
658
659 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
662 let context = self.observability.get_or_insert_with(Default::default);
663 context.session_id = Some(session_id.into());
664 self
665 }
666
667 pub fn build(self) -> LLMRequest {
669 LLMRequest {
670 system: self.system,
671 messages: self.messages,
672 temperature: self.temperature.unwrap_or(Some(0.7)),
673 max_tokens: self.max_tokens,
674 model: self.model,
675 response_format: self.response_format,
676 tools: self.tools,
677 reasoning: self.reasoning,
678 verbosity: self.verbosity,
679 extra_body: self.extra_body,
680 observability: self.observability,
681 }
682 }
683}
684
685#[derive(Debug, Clone, Default, Serialize, Deserialize)]
690pub struct LLMResponse {
691 pub content: String,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub reasoning: Option<String>,
701 pub model: String,
703 pub usage: TokenUsage,
705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
711 pub tool_calls: Vec<crate::llm::ToolCall>,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub finish_reason: Option<String>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub id: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub created: Option<u64>,
721}
722
723#[derive(Debug, Clone, Default, Serialize, Deserialize)]
725pub struct TokenUsage {
726 pub prompt_tokens: u32,
728 pub completion_tokens: u32,
730 pub total_tokens: u32,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub reasoning_tokens: Option<u32>,
736}
737
738#[derive(Debug, Clone)]
745#[non_exhaustive]
746pub enum StreamEvent {
747 Delta {
749 content: String,
751 },
752 ReasoningDelta {
765 content: String,
767 },
768 Stopped {
776 reason: String,
778 },
779 Usage(TokenUsage),
781 Done,
783}
784
785#[cfg(feature = "client-async")]
787pub type LLMStream =
788 Pin<Box<dyn futures_core::Stream<Item = crate::error::Result<StreamEvent>> + Send>>;
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn message_role_display() {
796 assert_eq!(MessageRole::System.to_string(), "system");
797 assert_eq!(MessageRole::User.to_string(), "user");
798 assert_eq!(MessageRole::Assistant.to_string(), "assistant");
799 assert_eq!(MessageRole::Tool.to_string(), "tool");
800 }
801
802 #[test]
803 fn message_role_serde_roundtrip() {
804 let json = serde_json::to_string(&MessageRole::User).unwrap();
805 assert_eq!(json, "\"user\"");
806 let back: MessageRole = serde_json::from_str(&json).unwrap();
807 assert_eq!(back, MessageRole::User);
808 }
809
810 #[test]
811 fn chat_message_constructors() {
812 let sys = ChatMessage::system("instructions");
813 assert_eq!(sys.role, MessageRole::System);
814
815 let user = ChatMessage::user("hello");
816 assert_eq!(user.role, MessageRole::User);
817
818 let asst = ChatMessage::assistant("hi there");
819 assert_eq!(asst.role, MessageRole::Assistant);
820
821 let tool = ChatMessage::tool("result");
822 assert_eq!(tool.role, MessageRole::Tool);
823 }
824
825 #[test]
826 fn single_text_serializes_as_string() {
827 let msg = ChatMessage::user("hello");
828 let json = serde_json::to_string(&msg).unwrap();
829 assert!(json.contains("\"content\":\"hello\""), "got: {json}");
830 }
831
832 #[test]
833 fn multipart_serializes_as_array() {
834 let msg = ChatMessage::user_multimodal(vec![
835 ContentPart::text("describe this"),
836 ContentPart::image_url("https://example.com/img.png"),
837 ]);
838 let json = serde_json::to_string(&msg).unwrap();
839 assert!(
840 json.contains("\"content\":["),
841 "expected array serialization, got: {json}"
842 );
843 }
844
845 #[test]
846 fn single_text_deserialize_from_string() {
847 let json = r#"{"role":"user","content":"hello"}"#;
848 let msg: ChatMessage = serde_json::from_str(json).unwrap();
849 assert_eq!(msg.role, MessageRole::User);
850 assert_eq!(msg.content.len(), 1);
851 assert_eq!(msg.text_content(), "hello");
852 }
853
854 #[test]
855 fn multipart_deserialize_from_array() {
856 let json = r#"{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","url":"https://x.com/img.png"}]}"#;
857 let msg: ChatMessage = serde_json::from_str(json).unwrap();
858 assert_eq!(msg.content.len(), 2);
859 }
860
861 #[test]
862 fn content_part_text_helper() {
863 let p = ContentPart::text("hello");
864 assert_eq!(p.as_text(), Some("hello"));
865 }
866
867 #[test]
868 fn response_format_json_serialization() {
869 let fmt = ResponseFormat::Json;
870 let json = serde_json::to_string(&fmt).unwrap();
871 assert!(json.contains("\"type\":\"json\""), "got: {json}");
872 }
873
874 #[test]
875 fn response_format_text_serialization() {
876 let fmt = ResponseFormat::Text;
877 let json = serde_json::to_string(&fmt).unwrap();
878 assert!(json.contains("\"type\":\"text\""), "got: {json}");
879 }
880
881 #[test]
882 fn response_format_json_schema() {
883 let fmt = ResponseFormat::JsonSchema {
884 schema: serde_json::json!({"type": "object"}),
885 };
886 let json = serde_json::to_string(&fmt).unwrap();
887 assert!(json.contains("json_schema"), "got: {json}");
888 }
889
890 #[test]
891 fn builder_basic() {
892 let req = LLMRequest::builder()
893 .system("you are helpful")
894 .user_message("hello")
895 .temperature(0.5)
896 .build();
897 assert_eq!(req.system.as_deref(), Some("you are helpful"));
898 assert_eq!(req.messages.len(), 1);
899 assert_eq!(req.temperature, Some(0.5));
900 }
901
902 #[test]
903 fn builder_maybe_temperature_none_omits_parameter() {
904 let req = LLMRequest::builder().maybe_temperature(None).build();
905 assert_eq!(req.temperature, None);
906 assert_eq!(LLMRequest::builder().build().temperature, Some(0.7));
908 assert_eq!(LLMRequest::default().temperature, Some(0.7));
910 }
911
912 #[test]
913 fn builder_with_model_and_format() {
914 let req = LLMRequest::builder()
915 .user_message("test")
916 .model("gpt-4o-mini")
917 .response_format(ResponseFormat::Json)
918 .max_tokens(100)
919 .build();
920 assert_eq!(req.model.as_deref(), Some("gpt-4o-mini"));
921 assert!(matches!(req.response_format, Some(ResponseFormat::Json)));
922 assert_eq!(req.max_tokens, Some(100));
923 }
924
925 #[test]
926 fn builder_with_tools() {
927 use crate::llm::ToolDefinition;
928 let req = LLMRequest::builder()
929 .user_message("what's the weather?")
930 .tools(vec![ToolDefinition {
931 name: "get_weather".into(),
932 description: "Get weather".into(),
933 input_schema: serde_json::json!({"type": "object"}),
934 }])
935 .build();
936 assert!(req.tools.is_some());
937 assert_eq!(req.tools.unwrap().len(), 1);
938 }
939
940 #[test]
945 fn default_matches_builder_default() {
946 let from_default = LLMRequest::default();
947 let from_builder = LLMRequest::builder().build();
948 assert_eq!(from_default.temperature, from_builder.temperature);
949 assert_eq!(from_default.temperature, Some(0.7));
950 assert!(from_default.system.is_none());
951 assert!(from_default.messages.is_empty());
952 assert!(from_default.max_tokens.is_none());
953 assert!(from_default.model.is_none());
954 assert!(from_default.response_format.is_none());
955 assert!(from_default.tools.is_none());
956 assert!(from_default.reasoning.is_none());
957 assert!(from_default.verbosity.is_none());
958 assert!(from_default.extra_body.is_none());
959 }
960
961 #[test]
962 fn reasoning_config_disabled_serializes_enabled_only() {
963 let json = serde_json::to_value(ReasoningConfig::disabled()).unwrap();
964 assert_eq!(json, serde_json::json!({"enabled": false}));
965 }
966
967 #[test]
968 fn reasoning_config_effort_serializes_effort_only() {
969 let json = serde_json::to_value(ReasoningConfig::effort(ReasoningEffort::Medium)).unwrap();
970 assert_eq!(json, serde_json::json!({"effort": "medium"}));
971 assert_eq!(
973 serde_json::to_value(ReasoningConfig::default()).unwrap(),
974 serde_json::json!({})
975 );
976 }
977
978 #[test]
979 fn observability_context_roundtrip() {
980 let mut metadata = std::collections::BTreeMap::new();
981 metadata.insert("source".to_string(), "router".to_string());
982 let req = LLMRequest::builder()
983 .user_message("hi")
984 .observability(ObservabilityContext {
985 traceparent: Some(
986 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
987 ),
988 session_id: Some("s-1".to_string()),
989 name: Some("generate-reply".to_string()),
990 tags: vec!["prod".to_string()],
991 metadata,
992 })
993 .build();
994 let ctx = req.observability.as_ref().unwrap();
995 assert_eq!(ctx.name.as_deref(), Some("generate-reply"));
996 assert_eq!(
997 ctx.metadata.get("source").map(String::as_str),
998 Some("router")
999 );
1000
1001 let json = serde_json::to_value(&req).unwrap();
1003 assert_eq!(json["observability"]["session_id"], "s-1");
1004 let back: LLMRequest = serde_json::from_value(json).unwrap();
1005 assert_eq!(back.observability, req.observability);
1006 let legacy = serde_json::json!({"messages": [], "temperature": 0.5});
1007 let plain: LLMRequest = serde_json::from_value(legacy).unwrap();
1008 assert!(plain.observability.is_none());
1009 }
1010
1011 #[test]
1012 fn with_session_preserves_other_context_fields() {
1013 let req = LLMRequest::builder()
1014 .observability(ObservabilityContext {
1015 name: Some("generate-reply".to_string()),
1016 ..Default::default()
1017 })
1018 .with_session("s-2")
1019 .build();
1020 let ctx = req.observability.as_ref().unwrap();
1021 assert_eq!(ctx.session_id.as_deref(), Some("s-2"));
1022 assert_eq!(ctx.name.as_deref(), Some("generate-reply"));
1023 let fresh = LLMRequest::builder().with_session("s-3").build();
1025 assert_eq!(
1026 fresh.observability.unwrap().session_id.as_deref(),
1027 Some("s-3")
1028 );
1029 }
1030
1031 #[test]
1032 fn builder_reasoning_roundtrip() {
1033 let req = LLMRequest::builder()
1034 .user_message("hi")
1035 .reasoning(ReasoningConfig::disabled())
1036 .build();
1037 assert_eq!(req.reasoning.as_ref().unwrap().enabled, Some(false));
1038
1039 let with = serde_json::to_value(&req).unwrap();
1041 assert_eq!(with["reasoning"]["enabled"], false);
1042 let without = serde_json::to_value(LLMRequest::default()).unwrap();
1043 assert!(without.get("reasoning").is_none());
1044 }
1045
1046 #[test]
1047 fn builder_verbosity_roundtrip() {
1048 let req = LLMRequest::builder()
1049 .user_message("hi")
1050 .verbosity(Verbosity::Low)
1051 .build();
1052 assert_eq!(req.verbosity, Some(Verbosity::Low));
1053 let json = serde_json::to_value(&req).unwrap();
1054 assert_eq!(json["verbosity"], "low");
1055 assert!(
1057 serde_json::to_value(LLMRequest::default())
1058 .unwrap()
1059 .get("verbosity")
1060 .is_none()
1061 );
1062 }
1063
1064 #[test]
1065 fn builder_extra_body_roundtrip() {
1066 let mut extra = serde_json::Map::new();
1067 extra.insert("seed".into(), 42.into());
1068 extra.insert("stop".into(), ["\n\nUser:"].into());
1069 let req = LLMRequest::builder()
1070 .user_message("hi")
1071 .extra_body(extra)
1072 .build();
1073 let json = serde_json::to_value(&req).unwrap();
1074 assert_eq!(json["extra_body"]["seed"], 42);
1075 assert!(
1076 serde_json::to_value(LLMRequest::default())
1077 .unwrap()
1078 .get("extra_body")
1079 .is_none()
1080 );
1081 }
1082
1083 #[test]
1084 fn llm_request_deserializes_without_reasoning_field() {
1085 let json =
1087 r#"{"system":null,"messages":[],"temperature":0.7,"max_tokens":null,"model":null}"#;
1088 let req: LLMRequest = serde_json::from_str(json).unwrap();
1089 assert!(req.reasoning.is_none());
1090 assert!(req.verbosity.is_none());
1091 }
1092
1093 #[test]
1094 fn builder_messages_setter_replaces_list() {
1095 let conv = vec![ChatMessage::user("first"), ChatMessage::assistant("second")];
1096 let req = LLMRequest::builder().messages(conv).build();
1097 assert_eq!(req.messages.len(), 2);
1098 assert_eq!(req.messages[0].role, MessageRole::User);
1099 assert_eq!(req.messages[1].role, MessageRole::Assistant);
1100 }
1101
1102 #[test]
1103 fn builder_maybe_max_tokens_accepts_option() {
1104 let req = LLMRequest::builder().maybe_max_tokens(Some(512)).build();
1106 assert_eq!(req.max_tokens, Some(512));
1107 let req = LLMRequest::builder().maybe_max_tokens(None).build();
1109 assert_eq!(req.max_tokens, None);
1110 }
1111
1112 #[test]
1113 fn into_openai_messages_with_system() {
1114 let req = LLMRequest::builder()
1115 .system("be helpful")
1116 .user_message("hi")
1117 .assistant_message("hello")
1118 .build();
1119 let msgs = req.into_openai_messages();
1120 assert_eq!(msgs.len(), 3);
1121 assert_eq!(msgs[0].0, "system");
1122 assert_eq!(msgs[1].0, "user");
1123 assert_eq!(msgs[2].0, "assistant");
1124 }
1125
1126 #[test]
1127 fn into_anthropic_messages_excludes_system() {
1128 let req = LLMRequest::builder()
1129 .system("be helpful")
1130 .user_message("hi")
1131 .build();
1132 let msgs = req.into_anthropic_messages();
1133 assert_eq!(msgs.len(), 1);
1134 assert_eq!(msgs[0].0, "user");
1135 }
1136
1137 #[test]
1138 fn text_content_extracts_text() {
1139 let msg = ChatMessage::user_multimodal(vec![
1140 ContentPart::text("hello "),
1141 ContentPart::image_url("http://x.com/i.png"),
1142 ContentPart::text("world"),
1143 ]);
1144 assert_eq!(msg.text_content(), "hello world");
1145 }
1146
1147 #[test]
1148 fn llm_response_back_compat_without_reasoning_field() {
1149 let json = r#"{"content":"hi","model":"m","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
1151 let r: LLMResponse = serde_json::from_str(json).unwrap();
1152 assert_eq!(r.content, "hi");
1153 assert!(r.reasoning.is_none());
1154 }
1155
1156 #[test]
1157 fn token_usage_back_compat_without_reasoning_tokens() {
1158 let json = r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#;
1159 let u: TokenUsage = serde_json::from_str(json).unwrap();
1160 assert_eq!(u.total_tokens, 3);
1161 assert!(u.reasoning_tokens.is_none());
1162 }
1163}