Skip to main content

llm_kernel/llm/
types.rs

1//! Core types for the LLM client module.
2#![deny(missing_docs)]
3
4use std::fmt;
5use std::pin::Pin;
6
7use serde::{Deserialize, Serialize};
8
9/// Role of a message sender in a chat conversation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum MessageRole {
13    /// System-level instruction message.
14    System,
15    /// User input message.
16    User,
17    /// Assistant response message.
18    Assistant,
19    /// Tool/function result message.
20    Tool,
21}
22
23impl fmt::Display for MessageRole {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::System => write!(f, "system"),
27            Self::User => write!(f, "user"),
28            Self::Assistant => write!(f, "assistant"),
29            Self::Tool => write!(f, "tool"),
30        }
31    }
32}
33
34/// A single content part in a multimodal chat message.
35///
36/// Supports text, image URLs, and base64-encoded images.
37/// Single-text messages serialize as a plain string for backward compatibility
38/// with OpenAI and Anthropic APIs.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum ContentPart {
42    /// Plain text content.
43    Text {
44        /// The text string.
45        text: String,
46    },
47    /// Image specified by URL.
48    ImageUrl {
49        /// URL pointing to the image.
50        url: String,
51    },
52    /// Image specified as base64-encoded data.
53    ImageBase64 {
54        /// MIME type (e.g. `"image/png"`).
55        media_type: String,
56        /// Base64-encoded image data.
57        data: String,
58    },
59}
60
61impl ContentPart {
62    /// Create a text content part.
63    pub fn text(s: impl Into<String>) -> Self {
64        Self::Text { text: s.into() }
65    }
66
67    /// Create an image URL content part.
68    pub fn image_url(url: impl Into<String>) -> Self {
69        Self::ImageUrl { url: url.into() }
70    }
71
72    /// Extract text content, if this is a text part.
73    pub fn as_text(&self) -> Option<&str> {
74        match self {
75            Self::Text { text } => Some(text),
76            _ => None,
77        }
78    }
79}
80
81/// Serde helper: serialize `Vec<ContentPart>` as a plain string when there's
82/// a single text entry, or as an array otherwise.
83mod content_vec_serde {
84    use super::ContentPart;
85    use serde::{Deserialize, Deserializer, Serialize, Serializer};
86
87    pub fn serialize<S: Serializer>(parts: &[ContentPart], s: S) -> Result<S::Ok, S::Error> {
88        if parts.len() == 1
89            && let ContentPart::Text { text } = &parts[0]
90        {
91            return s.serialize_str(text);
92        }
93        parts.serialize(s)
94    }
95
96    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<ContentPart>, D::Error> {
97        #[derive(Deserialize)]
98        #[serde(untagged)]
99        enum StringOrParts {
100            S(String),
101            P(Vec<ContentPart>),
102        }
103        match StringOrParts::deserialize(d)? {
104            StringOrParts::S(s) => Ok(vec![ContentPart::text(s)]),
105            StringOrParts::P(v) => Ok(v),
106        }
107    }
108}
109
110/// A single message in a chat conversation.
111///
112/// Implements [`Default`] for forward-compatible struct-update syntax.
113/// Prefer the `ChatMessage::system` / `::user` / `::assistant` / `::tool`
114/// constructors for clarity.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ChatMessage {
117    /// Role of the message sender.
118    pub role: MessageRole,
119    /// Content parts (text, images). Serializes as a plain string when
120    /// containing a single text part for backward compatibility.
121    #[serde(with = "content_vec_serde")]
122    pub content: Vec<ContentPart>,
123}
124
125impl Default for ChatMessage {
126    fn default() -> Self {
127        Self {
128            role: MessageRole::User,
129            content: Vec::new(),
130        }
131    }
132}
133
134impl ChatMessage {
135    /// Create a system message with text content.
136    pub fn system(content: impl Into<String>) -> Self {
137        Self {
138            role: MessageRole::System,
139            content: vec![ContentPart::text(content)],
140        }
141    }
142
143    /// Create a user message with text content.
144    pub fn user(content: impl Into<String>) -> Self {
145        Self {
146            role: MessageRole::User,
147            content: vec![ContentPart::text(content)],
148        }
149    }
150
151    /// Create an assistant message with text content.
152    pub fn assistant(content: impl Into<String>) -> Self {
153        Self {
154            role: MessageRole::Assistant,
155            content: vec![ContentPart::text(content)],
156        }
157    }
158
159    /// Create a tool result message.
160    pub fn tool(content: impl Into<String>) -> Self {
161        Self {
162            role: MessageRole::Tool,
163            content: vec![ContentPart::text(content)],
164        }
165    }
166
167    /// Create a user message with multimodal content parts.
168    pub fn user_multimodal(parts: Vec<ContentPart>) -> Self {
169        Self {
170            role: MessageRole::User,
171            content: parts,
172        }
173    }
174
175    /// Extract all text from this message's content parts.
176    pub fn text_content(&self) -> String {
177        self.content
178            .iter()
179            .filter_map(|p| p.as_text())
180            .collect::<Vec<_>>()
181            .join("")
182    }
183}
184
185/// Configuration for a specific LLM model and provider.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ModelConfig {
188    /// Provider name (e.g. `"openai"`, `"anthropic"`).
189    pub provider: String,
190    /// Model identifier (e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`).
191    pub model: String,
192    /// Environment variable name holding the API key.
193    pub api_key_env: String,
194    /// Optional base URL override for the provider API.
195    pub base_url: Option<String>,
196    /// Sampling temperature (0.0–2.0).
197    pub temperature: f32,
198    /// Maximum tokens to generate in the response.
199    pub max_tokens: Option<u32>,
200}
201
202impl Default for ModelConfig {
203    fn default() -> Self {
204        Self {
205            provider: "openai".into(),
206            model: "gpt-4o".into(),
207            api_key_env: "OPENAI_API_KEY".into(),
208            base_url: None,
209            temperature: 0.7,
210            max_tokens: Some(4096),
211        }
212    }
213}
214
215/// Desired output format for the LLM response.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(tag = "type", rename_all = "snake_case")]
218pub enum ResponseFormat {
219    /// Plain text response (default).
220    Text,
221    /// JSON object response.
222    Json,
223    /// JSON response conforming to the given schema.
224    JsonSchema {
225        /// JSON Schema the response must satisfy.
226        schema: serde_json::Value,
227    },
228}
229
230/// A chat completion request to an LLM provider.
231///
232/// This struct implements [`Default`] so callers can use struct-update syntax
233/// to stay forward-compatible with future field additions:
234///
235/// ```rust,ignore
236/// let req = LLMRequest {
237///     system: Some("...".into()),
238///     messages: vec![ChatMessage::user("hi")],
239///     ..LLMRequest::default()
240/// };
241/// ```
242///
243/// New fields added to `LLMRequest` in future non-breaking releases are
244/// absorbed by `..LLMRequest::default()` and will not break such call sites
245/// (unlike full struct literals, which must enumerate every field). For the
246/// fluent equivalent, see [`LLMRequest::builder`].
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct LLMRequest {
249    /// Optional system prompt prepended to the conversation.
250    pub system: Option<String>,
251    /// Ordered list of chat messages forming the conversation.
252    pub messages: Vec<ChatMessage>,
253    /// Sampling temperature (0.0–2.0).
254    pub temperature: f32,
255    /// Maximum tokens to generate. `None` uses the provider default.
256    pub max_tokens: Option<u32>,
257    /// Model override for this request. `None` uses the client default.
258    pub model: Option<String>,
259    /// Desired response format. `None` uses the provider default.
260    ///
261    /// Forwarded to the provider by [`OpenAIClient`](crate::llm::OpenAIClient)
262    /// (OpenAI `response_format`) and, for [`ResponseFormat::JsonSchema`], by
263    /// [`AnthropicClient`](crate::llm::AnthropicClient) (Anthropic
264    /// `output_config.format`). [`ResponseFormat::Json`] without a schema has no
265    /// native Anthropic equivalent and is a no-op there.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub response_format: Option<ResponseFormat>,
268    /// Tool definitions available to the model for this request.
269    ///
270    /// Forwarded to both OpenAI (`tools` with `type: "function"`) and Anthropic
271    /// (`tools` with `input_schema`). Any tool calls the model makes are returned
272    /// in [`LLMResponse::tool_calls`].
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub tools: Option<Vec<crate::llm::ToolDefinition>>,
275}
276
277impl Default for LLMRequest {
278    fn default() -> Self {
279        Self {
280            system: None,
281            messages: Vec::new(),
282            // Matches `LLMRequestBuilder::build()`'s `unwrap_or(0.7)` so the two
283            // construction paths agree. Keep these coupled.
284            temperature: 0.7,
285            max_tokens: None,
286            model: None,
287            response_format: None,
288            tools: None,
289        }
290    }
291}
292
293impl LLMRequest {
294    /// Create a new builder for constructing an `LLMRequest`.
295    pub fn builder() -> LLMRequestBuilder {
296        LLMRequestBuilder::default()
297    }
298
299    /// Convert into OpenAI-format messages, consuming the request.
300    ///
301    /// Prepends a system message if `self.system` is set.
302    pub(crate) fn into_openai_messages(self) -> Vec<(String, String)> {
303        let mut out = Vec::with_capacity(self.messages.len() + 1);
304        if let Some(system) = self.system {
305            out.push(("system".into(), system));
306        }
307        for msg in self.messages {
308            out.push((msg.role.to_string(), msg.text_content()));
309        }
310        out
311    }
312
313    /// Convert into Anthropic-format messages, consuming the request.
314    ///
315    /// Returns only user/assistant messages (system is handled separately by Anthropic API).
316    pub(crate) fn into_anthropic_messages(self) -> Vec<(String, String)> {
317        self.messages
318            .into_iter()
319            .map(|m| (m.role.to_string(), m.text_content()))
320            .collect()
321    }
322}
323
324/// Builder for constructing `LLMRequest` instances with a fluent API.
325///
326/// # Example
327///
328/// ```no_run
329/// use llm_kernel::llm::LLMRequest;
330///
331/// let request = LLMRequest::builder()
332///     .system("You are concise.")
333///     .user_message("Summarise Rust ownership in one line.")
334///     .temperature(0.0)
335///     .build();
336/// ```
337#[derive(Debug, Clone, Default)]
338pub struct LLMRequestBuilder {
339    system: Option<String>,
340    messages: Vec<ChatMessage>,
341    temperature: Option<f32>,
342    max_tokens: Option<u32>,
343    model: Option<String>,
344    response_format: Option<ResponseFormat>,
345    tools: Option<Vec<crate::llm::ToolDefinition>>,
346}
347
348impl LLMRequestBuilder {
349    /// Set the system prompt.
350    pub fn system(mut self, prompt: impl Into<String>) -> Self {
351        self.system = Some(prompt.into());
352        self
353    }
354
355    /// Append a user message.
356    pub fn user_message(mut self, content: impl Into<String>) -> Self {
357        self.messages.push(ChatMessage::user(content));
358        self
359    }
360
361    /// Append an assistant message.
362    pub fn assistant_message(mut self, content: impl Into<String>) -> Self {
363        self.messages.push(ChatMessage::assistant(content));
364        self
365    }
366
367    /// Append a raw `ChatMessage`.
368    pub fn message(mut self, msg: ChatMessage) -> Self {
369        self.messages.push(msg);
370        self
371    }
372
373    /// Replace the message list with the provided messages.
374    ///
375    /// Convenience for callers that already hold a `Vec<ChatMessage>` (e.g. a
376    /// pre-built conversation), avoiding repeated `.message()` calls.
377    pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
378        self.messages = messages;
379        self
380    }
381
382    /// Set the sampling temperature.
383    pub fn temperature(mut self, temp: f32) -> Self {
384        self.temperature = Some(temp);
385        self
386    }
387
388    /// Set the maximum tokens to generate.
389    pub fn max_tokens(mut self, tokens: u32) -> Self {
390        self.max_tokens = Some(tokens);
391        self
392    }
393
394    /// Set the maximum tokens to generate, or `None` to use the provider default.
395    ///
396    /// Convenience for callers that already hold an `Option<u32>` (e.g. a
397    /// config field), avoiding a conditional chain.
398    pub fn maybe_max_tokens(mut self, tokens: Option<u32>) -> Self {
399        self.max_tokens = tokens;
400        self
401    }
402
403    /// Override the model for this request.
404    pub fn model(mut self, model: impl Into<String>) -> Self {
405        self.model = Some(model.into());
406        self
407    }
408
409    /// Set the desired response format.
410    pub fn response_format(mut self, format: ResponseFormat) -> Self {
411        self.response_format = Some(format);
412        self
413    }
414
415    /// Set the tool definitions available to the model.
416    pub fn tools(mut self, tools: Vec<crate::llm::ToolDefinition>) -> Self {
417        self.tools = Some(tools);
418        self
419    }
420
421    /// Build the `LLMRequest`.
422    pub fn build(self) -> LLMRequest {
423        LLMRequest {
424            system: self.system,
425            messages: self.messages,
426            temperature: self.temperature.unwrap_or(0.7),
427            max_tokens: self.max_tokens,
428            model: self.model,
429            response_format: self.response_format,
430            tools: self.tools,
431        }
432    }
433}
434
435/// A chat completion response from an LLM provider.
436///
437/// Implements [`Default`] for forward-compatible struct-update syntax
438/// (`LLMResponse { ..LLMResponse::default() }`).
439#[derive(Debug, Clone, Default, Serialize, Deserialize)]
440pub struct LLMResponse {
441    /// Generated text content.
442    pub content: String,
443    /// Reasoning model's chain-of-thought (GLM-4.5+/z.ai, OpenAI o1, DeepSeek-R1).
444    ///
445    /// When the provider leaves `content` empty and returns the final answer in
446    /// `reasoning_content`, the client promotes the reasoning into `content` and
447    /// still preserves the original here. `None` for non-reasoning models and for
448    /// cache entries written before this field existed (serde default).
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub reasoning: Option<String>,
451    /// Model that produced this response.
452    pub model: String,
453    /// Token usage statistics.
454    pub usage: TokenUsage,
455    /// Tool calls the model requested this turn.
456    ///
457    /// Empty unless the request supplied [`LLMRequest::tools`] and the model
458    /// chose to call one. Each entry carries the provider-assigned call `id`,
459    /// tool `name`, and JSON-encoded `arguments`.
460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
461    pub tool_calls: Vec<crate::llm::ToolCall>,
462    /// Reason the generation stopped (e.g. `"stop"`, `"length"`, `"tool_calls"`).
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub finish_reason: Option<String>,
465    /// Provider-assigned response ID (useful for logging and deduplication).
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub id: Option<String>,
468    /// Unix timestamp (seconds) when the response was created.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub created: Option<u64>,
471}
472
473/// Token usage statistics from an LLM response.
474#[derive(Debug, Clone, Default, Serialize, Deserialize)]
475pub struct TokenUsage {
476    /// Number of tokens in the prompt.
477    pub prompt_tokens: u32,
478    /// Number of tokens in the completion.
479    pub completion_tokens: u32,
480    /// Total tokens (prompt + completion).
481    pub total_tokens: u32,
482    /// Reasoning-only tokens (o1 / GLM-4.7 `completion_tokens_details.reasoning_tokens`).
483    /// `None` when the provider does not report it.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub reasoning_tokens: Option<u32>,
486}
487
488/// A single event in an LLM streaming response.
489///
490/// `#[non_exhaustive]`: new variants (e.g. reasoning/tool-call streaming) may be
491/// added in a minor release without breaking downstream `match` arms — external
492/// consumers must include a `_ =>` arm. Within this crate, exhaustive matching is
493/// still permitted.
494#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub enum StreamEvent {
497    /// Partial text content arrived.
498    Delta {
499        /// The partial text chunk.
500        content: String,
501    },
502    /// Partial reasoning content arrived (GLM `delta.reasoning_content`,
503    /// Anthropic `thinking_delta`).
504    ///
505    /// **Streaming/reasoning asymmetry (important):** the non-streaming
506    /// `LLMClient::complete` path promotes reasoning into `content` when the
507    /// provider leaves `content` empty (notably GLM-4.7), so non-streaming callers
508    /// transparently receive the final answer. The streaming path does **not**
509    /// perform this promotion — it emits `ReasoningDelta` and `Delta` as separate
510    /// events and leaves accumulation to the consumer. For reasoning-only models
511    /// that put the answer in `reasoning_content` (GLM-4.7), streaming consumers
512    /// **must** accumulate both `ReasoningDelta` and `Delta` chunks to reconstruct
513    /// the full answer; accumulating `Delta` alone yields an empty result.
514    ReasoningDelta {
515        /// The partial reasoning chunk.
516        content: String,
517    },
518    /// Final token usage statistics.
519    Usage(TokenUsage),
520    /// Stream has ended.
521    Done,
522}
523
524/// Type alias for a boxed streaming response.
525#[cfg(feature = "client-async")]
526pub type LLMStream =
527    Pin<Box<dyn futures_core::Stream<Item = crate::error::Result<StreamEvent>> + Send>>;
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn message_role_display() {
535        assert_eq!(MessageRole::System.to_string(), "system");
536        assert_eq!(MessageRole::User.to_string(), "user");
537        assert_eq!(MessageRole::Assistant.to_string(), "assistant");
538        assert_eq!(MessageRole::Tool.to_string(), "tool");
539    }
540
541    #[test]
542    fn message_role_serde_roundtrip() {
543        let json = serde_json::to_string(&MessageRole::User).unwrap();
544        assert_eq!(json, "\"user\"");
545        let back: MessageRole = serde_json::from_str(&json).unwrap();
546        assert_eq!(back, MessageRole::User);
547    }
548
549    #[test]
550    fn chat_message_constructors() {
551        let sys = ChatMessage::system("instructions");
552        assert_eq!(sys.role, MessageRole::System);
553
554        let user = ChatMessage::user("hello");
555        assert_eq!(user.role, MessageRole::User);
556
557        let asst = ChatMessage::assistant("hi there");
558        assert_eq!(asst.role, MessageRole::Assistant);
559
560        let tool = ChatMessage::tool("result");
561        assert_eq!(tool.role, MessageRole::Tool);
562    }
563
564    #[test]
565    fn single_text_serializes_as_string() {
566        let msg = ChatMessage::user("hello");
567        let json = serde_json::to_string(&msg).unwrap();
568        assert!(json.contains("\"content\":\"hello\""), "got: {json}");
569    }
570
571    #[test]
572    fn multipart_serializes_as_array() {
573        let msg = ChatMessage::user_multimodal(vec![
574            ContentPart::text("describe this"),
575            ContentPart::image_url("https://example.com/img.png"),
576        ]);
577        let json = serde_json::to_string(&msg).unwrap();
578        assert!(
579            json.contains("\"content\":["),
580            "expected array serialization, got: {json}"
581        );
582    }
583
584    #[test]
585    fn single_text_deserialize_from_string() {
586        let json = r#"{"role":"user","content":"hello"}"#;
587        let msg: ChatMessage = serde_json::from_str(json).unwrap();
588        assert_eq!(msg.role, MessageRole::User);
589        assert_eq!(msg.content.len(), 1);
590        assert_eq!(msg.text_content(), "hello");
591    }
592
593    #[test]
594    fn multipart_deserialize_from_array() {
595        let json = r#"{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","url":"https://x.com/img.png"}]}"#;
596        let msg: ChatMessage = serde_json::from_str(json).unwrap();
597        assert_eq!(msg.content.len(), 2);
598    }
599
600    #[test]
601    fn content_part_text_helper() {
602        let p = ContentPart::text("hello");
603        assert_eq!(p.as_text(), Some("hello"));
604    }
605
606    #[test]
607    fn response_format_json_serialization() {
608        let fmt = ResponseFormat::Json;
609        let json = serde_json::to_string(&fmt).unwrap();
610        assert!(json.contains("\"type\":\"json\""), "got: {json}");
611    }
612
613    #[test]
614    fn response_format_text_serialization() {
615        let fmt = ResponseFormat::Text;
616        let json = serde_json::to_string(&fmt).unwrap();
617        assert!(json.contains("\"type\":\"text\""), "got: {json}");
618    }
619
620    #[test]
621    fn response_format_json_schema() {
622        let fmt = ResponseFormat::JsonSchema {
623            schema: serde_json::json!({"type": "object"}),
624        };
625        let json = serde_json::to_string(&fmt).unwrap();
626        assert!(json.contains("json_schema"), "got: {json}");
627    }
628
629    #[test]
630    fn builder_basic() {
631        let req = LLMRequest::builder()
632            .system("you are helpful")
633            .user_message("hello")
634            .temperature(0.5)
635            .build();
636        assert_eq!(req.system.as_deref(), Some("you are helpful"));
637        assert_eq!(req.messages.len(), 1);
638        assert_eq!(req.temperature, 0.5);
639    }
640
641    #[test]
642    fn builder_with_model_and_format() {
643        let req = LLMRequest::builder()
644            .user_message("test")
645            .model("gpt-4o-mini")
646            .response_format(ResponseFormat::Json)
647            .max_tokens(100)
648            .build();
649        assert_eq!(req.model.as_deref(), Some("gpt-4o-mini"));
650        assert!(matches!(req.response_format, Some(ResponseFormat::Json)));
651        assert_eq!(req.max_tokens, Some(100));
652    }
653
654    #[test]
655    fn builder_with_tools() {
656        use crate::llm::ToolDefinition;
657        let req = LLMRequest::builder()
658            .user_message("what's the weather?")
659            .tools(vec![ToolDefinition {
660                name: "get_weather".into(),
661                description: "Get weather".into(),
662                input_schema: serde_json::json!({"type": "object"}),
663            }])
664            .build();
665        assert!(req.tools.is_some());
666        assert_eq!(req.tools.unwrap().len(), 1);
667    }
668
669    /// `LLMRequest::default()` and `LLMRequest::builder().build()` must agree on
670    /// every field. The temperature default (0.7) in particular is duplicated
671    /// between the manual `Default` impl and the builder's `unwrap_or(0.7)`;
672    /// this test couples them so a future edit to one without the other is caught.
673    #[test]
674    fn default_matches_builder_default() {
675        let from_default = LLMRequest::default();
676        let from_builder = LLMRequest::builder().build();
677        assert_eq!(from_default.temperature, from_builder.temperature);
678        assert_eq!(from_default.temperature, 0.7);
679        assert!(from_default.system.is_none());
680        assert!(from_default.messages.is_empty());
681        assert!(from_default.max_tokens.is_none());
682        assert!(from_default.model.is_none());
683        assert!(from_default.response_format.is_none());
684        assert!(from_default.tools.is_none());
685    }
686
687    #[test]
688    fn builder_messages_setter_replaces_list() {
689        let conv = vec![ChatMessage::user("first"), ChatMessage::assistant("second")];
690        let req = LLMRequest::builder().messages(conv).build();
691        assert_eq!(req.messages.len(), 2);
692        assert_eq!(req.messages[0].role, MessageRole::User);
693        assert_eq!(req.messages[1].role, MessageRole::Assistant);
694    }
695
696    #[test]
697    fn builder_maybe_max_tokens_accepts_option() {
698        // Some — sets the value
699        let req = LLMRequest::builder().maybe_max_tokens(Some(512)).build();
700        assert_eq!(req.max_tokens, Some(512));
701        // None — explicitly defers to provider default
702        let req = LLMRequest::builder().maybe_max_tokens(None).build();
703        assert_eq!(req.max_tokens, None);
704    }
705
706    #[test]
707    fn into_openai_messages_with_system() {
708        let req = LLMRequest::builder()
709            .system("be helpful")
710            .user_message("hi")
711            .assistant_message("hello")
712            .build();
713        let msgs = req.into_openai_messages();
714        assert_eq!(msgs.len(), 3);
715        assert_eq!(msgs[0].0, "system");
716        assert_eq!(msgs[1].0, "user");
717        assert_eq!(msgs[2].0, "assistant");
718    }
719
720    #[test]
721    fn into_anthropic_messages_excludes_system() {
722        let req = LLMRequest::builder()
723            .system("be helpful")
724            .user_message("hi")
725            .build();
726        let msgs = req.into_anthropic_messages();
727        assert_eq!(msgs.len(), 1);
728        assert_eq!(msgs[0].0, "user");
729    }
730
731    #[test]
732    fn text_content_extracts_text() {
733        let msg = ChatMessage::user_multimodal(vec![
734            ContentPart::text("hello "),
735            ContentPart::image_url("http://x.com/i.png"),
736            ContentPart::text("world"),
737        ]);
738        assert_eq!(msg.text_content(), "hello world");
739    }
740
741    #[test]
742    fn llm_response_back_compat_without_reasoning_field() {
743        // Cache entries written before `reasoning` existed must still deserialize.
744        let json = r#"{"content":"hi","model":"m","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
745        let r: LLMResponse = serde_json::from_str(json).unwrap();
746        assert_eq!(r.content, "hi");
747        assert!(r.reasoning.is_none());
748    }
749
750    #[test]
751    fn token_usage_back_compat_without_reasoning_tokens() {
752        let json = r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#;
753        let u: TokenUsage = serde_json::from_str(json).unwrap();
754        assert_eq!(u.total_tokens, 3);
755        assert!(u.reasoning_tokens.is_none());
756    }
757}