Skip to main content

klieo_core/
llm.rs

1//! LLM client trait and request/response types.
2//!
3//! Providers implement [`LlmClient`]. The runtime never depends on a
4//! specific provider crate — selection is explicit at app startup.
5
6/// Re-exported so the error this module's traits raise is reachable from the
7/// module that raises it. `LlmError` is defined in [`crate::error`]; without
8/// this, `klieo_core::llm::LlmError` — the path a caller writing against
9/// [`LlmClient`] naturally reaches for — does not resolve.
10pub use crate::error::LlmError;
11use async_trait::async_trait;
12use futures_core::Stream;
13use serde::{Deserialize, Serialize};
14use std::pin::Pin;
15use std::time::Duration;
16
17/// One message in a chat conversation.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Message {
20    /// Speaker role.
21    pub role: Role,
22    /// Body text. May be empty when `tool_calls` carries the payload.
23    pub content: String,
24    /// Tool calls the assistant requested in this message.
25    #[serde(default)]
26    pub tool_calls: Vec<ToolCall>,
27    /// When this message answers a tool call, the id of that call.
28    #[serde(default)]
29    pub tool_call_id: Option<String>,
30}
31
32impl Message {
33    /// [`Role::System`] message; `tool_calls` is empty and `tool_call_id` is `None`.
34    pub fn system(content: impl Into<String>) -> Self {
35        Self::text(Role::System, content)
36    }
37
38    /// [`Role::User`] message; `tool_calls` is empty and `tool_call_id` is `None`.
39    pub fn user(content: impl Into<String>) -> Self {
40        Self::text(Role::User, content)
41    }
42
43    /// [`Role::Assistant`] message; `tool_calls` is empty and `tool_call_id` is `None`.
44    pub fn assistant(content: impl Into<String>) -> Self {
45        Self::text(Role::Assistant, content)
46    }
47
48    fn text(role: Role, content: impl Into<String>) -> Self {
49        Self {
50            role,
51            content: content.into(),
52            tool_calls: Vec::new(),
53            tool_call_id: None,
54        }
55    }
56}
57
58/// Speaker role.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61#[non_exhaustive]
62pub enum Role {
63    /// System prompt.
64    System,
65    /// User-supplied input.
66    User,
67    /// Model output.
68    Assistant,
69    /// Tool call result fed back into the conversation.
70    Tool,
71}
72
73/// One tool call requested by the assistant.
74///
75/// Marked `#[non_exhaustive]` — future field additions are
76/// additive (no SemVer-major bump required). Code outside `klieo-core`
77/// that constructs a `ToolCall` must use [`ToolCall::new`] instead of
78/// a struct literal; pattern matching must use the `..` rest pattern.
79/// In-crate construction is unaffected.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[non_exhaustive]
82pub struct ToolCall {
83    /// Provider-stable id; echoed back in the matching tool response.
84    pub id: String,
85    /// Tool name.
86    pub name: String,
87    /// JSON arguments.
88    pub args: serde_json::Value,
89}
90
91impl ToolCall {
92    /// Prefer this over struct literals outside `klieo-core` —
93    /// `#[non_exhaustive]` forbids external struct-literal construction so that
94    /// future field additions remain additive.
95    pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
96        Self {
97            id: id.into(),
98            name: name.into(),
99            args,
100        }
101    }
102}
103
104/// Tool catalogue entry shown to the LLM.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[non_exhaustive]
107pub struct ToolDef {
108    /// Tool name (must be unique within the catalogue).
109    pub name: String,
110    /// Human-readable description for the LLM.
111    pub description: String,
112    /// JSON-schema for arguments.
113    pub json_schema: serde_json::Value,
114}
115
116impl ToolDef {
117    /// One entry in the tool catalogue offered to the model.
118    ///
119    /// Prefer this constructor over struct literals in code outside
120    /// `klieo-core` — `#[non_exhaustive]` forbids external struct-literal
121    /// construction so that future field additions remain additive.
122    pub fn new(
123        name: impl Into<String>,
124        description: impl Into<String>,
125        json_schema: serde_json::Value,
126    ) -> Self {
127        Self {
128            name: name.into(),
129            description: description.into(),
130            json_schema,
131        }
132    }
133}
134
135/// Chat completion request.
136#[derive(Debug, Clone)]
137pub struct ChatRequest {
138    /// Conversation history.
139    pub messages: Vec<Message>,
140    /// Tools available for the model to call.
141    pub tools: Vec<ToolDef>,
142    /// Sampling temperature.
143    pub temperature: Option<f32>,
144    /// Maximum response tokens.
145    pub max_tokens: Option<u32>,
146    /// Output format hint.
147    pub response_format: ResponseFormat,
148    /// Stop sequences.
149    pub stop: Vec<String>,
150    /// Per-request deadline. Provider should abort if exceeded.
151    pub timeout: Option<Duration>,
152}
153
154impl ChatRequest {
155    /// Build a request with the supplied messages and no tools.
156    pub fn new(messages: Vec<Message>) -> Self {
157        Self {
158            messages,
159            tools: Vec::new(),
160            temperature: None,
161            max_tokens: None,
162            response_format: ResponseFormat::Text,
163            stop: Vec::new(),
164            timeout: None,
165        }
166    }
167
168    /// Start building a request; messages are sent in the order they are added.
169    ///
170    /// ```
171    /// use klieo_core::llm::ChatRequest;
172    /// let req = ChatRequest::builder().system("be terse").user("hi").build();
173    /// assert_eq!(req.messages.len(), 2);
174    /// ```
175    pub fn builder() -> ChatRequestBuilder {
176        ChatRequestBuilder::default()
177    }
178}
179
180/// Builder for [`ChatRequest`]: messages accumulate in call order, and any
181/// option left unset falls back to the [`ChatRequest::new`] default on `build`.
182#[derive(Default)]
183pub struct ChatRequestBuilder {
184    messages: Vec<Message>,
185    tools: Vec<ToolDef>,
186    temperature: Option<f32>,
187    max_tokens: Option<u32>,
188    response_format: Option<ResponseFormat>,
189    timeout: Option<Duration>,
190}
191
192impl ChatRequestBuilder {
193    /// Appends a [`Role::System`] message after any already added.
194    pub fn system(mut self, content: impl Into<String>) -> Self {
195        self.messages.push(Message::system(content));
196        self
197    }
198
199    /// Appends a [`Role::User`] message after any already added.
200    pub fn user(mut self, content: impl Into<String>) -> Self {
201        self.messages.push(Message::user(content));
202        self
203    }
204
205    /// Appends a [`Role::Assistant`] message after any already added.
206    pub fn assistant(mut self, content: impl Into<String>) -> Self {
207        self.messages.push(Message::assistant(content));
208        self
209    }
210
211    /// Appends a pre-built message — the only way to add a tool-result turn.
212    pub fn message(mut self, message: Message) -> Self {
213        self.messages.push(message);
214        self
215    }
216
217    /// Replaces the tool catalogue; empty (no tools) when never called.
218    pub fn tools(mut self, tools: Vec<ToolDef>) -> Self {
219        self.tools = tools;
220        self
221    }
222
223    /// Sampling temperature; left to the provider default when never called.
224    pub fn temperature(mut self, temperature: f32) -> Self {
225        self.temperature = Some(temperature);
226        self
227    }
228
229    /// Response-token cap; provider default when never called.
230    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
231        self.max_tokens = Some(max_tokens);
232        self
233    }
234
235    /// Output format; falls back to [`ResponseFormat::Text`] when never called.
236    pub fn response_format(mut self, response_format: ResponseFormat) -> Self {
237        self.response_format = Some(response_format);
238        self
239    }
240
241    /// Per-request deadline; none (provider transport governs) when never called.
242    pub fn timeout(mut self, timeout: Duration) -> Self {
243        self.timeout = Some(timeout);
244        self
245    }
246
247    /// Builds the request; the `stop` list is always empty and other unset
248    /// options take their [`ChatRequest::new`] defaults.
249    pub fn build(self) -> ChatRequest {
250        ChatRequest {
251            messages: self.messages,
252            tools: self.tools,
253            temperature: self.temperature,
254            max_tokens: self.max_tokens,
255            response_format: self.response_format.unwrap_or(ResponseFormat::Text),
256            stop: Vec::new(),
257            timeout: self.timeout,
258        }
259    }
260}
261
262/// Output format hint passed to the provider.
263#[derive(Debug, Clone)]
264#[non_exhaustive]
265pub enum ResponseFormat {
266    /// Plain text.
267    Text,
268    /// Best-effort JSON output.
269    Json {
270        /// Schema describing expected JSON shape.
271        schema: serde_json::Value,
272    },
273    /// Strict structured output validated against the schema.
274    StructuredOutput {
275        /// Schema describing expected shape.
276        schema: serde_json::Value,
277    },
278}
279
280/// Chat completion response.
281///
282/// Marked `#[non_exhaustive]` — future field additions are additive.
283/// All construction outside `klieo-core` must use [`ChatResponse::new`].
284#[derive(Debug, Clone)]
285#[non_exhaustive]
286pub struct ChatResponse {
287    /// Assistant message returned by the provider.
288    pub message: Message,
289    /// Token usage.
290    pub usage: Usage,
291    /// Why the provider stopped generating.
292    pub finish_reason: FinishReason,
293    /// Model that served this response — provider-reported when available
294    /// (Ollama), else the client-configured model the request used. Recorded
295    /// into `Capture.model_version` for reproducibility; best-effort metadata,
296    /// not an authorization or correctness input.
297    pub model: String,
298}
299
300impl ChatResponse {
301    /// Provider response for one completion.
302    ///
303    /// `model` is the provider-reported model name when the response body
304    /// carries one (e.g. Ollama), or the configured model used for the
305    /// request otherwise.
306    pub fn new(
307        message: Message,
308        usage: Usage,
309        finish_reason: FinishReason,
310        model: impl Into<String>,
311    ) -> Self {
312        Self {
313            message,
314            usage,
315            finish_reason,
316            model: model.into(),
317        }
318    }
319}
320
321/// Token usage report.
322///
323/// Marked `#[non_exhaustive]` — future field additions are
324/// additive (no SemVer-major bump required). Code outside `klieo-core`
325/// that constructs a `Usage` must use [`Usage::new`] instead of
326/// a struct literal; pattern matching must use the `..` rest pattern.
327/// In-crate construction is unaffected.
328#[derive(Debug, Clone, Default, Serialize, Deserialize)]
329#[non_exhaustive]
330pub struct Usage {
331    /// Prompt tokens. For providers with prompt caching (e.g. Anthropic) this
332    /// is the **uncached** prompt count; cached tokens are reported separately
333    /// in [`Usage::cache_read_tokens`] / [`Usage::cache_creation_tokens`].
334    pub prompt_tokens: u32,
335    /// Completion tokens. Includes reasoning/thinking tokens where the provider
336    /// bills them as output (e.g. Gemini `thoughtsTokenCount`).
337    pub completion_tokens: u32,
338    /// Prompt tokens served from the provider's cache. Billed at a reduced tier
339    /// (Anthropic ~0.1x the input rate); 0 when caching is absent or unused.
340    /// `#[serde(default)]` keeps pre-cache `Usage` JSON deserializable.
341    #[serde(default)]
342    pub cache_read_tokens: u32,
343    /// Prompt tokens written to the provider's cache. Billed at a premium tier
344    /// (Anthropic ~1.25x the input rate); 0 when caching is absent or unused.
345    #[serde(default)]
346    pub cache_creation_tokens: u32,
347}
348
349impl Usage {
350    /// Prefer this over struct literals outside `klieo-core` —
351    /// `#[non_exhaustive]` forbids external struct-literal construction so that
352    /// future field additions remain additive. Cache token counts default to 0;
353    /// set them with [`Usage::with_cache_tokens`].
354    pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
355        Self {
356            prompt_tokens,
357            completion_tokens,
358            cache_read_tokens: 0,
359            cache_creation_tokens: 0,
360        }
361    }
362
363    /// Attach prompt-cache token counts (read = served from cache, creation =
364    /// written to cache). Both bill at provider-specific tiers distinct from the
365    /// base prompt rate, so they are tracked apart for correct cost pricing.
366    #[must_use]
367    pub fn with_cache_tokens(mut self, cache_read_tokens: u32, cache_creation_tokens: u32) -> Self {
368        self.cache_read_tokens = cache_read_tokens;
369        self.cache_creation_tokens = cache_creation_tokens;
370        self
371    }
372}
373
374/// Reason the provider stopped generating.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376#[non_exhaustive]
377pub enum FinishReason {
378    /// Model emitted a stop token or end-of-turn.
379    Stop,
380    /// Model emitted tool calls; runtime must dispatch them.
381    ToolCalls,
382    /// Hit `max_tokens`.
383    Length,
384    /// Provider applied a content filter.
385    ContentFilter,
386    /// Provider error mid-stream.
387    Error,
388}
389
390/// Provider capability declaration.
391#[derive(Debug, Clone, Default)]
392pub struct Capabilities {
393    /// Supports tool calls.
394    pub tool_calling: bool,
395    /// Supports streaming responses.
396    pub streaming: bool,
397    /// Supports schema-validated structured output.
398    pub structured_output: bool,
399    /// Supports embeddings.
400    pub embeddings: bool,
401    /// Maximum context window in tokens.
402    pub max_context_tokens: u32,
403    /// Supports vision input. (Not used in foundation MVP.)
404    pub vision: bool,
405}
406
407impl Capabilities {
408    /// Start a fluent builder. Equivalent to `Capabilities::default()`
409    /// followed by chained setters.
410    ///
411    /// ```
412    /// use klieo_core::Capabilities;
413    /// let caps = Capabilities::builder()
414    ///     .tool_calling(true)
415    ///     .streaming(true)
416    ///     .max_context_tokens(8000)
417    ///     .build();
418    /// assert!(caps.tool_calling);
419    /// assert!(caps.streaming);
420    /// assert_eq!(caps.max_context_tokens, 8000);
421    /// ```
422    pub fn builder() -> CapabilitiesBuilder {
423        CapabilitiesBuilder(Capabilities::default())
424    }
425}
426
427/// Fluent builder for [`Capabilities`]. Build via [`Capabilities::builder`].
428#[derive(Debug, Clone, Default)]
429pub struct CapabilitiesBuilder(Capabilities);
430
431impl CapabilitiesBuilder {
432    /// Set the `tool_calling` flag.
433    pub fn tool_calling(mut self, v: bool) -> Self {
434        self.0.tool_calling = v;
435        self
436    }
437    /// Set the `streaming` flag.
438    pub fn streaming(mut self, v: bool) -> Self {
439        self.0.streaming = v;
440        self
441    }
442    /// Set the `structured_output` flag.
443    pub fn structured_output(mut self, v: bool) -> Self {
444        self.0.structured_output = v;
445        self
446    }
447    /// Set the `embeddings` flag.
448    pub fn embeddings(mut self, v: bool) -> Self {
449        self.0.embeddings = v;
450        self
451    }
452    /// Set the maximum context window in tokens.
453    pub fn max_context_tokens(mut self, v: u32) -> Self {
454        self.0.max_context_tokens = v;
455        self
456    }
457    /// Set the `vision` flag.
458    pub fn vision(mut self, v: bool) -> Self {
459        self.0.vision = v;
460        self
461    }
462    /// Consume the builder and return the configured [`Capabilities`].
463    pub fn build(self) -> Capabilities {
464        self.0
465    }
466}
467
468/// One chunk of a streaming response.
469///
470/// Marked `#[non_exhaustive]` — future field additions are
471/// additive (no SemVer-major bump required). Code outside `klieo-core`
472/// that constructs a `ChatChunk` must use [`ChatChunk::new`] instead of
473/// a struct literal; pattern matching must use the `..` rest pattern.
474/// In-crate construction is unaffected.
475#[derive(Debug, Clone, Default)]
476#[non_exhaustive]
477pub struct ChatChunk {
478    /// Incremental content delta.
479    pub delta: String,
480    /// Tool calls emitted in this chunk (rare; usually only at end).
481    pub tool_calls: Vec<ToolCall>,
482    /// `Some` once the provider signals completion.
483    pub finish_reason: Option<FinishReason>,
484    /// Token usage. `Some` only on the final chunk for providers
485    /// that surface usage in stream mode; `None` otherwise.
486    /// Consumers should treat absence as "unknown" rather than zero.
487    pub usage: Option<Usage>,
488}
489
490impl ChatChunk {
491    /// Construct a [`ChatChunk`] from its constituent fields.
492    ///
493    /// Prefer this constructor over struct literals in code outside
494    /// `klieo-core` — `#[non_exhaustive]` forbids external struct-literal
495    /// construction so that future field additions remain additive.
496    pub fn new(
497        delta: String,
498        tool_calls: Vec<ToolCall>,
499        finish_reason: Option<FinishReason>,
500        usage: Option<Usage>,
501    ) -> Self {
502        Self {
503            delta,
504            tool_calls,
505            finish_reason,
506            usage,
507        }
508    }
509}
510
511/// Streaming response handle.
512pub type ChunkStream = Pin<Box<dyn Stream<Item = Result<ChatChunk, LlmError>> + Send + 'static>>;
513
514/// Vector embedding for one input text.
515pub type Embedding = Vec<f32>;
516
517/// LLM provider trait.
518///
519/// Implementors live in their own crates (`klieo-llm-ollama`, etc.).
520/// `Capabilities` are inspected by the runtime before it sends an
521/// unsupported request.
522///
523/// ```
524/// # tokio_test::block_on(async {
525/// use klieo_core::test_utils::{FakeLlmClient, FakeLlmStep};
526/// use klieo_core::{ChatRequest, FinishReason, LlmClient};
527/// let llm = FakeLlmClient::new("fake")
528///     .with_steps(vec![FakeLlmStep::Text("hello".into())]);
529/// assert_eq!(llm.name(), "fake");
530/// assert!(llm.capabilities().tool_calling);
531/// let resp = llm.complete(ChatRequest::new(vec![])).await.unwrap();
532/// assert_eq!(resp.message.content, "hello");
533/// assert_eq!(resp.finish_reason, FinishReason::Stop);
534/// # });
535/// ```
536#[async_trait]
537pub trait LlmClient: Send + Sync {
538    /// Stable identifier for this client (e.g. `"ollama:qwen2.5:14b"`).
539    fn name(&self) -> &str;
540
541    /// Capabilities declared by this client.
542    fn capabilities(&self) -> &Capabilities;
543
544    /// One-shot completion.
545    ///
546    /// ```
547    /// # tokio_test::block_on(async {
548    /// use klieo_core::test_utils::{FakeLlmClient, FakeLlmStep};
549    /// use klieo_core::{ChatRequest, FinishReason, LlmClient};
550    /// let llm = FakeLlmClient::new("fake")
551    ///     .with_steps(vec![FakeLlmStep::Text("hello".into())]);
552    /// let resp = llm.complete(ChatRequest::new(vec![])).await.unwrap();
553    /// assert_eq!(resp.message.content, "hello");
554    /// assert_eq!(resp.finish_reason, FinishReason::Stop);
555    /// # });
556    /// ```
557    async fn complete(&self, req: ChatRequest) -> Result<ChatResponse, LlmError>;
558
559    /// Streaming completion.
560    ///
561    /// ```
562    /// # tokio_test::block_on(async {
563    /// use klieo_core::test_utils::FakeLlmClient;
564    /// use klieo_core::{ChatRequest, LlmClient, LlmError};
565    /// let llm = FakeLlmClient::new("fake");
566    /// match llm.stream(ChatRequest::new(vec![])).await {
567    ///     Ok(_) => panic!("expected Unsupported"),
568    ///     Err(e) => assert!(matches!(e, LlmError::Unsupported(_))),
569    /// }
570    /// # });
571    /// ```
572    async fn stream(&self, req: ChatRequest) -> Result<ChunkStream, LlmError>;
573
574    /// Compute embeddings for the supplied texts.
575    ///
576    /// ```
577    /// # tokio_test::block_on(async {
578    /// use klieo_core::test_utils::FakeLlmClient;
579    /// use klieo_core::{LlmClient, LlmError};
580    /// let llm = FakeLlmClient::new("fake");
581    /// let err = llm.embed(&["hello".into()]).await.unwrap_err();
582    /// assert!(matches!(err, LlmError::Unsupported(_)));
583    /// # });
584    /// ```
585    async fn embed(&self, texts: &[String]) -> Result<Vec<Embedding>, LlmError>;
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    /// Compile-time check that LlmClient is dyn-compatible.
593    #[allow(dead_code)]
594    fn _assert_dyn_compatible(_: &dyn LlmClient) {}
595
596    #[test]
597    fn chat_request_default_has_no_tools() {
598        let req = ChatRequest::new(vec![]);
599        assert!(req.tools.is_empty());
600        assert!(matches!(req.response_format, ResponseFormat::Text));
601    }
602
603    #[test]
604    fn message_constructors_set_role_and_clear_tool_fields() {
605        let m = Message::system("be terse");
606        assert_eq!(m.role, Role::System);
607        assert_eq!(m.content, "be terse");
608        assert!(m.tool_calls.is_empty());
609        assert!(m.tool_call_id.is_none());
610        let user = Message::user("hi");
611        assert_eq!((user.role, user.content.as_str()), (Role::User, "hi"));
612        let assistant = Message::assistant("ok");
613        assert_eq!(
614            (assistant.role, assistant.content.as_str()),
615            (Role::Assistant, "ok")
616        );
617    }
618
619    #[test]
620    fn chat_request_builder_sets_every_option_and_appends_raw_messages() {
621        let timeout = Duration::from_secs(7);
622        let schema = serde_json::json!({"type": "object"});
623        let req = ChatRequest::builder()
624            .assistant("a")
625            .message(Message::user("raw"))
626            .tools(vec![ToolDef::new("t", "desc", serde_json::json!({}))])
627            .max_tokens(42)
628            .response_format(ResponseFormat::Json {
629                schema: schema.clone(),
630            })
631            .timeout(timeout)
632            .build();
633        let roles: Vec<Role> = req.messages.iter().map(|m| m.role).collect();
634        assert_eq!(roles, vec![Role::Assistant, Role::User]);
635        assert_eq!(req.messages[1].content, "raw");
636        assert_eq!(req.tools.len(), 1);
637        assert_eq!(req.max_tokens, Some(42));
638        assert_eq!(req.timeout, Some(timeout));
639        assert!(matches!(req.response_format, ResponseFormat::Json { .. }));
640    }
641
642    #[test]
643    fn chat_request_builder_orders_messages_and_keeps_defaults() {
644        let req = ChatRequest::builder()
645            .system("sys")
646            .user("u")
647            .temperature(0.5)
648            .build();
649        let roles: Vec<Role> = req.messages.iter().map(|m| m.role).collect();
650        assert_eq!(roles, vec![Role::System, Role::User]);
651        assert_eq!(req.temperature, Some(0.5));
652        assert!(req.tools.is_empty());
653        assert!(matches!(req.response_format, ResponseFormat::Text));
654    }
655
656    #[test]
657    fn capabilities_builder_default_matches_struct_default() {
658        let built = Capabilities::builder().build();
659        let direct = Capabilities::default();
660        assert_eq!(built.tool_calling, direct.tool_calling);
661        assert_eq!(built.streaming, direct.streaming);
662        assert_eq!(built.structured_output, direct.structured_output);
663        assert_eq!(built.embeddings, direct.embeddings);
664        assert_eq!(built.max_context_tokens, direct.max_context_tokens);
665        assert_eq!(built.vision, direct.vision);
666    }
667
668    #[test]
669    fn capabilities_builder_sets_tool_calling() {
670        let c = Capabilities::builder().tool_calling(true).build();
671        assert!(c.tool_calling);
672    }
673
674    #[test]
675    fn capabilities_builder_sets_streaming() {
676        let c = Capabilities::builder().streaming(true).build();
677        assert!(c.streaming);
678    }
679
680    #[test]
681    fn capabilities_builder_sets_structured_output() {
682        let c = Capabilities::builder().structured_output(true).build();
683        assert!(c.structured_output);
684    }
685
686    #[test]
687    fn capabilities_builder_sets_embeddings() {
688        let c = Capabilities::builder().embeddings(true).build();
689        assert!(c.embeddings);
690    }
691
692    #[test]
693    fn capabilities_builder_sets_max_context_tokens() {
694        let c = Capabilities::builder().max_context_tokens(8000).build();
695        assert_eq!(c.max_context_tokens, 8000);
696    }
697
698    #[test]
699    fn capabilities_builder_sets_vision() {
700        let c = Capabilities::builder().vision(true).build();
701        assert!(c.vision);
702    }
703
704    #[test]
705    fn capabilities_builder_chains_all_setters() {
706        let c = Capabilities::builder()
707            .tool_calling(true)
708            .streaming(true)
709            .structured_output(true)
710            .embeddings(true)
711            .max_context_tokens(32_000)
712            .vision(false)
713            .build();
714        assert!(c.tool_calling && c.streaming && c.structured_output && c.embeddings);
715        assert_eq!(c.max_context_tokens, 32_000);
716        assert!(!c.vision);
717    }
718
719    #[test]
720    fn chat_chunk_usage_defaults_to_none_in_struct_literal() {
721        let chunk = ChatChunk {
722            delta: String::new(),
723            tool_calls: vec![],
724            finish_reason: None,
725            usage: None,
726        };
727        assert!(chunk.usage.is_none());
728    }
729
730    #[test]
731    fn chat_chunk_with_usage_round_trips() {
732        let chunk = ChatChunk {
733            delta: "done".into(),
734            tool_calls: vec![],
735            finish_reason: Some(FinishReason::Stop),
736            usage: Some(Usage::new(10, 32)),
737        };
738        let u = chunk.usage.as_ref().expect("usage set");
739        assert_eq!(u.prompt_tokens, 10);
740        assert_eq!(u.completion_tokens, 32);
741    }
742
743    #[test]
744    fn usage_new_sets_token_counts() {
745        let u = Usage::new(12, 34);
746        assert_eq!(u.prompt_tokens, 12);
747        assert_eq!(u.completion_tokens, 34);
748    }
749
750    #[test]
751    fn usage_cache_tokens_default_to_zero() {
752        let u = Usage::new(12, 34);
753        assert_eq!(u.cache_read_tokens, 0);
754        assert_eq!(u.cache_creation_tokens, 0);
755    }
756
757    #[test]
758    fn usage_deserializes_legacy_json_without_cache_fields() {
759        // Pre-cache `Usage` JSON lacks the cache fields; `#[serde(default)]`
760        // must keep it deserializable (cache counts read back as 0).
761        let legacy = r#"{"prompt_tokens":10,"completion_tokens":5}"#;
762        let u: Usage = serde_json::from_str(legacy).unwrap();
763        assert_eq!(u.cache_read_tokens, 0);
764        assert_eq!(u.cache_creation_tokens, 0);
765    }
766
767    #[test]
768    fn usage_with_cache_tokens_sets_cache_fields() {
769        // Cache tokens are billed at provider-specific tiers (Anthropic reads
770        // ~0.1x input, creation ~1.25x), so they are tracked apart from the
771        // base prompt/completion counts for correct cost pricing.
772        let u = Usage::new(12, 34).with_cache_tokens(100, 8);
773        assert_eq!(u.prompt_tokens, 12);
774        assert_eq!(u.completion_tokens, 34);
775        assert_eq!(u.cache_read_tokens, 100);
776        assert_eq!(u.cache_creation_tokens, 8);
777    }
778
779    #[test]
780    fn tooldef_new_sets_fields() {
781        let d = ToolDef::new("echo", "echoes input", serde_json::json!({"type":"object"}));
782        assert_eq!(d.name, "echo");
783        assert_eq!(d.description, "echoes input");
784        assert_eq!(d.json_schema, serde_json::json!({"type":"object"}));
785    }
786
787    #[test]
788    fn toolcall_new_sets_fields() {
789        let c = ToolCall::new("id-1", "search", serde_json::json!({"q":"x"}));
790        assert_eq!(c.id, "id-1");
791        assert_eq!(c.name, "search");
792        assert_eq!(c.args, serde_json::json!({"q":"x"}));
793    }
794
795    #[test]
796    fn chatresponse_new_carries_model() {
797        let msg = Message {
798            role: Role::Assistant,
799            content: "hi".into(),
800            tool_calls: vec![],
801            tool_call_id: None,
802        };
803        let r = ChatResponse::new(
804            msg,
805            Usage::new(1, 2),
806            FinishReason::Stop,
807            "anthropic:claude-sonnet-4-6",
808        );
809        assert_eq!(r.model, "anthropic:claude-sonnet-4-6");
810        assert_eq!(r.finish_reason, FinishReason::Stop);
811        assert_eq!(r.message.content, "hi");
812        assert_eq!(r.usage.prompt_tokens, 1);
813    }
814}