Skip to main content

edge_completions/
types.rs

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/// A validated provider model identifier, such as `moonshotai/kimi-k3`.
9///
10/// ```
11/// use edge_completions::ModelId;
12///
13/// let model = ModelId::new("moonshotai/kimi-k3")?;
14/// assert_eq!(model.as_str(), "moonshotai/kimi-k3");
15/// # Ok::<(), edge_completions::InvalidConfiguration>(())
16/// ```
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
18#[serde(transparent)]
19pub struct ModelId(String);
20
21impl ModelId {
22    /// Returns the supported Kimi K3 model identifier.
23    #[must_use]
24    pub fn kimi_k3() -> Self {
25        Self("moonshotai/kimi-k3".to_owned())
26    }
27
28    /// Validates and constructs a model identifier.
29    ///
30    /// Leading and trailing whitespace is removed before storage.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`InvalidConfiguration::EmptyModelId`] when the trimmed value is
35    /// empty.
36    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    /// Returns the validated provider model identifier.
46    #[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/// A typed chat message accepted by the provider request contract.
82///
83/// Use the role-specific constructors instead of constructing provider payloads
84/// directly:
85///
86/// ```
87/// use edge_completions::ChatMessage;
88///
89/// let message = ChatMessage::user("Explain trait boundaries.");
90/// assert_eq!(message, ChatMessage::user("Explain trait boundaries."));
91/// ```
92#[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    /// Creates a system instruction message.
118    #[must_use]
119    pub fn system(content: impl Into<String>) -> Self {
120        Self(MessagePayload::System {
121            content: content.into(),
122        })
123    }
124
125    /// Creates a user message.
126    #[must_use]
127    pub fn user(content: impl Into<String>) -> Self {
128        Self(MessagePayload::User {
129            content: content.into(),
130        })
131    }
132
133    /// Creates an assistant text message.
134    #[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    /// Replays provider-proposed tool calls in a follow-up request.
143    #[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    /// Encodes a typed tool result after confirming that the call name matches `T`.
152    ///
153    /// Prefer [`ValidatedToolCall::result`] after calling
154    /// [`ToolCall::validate`]. That path carries the successful name and
155    /// argument validation in the type system.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`ToolError::UnexpectedName`] when the proposed function name
160    /// does not match `T::NAME`, or [`ToolError::ResultEncoding`] when the typed
161    /// output cannot be serialized.
162    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
210/// A typed tool contract used for schema generation, argument decoding, and result encoding.
211///
212/// The associated types ensure that one tool name, one validated argument type,
213/// and one result type travel together through the SDK.
214///
215/// ```
216/// use edge_completions::ToolDefinition;
217/// use schemars::JsonSchema;
218/// use serde::{Deserialize, Serialize};
219///
220/// struct LookupWeather;
221///
222/// #[derive(Deserialize, JsonSchema)]
223/// struct Arguments { city: String }
224///
225/// #[derive(Serialize)]
226/// struct Report { temperature_celsius: i16 }
227///
228/// impl ToolDefinition for LookupWeather {
229///     type Arguments = Arguments;
230///     type Output = Report;
231///     const NAME: &'static str = "lookup_weather";
232///     const DESCRIPTION: &'static str = "Look up the weather for a city";
233/// }
234///
235/// assert_eq!(LookupWeather::NAME, "lookup_weather");
236/// ```
237pub trait ToolDefinition {
238    /// Validated application type decoded from model-produced JSON arguments.
239    type Arguments: serde::de::DeserializeOwned + JsonSchema;
240
241    /// Application type serialized into the tool-result message.
242    type Output: Serialize;
243
244    /// Stable function name exposed to the model.
245    const NAME: &'static str;
246
247    /// Clear function description exposed to the model.
248    const DESCRIPTION: &'static str;
249}
250
251/// A validated OpenAI-compatible function-tool definition.
252///
253/// ```
254/// # use edge_completions::{FunctionTool, ToolDefinition};
255/// # use schemars::JsonSchema;
256/// # use serde::{Deserialize, Serialize};
257/// # struct LookupWeather;
258/// # #[derive(Deserialize, JsonSchema)] struct Arguments { city: String }
259/// # #[derive(Serialize)] struct Report { temperature_celsius: i16 }
260/// # impl ToolDefinition for LookupWeather {
261/// #     type Arguments = Arguments;
262/// #     type Output = Report;
263/// #     const NAME: &'static str = "lookup_weather";
264/// #     const DESCRIPTION: &'static str = "Look up the weather for a city";
265/// # }
266/// let tool = FunctionTool::for_tool::<LookupWeather>()?;
267/// assert_eq!(tool.name(), "lookup_weather");
268/// # Ok::<(), edge_completions::ToolError>(())
269/// ```
270#[derive(Debug, Clone, PartialEq, Serialize)]
271pub struct FunctionTool {
272    #[serde(rename = "type")]
273    kind: FunctionToolKind,
274    function: FunctionDefinition,
275}
276
277impl FunctionTool {
278    /// Generates a function definition from the typed contract `T`.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`ToolError::InvalidDefinition`] when `T::NAME` or
283    /// `T::DESCRIPTION` is empty after trimming.
284    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    /// Returns the function name exposed by this definition.
304    #[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/// Sampling temperature constrained to the provider's documented `[0, 2]` range.
317///
318/// ```
319/// use edge_completions::Temperature;
320///
321/// let temperature = Temperature::new(0.2)?;
322/// assert_eq!(temperature.value(), 0.2);
323/// # Ok::<(), edge_completions::InvalidConfiguration>(())
324/// ```
325#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
326#[serde(transparent)]
327pub struct Temperature(f32);
328
329impl Temperature {
330    /// Creates a temperature in the inclusive range from zero to two.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`InvalidConfiguration::InvalidTemperature`] when `value` is not
335    /// finite or falls outside the inclusive range `0.0..=2.0`.
336    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    /// Returns the validated temperature.
345    #[must_use]
346    pub fn value(self) -> f32 {
347        self.0
348    }
349}
350
351/// A validated, non-zero completion-token limit.
352///
353/// ```
354/// use edge_completions::MaxTokens;
355///
356/// let limit = MaxTokens::new(256)?;
357/// assert_eq!(limit.value(), 256);
358/// # Ok::<(), edge_completions::InvalidConfiguration>(())
359/// ```
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
361#[serde(transparent)]
362pub struct MaxTokens(u32);
363
364impl MaxTokens {
365    /// Creates a non-zero completion-token limit.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`InvalidConfiguration::ZeroMaxTokens`] when `value` is zero.
370    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    /// Returns the validated token limit.
379    #[must_use]
380    pub fn value(self) -> u32 {
381        self.0
382    }
383}
384
385/// Provider setting for selecting function tools.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "lowercase")]
388#[non_exhaustive]
389pub enum ToolChoice {
390    /// Do not let the model propose a tool call.
391    None,
392    /// Let the model choose whether to propose a tool call.
393    Auto,
394    /// Require the model to propose a tool call.
395    Required,
396}
397
398/// Compile-time states used by [`ChatRequestBuilder`].
399///
400/// The traits are sealed: callers can name the states in generic APIs, but
401/// only this crate can define new legal request states.
402pub mod request_state {
403    mod sealed {
404        pub trait Sealed {}
405    }
406
407    /// A sealed witness for the message-cardinality state of a request builder.
408    pub trait MessageState: sealed::Sealed {}
409
410    /// A sealed witness for the tool-cardinality state of a request builder.
411    pub trait ToolState: sealed::Sealed {}
412
413    /// Witness that a request builder does not yet contain a message.
414    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
415    pub struct NeedsMessage;
416
417    /// Witness that a request builder contains at least one message.
418    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
419    pub struct HasMessages;
420
421    /// Witness that a request builder does not contain a tool definition.
422    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
423    pub struct WithoutTools;
424
425    /// Witness that a request builder contains at least one tool definition.
426    #[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/// A typestate builder that makes invalid request-construction sequences fail
443/// to compile.
444///
445/// `M` witnesses whether the builder has a message, and `T` witnesses whether
446/// it has a tool. Methods consume one state and return the next legal state.
447/// In categorical terms, the state types are objects and the available methods
448/// are composable morphisms; Rust's trait bounds reject illegal compositions.
449///
450/// # Example
451///
452/// ```
453/// use edge_completions::{ChatMessage, ChatRequest};
454///
455/// let request = ChatRequest::kimi_k3_builder()
456///     .message(ChatMessage::user("Explain typestate briefly."))
457///     .build();
458///
459/// assert_eq!(request.model().as_str(), "moonshotai/kimi-k3");
460/// ```
461///
462/// # Compile-time guarantees
463///
464/// A request without a message has no `build` method:
465///
466/// ```compile_fail
467/// use edge_completions::ChatRequest;
468///
469/// let _request = ChatRequest::kimi_k3_builder().build();
470/// ```
471///
472/// A tool choice cannot be selected before a tool exists:
473///
474/// ```compile_fail
475/// use edge_completions::{ChatRequest, ToolChoice};
476///
477/// let _builder = ChatRequest::kimi_k3_builder()
478///     .tool_choice(ToolChoice::Required);
479/// ```
480#[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    /// Sets a validated sampling temperature without changing either state witness.
506    pub fn temperature(mut self, temperature: Temperature) -> Self {
507        self.temperature = Some(temperature);
508        self
509    }
510
511    /// Sets a validated completion-token limit without changing either state witness.
512    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    /// Adds the first message and returns the [`HasMessages`] witness state.
520    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    /// Appends another message while preserving the [`HasMessages`] witness.
528    pub fn message(mut self, message: ChatMessage) -> Self {
529        self.messages.push(message);
530        self
531    }
532
533    /// Builds a request after the [`HasMessages`] witness proves that it is non-empty.
534    #[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    /// Adds the first tool and returns the [`WithTools`] witness state.
549    ///
550    /// Tool selection defaults to [`ToolChoice::Auto`] and can be changed only
551    /// after this transition.
552    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    /// Appends another tool while preserving the [`WithTools`] witness.
561    pub fn tool(mut self, tool: FunctionTool) -> Self {
562        self.tools.push(tool);
563        self
564    }
565
566    /// Selects the provider's tool-choice setting after at least one tool exists.
567    pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
568        self.tool_choice = Some(choice);
569        self
570    }
571}
572
573/// A validated, serializable chat-completion request.
574///
575/// Prefer [`ChatRequest::builder`] or [`ChatRequest::kimi_k3_builder`] for new
576/// code. Their state parameters make an empty request impossible to build.
577#[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    /// Starts a typestate builder for a validated model.
593    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    /// Starts a typestate builder for `moonshotai/kimi-k3`.
606    pub fn kimi_k3_builder() -> ChatRequestBuilder<NeedsMessage, WithoutTools> {
607        Self::builder(ModelId::kimi_k3())
608    }
609
610    /// Creates a request for `moonshotai/kimi-k3` with at least one message.
611    ///
612    /// This compatibility constructor performs the message-cardinality check at
613    /// runtime. New code can use [`ChatRequest::kimi_k3_builder`] to move the
614    /// same invariant to compile time.
615    ///
616    /// # Errors
617    ///
618    /// Returns [`InvalidConfiguration::EmptyMessages`] when `messages` is empty.
619    pub fn kimi_k3(messages: Vec<ChatMessage>) -> Result<Self, InvalidConfiguration> {
620        Self::new(ModelId::kimi_k3(), messages)
621    }
622
623    /// Creates a request for a validated model with at least one message.
624    ///
625    /// This compatibility constructor performs the message-cardinality check at
626    /// runtime. New code can use [`ChatRequest::builder`] instead.
627    ///
628    /// # Errors
629    ///
630    /// Returns [`InvalidConfiguration::EmptyMessages`] when `messages` is empty.
631    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    /// Enables one typed function tool using the selected tool choice.
646    #[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    /// Enables one or more typed function tools using the selected tool choice.
654    ///
655    /// This compatibility method validates tool cardinality at runtime. The
656    /// typestate builder makes [`ChatRequestBuilder::tool_choice`] available only
657    /// after at least one call to [`ChatRequestBuilder::tool`].
658    ///
659    /// # Errors
660    ///
661    /// Returns [`InvalidConfiguration::EmptyTools`] when `tools` is empty.
662    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    /// Sets a validated sampling temperature.
676    #[must_use]
677    pub fn with_temperature(mut self, temperature: Temperature) -> Self {
678        self.temperature = Some(temperature);
679        self
680    }
681
682    /// Sets a validated completion-token limit.
683    #[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    /// Returns the selected model.
690    #[must_use]
691    pub fn model(&self) -> &ModelId {
692        &self.model
693    }
694}
695
696/// A typed chat-completion response.
697#[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    /// Returns the provider completion identifier.
709    #[must_use]
710    pub fn id(&self) -> &str {
711        &self.id.0
712    }
713
714    /// Returns the model reported by the provider.
715    #[must_use]
716    pub fn model(&self) -> &ModelId {
717        &self.model
718    }
719
720    /// Returns the provider's Unix creation timestamp in seconds.
721    #[must_use]
722    pub fn created_unix_seconds(&self) -> u64 {
723        self.created
724    }
725
726    /// Returns all completion choices.
727    #[must_use]
728    pub fn choices(&self) -> &[ChatChoice] {
729        &self.choices
730    }
731
732    /// Returns the first choice or a typed missing-choice error.
733    ///
734    /// # Errors
735    ///
736    /// Returns [`Error::MissingChoice`] when the provider response contains no
737    /// choices.
738    pub fn first_choice(&self) -> Result<&ChatChoice, Error> {
739        self.choices.first().ok_or(Error::MissingChoice)
740    }
741
742    /// Returns token usage when the provider supplied it.
743    #[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/// One provider-generated completion choice.
769#[derive(Debug, Clone, PartialEq, Deserialize)]
770pub struct ChatChoice {
771    index: u32,
772    message: AssistantMessage,
773    finish_reason: Option<FinishReason>,
774}
775
776impl ChatChoice {
777    /// Returns this choice's provider index.
778    #[must_use]
779    pub fn index(&self) -> u32 {
780        self.index
781    }
782
783    /// Returns the assistant message.
784    #[must_use]
785    pub fn message(&self) -> &AssistantMessage {
786        &self.message
787    }
788
789    /// Returns the typed finish reason when supplied by the provider.
790    #[must_use]
791    pub fn finish_reason(&self) -> Option<&FinishReason> {
792        self.finish_reason.as_ref()
793    }
794}
795
796/// A typed assistant message returned by the provider.
797#[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/// Exhaustive alternatives carried by a typed assistant message.
806///
807/// This enum is a sum type (a coproduct): callers must select one branch for
808/// every supported provider outcome. `TextAndToolCalls` preserves providers
809/// that return both values rather than silently discarding either one.
810///
811/// ```
812/// use edge_completions::{AssistantMessage, AssistantOutput};
813///
814/// fn output_kind(message: &AssistantMessage) -> &'static str {
815///     match message.output() {
816///         AssistantOutput::Text(_) => "text",
817///         AssistantOutput::ToolCalls(_) => "tools",
818///         AssistantOutput::TextAndToolCalls { .. } => "text-and-tool-calls",
819///         AssistantOutput::Empty => "empty",
820///     }
821/// }
822/// ```
823#[must_use = "assistant output should be handled explicitly"]
824#[derive(Debug, Clone, Copy, PartialEq)]
825pub enum AssistantOutput<'a> {
826    /// The assistant returned final text without tool calls.
827    Text(&'a str),
828    /// The assistant proposed tool calls without text.
829    ToolCalls(&'a [ToolCall]),
830    /// The assistant returned text and proposed one or more tool calls.
831    TextAndToolCalls {
832        /// Provider-generated assistant text.
833        text: &'a str,
834        /// Untrusted tool calls that still require typed validation.
835        tool_calls: &'a [ToolCall],
836    },
837    /// The assistant returned neither text nor tool calls.
838    Empty,
839}
840
841impl AssistantMessage {
842    /// Classifies assistant output into an exhaustive typed alternative.
843    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    /// Returns text content when the assistant produced a final answer.
853    #[must_use]
854    pub fn content(&self) -> Option<&str> {
855        self.content.as_deref()
856    }
857
858    /// Returns all tool calls proposed by the model.
859    #[must_use]
860    pub fn tool_calls(&self) -> &[ToolCall] {
861        &self.tool_calls
862    }
863
864    /// Returns the first proposed tool call or a typed missing-call error.
865    ///
866    /// # Errors
867    ///
868    /// Returns [`Error::MissingToolCall`] when the assistant proposed no tool
869    /// calls.
870    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/// A function-tool call proposed by the model.
882#[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/// Proof that a model-produced tool call matched and decoded through `T`.
891///
892/// The witness binds the validated arguments and any encoded result to the
893/// same [`ToolDefinition`]. Construct it only through [`ToolCall::validate`].
894///
895/// A validated call gives application code typed arguments and accepts only the
896/// matching output type:
897///
898/// ```
899/// use edge_completions::{ChatMessage, ToolCall, ToolDefinition, ToolError};
900/// use schemars::JsonSchema;
901/// use serde::{Deserialize, Serialize};
902///
903/// struct LookupWeather;
904/// #[derive(Deserialize, JsonSchema)]
905/// struct Arguments { city: String }
906/// #[derive(Serialize)]
907/// struct Report { temperature_celsius: i16 }
908///
909/// impl ToolDefinition for LookupWeather {
910///     type Arguments = Arguments;
911///     type Output = Report;
912///     const NAME: &'static str = "lookup_weather";
913///     const DESCRIPTION: &'static str = "Look up weather";
914/// }
915///
916/// fn result_for(call: &ToolCall) -> Result<ChatMessage, ToolError> {
917///     let validated = call.validate::<LookupWeather>()?;
918///     assert!(!validated.arguments().city.is_empty());
919///     validated.result(&Report { temperature_celsius: 18 })
920/// }
921/// ```
922///
923/// Passing a different output type fails to compile:
924///
925/// ```compile_fail
926/// use edge_completions::{ToolCall, ToolDefinition, ToolError};
927/// use schemars::JsonSchema;
928/// use serde::{Deserialize, Serialize};
929///
930/// struct LookupWeather;
931/// #[derive(Deserialize, JsonSchema)]
932/// struct Arguments { city: String }
933/// #[derive(Serialize)]
934/// struct WeatherReport { temperature_celsius: i16 }
935///
936/// impl ToolDefinition for LookupWeather {
937///     type Arguments = Arguments;
938///     type Output = WeatherReport;
939///     const NAME: &'static str = "lookup_weather";
940///     const DESCRIPTION: &'static str = "Look up weather";
941/// }
942///
943/// fn invalid_result(call: &ToolCall) -> Result<(), ToolError> {
944///     let validated = call.validate::<LookupWeather>()?;
945///     validated.result(&String::from("wrong output type"))?;
946///     Ok(())
947/// }
948/// ```
949#[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    /// Returns the validated arguments associated with `T`.
958    #[must_use]
959    pub fn arguments(&self) -> &T::Arguments {
960        &self.arguments
961    }
962
963    /// Consumes the proof and returns its validated arguments.
964    #[must_use]
965    pub fn into_arguments(self) -> T::Arguments {
966        self.arguments
967    }
968
969    /// Encodes an output whose type is associated with the same tool proof.
970    ///
971    /// # Errors
972    ///
973    /// Returns [`ToolError::ResultEncoding`] when `result` cannot be serialized.
974    /// A name mismatch is unrepresentable here because this witness is created
975    /// only after [`ToolCall::validate`] succeeds for the same `T`.
976    pub fn result(&self, result: &T::Output) -> Result<ChatMessage, ToolError> {
977        ChatMessage::tool_result::<T>(self.call, result)
978    }
979
980    /// Returns the underlying provider tool-call identifier.
981    #[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    // The provider encodes JSON arguments inside a string.
997    arguments: String,
998}
999
1000impl ToolCall {
1001    /// Returns the provider's tool-call identifier.
1002    #[must_use]
1003    pub fn id(&self) -> &str {
1004        &self.id.0
1005    }
1006
1007    /// Returns the untrusted function name proposed by the model.
1008    #[must_use]
1009    pub fn name(&self) -> &str {
1010        &self.function.name
1011    }
1012
1013    /// Validates the proposed name and arguments, returning a proof bound to
1014    /// the matching tool contract.
1015    ///
1016    /// This is a runtime trust-boundary check: tool calls come from the model and
1017    /// cannot be proven valid at compile time. After validation, the returned
1018    /// witness carries the established relationship between `T::Arguments` and
1019    /// `T::Output`.
1020    ///
1021    /// # Errors
1022    ///
1023    /// Returns [`ToolError::UnexpectedName`] when the model proposed a different
1024    /// function, or [`ToolError::InvalidArguments`] when the arguments cannot be
1025    /// decoded into `T::Arguments`.
1026    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    /// Validates the proposed name and decodes arguments into `T::Arguments`.
1042    ///
1043    /// Prefer [`ToolCall::validate`] when the application also needs to encode a
1044    /// result. This compatibility helper consumes the proof and returns only the
1045    /// validated arguments.
1046    ///
1047    /// # Errors
1048    ///
1049    /// Returns [`ToolError::UnexpectedName`] when the model proposed a different
1050    /// function, or [`ToolError::InvalidArguments`] when the arguments cannot be
1051    /// decoded into `T::Arguments`.
1052    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/// Why the provider stopped generating a completion.
1068#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
1069#[serde(rename_all = "snake_case")]
1070#[non_exhaustive]
1071pub enum FinishReason {
1072    /// The model reached a natural stopping point.
1073    Stop,
1074    /// The configured or provider token limit was reached.
1075    Length,
1076    /// The model proposed one or more tool calls.
1077    ToolCalls,
1078    /// The provider filtered generated content.
1079    ContentFilter,
1080    /// The provider returned a newer finish reason not yet modeled by this crate.
1081    #[serde(other)]
1082    Unknown,
1083}
1084
1085/// Token accounting returned by the provider.
1086#[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    /// Returns the number of input tokens reported by the provider.
1095    #[must_use]
1096    pub fn prompt_tokens(&self) -> u64 {
1097        self.prompt_tokens
1098    }
1099
1100    /// Returns the number of generated tokens reported by the provider.
1101    #[must_use]
1102    pub fn completion_tokens(&self) -> u64 {
1103        self.completion_tokens
1104    }
1105
1106    /// Returns the total token count reported by the provider.
1107    #[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}