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}