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}
410
411impl Default for LLMRequest {
412 fn default() -> Self {
413 Self {
414 system: None,
415 messages: Vec::new(),
416 temperature: 0.7,
419 max_tokens: None,
420 model: None,
421 response_format: None,
422 tools: None,
423 reasoning: None,
424 verbosity: None,
425 extra_body: None,
426 }
427 }
428}
429
430impl LLMRequest {
431 pub fn builder() -> LLMRequestBuilder {
433 LLMRequestBuilder::default()
434 }
435
436 pub(crate) fn into_openai_messages(self) -> Vec<(String, String)> {
440 let mut out = Vec::with_capacity(self.messages.len() + 1);
441 if let Some(system) = self.system {
442 out.push(("system".into(), system));
443 }
444 for msg in self.messages {
445 out.push((msg.role.to_string(), msg.text_content()));
446 }
447 out
448 }
449
450 pub(crate) fn into_anthropic_messages(self) -> Vec<(String, String)> {
454 self.messages
455 .into_iter()
456 .map(|m| (m.role.to_string(), m.text_content()))
457 .collect()
458 }
459}
460
461#[derive(Debug, Clone, Default)]
475pub struct LLMRequestBuilder {
476 system: Option<String>,
477 messages: Vec<ChatMessage>,
478 temperature: Option<f32>,
479 max_tokens: Option<u32>,
480 model: Option<String>,
481 response_format: Option<ResponseFormat>,
482 tools: Option<Vec<crate::llm::ToolDefinition>>,
483 reasoning: Option<ReasoningConfig>,
484 verbosity: Option<Verbosity>,
485 extra_body: Option<serde_json::Map<String, serde_json::Value>>,
486}
487
488impl LLMRequestBuilder {
489 pub fn system(mut self, prompt: impl Into<String>) -> Self {
491 self.system = Some(prompt.into());
492 self
493 }
494
495 pub fn user_message(mut self, content: impl Into<String>) -> Self {
497 self.messages.push(ChatMessage::user(content));
498 self
499 }
500
501 pub fn assistant_message(mut self, content: impl Into<String>) -> Self {
503 self.messages.push(ChatMessage::assistant(content));
504 self
505 }
506
507 pub fn message(mut self, msg: ChatMessage) -> Self {
509 self.messages.push(msg);
510 self
511 }
512
513 pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
518 self.messages = messages;
519 self
520 }
521
522 pub fn temperature(mut self, temp: f32) -> Self {
524 self.temperature = Some(temp);
525 self
526 }
527
528 pub fn max_tokens(mut self, tokens: u32) -> Self {
530 self.max_tokens = Some(tokens);
531 self
532 }
533
534 pub fn maybe_max_tokens(mut self, tokens: Option<u32>) -> Self {
539 self.max_tokens = tokens;
540 self
541 }
542
543 pub fn model(mut self, model: impl Into<String>) -> Self {
545 self.model = Some(model.into());
546 self
547 }
548
549 pub fn response_format(mut self, format: ResponseFormat) -> Self {
551 self.response_format = Some(format);
552 self
553 }
554
555 pub fn tools(mut self, tools: Vec<crate::llm::ToolDefinition>) -> Self {
557 self.tools = Some(tools);
558 self
559 }
560
561 pub fn reasoning(mut self, cfg: ReasoningConfig) -> Self {
563 self.reasoning = Some(cfg);
564 self
565 }
566
567 pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
569 self.verbosity = Some(verbosity);
570 self
571 }
572
573 pub fn extra_body(mut self, extra: serde_json::Map<String, serde_json::Value>) -> Self {
576 self.extra_body = Some(extra);
577 self
578 }
579
580 pub fn build(self) -> LLMRequest {
582 LLMRequest {
583 system: self.system,
584 messages: self.messages,
585 temperature: self.temperature.unwrap_or(0.7),
586 max_tokens: self.max_tokens,
587 model: self.model,
588 response_format: self.response_format,
589 tools: self.tools,
590 reasoning: self.reasoning,
591 verbosity: self.verbosity,
592 extra_body: self.extra_body,
593 }
594 }
595}
596
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
602pub struct LLMResponse {
603 pub content: String,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub reasoning: Option<String>,
613 pub model: String,
615 pub usage: TokenUsage,
617 #[serde(default, skip_serializing_if = "Vec::is_empty")]
623 pub tool_calls: Vec<crate::llm::ToolCall>,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub finish_reason: Option<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub id: Option<String>,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub created: Option<u64>,
633}
634
635#[derive(Debug, Clone, Default, Serialize, Deserialize)]
637pub struct TokenUsage {
638 pub prompt_tokens: u32,
640 pub completion_tokens: u32,
642 pub total_tokens: u32,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub reasoning_tokens: Option<u32>,
648}
649
650#[derive(Debug, Clone)]
657#[non_exhaustive]
658pub enum StreamEvent {
659 Delta {
661 content: String,
663 },
664 ReasoningDelta {
677 content: String,
679 },
680 Usage(TokenUsage),
682 Done,
684}
685
686#[cfg(feature = "client-async")]
688pub type LLMStream =
689 Pin<Box<dyn futures_core::Stream<Item = crate::error::Result<StreamEvent>> + Send>>;
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn message_role_display() {
697 assert_eq!(MessageRole::System.to_string(), "system");
698 assert_eq!(MessageRole::User.to_string(), "user");
699 assert_eq!(MessageRole::Assistant.to_string(), "assistant");
700 assert_eq!(MessageRole::Tool.to_string(), "tool");
701 }
702
703 #[test]
704 fn message_role_serde_roundtrip() {
705 let json = serde_json::to_string(&MessageRole::User).unwrap();
706 assert_eq!(json, "\"user\"");
707 let back: MessageRole = serde_json::from_str(&json).unwrap();
708 assert_eq!(back, MessageRole::User);
709 }
710
711 #[test]
712 fn chat_message_constructors() {
713 let sys = ChatMessage::system("instructions");
714 assert_eq!(sys.role, MessageRole::System);
715
716 let user = ChatMessage::user("hello");
717 assert_eq!(user.role, MessageRole::User);
718
719 let asst = ChatMessage::assistant("hi there");
720 assert_eq!(asst.role, MessageRole::Assistant);
721
722 let tool = ChatMessage::tool("result");
723 assert_eq!(tool.role, MessageRole::Tool);
724 }
725
726 #[test]
727 fn single_text_serializes_as_string() {
728 let msg = ChatMessage::user("hello");
729 let json = serde_json::to_string(&msg).unwrap();
730 assert!(json.contains("\"content\":\"hello\""), "got: {json}");
731 }
732
733 #[test]
734 fn multipart_serializes_as_array() {
735 let msg = ChatMessage::user_multimodal(vec![
736 ContentPart::text("describe this"),
737 ContentPart::image_url("https://example.com/img.png"),
738 ]);
739 let json = serde_json::to_string(&msg).unwrap();
740 assert!(
741 json.contains("\"content\":["),
742 "expected array serialization, got: {json}"
743 );
744 }
745
746 #[test]
747 fn single_text_deserialize_from_string() {
748 let json = r#"{"role":"user","content":"hello"}"#;
749 let msg: ChatMessage = serde_json::from_str(json).unwrap();
750 assert_eq!(msg.role, MessageRole::User);
751 assert_eq!(msg.content.len(), 1);
752 assert_eq!(msg.text_content(), "hello");
753 }
754
755 #[test]
756 fn multipart_deserialize_from_array() {
757 let json = r#"{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","url":"https://x.com/img.png"}]}"#;
758 let msg: ChatMessage = serde_json::from_str(json).unwrap();
759 assert_eq!(msg.content.len(), 2);
760 }
761
762 #[test]
763 fn content_part_text_helper() {
764 let p = ContentPart::text("hello");
765 assert_eq!(p.as_text(), Some("hello"));
766 }
767
768 #[test]
769 fn response_format_json_serialization() {
770 let fmt = ResponseFormat::Json;
771 let json = serde_json::to_string(&fmt).unwrap();
772 assert!(json.contains("\"type\":\"json\""), "got: {json}");
773 }
774
775 #[test]
776 fn response_format_text_serialization() {
777 let fmt = ResponseFormat::Text;
778 let json = serde_json::to_string(&fmt).unwrap();
779 assert!(json.contains("\"type\":\"text\""), "got: {json}");
780 }
781
782 #[test]
783 fn response_format_json_schema() {
784 let fmt = ResponseFormat::JsonSchema {
785 schema: serde_json::json!({"type": "object"}),
786 };
787 let json = serde_json::to_string(&fmt).unwrap();
788 assert!(json.contains("json_schema"), "got: {json}");
789 }
790
791 #[test]
792 fn builder_basic() {
793 let req = LLMRequest::builder()
794 .system("you are helpful")
795 .user_message("hello")
796 .temperature(0.5)
797 .build();
798 assert_eq!(req.system.as_deref(), Some("you are helpful"));
799 assert_eq!(req.messages.len(), 1);
800 assert_eq!(req.temperature, 0.5);
801 }
802
803 #[test]
804 fn builder_with_model_and_format() {
805 let req = LLMRequest::builder()
806 .user_message("test")
807 .model("gpt-4o-mini")
808 .response_format(ResponseFormat::Json)
809 .max_tokens(100)
810 .build();
811 assert_eq!(req.model.as_deref(), Some("gpt-4o-mini"));
812 assert!(matches!(req.response_format, Some(ResponseFormat::Json)));
813 assert_eq!(req.max_tokens, Some(100));
814 }
815
816 #[test]
817 fn builder_with_tools() {
818 use crate::llm::ToolDefinition;
819 let req = LLMRequest::builder()
820 .user_message("what's the weather?")
821 .tools(vec![ToolDefinition {
822 name: "get_weather".into(),
823 description: "Get weather".into(),
824 input_schema: serde_json::json!({"type": "object"}),
825 }])
826 .build();
827 assert!(req.tools.is_some());
828 assert_eq!(req.tools.unwrap().len(), 1);
829 }
830
831 #[test]
836 fn default_matches_builder_default() {
837 let from_default = LLMRequest::default();
838 let from_builder = LLMRequest::builder().build();
839 assert_eq!(from_default.temperature, from_builder.temperature);
840 assert_eq!(from_default.temperature, 0.7);
841 assert!(from_default.system.is_none());
842 assert!(from_default.messages.is_empty());
843 assert!(from_default.max_tokens.is_none());
844 assert!(from_default.model.is_none());
845 assert!(from_default.response_format.is_none());
846 assert!(from_default.tools.is_none());
847 assert!(from_default.reasoning.is_none());
848 assert!(from_default.verbosity.is_none());
849 assert!(from_default.extra_body.is_none());
850 }
851
852 #[test]
853 fn reasoning_config_disabled_serializes_enabled_only() {
854 let json = serde_json::to_value(ReasoningConfig::disabled()).unwrap();
855 assert_eq!(json, serde_json::json!({"enabled": false}));
856 }
857
858 #[test]
859 fn reasoning_config_effort_serializes_effort_only() {
860 let json = serde_json::to_value(ReasoningConfig::effort(ReasoningEffort::Medium)).unwrap();
861 assert_eq!(json, serde_json::json!({"effort": "medium"}));
862 assert_eq!(
864 serde_json::to_value(ReasoningConfig::default()).unwrap(),
865 serde_json::json!({})
866 );
867 }
868
869 #[test]
870 fn builder_reasoning_roundtrip() {
871 let req = LLMRequest::builder()
872 .user_message("hi")
873 .reasoning(ReasoningConfig::disabled())
874 .build();
875 assert_eq!(req.reasoning.as_ref().unwrap().enabled, Some(false));
876
877 let with = serde_json::to_value(&req).unwrap();
879 assert_eq!(with["reasoning"]["enabled"], false);
880 let without = serde_json::to_value(LLMRequest::default()).unwrap();
881 assert!(without.get("reasoning").is_none());
882 }
883
884 #[test]
885 fn builder_verbosity_roundtrip() {
886 let req = LLMRequest::builder()
887 .user_message("hi")
888 .verbosity(Verbosity::Low)
889 .build();
890 assert_eq!(req.verbosity, Some(Verbosity::Low));
891 let json = serde_json::to_value(&req).unwrap();
892 assert_eq!(json["verbosity"], "low");
893 assert!(
895 serde_json::to_value(LLMRequest::default())
896 .unwrap()
897 .get("verbosity")
898 .is_none()
899 );
900 }
901
902 #[test]
903 fn builder_extra_body_roundtrip() {
904 let mut extra = serde_json::Map::new();
905 extra.insert("seed".into(), 42.into());
906 extra.insert("stop".into(), ["\n\nUser:"].into());
907 let req = LLMRequest::builder()
908 .user_message("hi")
909 .extra_body(extra)
910 .build();
911 let json = serde_json::to_value(&req).unwrap();
912 assert_eq!(json["extra_body"]["seed"], 42);
913 assert!(
914 serde_json::to_value(LLMRequest::default())
915 .unwrap()
916 .get("extra_body")
917 .is_none()
918 );
919 }
920
921 #[test]
922 fn llm_request_deserializes_without_reasoning_field() {
923 let json =
925 r#"{"system":null,"messages":[],"temperature":0.7,"max_tokens":null,"model":null}"#;
926 let req: LLMRequest = serde_json::from_str(json).unwrap();
927 assert!(req.reasoning.is_none());
928 assert!(req.verbosity.is_none());
929 }
930
931 #[test]
932 fn builder_messages_setter_replaces_list() {
933 let conv = vec![ChatMessage::user("first"), ChatMessage::assistant("second")];
934 let req = LLMRequest::builder().messages(conv).build();
935 assert_eq!(req.messages.len(), 2);
936 assert_eq!(req.messages[0].role, MessageRole::User);
937 assert_eq!(req.messages[1].role, MessageRole::Assistant);
938 }
939
940 #[test]
941 fn builder_maybe_max_tokens_accepts_option() {
942 let req = LLMRequest::builder().maybe_max_tokens(Some(512)).build();
944 assert_eq!(req.max_tokens, Some(512));
945 let req = LLMRequest::builder().maybe_max_tokens(None).build();
947 assert_eq!(req.max_tokens, None);
948 }
949
950 #[test]
951 fn into_openai_messages_with_system() {
952 let req = LLMRequest::builder()
953 .system("be helpful")
954 .user_message("hi")
955 .assistant_message("hello")
956 .build();
957 let msgs = req.into_openai_messages();
958 assert_eq!(msgs.len(), 3);
959 assert_eq!(msgs[0].0, "system");
960 assert_eq!(msgs[1].0, "user");
961 assert_eq!(msgs[2].0, "assistant");
962 }
963
964 #[test]
965 fn into_anthropic_messages_excludes_system() {
966 let req = LLMRequest::builder()
967 .system("be helpful")
968 .user_message("hi")
969 .build();
970 let msgs = req.into_anthropic_messages();
971 assert_eq!(msgs.len(), 1);
972 assert_eq!(msgs[0].0, "user");
973 }
974
975 #[test]
976 fn text_content_extracts_text() {
977 let msg = ChatMessage::user_multimodal(vec![
978 ContentPart::text("hello "),
979 ContentPart::image_url("http://x.com/i.png"),
980 ContentPart::text("world"),
981 ]);
982 assert_eq!(msg.text_content(), "hello world");
983 }
984
985 #[test]
986 fn llm_response_back_compat_without_reasoning_field() {
987 let json = r#"{"content":"hi","model":"m","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
989 let r: LLMResponse = serde_json::from_str(json).unwrap();
990 assert_eq!(r.content, "hi");
991 assert!(r.reasoning.is_none());
992 }
993
994 #[test]
995 fn token_usage_back_compat_without_reasoning_tokens() {
996 let json = r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#;
997 let u: TokenUsage = serde_json::from_str(json).unwrap();
998 assert_eq!(u.total_tokens, 3);
999 assert!(u.reasoning_tokens.is_none());
1000 }
1001}