1use std::{fmt, marker::PhantomData, str::FromStr};
2
3use schemars::{JsonSchema, Schema};
4use serde::{Deserialize, Deserializer, Serialize, de};
5
6use crate::{Error, InvalidConfiguration, ToolError};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
18#[serde(transparent)]
19pub struct ModelId(String);
20
21impl ModelId {
22 #[must_use]
24 pub fn kimi_k3() -> Self {
25 Self("moonshotai/kimi-k3".to_owned())
26 }
27
28 pub fn new(value: impl Into<String>) -> Result<Self, InvalidConfiguration> {
37 let value = value.into();
38 let trimmed = value.trim();
39 if trimmed.is_empty() {
40 return Err(InvalidConfiguration::EmptyModelId);
41 }
42 Ok(Self(trimmed.to_owned()))
43 }
44
45 #[must_use]
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50}
51
52impl fmt::Display for ModelId {
53 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54 formatter.write_str(&self.0)
55 }
56}
57
58impl<'de> Deserialize<'de> for ModelId {
59 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
60 let value = String::deserialize(deserializer)?;
61 Self::new(value).map_err(de::Error::custom)
62 }
63}
64
65impl TryFrom<&str> for ModelId {
66 type Error = InvalidConfiguration;
67
68 fn try_from(value: &str) -> Result<Self, Self::Error> {
69 Self::new(value)
70 }
71}
72
73impl FromStr for ModelId {
74 type Err = InvalidConfiguration;
75
76 fn from_str(value: &str) -> Result<Self, Self::Err> {
77 Self::new(value)
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(transparent)]
94pub struct ChatMessage(MessagePayload);
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "role", rename_all = "lowercase")]
98enum MessagePayload {
99 System {
100 content: String,
101 },
102 User {
103 content: String,
104 },
105 Assistant {
106 content: Option<String>,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 tool_calls: Vec<ToolCall>,
109 },
110 Tool {
111 tool_call_id: ToolCallId,
112 content: String,
113 },
114}
115
116impl ChatMessage {
117 #[must_use]
119 pub fn system(content: impl Into<String>) -> Self {
120 Self(MessagePayload::System {
121 content: content.into(),
122 })
123 }
124
125 #[must_use]
127 pub fn user(content: impl Into<String>) -> Self {
128 Self(MessagePayload::User {
129 content: content.into(),
130 })
131 }
132
133 #[must_use]
135 pub fn assistant(content: impl Into<String>) -> Self {
136 Self(MessagePayload::Assistant {
137 content: Some(content.into()),
138 tool_calls: Vec::new(),
139 })
140 }
141
142 #[must_use]
144 pub fn assistant_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
145 Self(MessagePayload::Assistant {
146 content: None,
147 tool_calls,
148 })
149 }
150
151 pub fn tool_result<T: ToolDefinition>(
163 call: &ToolCall,
164 result: &T::Output,
165 ) -> Result<Self, ToolError> {
166 call.ensure_name::<T>()?;
167 let content =
168 serde_json::to_string(result).map_err(|source| ToolError::ResultEncoding {
169 tool: call.name().to_owned(),
170 source,
171 })?;
172 Ok(Self(MessagePayload::Tool {
173 tool_call_id: call.id.clone(),
174 content,
175 }))
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
180#[serde(transparent)]
181struct ToolCallId(String);
182
183impl<'de> Deserialize<'de> for ToolCallId {
184 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
185 let value = String::deserialize(deserializer)?;
186 if value.trim().is_empty() {
187 return Err(de::Error::custom("tool-call ID must not be empty"));
188 }
189 Ok(Self(value))
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize)]
194struct FunctionDefinition {
195 name: String,
196 description: String,
197 parameters: Schema,
198}
199
200impl FunctionDefinition {
201 fn new(name: impl Into<String>, description: impl Into<String>, parameters: Schema) -> Self {
202 Self {
203 name: name.into(),
204 description: description.into(),
205 parameters,
206 }
207 }
208}
209
210pub trait ToolDefinition {
238 type Arguments: serde::de::DeserializeOwned + JsonSchema;
240
241 type Output: Serialize;
243
244 const NAME: &'static str;
246
247 const DESCRIPTION: &'static str;
249}
250
251#[derive(Debug, Clone, PartialEq, Serialize)]
271pub struct FunctionTool {
272 #[serde(rename = "type")]
273 kind: FunctionToolKind,
274 function: FunctionDefinition,
275}
276
277impl FunctionTool {
278 pub fn for_tool<T: ToolDefinition>() -> Result<Self, ToolError> {
285 if T::NAME.trim().is_empty() {
286 return Err(ToolError::InvalidDefinition { field: "name" });
287 }
288 if T::DESCRIPTION.trim().is_empty() {
289 return Err(ToolError::InvalidDefinition {
290 field: "description",
291 });
292 }
293 Ok(Self {
294 kind: FunctionToolKind::Function,
295 function: FunctionDefinition::new(
296 T::NAME,
297 T::DESCRIPTION,
298 schemars::schema_for!(T::Arguments),
299 ),
300 })
301 }
302
303 #[must_use]
305 pub fn name(&self) -> &str {
306 &self.function.name
307 }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312enum FunctionToolKind {
313 Function,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
326#[serde(transparent)]
327pub struct Temperature(f32);
328
329impl Temperature {
330 pub fn new(value: f32) -> Result<Self, InvalidConfiguration> {
337 if value.is_finite() && (0.0..=2.0).contains(&value) {
338 Ok(Self(value))
339 } else {
340 Err(InvalidConfiguration::InvalidTemperature { value })
341 }
342 }
343
344 #[must_use]
346 pub fn value(self) -> f32 {
347 self.0
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
361#[serde(transparent)]
362pub struct MaxTokens(u32);
363
364impl MaxTokens {
365 pub fn new(value: u32) -> Result<Self, InvalidConfiguration> {
371 if value == 0 {
372 Err(InvalidConfiguration::ZeroMaxTokens)
373 } else {
374 Ok(Self(value))
375 }
376 }
377
378 #[must_use]
380 pub fn value(self) -> u32 {
381 self.0
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "lowercase")]
388#[non_exhaustive]
389pub enum ToolChoice {
390 None,
392 Auto,
394 Required,
396}
397
398pub mod request_state {
403 mod sealed {
404 pub trait Sealed {}
405 }
406
407 pub trait MessageState: sealed::Sealed {}
409
410 pub trait ToolState: sealed::Sealed {}
412
413 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
415 pub struct NeedsMessage;
416
417 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
419 pub struct HasMessages;
420
421 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
423 pub struct WithoutTools;
424
425 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
427 pub struct WithTools;
428
429 impl sealed::Sealed for NeedsMessage {}
430 impl sealed::Sealed for HasMessages {}
431 impl sealed::Sealed for WithoutTools {}
432 impl sealed::Sealed for WithTools {}
433
434 impl MessageState for NeedsMessage {}
435 impl MessageState for HasMessages {}
436 impl ToolState for WithoutTools {}
437 impl ToolState for WithTools {}
438}
439
440use request_state::{HasMessages, MessageState, NeedsMessage, ToolState, WithTools, WithoutTools};
441
442#[must_use = "request builders must be transitioned and built"]
481#[derive(Debug, Clone)]
482pub struct ChatRequestBuilder<M: MessageState, T: ToolState> {
483 model: ModelId,
484 messages: Vec<ChatMessage>,
485 tools: Vec<FunctionTool>,
486 tool_choice: Option<ToolChoice>,
487 temperature: Option<Temperature>,
488 max_tokens: Option<MaxTokens>,
489 state: PhantomData<(M, T)>,
490}
491
492impl<M: MessageState, T: ToolState> ChatRequestBuilder<M, T> {
493 fn transition<NextM: MessageState, NextT: ToolState>(self) -> ChatRequestBuilder<NextM, NextT> {
494 ChatRequestBuilder {
495 model: self.model,
496 messages: self.messages,
497 tools: self.tools,
498 tool_choice: self.tool_choice,
499 temperature: self.temperature,
500 max_tokens: self.max_tokens,
501 state: PhantomData,
502 }
503 }
504
505 pub fn temperature(mut self, temperature: Temperature) -> Self {
507 self.temperature = Some(temperature);
508 self
509 }
510
511 pub fn max_tokens(mut self, max_tokens: MaxTokens) -> Self {
513 self.max_tokens = Some(max_tokens);
514 self
515 }
516}
517
518impl<T: ToolState> ChatRequestBuilder<NeedsMessage, T> {
519 pub fn message(mut self, message: ChatMessage) -> ChatRequestBuilder<HasMessages, T> {
521 self.messages.push(message);
522 self.transition()
523 }
524}
525
526impl<T: ToolState> ChatRequestBuilder<HasMessages, T> {
527 pub fn message(mut self, message: ChatMessage) -> Self {
529 self.messages.push(message);
530 self
531 }
532
533 #[must_use]
535 pub fn build(self) -> ChatRequest {
536 ChatRequest {
537 model: self.model,
538 messages: self.messages,
539 tools: self.tools,
540 tool_choice: self.tool_choice,
541 temperature: self.temperature,
542 max_tokens: self.max_tokens,
543 }
544 }
545}
546
547impl<M: MessageState> ChatRequestBuilder<M, WithoutTools> {
548 pub fn tool(mut self, tool: FunctionTool) -> ChatRequestBuilder<M, WithTools> {
553 self.tools.push(tool);
554 self.tool_choice = Some(ToolChoice::Auto);
555 self.transition()
556 }
557}
558
559impl<M: MessageState> ChatRequestBuilder<M, WithTools> {
560 pub fn tool(mut self, tool: FunctionTool) -> Self {
562 self.tools.push(tool);
563 self
564 }
565
566 pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
568 self.tool_choice = Some(choice);
569 self
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Serialize)]
578pub struct ChatRequest {
579 model: ModelId,
580 messages: Vec<ChatMessage>,
581 #[serde(skip_serializing_if = "Vec::is_empty")]
582 tools: Vec<FunctionTool>,
583 #[serde(skip_serializing_if = "Option::is_none")]
584 tool_choice: Option<ToolChoice>,
585 #[serde(skip_serializing_if = "Option::is_none")]
586 temperature: Option<Temperature>,
587 #[serde(skip_serializing_if = "Option::is_none")]
588 max_tokens: Option<MaxTokens>,
589}
590
591impl ChatRequest {
592 pub fn builder(model: ModelId) -> ChatRequestBuilder<NeedsMessage, WithoutTools> {
594 ChatRequestBuilder {
595 model,
596 messages: Vec::new(),
597 tools: Vec::new(),
598 tool_choice: None,
599 temperature: None,
600 max_tokens: None,
601 state: PhantomData,
602 }
603 }
604
605 pub fn kimi_k3_builder() -> ChatRequestBuilder<NeedsMessage, WithoutTools> {
607 Self::builder(ModelId::kimi_k3())
608 }
609
610 pub fn kimi_k3(messages: Vec<ChatMessage>) -> Result<Self, InvalidConfiguration> {
620 Self::new(ModelId::kimi_k3(), messages)
621 }
622
623 pub fn new(model: ModelId, messages: Vec<ChatMessage>) -> Result<Self, InvalidConfiguration> {
632 if messages.is_empty() {
633 return Err(InvalidConfiguration::EmptyMessages);
634 }
635 Ok(Self {
636 model,
637 messages,
638 tools: Vec::new(),
639 tool_choice: None,
640 temperature: None,
641 max_tokens: None,
642 })
643 }
644
645 #[must_use]
647 pub fn with_tool(mut self, tool: FunctionTool, choice: ToolChoice) -> Self {
648 self.tools = vec![tool];
649 self.tool_choice = Some(choice);
650 self
651 }
652
653 pub fn with_tools(
663 mut self,
664 tools: Vec<FunctionTool>,
665 choice: ToolChoice,
666 ) -> Result<Self, InvalidConfiguration> {
667 if tools.is_empty() {
668 return Err(InvalidConfiguration::EmptyTools);
669 }
670 self.tools = tools;
671 self.tool_choice = Some(choice);
672 Ok(self)
673 }
674
675 #[must_use]
677 pub fn with_temperature(mut self, temperature: Temperature) -> Self {
678 self.temperature = Some(temperature);
679 self
680 }
681
682 #[must_use]
684 pub fn with_max_tokens(mut self, max_tokens: MaxTokens) -> Self {
685 self.max_tokens = Some(max_tokens);
686 self
687 }
688
689 #[must_use]
691 pub fn model(&self) -> &ModelId {
692 &self.model
693 }
694}
695
696#[derive(Debug, Clone, PartialEq, Deserialize)]
698pub struct ChatCompletion {
699 id: CompletionId,
700 object: CompletionObject,
701 created: u64,
702 model: ModelId,
703 choices: Vec<ChatChoice>,
704 usage: Option<Usage>,
705}
706
707impl ChatCompletion {
708 #[must_use]
710 pub fn id(&self) -> &str {
711 &self.id.0
712 }
713
714 #[must_use]
716 pub fn model(&self) -> &ModelId {
717 &self.model
718 }
719
720 #[must_use]
722 pub fn created_unix_seconds(&self) -> u64 {
723 self.created
724 }
725
726 #[must_use]
728 pub fn choices(&self) -> &[ChatChoice] {
729 &self.choices
730 }
731
732 pub fn first_choice(&self) -> Result<&ChatChoice, Error> {
739 self.choices.first().ok_or(Error::MissingChoice)
740 }
741
742 #[must_use]
744 pub fn usage(&self) -> Option<&Usage> {
745 self.usage.as_ref()
746 }
747}
748
749#[derive(Debug, Clone, PartialEq, Eq)]
750struct CompletionId(String);
751
752impl<'de> Deserialize<'de> for CompletionId {
753 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
754 let value = String::deserialize(deserializer)?;
755 if value.trim().is_empty() {
756 return Err(de::Error::custom("completion ID must not be empty"));
757 }
758 Ok(Self(value))
759 }
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
763enum CompletionObject {
764 #[serde(rename = "chat.completion")]
765 ChatCompletion,
766}
767
768#[derive(Debug, Clone, PartialEq, Deserialize)]
770pub struct ChatChoice {
771 index: u32,
772 message: AssistantMessage,
773 finish_reason: Option<FinishReason>,
774}
775
776impl ChatChoice {
777 #[must_use]
779 pub fn index(&self) -> u32 {
780 self.index
781 }
782
783 #[must_use]
785 pub fn message(&self) -> &AssistantMessage {
786 &self.message
787 }
788
789 #[must_use]
791 pub fn finish_reason(&self) -> Option<&FinishReason> {
792 self.finish_reason.as_ref()
793 }
794}
795
796#[derive(Debug, Clone, PartialEq, Deserialize)]
798pub struct AssistantMessage {
799 role: AssistantRole,
800 content: Option<String>,
801 #[serde(default)]
802 tool_calls: Vec<ToolCall>,
803}
804
805#[must_use = "assistant output should be handled explicitly"]
824#[derive(Debug, Clone, Copy, PartialEq)]
825pub enum AssistantOutput<'a> {
826 Text(&'a str),
828 ToolCalls(&'a [ToolCall]),
830 TextAndToolCalls {
832 text: &'a str,
834 tool_calls: &'a [ToolCall],
836 },
837 Empty,
839}
840
841impl AssistantMessage {
842 pub fn output(&self) -> AssistantOutput<'_> {
844 match (self.content.as_deref(), self.tool_calls.as_slice()) {
845 (Some(text), []) => AssistantOutput::Text(text),
846 (None, []) => AssistantOutput::Empty,
847 (None, tool_calls) => AssistantOutput::ToolCalls(tool_calls),
848 (Some(text), tool_calls) => AssistantOutput::TextAndToolCalls { text, tool_calls },
849 }
850 }
851
852 #[must_use]
854 pub fn content(&self) -> Option<&str> {
855 self.content.as_deref()
856 }
857
858 #[must_use]
860 pub fn tool_calls(&self) -> &[ToolCall] {
861 &self.tool_calls
862 }
863
864 pub fn first_tool_call(&self) -> Result<&ToolCall, Error> {
871 self.tool_calls.first().ok_or(Error::MissingToolCall)
872 }
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
876#[serde(rename_all = "lowercase")]
877enum AssistantRole {
878 Assistant,
879}
880
881#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
883pub struct ToolCall {
884 id: ToolCallId,
885 #[serde(rename = "type")]
886 kind: ToolCallKind,
887 function: ToolCallFunction,
888}
889
890#[must_use = "validated tool calls should be inspected or converted into results"]
950pub struct ValidatedToolCall<'a, T: ToolDefinition> {
951 call: &'a ToolCall,
952 arguments: T::Arguments,
953 tool: PhantomData<T>,
954}
955
956impl<T: ToolDefinition> ValidatedToolCall<'_, T> {
957 #[must_use]
959 pub fn arguments(&self) -> &T::Arguments {
960 &self.arguments
961 }
962
963 #[must_use]
965 pub fn into_arguments(self) -> T::Arguments {
966 self.arguments
967 }
968
969 pub fn result(&self, result: &T::Output) -> Result<ChatMessage, ToolError> {
977 ChatMessage::tool_result::<T>(self.call, result)
978 }
979
980 #[must_use]
982 pub fn id(&self) -> &str {
983 self.call.id()
984 }
985}
986
987#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
988#[serde(rename_all = "lowercase")]
989enum ToolCallKind {
990 Function,
991}
992
993#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
994struct ToolCallFunction {
995 name: String,
996 arguments: String,
998}
999
1000impl ToolCall {
1001 #[must_use]
1003 pub fn id(&self) -> &str {
1004 &self.id.0
1005 }
1006
1007 #[must_use]
1009 pub fn name(&self) -> &str {
1010 &self.function.name
1011 }
1012
1013 pub fn validate<T: ToolDefinition>(&self) -> Result<ValidatedToolCall<'_, T>, ToolError> {
1027 self.ensure_name::<T>()?;
1028 let arguments = serde_json::from_str(&self.function.arguments).map_err(|source| {
1029 ToolError::InvalidArguments {
1030 tool: self.name().to_owned(),
1031 source,
1032 }
1033 })?;
1034 Ok(ValidatedToolCall {
1035 call: self,
1036 arguments,
1037 tool: PhantomData,
1038 })
1039 }
1040
1041 pub fn arguments_for<T: ToolDefinition>(&self) -> Result<T::Arguments, ToolError> {
1053 Ok(self.validate::<T>()?.into_arguments())
1054 }
1055
1056 fn ensure_name<T: ToolDefinition>(&self) -> Result<(), ToolError> {
1057 if self.name() != T::NAME {
1058 return Err(ToolError::UnexpectedName {
1059 expected: T::NAME,
1060 actual: self.name().to_owned(),
1061 });
1062 }
1063 Ok(())
1064 }
1065}
1066
1067#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
1069#[serde(rename_all = "snake_case")]
1070#[non_exhaustive]
1071pub enum FinishReason {
1072 Stop,
1074 Length,
1076 ToolCalls,
1078 ContentFilter,
1080 #[serde(other)]
1082 Unknown,
1083}
1084
1085#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
1087pub struct Usage {
1088 prompt_tokens: u64,
1089 completion_tokens: u64,
1090 total_tokens: u64,
1091}
1092
1093impl Usage {
1094 #[must_use]
1096 pub fn prompt_tokens(&self) -> u64 {
1097 self.prompt_tokens
1098 }
1099
1100 #[must_use]
1102 pub fn completion_tokens(&self) -> u64 {
1103 self.completion_tokens
1104 }
1105
1106 #[must_use]
1108 pub fn total_tokens(&self) -> u64 {
1109 self.total_tokens
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use schemars::JsonSchema;
1116 use serde::Deserialize;
1117
1118 use super::*;
1119
1120 struct GetWeather;
1121
1122 impl ToolDefinition for GetWeather {
1123 type Arguments = WeatherArgs;
1124 type Output = WeatherReport;
1125
1126 const NAME: &'static str = "get_weather";
1127 const DESCRIPTION: &'static str = "Get the weather for a city";
1128 }
1129
1130 #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
1131 struct WeatherArgs {
1132 city: String,
1133 }
1134
1135 #[derive(Serialize)]
1136 struct WeatherReport {
1137 temperature_celsius: i16,
1138 }
1139
1140 fn tool_call(name: &str, arguments: &str) -> ToolCall {
1141 ToolCall {
1142 id: ToolCallId("call_1".into()),
1143 kind: ToolCallKind::Function,
1144 function: ToolCallFunction {
1145 name: name.into(),
1146 arguments: arguments.into(),
1147 },
1148 }
1149 }
1150
1151 fn assistant_message(content: Option<&str>, tool_calls: Vec<ToolCall>) -> AssistantMessage {
1152 AssistantMessage {
1153 role: AssistantRole::Assistant,
1154 content: content.map(ToOwned::to_owned),
1155 tool_calls,
1156 }
1157 }
1158
1159 #[test]
1160 fn serializes_required_function_tool_request() -> Result<(), Box<dyn std::error::Error>> {
1161 let request = ChatRequest::kimi_k3(vec![ChatMessage::user("What is the weather?")])?
1162 .with_tool(
1163 FunctionTool::for_tool::<GetWeather>()?,
1164 ToolChoice::Required,
1165 );
1166
1167 let value = serde_json::to_value(request)?;
1168 assert_eq!(value["model"], "moonshotai/kimi-k3");
1169 assert_eq!(value["tool_choice"], "required");
1170 assert_eq!(value["tools"][0]["type"], "function");
1171 assert_eq!(value["tools"][0]["function"]["name"], "get_weather");
1172 assert_eq!(
1173 value["tools"][0]["function"]["parameters"]["type"],
1174 "object"
1175 );
1176 Ok(())
1177 }
1178
1179 #[test]
1180 fn typestate_builder_preserves_the_existing_request_contract()
1181 -> Result<(), Box<dyn std::error::Error>> {
1182 let tool = FunctionTool::for_tool::<GetWeather>()?;
1183 let legacy = ChatRequest::kimi_k3(vec![ChatMessage::user("What is the weather?")])?
1184 .with_tool(tool.clone(), ToolChoice::Required);
1185 let typestate = ChatRequest::kimi_k3_builder()
1186 .tool(tool)
1187 .tool_choice(ToolChoice::Required)
1188 .message(ChatMessage::user("What is the weather?"))
1189 .build();
1190
1191 assert_eq!(typestate, legacy);
1192 Ok(())
1193 }
1194
1195 #[test]
1196 fn independent_builder_endomorphisms_commute() -> Result<(), Box<dyn std::error::Error>> {
1197 let temperature = Temperature::new(0.4)?;
1198 let max_tokens = MaxTokens::new(120)?;
1199 let temperature_then_tokens = ChatRequest::kimi_k3_builder()
1200 .temperature(temperature)
1201 .max_tokens(max_tokens)
1202 .message(ChatMessage::user("Explain composition"))
1203 .build();
1204 let tokens_then_temperature = ChatRequest::kimi_k3_builder()
1205 .max_tokens(max_tokens)
1206 .temperature(temperature)
1207 .message(ChatMessage::user("Explain composition"))
1208 .build();
1209
1210 assert_eq!(temperature_then_tokens, tokens_then_temperature);
1211 Ok(())
1212 }
1213
1214 #[test]
1215 fn classifies_every_assistant_output_sum_variant() {
1216 let call = tool_call("get_weather", r#"{"city":"Paris"}"#);
1217 let text = assistant_message(Some("Clear skies"), Vec::new());
1218 let tools = assistant_message(None, vec![call.clone()]);
1219 let both = assistant_message(Some("Checking"), vec![call]);
1220 let empty = assistant_message(None, Vec::new());
1221
1222 assert_eq!(text.output(), AssistantOutput::Text("Clear skies"));
1223 assert!(matches!(
1224 tools.output(),
1225 AssistantOutput::ToolCalls(tool_calls) if tool_calls.len() == 1
1226 ));
1227 assert!(matches!(
1228 both.output(),
1229 AssistantOutput::TextAndToolCalls {
1230 text: "Checking",
1231 tool_calls
1232 } if tool_calls.len() == 1
1233 ));
1234 assert_eq!(empty.output(), AssistantOutput::Empty);
1235 }
1236
1237 #[test]
1238 fn rejects_an_empty_message_list() {
1239 assert!(matches!(
1240 ChatRequest::kimi_k3(Vec::new()),
1241 Err(InvalidConfiguration::EmptyMessages)
1242 ));
1243 }
1244
1245 #[test]
1246 fn rejects_an_empty_tool_definition_field() {
1247 struct InvalidTool;
1248 impl ToolDefinition for InvalidTool {
1249 type Arguments = WeatherArgs;
1250 type Output = WeatherReport;
1251 const NAME: &'static str = "";
1252 const DESCRIPTION: &'static str = "Description";
1253 }
1254
1255 assert!(matches!(
1256 FunctionTool::for_tool::<InvalidTool>(),
1257 Err(ToolError::InvalidDefinition { field: "name" })
1258 ));
1259 }
1260
1261 #[test]
1262 fn assistant_tool_call_round_trip_preserves_null_content()
1263 -> Result<(), Box<dyn std::error::Error>> {
1264 let message = ChatMessage::assistant_tool_calls(vec![tool_call(
1265 "get_weather",
1266 r#"{"city":"Paris"}"#,
1267 )]);
1268 let value = serde_json::to_value(message)?;
1269 assert!(value["content"].is_null());
1270 assert_eq!(value["tool_calls"][0]["id"], "call_1");
1271 Ok(())
1272 }
1273
1274 #[test]
1275 fn parses_typed_tool_arguments() -> Result<(), Box<dyn std::error::Error>> {
1276 let call = tool_call("get_weather", r#"{"city":"Paris"}"#);
1277 assert_eq!(
1278 call.arguments_for::<GetWeather>()?,
1279 WeatherArgs {
1280 city: "Paris".into()
1281 }
1282 );
1283 Ok(())
1284 }
1285
1286 #[test]
1287 fn validated_tool_call_carries_arguments_and_output_contract()
1288 -> Result<(), Box<dyn std::error::Error>> {
1289 let call = tool_call("get_weather", r#"{"city":"Paris"}"#);
1290 let validated = call.validate::<GetWeather>()?;
1291
1292 assert_eq!(validated.id(), "call_1");
1293 assert_eq!(validated.arguments().city, "Paris");
1294 assert_eq!(
1295 serde_json::to_value(validated.result(&WeatherReport {
1296 temperature_celsius: 18,
1297 })?)?,
1298 serde_json::json!({
1299 "role": "tool",
1300 "tool_call_id": "call_1",
1301 "content": "{\"temperature_celsius\":18}"
1302 })
1303 );
1304 Ok(())
1305 }
1306
1307 #[test]
1308 fn rejects_arguments_for_a_different_typed_tool() {
1309 struct OtherTool;
1310 impl ToolDefinition for OtherTool {
1311 type Arguments = WeatherArgs;
1312 type Output = WeatherReport;
1313 const NAME: &'static str = "other_tool";
1314 const DESCRIPTION: &'static str = "A different tool";
1315 }
1316
1317 let result = tool_call("get_weather", r#"{"city":"Paris"}"#).arguments_for::<OtherTool>();
1318
1319 assert!(matches!(result, Err(ToolError::UnexpectedName { .. })));
1320 }
1321
1322 #[test]
1323 fn rejects_malformed_tool_arguments_with_a_typed_error() {
1324 assert!(matches!(
1325 tool_call("get_weather", "not-json").arguments_for::<GetWeather>(),
1326 Err(ToolError::InvalidArguments { .. })
1327 ));
1328 }
1329
1330 #[test]
1331 fn encodes_a_typed_tool_result_without_exposing_raw_json()
1332 -> Result<(), Box<dyn std::error::Error>> {
1333 let message = ChatMessage::tool_result::<GetWeather>(
1334 &tool_call("get_weather", r#"{"city":"Paris"}"#),
1335 &WeatherReport {
1336 temperature_celsius: 18,
1337 },
1338 )?;
1339 let encoded = serde_json::to_value(message)?;
1340
1341 assert_eq!(encoded["tool_call_id"], "call_1");
1342 assert_eq!(encoded["content"], r#"{"temperature_celsius":18}"#);
1343 Ok(())
1344 }
1345
1346 #[test]
1347 fn rejects_a_tool_result_for_a_different_contract() {
1348 struct OtherTool;
1349 impl ToolDefinition for OtherTool {
1350 type Arguments = WeatherArgs;
1351 type Output = WeatherReport;
1352 const NAME: &'static str = "other_tool";
1353 const DESCRIPTION: &'static str = "A different tool";
1354 }
1355
1356 assert!(matches!(
1357 ChatMessage::tool_result::<OtherTool>(
1358 &tool_call("get_weather", r#"{"city":"Paris"}"#),
1359 &WeatherReport {
1360 temperature_celsius: 18,
1361 },
1362 ),
1363 Err(ToolError::UnexpectedName { .. })
1364 ));
1365 }
1366
1367 #[test]
1368 fn rejects_a_completion_without_choices() -> Result<(), Box<dyn std::error::Error>> {
1369 let completion: ChatCompletion = serde_json::from_value(serde_json::json!({
1370 "id": "chatcmpl-1",
1371 "object": "chat.completion",
1372 "created": 1,
1373 "model": "moonshotai/kimi-k3",
1374 "choices": [],
1375 "usage": null
1376 }))?;
1377
1378 assert!(matches!(
1379 completion.first_choice(),
1380 Err(Error::MissingChoice)
1381 ));
1382 Ok(())
1383 }
1384
1385 #[test]
1386 fn rejects_an_unexpected_response_object() {
1387 let result = serde_json::from_value::<ChatCompletion>(serde_json::json!({
1388 "id": "chatcmpl-1",
1389 "object": "unexpected",
1390 "created": 1,
1391 "model": "moonshotai/kimi-k3",
1392 "choices": [],
1393 "usage": null
1394 }));
1395
1396 assert!(result.is_err());
1397 }
1398
1399 #[test]
1400 fn rejects_generation_values_outside_provider_contract() {
1401 assert!(matches!(
1402 Temperature::new(f32::NAN),
1403 Err(InvalidConfiguration::InvalidTemperature { .. })
1404 ));
1405 assert!(matches!(
1406 Temperature::new(2.1),
1407 Err(InvalidConfiguration::InvalidTemperature { .. })
1408 ));
1409 assert!(matches!(
1410 MaxTokens::new(0),
1411 Err(InvalidConfiguration::ZeroMaxTokens)
1412 ));
1413 }
1414}