use std::{fmt, marker::PhantomData, str::FromStr};
use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Deserializer, Serialize, de};
use crate::{Error, InvalidConfiguration, ToolError};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ModelId(String);
impl ModelId {
#[must_use]
pub fn kimi_k3() -> Self {
Self("moonshotai/kimi-k3".to_owned())
}
pub fn new(value: impl Into<String>) -> Result<Self, InvalidConfiguration> {
let value = value.into();
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(InvalidConfiguration::EmptyModelId);
}
Ok(Self(trimmed.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ModelId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for ModelId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(de::Error::custom)
}
}
impl TryFrom<&str> for ModelId {
type Error = InvalidConfiguration;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl FromStr for ModelId {
type Err = InvalidConfiguration;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ChatMessage(MessagePayload);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "lowercase")]
enum MessagePayload {
System {
content: String,
},
User {
content: String,
},
Assistant {
content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
},
Tool {
tool_call_id: ToolCallId,
content: String,
},
}
impl ChatMessage {
#[must_use]
pub fn system(content: impl Into<String>) -> Self {
Self(MessagePayload::System {
content: content.into(),
})
}
#[must_use]
pub fn user(content: impl Into<String>) -> Self {
Self(MessagePayload::User {
content: content.into(),
})
}
#[must_use]
pub fn assistant(content: impl Into<String>) -> Self {
Self(MessagePayload::Assistant {
content: Some(content.into()),
tool_calls: Vec::new(),
})
}
#[must_use]
pub fn assistant_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
Self(MessagePayload::Assistant {
content: None,
tool_calls,
})
}
pub fn tool_result<T: ToolDefinition>(
call: &ToolCall,
result: &T::Output,
) -> Result<Self, ToolError> {
call.ensure_name::<T>()?;
let content =
serde_json::to_string(result).map_err(|source| ToolError::ResultEncoding {
tool: call.name().to_owned(),
source,
})?;
Ok(Self(MessagePayload::Tool {
tool_call_id: call.id.clone(),
content,
}))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
struct ToolCallId(String);
impl<'de> Deserialize<'de> for ToolCallId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
if value.trim().is_empty() {
return Err(de::Error::custom("tool-call ID must not be empty"));
}
Ok(Self(value))
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
struct FunctionDefinition {
name: String,
description: String,
parameters: Schema,
}
impl FunctionDefinition {
fn new(name: impl Into<String>, description: impl Into<String>, parameters: Schema) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters,
}
}
}
pub trait ToolDefinition {
type Arguments: serde::de::DeserializeOwned + JsonSchema;
type Output: Serialize;
const NAME: &'static str;
const DESCRIPTION: &'static str;
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FunctionTool {
#[serde(rename = "type")]
kind: FunctionToolKind,
function: FunctionDefinition,
}
impl FunctionTool {
pub fn for_tool<T: ToolDefinition>() -> Result<Self, ToolError> {
if T::NAME.trim().is_empty() {
return Err(ToolError::InvalidDefinition { field: "name" });
}
if T::DESCRIPTION.trim().is_empty() {
return Err(ToolError::InvalidDefinition {
field: "description",
});
}
Ok(Self {
kind: FunctionToolKind::Function,
function: FunctionDefinition::new(
T::NAME,
T::DESCRIPTION,
schemars::schema_for!(T::Arguments),
),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.function.name
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum FunctionToolKind {
Function,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(transparent)]
pub struct Temperature(f32);
impl Temperature {
pub fn new(value: f32) -> Result<Self, InvalidConfiguration> {
if value.is_finite() && (0.0..=2.0).contains(&value) {
Ok(Self(value))
} else {
Err(InvalidConfiguration::InvalidTemperature { value })
}
}
#[must_use]
pub fn value(self) -> f32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct MaxTokens(u32);
impl MaxTokens {
pub fn new(value: u32) -> Result<Self, InvalidConfiguration> {
if value == 0 {
Err(InvalidConfiguration::ZeroMaxTokens)
} else {
Ok(Self(value))
}
}
#[must_use]
pub fn value(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ToolChoice {
None,
Auto,
Required,
}
pub mod request_state {
mod sealed {
pub trait Sealed {}
}
pub trait MessageState: sealed::Sealed {}
pub trait ToolState: sealed::Sealed {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NeedsMessage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HasMessages;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WithoutTools;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WithTools;
impl sealed::Sealed for NeedsMessage {}
impl sealed::Sealed for HasMessages {}
impl sealed::Sealed for WithoutTools {}
impl sealed::Sealed for WithTools {}
impl MessageState for NeedsMessage {}
impl MessageState for HasMessages {}
impl ToolState for WithoutTools {}
impl ToolState for WithTools {}
}
use request_state::{HasMessages, MessageState, NeedsMessage, ToolState, WithTools, WithoutTools};
#[must_use = "request builders must be transitioned and built"]
#[derive(Debug, Clone)]
pub struct ChatRequestBuilder<M: MessageState, T: ToolState> {
model: ModelId,
messages: Vec<ChatMessage>,
tools: Vec<FunctionTool>,
tool_choice: Option<ToolChoice>,
temperature: Option<Temperature>,
max_tokens: Option<MaxTokens>,
state: PhantomData<(M, T)>,
}
impl<M: MessageState, T: ToolState> ChatRequestBuilder<M, T> {
fn transition<NextM: MessageState, NextT: ToolState>(self) -> ChatRequestBuilder<NextM, NextT> {
ChatRequestBuilder {
model: self.model,
messages: self.messages,
tools: self.tools,
tool_choice: self.tool_choice,
temperature: self.temperature,
max_tokens: self.max_tokens,
state: PhantomData,
}
}
pub fn temperature(mut self, temperature: Temperature) -> Self {
self.temperature = Some(temperature);
self
}
pub fn max_tokens(mut self, max_tokens: MaxTokens) -> Self {
self.max_tokens = Some(max_tokens);
self
}
}
impl<T: ToolState> ChatRequestBuilder<NeedsMessage, T> {
pub fn message(mut self, message: ChatMessage) -> ChatRequestBuilder<HasMessages, T> {
self.messages.push(message);
self.transition()
}
}
impl<T: ToolState> ChatRequestBuilder<HasMessages, T> {
pub fn message(mut self, message: ChatMessage) -> Self {
self.messages.push(message);
self
}
#[must_use]
pub fn build(self) -> ChatRequest {
ChatRequest {
model: self.model,
messages: self.messages,
tools: self.tools,
tool_choice: self.tool_choice,
temperature: self.temperature,
max_tokens: self.max_tokens,
}
}
}
impl<M: MessageState> ChatRequestBuilder<M, WithoutTools> {
pub fn tool(mut self, tool: FunctionTool) -> ChatRequestBuilder<M, WithTools> {
self.tools.push(tool);
self.tool_choice = Some(ToolChoice::Auto);
self.transition()
}
}
impl<M: MessageState> ChatRequestBuilder<M, WithTools> {
pub fn tool(mut self, tool: FunctionTool) -> Self {
self.tools.push(tool);
self
}
pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
self.tool_choice = Some(choice);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChatRequest {
model: ModelId,
messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<FunctionTool>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<Temperature>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<MaxTokens>,
}
impl ChatRequest {
pub fn builder(model: ModelId) -> ChatRequestBuilder<NeedsMessage, WithoutTools> {
ChatRequestBuilder {
model,
messages: Vec::new(),
tools: Vec::new(),
tool_choice: None,
temperature: None,
max_tokens: None,
state: PhantomData,
}
}
pub fn kimi_k3_builder() -> ChatRequestBuilder<NeedsMessage, WithoutTools> {
Self::builder(ModelId::kimi_k3())
}
pub fn kimi_k3(messages: Vec<ChatMessage>) -> Result<Self, InvalidConfiguration> {
Self::new(ModelId::kimi_k3(), messages)
}
pub fn new(model: ModelId, messages: Vec<ChatMessage>) -> Result<Self, InvalidConfiguration> {
if messages.is_empty() {
return Err(InvalidConfiguration::EmptyMessages);
}
Ok(Self {
model,
messages,
tools: Vec::new(),
tool_choice: None,
temperature: None,
max_tokens: None,
})
}
#[must_use]
pub fn with_tool(mut self, tool: FunctionTool, choice: ToolChoice) -> Self {
self.tools = vec![tool];
self.tool_choice = Some(choice);
self
}
pub fn with_tools(
mut self,
tools: Vec<FunctionTool>,
choice: ToolChoice,
) -> Result<Self, InvalidConfiguration> {
if tools.is_empty() {
return Err(InvalidConfiguration::EmptyTools);
}
self.tools = tools;
self.tool_choice = Some(choice);
Ok(self)
}
#[must_use]
pub fn with_temperature(mut self, temperature: Temperature) -> Self {
self.temperature = Some(temperature);
self
}
#[must_use]
pub fn with_max_tokens(mut self, max_tokens: MaxTokens) -> Self {
self.max_tokens = Some(max_tokens);
self
}
#[must_use]
pub fn model(&self) -> &ModelId {
&self.model
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ChatCompletion {
id: CompletionId,
object: CompletionObject,
created: u64,
model: ModelId,
choices: Vec<ChatChoice>,
usage: Option<Usage>,
}
impl ChatCompletion {
#[must_use]
pub fn id(&self) -> &str {
&self.id.0
}
#[must_use]
pub fn model(&self) -> &ModelId {
&self.model
}
#[must_use]
pub fn created_unix_seconds(&self) -> u64 {
self.created
}
#[must_use]
pub fn choices(&self) -> &[ChatChoice] {
&self.choices
}
pub fn first_choice(&self) -> Result<&ChatChoice, Error> {
self.choices.first().ok_or(Error::MissingChoice)
}
#[must_use]
pub fn usage(&self) -> Option<&Usage> {
self.usage.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompletionId(String);
impl<'de> Deserialize<'de> for CompletionId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
if value.trim().is_empty() {
return Err(de::Error::custom("completion ID must not be empty"));
}
Ok(Self(value))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
enum CompletionObject {
#[serde(rename = "chat.completion")]
ChatCompletion,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ChatChoice {
index: u32,
message: AssistantMessage,
finish_reason: Option<FinishReason>,
}
impl ChatChoice {
#[must_use]
pub fn index(&self) -> u32 {
self.index
}
#[must_use]
pub fn message(&self) -> &AssistantMessage {
&self.message
}
#[must_use]
pub fn finish_reason(&self) -> Option<&FinishReason> {
self.finish_reason.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AssistantMessage {
role: AssistantRole,
content: Option<String>,
#[serde(default)]
tool_calls: Vec<ToolCall>,
}
#[must_use = "assistant output should be handled explicitly"]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AssistantOutput<'a> {
Text(&'a str),
ToolCalls(&'a [ToolCall]),
TextAndToolCalls {
text: &'a str,
tool_calls: &'a [ToolCall],
},
Empty,
}
impl AssistantMessage {
pub fn output(&self) -> AssistantOutput<'_> {
match (self.content.as_deref(), self.tool_calls.as_slice()) {
(Some(text), []) => AssistantOutput::Text(text),
(None, []) => AssistantOutput::Empty,
(None, tool_calls) => AssistantOutput::ToolCalls(tool_calls),
(Some(text), tool_calls) => AssistantOutput::TextAndToolCalls { text, tool_calls },
}
}
#[must_use]
pub fn content(&self) -> Option<&str> {
self.content.as_deref()
}
#[must_use]
pub fn tool_calls(&self) -> &[ToolCall] {
&self.tool_calls
}
pub fn first_tool_call(&self) -> Result<&ToolCall, Error> {
self.tool_calls.first().ok_or(Error::MissingToolCall)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum AssistantRole {
Assistant,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
id: ToolCallId,
#[serde(rename = "type")]
kind: ToolCallKind,
function: ToolCallFunction,
}
#[must_use = "validated tool calls should be inspected or converted into results"]
pub struct ValidatedToolCall<'a, T: ToolDefinition> {
call: &'a ToolCall,
arguments: T::Arguments,
tool: PhantomData<T>,
}
impl<T: ToolDefinition> ValidatedToolCall<'_, T> {
#[must_use]
pub fn arguments(&self) -> &T::Arguments {
&self.arguments
}
#[must_use]
pub fn into_arguments(self) -> T::Arguments {
self.arguments
}
pub fn result(&self, result: &T::Output) -> Result<ChatMessage, ToolError> {
ChatMessage::tool_result::<T>(self.call, result)
}
#[must_use]
pub fn id(&self) -> &str {
self.call.id()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ToolCallKind {
Function,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct ToolCallFunction {
name: String,
arguments: String,
}
impl ToolCall {
#[must_use]
pub fn id(&self) -> &str {
&self.id.0
}
#[must_use]
pub fn name(&self) -> &str {
&self.function.name
}
pub fn validate<T: ToolDefinition>(&self) -> Result<ValidatedToolCall<'_, T>, ToolError> {
self.ensure_name::<T>()?;
let arguments = serde_json::from_str(&self.function.arguments).map_err(|source| {
ToolError::InvalidArguments {
tool: self.name().to_owned(),
source,
}
})?;
Ok(ValidatedToolCall {
call: self,
arguments,
tool: PhantomData,
})
}
pub fn arguments_for<T: ToolDefinition>(&self) -> Result<T::Arguments, ToolError> {
Ok(self.validate::<T>()?.into_arguments())
}
fn ensure_name<T: ToolDefinition>(&self) -> Result<(), ToolError> {
if self.name() != T::NAME {
return Err(ToolError::UnexpectedName {
expected: T::NAME,
actual: self.name().to_owned(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FinishReason {
Stop,
Length,
ToolCalls,
ContentFilter,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Usage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
}
impl Usage {
#[must_use]
pub fn prompt_tokens(&self) -> u64 {
self.prompt_tokens
}
#[must_use]
pub fn completion_tokens(&self) -> u64 {
self.completion_tokens
}
#[must_use]
pub fn total_tokens(&self) -> u64 {
self.total_tokens
}
}
#[cfg(test)]
mod tests {
use schemars::JsonSchema;
use serde::Deserialize;
use super::*;
struct GetWeather;
impl ToolDefinition for GetWeather {
type Arguments = WeatherArgs;
type Output = WeatherReport;
const NAME: &'static str = "get_weather";
const DESCRIPTION: &'static str = "Get the weather for a city";
}
#[derive(Debug, Deserialize, JsonSchema, PartialEq)]
struct WeatherArgs {
city: String,
}
#[derive(Serialize)]
struct WeatherReport {
temperature_celsius: i16,
}
fn tool_call(name: &str, arguments: &str) -> ToolCall {
ToolCall {
id: ToolCallId("call_1".into()),
kind: ToolCallKind::Function,
function: ToolCallFunction {
name: name.into(),
arguments: arguments.into(),
},
}
}
fn assistant_message(content: Option<&str>, tool_calls: Vec<ToolCall>) -> AssistantMessage {
AssistantMessage {
role: AssistantRole::Assistant,
content: content.map(ToOwned::to_owned),
tool_calls,
}
}
#[test]
fn serializes_required_function_tool_request() -> Result<(), Box<dyn std::error::Error>> {
let request = ChatRequest::kimi_k3(vec![ChatMessage::user("What is the weather?")])?
.with_tool(
FunctionTool::for_tool::<GetWeather>()?,
ToolChoice::Required,
);
let value = serde_json::to_value(request)?;
assert_eq!(value["model"], "moonshotai/kimi-k3");
assert_eq!(value["tool_choice"], "required");
assert_eq!(value["tools"][0]["type"], "function");
assert_eq!(value["tools"][0]["function"]["name"], "get_weather");
assert_eq!(
value["tools"][0]["function"]["parameters"]["type"],
"object"
);
Ok(())
}
#[test]
fn typestate_builder_preserves_the_existing_request_contract()
-> Result<(), Box<dyn std::error::Error>> {
let tool = FunctionTool::for_tool::<GetWeather>()?;
let legacy = ChatRequest::kimi_k3(vec![ChatMessage::user("What is the weather?")])?
.with_tool(tool.clone(), ToolChoice::Required);
let typestate = ChatRequest::kimi_k3_builder()
.tool(tool)
.tool_choice(ToolChoice::Required)
.message(ChatMessage::user("What is the weather?"))
.build();
assert_eq!(typestate, legacy);
Ok(())
}
#[test]
fn independent_builder_endomorphisms_commute() -> Result<(), Box<dyn std::error::Error>> {
let temperature = Temperature::new(0.4)?;
let max_tokens = MaxTokens::new(120)?;
let temperature_then_tokens = ChatRequest::kimi_k3_builder()
.temperature(temperature)
.max_tokens(max_tokens)
.message(ChatMessage::user("Explain composition"))
.build();
let tokens_then_temperature = ChatRequest::kimi_k3_builder()
.max_tokens(max_tokens)
.temperature(temperature)
.message(ChatMessage::user("Explain composition"))
.build();
assert_eq!(temperature_then_tokens, tokens_then_temperature);
Ok(())
}
#[test]
fn classifies_every_assistant_output_sum_variant() {
let call = tool_call("get_weather", r#"{"city":"Paris"}"#);
let text = assistant_message(Some("Clear skies"), Vec::new());
let tools = assistant_message(None, vec![call.clone()]);
let both = assistant_message(Some("Checking"), vec![call]);
let empty = assistant_message(None, Vec::new());
assert_eq!(text.output(), AssistantOutput::Text("Clear skies"));
assert!(matches!(
tools.output(),
AssistantOutput::ToolCalls(tool_calls) if tool_calls.len() == 1
));
assert!(matches!(
both.output(),
AssistantOutput::TextAndToolCalls {
text: "Checking",
tool_calls
} if tool_calls.len() == 1
));
assert_eq!(empty.output(), AssistantOutput::Empty);
}
#[test]
fn rejects_an_empty_message_list() {
assert!(matches!(
ChatRequest::kimi_k3(Vec::new()),
Err(InvalidConfiguration::EmptyMessages)
));
}
#[test]
fn rejects_an_empty_tool_definition_field() {
struct InvalidTool;
impl ToolDefinition for InvalidTool {
type Arguments = WeatherArgs;
type Output = WeatherReport;
const NAME: &'static str = "";
const DESCRIPTION: &'static str = "Description";
}
assert!(matches!(
FunctionTool::for_tool::<InvalidTool>(),
Err(ToolError::InvalidDefinition { field: "name" })
));
}
#[test]
fn assistant_tool_call_round_trip_preserves_null_content()
-> Result<(), Box<dyn std::error::Error>> {
let message = ChatMessage::assistant_tool_calls(vec![tool_call(
"get_weather",
r#"{"city":"Paris"}"#,
)]);
let value = serde_json::to_value(message)?;
assert!(value["content"].is_null());
assert_eq!(value["tool_calls"][0]["id"], "call_1");
Ok(())
}
#[test]
fn parses_typed_tool_arguments() -> Result<(), Box<dyn std::error::Error>> {
let call = tool_call("get_weather", r#"{"city":"Paris"}"#);
assert_eq!(
call.arguments_for::<GetWeather>()?,
WeatherArgs {
city: "Paris".into()
}
);
Ok(())
}
#[test]
fn validated_tool_call_carries_arguments_and_output_contract()
-> Result<(), Box<dyn std::error::Error>> {
let call = tool_call("get_weather", r#"{"city":"Paris"}"#);
let validated = call.validate::<GetWeather>()?;
assert_eq!(validated.id(), "call_1");
assert_eq!(validated.arguments().city, "Paris");
assert_eq!(
serde_json::to_value(validated.result(&WeatherReport {
temperature_celsius: 18,
})?)?,
serde_json::json!({
"role": "tool",
"tool_call_id": "call_1",
"content": "{\"temperature_celsius\":18}"
})
);
Ok(())
}
#[test]
fn rejects_arguments_for_a_different_typed_tool() {
struct OtherTool;
impl ToolDefinition for OtherTool {
type Arguments = WeatherArgs;
type Output = WeatherReport;
const NAME: &'static str = "other_tool";
const DESCRIPTION: &'static str = "A different tool";
}
let result = tool_call("get_weather", r#"{"city":"Paris"}"#).arguments_for::<OtherTool>();
assert!(matches!(result, Err(ToolError::UnexpectedName { .. })));
}
#[test]
fn rejects_malformed_tool_arguments_with_a_typed_error() {
assert!(matches!(
tool_call("get_weather", "not-json").arguments_for::<GetWeather>(),
Err(ToolError::InvalidArguments { .. })
));
}
#[test]
fn encodes_a_typed_tool_result_without_exposing_raw_json()
-> Result<(), Box<dyn std::error::Error>> {
let message = ChatMessage::tool_result::<GetWeather>(
&tool_call("get_weather", r#"{"city":"Paris"}"#),
&WeatherReport {
temperature_celsius: 18,
},
)?;
let encoded = serde_json::to_value(message)?;
assert_eq!(encoded["tool_call_id"], "call_1");
assert_eq!(encoded["content"], r#"{"temperature_celsius":18}"#);
Ok(())
}
#[test]
fn rejects_a_tool_result_for_a_different_contract() {
struct OtherTool;
impl ToolDefinition for OtherTool {
type Arguments = WeatherArgs;
type Output = WeatherReport;
const NAME: &'static str = "other_tool";
const DESCRIPTION: &'static str = "A different tool";
}
assert!(matches!(
ChatMessage::tool_result::<OtherTool>(
&tool_call("get_weather", r#"{"city":"Paris"}"#),
&WeatherReport {
temperature_celsius: 18,
},
),
Err(ToolError::UnexpectedName { .. })
));
}
#[test]
fn rejects_a_completion_without_choices() -> Result<(), Box<dyn std::error::Error>> {
let completion: ChatCompletion = serde_json::from_value(serde_json::json!({
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1,
"model": "moonshotai/kimi-k3",
"choices": [],
"usage": null
}))?;
assert!(matches!(
completion.first_choice(),
Err(Error::MissingChoice)
));
Ok(())
}
#[test]
fn rejects_an_unexpected_response_object() {
let result = serde_json::from_value::<ChatCompletion>(serde_json::json!({
"id": "chatcmpl-1",
"object": "unexpected",
"created": 1,
"model": "moonshotai/kimi-k3",
"choices": [],
"usage": null
}));
assert!(result.is_err());
}
#[test]
fn rejects_generation_values_outside_provider_contract() {
assert!(matches!(
Temperature::new(f32::NAN),
Err(InvalidConfiguration::InvalidTemperature { .. })
));
assert!(matches!(
Temperature::new(2.1),
Err(InvalidConfiguration::InvalidTemperature { .. })
));
assert!(matches!(
MaxTokens::new(0),
Err(InvalidConfiguration::ZeroMaxTokens)
));
}
}