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/// Reasoning effort for reasoning models (OpenAI `reasoning_effort`).
231///
232/// Wire values follow the official OpenAI Chat Completions parameter:
233/// `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Not every
234/// model supports every value (e.g. `none` is gpt-5.1+); see the OpenAI
235/// reasoning guide for model-specific support.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum ReasoningEffort {
239    /// Disable reasoning entirely (gpt-5.1+ only).
240    None,
241    /// Minimal reasoning (gpt-5+ only).
242    Minimal,
243    /// Low effort — latency-sensitive tasks.
244    Low,
245    /// Medium effort — balanced default for most workloads.
246    Medium,
247    /// High effort — hard reasoning, complex debugging.
248    High,
249    /// Extra-high effort — deep research, long agentic runs.
250    XHigh,
251    /// Maximum reasoning for the most complex tasks.
252    Max,
253}
254
255/// Verbosity of the model's response (OpenAI `verbosity`).
256///
257/// Wire values follow the official OpenAI Chat Completions parameter:
258/// `low`, `medium`, `high` (spec default `medium`).
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum Verbosity {
262    /// More concise responses.
263    Low,
264    /// Balanced (spec default).
265    Medium,
266    /// More verbose responses.
267    High,
268}
269
270/// Summary style for reasoning models (Responses-API `reasoning.summary`,
271/// also accepted inside OpenRouter's `reasoning` object).
272///
273/// Wire values follow the official OpenAI spec: `auto`, `concise`,
274/// `detailed`.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "lowercase")]
277pub enum ReasoningSummary {
278    /// Model/provider picks the summary style.
279    Auto,
280    /// Compact reasoning summary (gpt-5+ reasoning models).
281    Concise,
282    /// Detailed reasoning summary.
283    Detailed,
284}
285
286/// Reasoning-model controls for a chat completion request.
287///
288/// Three independent knobs, each serialized only when set:
289///
290/// - `effort` maps to the official OpenAI Chat Completions `reasoning_effort`
291///   parameter (accepted by OpenAI and most OpenAI-compatible gateways,
292///   including OpenRouter).
293/// - `enabled` maps to the OpenRouter extension object
294///   `reasoning: {"enabled": bool}`. `enabled: false` stops reasoning models
295///   from emitting chain-of-thought into `content` (which would otherwise
296///   also burn `max_tokens` on reasoning). This key is only sent when you
297///   explicitly set it — pure-OpenAI endpoints never see it otherwise.
298/// - `summary` rides inside the same `reasoning` object; the key and its
299///   values (`auto`/`concise`/`detailed`) follow the official OpenAI
300///   Responses-API `reasoning.summary` parameter, which OpenRouter's chat
301///   completions endpoint also accepts.
302///
303/// Forwarded by [`OpenAIClient`](crate::llm::OpenAIClient) in both `complete`
304/// and `stream_complete`. [`AnthropicClient`](crate::llm::AnthropicClient)
305/// has no mapping for these controls (extended thinking is configured
306/// per-model there) and ignores them.
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct ReasoningConfig {
309    /// On/off toggle for providers with an explicit switch (OpenRouter
310    /// `reasoning.enabled`).
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub enabled: Option<bool>,
313    /// How much the model reasons (OpenAI `reasoning_effort`).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub effort: Option<ReasoningEffort>,
316    /// Reasoning summary style (OpenAI Responses `reasoning.summary`).
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub summary: Option<ReasoningSummary>,
319}
320
321impl ReasoningConfig {
322    /// Turn reasoning off where the provider supports an explicit switch
323    /// (serializes `reasoning: {"enabled": false}` — the OpenRouter form).
324    pub fn disabled() -> Self {
325        Self {
326            enabled: Some(false),
327            effort: None,
328            summary: None,
329        }
330    }
331
332    /// Request a specific reasoning effort (serializes `reasoning_effort`).
333    pub fn effort(effort: ReasoningEffort) -> Self {
334        Self {
335            enabled: None,
336            effort: Some(effort),
337            summary: None,
338        }
339    }
340}
341
342/// A chat completion request to an LLM provider.
343///
344/// This struct implements [`Default`] so callers can use struct-update syntax
345/// to stay forward-compatible with future field additions:
346///
347/// ```rust,ignore
348/// let req = LLMRequest {
349///     system: Some("...".into()),
350///     messages: vec![ChatMessage::user("hi")],
351///     ..LLMRequest::default()
352/// };
353/// ```
354///
355/// New fields added to `LLMRequest` in future non-breaking releases are
356/// absorbed by `..LLMRequest::default()` and will not break such call sites
357/// (unlike full struct literals, which must enumerate every field). For the
358/// fluent equivalent, see [`LLMRequest::builder`].
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct LLMRequest {
361    /// Optional system prompt prepended to the conversation.
362    pub system: Option<String>,
363    /// Ordered list of chat messages forming the conversation.
364    pub messages: Vec<ChatMessage>,
365    /// Sampling temperature (0.0–2.0).
366    pub temperature: f32,
367    /// Maximum tokens to generate. `None` uses the provider default.
368    pub max_tokens: Option<u32>,
369    /// Model override for this request. `None` uses the client default.
370    pub model: Option<String>,
371    /// Desired response format. `None` uses the provider default.
372    ///
373    /// Forwarded to the provider by [`OpenAIClient`](crate::llm::OpenAIClient)
374    /// (OpenAI `response_format`) and, for [`ResponseFormat::JsonSchema`], by
375    /// [`AnthropicClient`](crate::llm::AnthropicClient) (Anthropic
376    /// `output_config.format`). [`ResponseFormat::Json`] without a schema has no
377    /// native Anthropic equivalent and is a no-op there.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub response_format: Option<ResponseFormat>,
380    /// Tool definitions available to the model for this request.
381    ///
382    /// Forwarded to both OpenAI (`tools` with `type: "function"`) and Anthropic
383    /// (`tools` with `input_schema`). Any tool calls the model makes are returned
384    /// in [`LLMResponse::tool_calls`].
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub tools: Option<Vec<crate::llm::ToolDefinition>>,
387    /// Reasoning-model controls (effort / on-off switch / summary). `None`
388    /// adds nothing to the request body. See [`ReasoningConfig`].
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub reasoning: Option<ReasoningConfig>,
391    /// Response verbosity (OpenAI `verbosity`: `low`/`medium`/`high`).
392    /// `None` adds nothing to the request body. Forwarded by
393    /// [`OpenAIClient`](crate::llm::OpenAIClient); ignored by
394    /// [`AnthropicClient`](crate::llm::AnthropicClient).
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub verbosity: Option<Verbosity>,
397    /// Escape hatch for provider parameters the kernel does not model
398    /// natively (named after the OpenAI SDK's `extra_body` convention).
399    ///
400    /// Keys are merged into the OpenAI-compatible request body verbatim,
401    /// last-write-wins over natively forwarded fields — any official spec
402    /// parameter (`seed`, `stop`, `logprobs`, `parallel_tool_calls`,
403    /// `service_tier`, `web_search_options`, …) or provider extension can be
404    /// sent on demand. `None` adds nothing. Only
405    /// [`OpenAIClient`](crate::llm::OpenAIClient) forwards it;
406    /// [`AnthropicClient`](crate::llm::AnthropicClient) ignores it.
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
409}
410
411impl Default for LLMRequest {
412    fn default() -> Self {
413        Self {
414            system: None,
415            messages: Vec::new(),
416            // Matches `LLMRequestBuilder::build()`'s `unwrap_or(0.7)` so the two
417            // construction paths agree. Keep these coupled.
418            temperature: 0.7,
419            max_tokens: None,
420            model: None,
421            response_format: None,
422            tools: None,
423            reasoning: None,
424            verbosity: None,
425            extra_body: None,
426        }
427    }
428}
429
430impl LLMRequest {
431    /// Create a new builder for constructing an `LLMRequest`.
432    pub fn builder() -> LLMRequestBuilder {
433        LLMRequestBuilder::default()
434    }
435
436    /// Convert into OpenAI-format messages, consuming the request.
437    ///
438    /// Prepends a system message if `self.system` is set.
439    pub(crate) fn into_openai_messages(self) -> Vec<(String, String)> {
440        let mut out = Vec::with_capacity(self.messages.len() + 1);
441        if let Some(system) = self.system {
442            out.push(("system".into(), system));
443        }
444        for msg in self.messages {
445            out.push((msg.role.to_string(), msg.text_content()));
446        }
447        out
448    }
449
450    /// Convert into Anthropic-format messages, consuming the request.
451    ///
452    /// Returns only user/assistant messages (system is handled separately by Anthropic API).
453    pub(crate) fn into_anthropic_messages(self) -> Vec<(String, String)> {
454        self.messages
455            .into_iter()
456            .map(|m| (m.role.to_string(), m.text_content()))
457            .collect()
458    }
459}
460
461/// Builder for constructing `LLMRequest` instances with a fluent API.
462///
463/// # Example
464///
465/// ```no_run
466/// use llm_kernel::llm::LLMRequest;
467///
468/// let request = LLMRequest::builder()
469///     .system("You are concise.")
470///     .user_message("Summarise Rust ownership in one line.")
471///     .temperature(0.0)
472///     .build();
473/// ```
474#[derive(Debug, Clone, Default)]
475pub struct LLMRequestBuilder {
476    system: Option<String>,
477    messages: Vec<ChatMessage>,
478    temperature: Option<f32>,
479    max_tokens: Option<u32>,
480    model: Option<String>,
481    response_format: Option<ResponseFormat>,
482    tools: Option<Vec<crate::llm::ToolDefinition>>,
483    reasoning: Option<ReasoningConfig>,
484    verbosity: Option<Verbosity>,
485    extra_body: Option<serde_json::Map<String, serde_json::Value>>,
486}
487
488impl LLMRequestBuilder {
489    /// Set the system prompt.
490    pub fn system(mut self, prompt: impl Into<String>) -> Self {
491        self.system = Some(prompt.into());
492        self
493    }
494
495    /// Append a user message.
496    pub fn user_message(mut self, content: impl Into<String>) -> Self {
497        self.messages.push(ChatMessage::user(content));
498        self
499    }
500
501    /// Append an assistant message.
502    pub fn assistant_message(mut self, content: impl Into<String>) -> Self {
503        self.messages.push(ChatMessage::assistant(content));
504        self
505    }
506
507    /// Append a raw `ChatMessage`.
508    pub fn message(mut self, msg: ChatMessage) -> Self {
509        self.messages.push(msg);
510        self
511    }
512
513    /// Replace the message list with the provided messages.
514    ///
515    /// Convenience for callers that already hold a `Vec<ChatMessage>` (e.g. a
516    /// pre-built conversation), avoiding repeated `.message()` calls.
517    pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
518        self.messages = messages;
519        self
520    }
521
522    /// Set the sampling temperature.
523    pub fn temperature(mut self, temp: f32) -> Self {
524        self.temperature = Some(temp);
525        self
526    }
527
528    /// Set the maximum tokens to generate.
529    pub fn max_tokens(mut self, tokens: u32) -> Self {
530        self.max_tokens = Some(tokens);
531        self
532    }
533
534    /// Set the maximum tokens to generate, or `None` to use the provider default.
535    ///
536    /// Convenience for callers that already hold an `Option<u32>` (e.g. a
537    /// config field), avoiding a conditional chain.
538    pub fn maybe_max_tokens(mut self, tokens: Option<u32>) -> Self {
539        self.max_tokens = tokens;
540        self
541    }
542
543    /// Override the model for this request.
544    pub fn model(mut self, model: impl Into<String>) -> Self {
545        self.model = Some(model.into());
546        self
547    }
548
549    /// Set the desired response format.
550    pub fn response_format(mut self, format: ResponseFormat) -> Self {
551        self.response_format = Some(format);
552        self
553    }
554
555    /// Set the tool definitions available to the model.
556    pub fn tools(mut self, tools: Vec<crate::llm::ToolDefinition>) -> Self {
557        self.tools = Some(tools);
558        self
559    }
560
561    /// Set reasoning-model controls (effort / on-off switch / summary).
562    pub fn reasoning(mut self, cfg: ReasoningConfig) -> Self {
563        self.reasoning = Some(cfg);
564        self
565    }
566
567    /// Set the response verbosity (OpenAI `verbosity`).
568    pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
569        self.verbosity = Some(verbosity);
570        self
571    }
572
573    /// Merge extra provider parameters into the request body verbatim
574    /// (last-write-wins). Any official spec parameter or provider extension.
575    pub fn extra_body(mut self, extra: serde_json::Map<String, serde_json::Value>) -> Self {
576        self.extra_body = Some(extra);
577        self
578    }
579
580    /// Build the `LLMRequest`.
581    pub fn build(self) -> LLMRequest {
582        LLMRequest {
583            system: self.system,
584            messages: self.messages,
585            temperature: self.temperature.unwrap_or(0.7),
586            max_tokens: self.max_tokens,
587            model: self.model,
588            response_format: self.response_format,
589            tools: self.tools,
590            reasoning: self.reasoning,
591            verbosity: self.verbosity,
592            extra_body: self.extra_body,
593        }
594    }
595}
596
597/// A chat completion response from an LLM provider.
598///
599/// Implements [`Default`] for forward-compatible struct-update syntax
600/// (`LLMResponse { ..LLMResponse::default() }`).
601#[derive(Debug, Clone, Default, Serialize, Deserialize)]
602pub struct LLMResponse {
603    /// Generated text content.
604    pub content: String,
605    /// Reasoning model's chain-of-thought (GLM-4.5+/z.ai, OpenAI o1, DeepSeek-R1).
606    ///
607    /// When the provider leaves `content` empty and returns the final answer in
608    /// `reasoning_content`, the client promotes the reasoning into `content` and
609    /// still preserves the original here. `None` for non-reasoning models and for
610    /// cache entries written before this field existed (serde default).
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub reasoning: Option<String>,
613    /// Model that produced this response.
614    pub model: String,
615    /// Token usage statistics.
616    pub usage: TokenUsage,
617    /// Tool calls the model requested this turn.
618    ///
619    /// Empty unless the request supplied [`LLMRequest::tools`] and the model
620    /// chose to call one. Each entry carries the provider-assigned call `id`,
621    /// tool `name`, and JSON-encoded `arguments`.
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    pub tool_calls: Vec<crate::llm::ToolCall>,
624    /// Reason the generation stopped (e.g. `"stop"`, `"length"`, `"tool_calls"`).
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub finish_reason: Option<String>,
627    /// Provider-assigned response ID (useful for logging and deduplication).
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub id: Option<String>,
630    /// Unix timestamp (seconds) when the response was created.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub created: Option<u64>,
633}
634
635/// Token usage statistics from an LLM response.
636#[derive(Debug, Clone, Default, Serialize, Deserialize)]
637pub struct TokenUsage {
638    /// Number of tokens in the prompt.
639    pub prompt_tokens: u32,
640    /// Number of tokens in the completion.
641    pub completion_tokens: u32,
642    /// Total tokens (prompt + completion).
643    pub total_tokens: u32,
644    /// Reasoning-only tokens (o1 / GLM-4.7 `completion_tokens_details.reasoning_tokens`).
645    /// `None` when the provider does not report it.
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub reasoning_tokens: Option<u32>,
648}
649
650/// A single event in an LLM streaming response.
651///
652/// `#[non_exhaustive]`: new variants (e.g. reasoning/tool-call streaming) may be
653/// added in a minor release without breaking downstream `match` arms — external
654/// consumers must include a `_ =>` arm. Within this crate, exhaustive matching is
655/// still permitted.
656#[derive(Debug, Clone)]
657#[non_exhaustive]
658pub enum StreamEvent {
659    /// Partial text content arrived.
660    Delta {
661        /// The partial text chunk.
662        content: String,
663    },
664    /// Partial reasoning content arrived (GLM `delta.reasoning_content`,
665    /// Anthropic `thinking_delta`).
666    ///
667    /// **Streaming/reasoning asymmetry (important):** the non-streaming
668    /// `LLMClient::complete` path promotes reasoning into `content` when the
669    /// provider leaves `content` empty (notably GLM-4.7), so non-streaming callers
670    /// transparently receive the final answer. The streaming path does **not**
671    /// perform this promotion — it emits `ReasoningDelta` and `Delta` as separate
672    /// events and leaves accumulation to the consumer. For reasoning-only models
673    /// that put the answer in `reasoning_content` (GLM-4.7), streaming consumers
674    /// **must** accumulate both `ReasoningDelta` and `Delta` chunks to reconstruct
675    /// the full answer; accumulating `Delta` alone yields an empty result.
676    ReasoningDelta {
677        /// The partial reasoning chunk.
678        content: String,
679    },
680    /// Final token usage statistics.
681    Usage(TokenUsage),
682    /// Stream has ended.
683    Done,
684}
685
686/// Type alias for a boxed streaming response.
687#[cfg(feature = "client-async")]
688pub type LLMStream =
689    Pin<Box<dyn futures_core::Stream<Item = crate::error::Result<StreamEvent>> + Send>>;
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn message_role_display() {
697        assert_eq!(MessageRole::System.to_string(), "system");
698        assert_eq!(MessageRole::User.to_string(), "user");
699        assert_eq!(MessageRole::Assistant.to_string(), "assistant");
700        assert_eq!(MessageRole::Tool.to_string(), "tool");
701    }
702
703    #[test]
704    fn message_role_serde_roundtrip() {
705        let json = serde_json::to_string(&MessageRole::User).unwrap();
706        assert_eq!(json, "\"user\"");
707        let back: MessageRole = serde_json::from_str(&json).unwrap();
708        assert_eq!(back, MessageRole::User);
709    }
710
711    #[test]
712    fn chat_message_constructors() {
713        let sys = ChatMessage::system("instructions");
714        assert_eq!(sys.role, MessageRole::System);
715
716        let user = ChatMessage::user("hello");
717        assert_eq!(user.role, MessageRole::User);
718
719        let asst = ChatMessage::assistant("hi there");
720        assert_eq!(asst.role, MessageRole::Assistant);
721
722        let tool = ChatMessage::tool("result");
723        assert_eq!(tool.role, MessageRole::Tool);
724    }
725
726    #[test]
727    fn single_text_serializes_as_string() {
728        let msg = ChatMessage::user("hello");
729        let json = serde_json::to_string(&msg).unwrap();
730        assert!(json.contains("\"content\":\"hello\""), "got: {json}");
731    }
732
733    #[test]
734    fn multipart_serializes_as_array() {
735        let msg = ChatMessage::user_multimodal(vec![
736            ContentPart::text("describe this"),
737            ContentPart::image_url("https://example.com/img.png"),
738        ]);
739        let json = serde_json::to_string(&msg).unwrap();
740        assert!(
741            json.contains("\"content\":["),
742            "expected array serialization, got: {json}"
743        );
744    }
745
746    #[test]
747    fn single_text_deserialize_from_string() {
748        let json = r#"{"role":"user","content":"hello"}"#;
749        let msg: ChatMessage = serde_json::from_str(json).unwrap();
750        assert_eq!(msg.role, MessageRole::User);
751        assert_eq!(msg.content.len(), 1);
752        assert_eq!(msg.text_content(), "hello");
753    }
754
755    #[test]
756    fn multipart_deserialize_from_array() {
757        let json = r#"{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","url":"https://x.com/img.png"}]}"#;
758        let msg: ChatMessage = serde_json::from_str(json).unwrap();
759        assert_eq!(msg.content.len(), 2);
760    }
761
762    #[test]
763    fn content_part_text_helper() {
764        let p = ContentPart::text("hello");
765        assert_eq!(p.as_text(), Some("hello"));
766    }
767
768    #[test]
769    fn response_format_json_serialization() {
770        let fmt = ResponseFormat::Json;
771        let json = serde_json::to_string(&fmt).unwrap();
772        assert!(json.contains("\"type\":\"json\""), "got: {json}");
773    }
774
775    #[test]
776    fn response_format_text_serialization() {
777        let fmt = ResponseFormat::Text;
778        let json = serde_json::to_string(&fmt).unwrap();
779        assert!(json.contains("\"type\":\"text\""), "got: {json}");
780    }
781
782    #[test]
783    fn response_format_json_schema() {
784        let fmt = ResponseFormat::JsonSchema {
785            schema: serde_json::json!({"type": "object"}),
786        };
787        let json = serde_json::to_string(&fmt).unwrap();
788        assert!(json.contains("json_schema"), "got: {json}");
789    }
790
791    #[test]
792    fn builder_basic() {
793        let req = LLMRequest::builder()
794            .system("you are helpful")
795            .user_message("hello")
796            .temperature(0.5)
797            .build();
798        assert_eq!(req.system.as_deref(), Some("you are helpful"));
799        assert_eq!(req.messages.len(), 1);
800        assert_eq!(req.temperature, 0.5);
801    }
802
803    #[test]
804    fn builder_with_model_and_format() {
805        let req = LLMRequest::builder()
806            .user_message("test")
807            .model("gpt-4o-mini")
808            .response_format(ResponseFormat::Json)
809            .max_tokens(100)
810            .build();
811        assert_eq!(req.model.as_deref(), Some("gpt-4o-mini"));
812        assert!(matches!(req.response_format, Some(ResponseFormat::Json)));
813        assert_eq!(req.max_tokens, Some(100));
814    }
815
816    #[test]
817    fn builder_with_tools() {
818        use crate::llm::ToolDefinition;
819        let req = LLMRequest::builder()
820            .user_message("what's the weather?")
821            .tools(vec![ToolDefinition {
822                name: "get_weather".into(),
823                description: "Get weather".into(),
824                input_schema: serde_json::json!({"type": "object"}),
825            }])
826            .build();
827        assert!(req.tools.is_some());
828        assert_eq!(req.tools.unwrap().len(), 1);
829    }
830
831    /// `LLMRequest::default()` and `LLMRequest::builder().build()` must agree on
832    /// every field. The temperature default (0.7) in particular is duplicated
833    /// between the manual `Default` impl and the builder's `unwrap_or(0.7)`;
834    /// this test couples them so a future edit to one without the other is caught.
835    #[test]
836    fn default_matches_builder_default() {
837        let from_default = LLMRequest::default();
838        let from_builder = LLMRequest::builder().build();
839        assert_eq!(from_default.temperature, from_builder.temperature);
840        assert_eq!(from_default.temperature, 0.7);
841        assert!(from_default.system.is_none());
842        assert!(from_default.messages.is_empty());
843        assert!(from_default.max_tokens.is_none());
844        assert!(from_default.model.is_none());
845        assert!(from_default.response_format.is_none());
846        assert!(from_default.tools.is_none());
847        assert!(from_default.reasoning.is_none());
848        assert!(from_default.verbosity.is_none());
849        assert!(from_default.extra_body.is_none());
850    }
851
852    #[test]
853    fn reasoning_config_disabled_serializes_enabled_only() {
854        let json = serde_json::to_value(ReasoningConfig::disabled()).unwrap();
855        assert_eq!(json, serde_json::json!({"enabled": false}));
856    }
857
858    #[test]
859    fn reasoning_config_effort_serializes_effort_only() {
860        let json = serde_json::to_value(ReasoningConfig::effort(ReasoningEffort::Medium)).unwrap();
861        assert_eq!(json, serde_json::json!({"effort": "medium"}));
862        // Empty config serializes to an empty object (no phantom keys).
863        assert_eq!(
864            serde_json::to_value(ReasoningConfig::default()).unwrap(),
865            serde_json::json!({})
866        );
867    }
868
869    #[test]
870    fn builder_reasoning_roundtrip() {
871        let req = LLMRequest::builder()
872            .user_message("hi")
873            .reasoning(ReasoningConfig::disabled())
874            .build();
875        assert_eq!(req.reasoning.as_ref().unwrap().enabled, Some(false));
876
877        // Serialized request nests under `reasoning`; absent when None.
878        let with = serde_json::to_value(&req).unwrap();
879        assert_eq!(with["reasoning"]["enabled"], false);
880        let without = serde_json::to_value(LLMRequest::default()).unwrap();
881        assert!(without.get("reasoning").is_none());
882    }
883
884    #[test]
885    fn builder_verbosity_roundtrip() {
886        let req = LLMRequest::builder()
887            .user_message("hi")
888            .verbosity(Verbosity::Low)
889            .build();
890        assert_eq!(req.verbosity, Some(Verbosity::Low));
891        let json = serde_json::to_value(&req).unwrap();
892        assert_eq!(json["verbosity"], "low");
893        // Absent when None.
894        assert!(
895            serde_json::to_value(LLMRequest::default())
896                .unwrap()
897                .get("verbosity")
898                .is_none()
899        );
900    }
901
902    #[test]
903    fn builder_extra_body_roundtrip() {
904        let mut extra = serde_json::Map::new();
905        extra.insert("seed".into(), 42.into());
906        extra.insert("stop".into(), ["\n\nUser:"].into());
907        let req = LLMRequest::builder()
908            .user_message("hi")
909            .extra_body(extra)
910            .build();
911        let json = serde_json::to_value(&req).unwrap();
912        assert_eq!(json["extra_body"]["seed"], 42);
913        assert!(
914            serde_json::to_value(LLMRequest::default())
915                .unwrap()
916                .get("extra_body")
917                .is_none()
918        );
919    }
920
921    #[test]
922    fn llm_request_deserializes_without_reasoning_field() {
923        // Payloads written before `reasoning` existed must still deserialize.
924        let json =
925            r#"{"system":null,"messages":[],"temperature":0.7,"max_tokens":null,"model":null}"#;
926        let req: LLMRequest = serde_json::from_str(json).unwrap();
927        assert!(req.reasoning.is_none());
928        assert!(req.verbosity.is_none());
929    }
930
931    #[test]
932    fn builder_messages_setter_replaces_list() {
933        let conv = vec![ChatMessage::user("first"), ChatMessage::assistant("second")];
934        let req = LLMRequest::builder().messages(conv).build();
935        assert_eq!(req.messages.len(), 2);
936        assert_eq!(req.messages[0].role, MessageRole::User);
937        assert_eq!(req.messages[1].role, MessageRole::Assistant);
938    }
939
940    #[test]
941    fn builder_maybe_max_tokens_accepts_option() {
942        // Some — sets the value
943        let req = LLMRequest::builder().maybe_max_tokens(Some(512)).build();
944        assert_eq!(req.max_tokens, Some(512));
945        // None — explicitly defers to provider default
946        let req = LLMRequest::builder().maybe_max_tokens(None).build();
947        assert_eq!(req.max_tokens, None);
948    }
949
950    #[test]
951    fn into_openai_messages_with_system() {
952        let req = LLMRequest::builder()
953            .system("be helpful")
954            .user_message("hi")
955            .assistant_message("hello")
956            .build();
957        let msgs = req.into_openai_messages();
958        assert_eq!(msgs.len(), 3);
959        assert_eq!(msgs[0].0, "system");
960        assert_eq!(msgs[1].0, "user");
961        assert_eq!(msgs[2].0, "assistant");
962    }
963
964    #[test]
965    fn into_anthropic_messages_excludes_system() {
966        let req = LLMRequest::builder()
967            .system("be helpful")
968            .user_message("hi")
969            .build();
970        let msgs = req.into_anthropic_messages();
971        assert_eq!(msgs.len(), 1);
972        assert_eq!(msgs[0].0, "user");
973    }
974
975    #[test]
976    fn text_content_extracts_text() {
977        let msg = ChatMessage::user_multimodal(vec![
978            ContentPart::text("hello "),
979            ContentPart::image_url("http://x.com/i.png"),
980            ContentPart::text("world"),
981        ]);
982        assert_eq!(msg.text_content(), "hello world");
983    }
984
985    #[test]
986    fn llm_response_back_compat_without_reasoning_field() {
987        // Cache entries written before `reasoning` existed must still deserialize.
988        let json = r#"{"content":"hi","model":"m","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
989        let r: LLMResponse = serde_json::from_str(json).unwrap();
990        assert_eq!(r.content, "hi");
991        assert!(r.reasoning.is_none());
992    }
993
994    #[test]
995    fn token_usage_back_compat_without_reasoning_tokens() {
996        let json = r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#;
997        let u: TokenUsage = serde_json::from_str(json).unwrap();
998        assert_eq!(u.total_tokens, 3);
999        assert!(u.reasoning_tokens.is_none());
1000    }
1001}