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    /// Caller-attached observability context. The kernel does not
410    /// interpret or forward this to providers — it exists so middleware
411    /// (e.g. an observability adapter) can nest the generation under a
412    /// caller-opened trace, vary sessions per call, and name
413    /// observations. See [`ObservabilityContext`].
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub observability: Option<ObservabilityContext>,
416}
417
418/// Vendor-neutral observability context carried on an [`LLMRequest`].
419///
420/// The kernel treats this as opaque data — it never parses or forwards
421/// the values. Observability middleware consumes it:
422///
423/// - `traceparent` lets an adapter attach the generation under a trace
424///   the caller already opened (W3C trace-context format, matching the
425///   standard `traceparent` header, so callers inside an OpenTelemetry
426///   context can extract and pass it directly).
427/// - `session_id` groups related calls per execution unit rather than
428///   per client.
429/// - `name` overrides the observation name (verb-first, low-cardinality,
430///   model-free — backends index and filter by name).
431/// - `tags`/`metadata` ride along for backend-side filtering.
432#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
433pub struct ObservabilityContext {
434    /// W3C trace context of the parent span (`traceparent` header format).
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub traceparent: Option<String>,
437    /// Session id grouping related calls in observability backends.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub session_id: Option<String>,
440    /// Observation name override (verb-first, low-cardinality, model-free).
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub name: Option<String>,
443    /// Tags attached to the trace in observability backends.
444    #[serde(default, skip_serializing_if = "Vec::is_empty")]
445    pub tags: Vec<String>,
446    /// Free-form string metadata forwarded to observability backends.
447    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
448    pub metadata: std::collections::BTreeMap<String, String>,
449}
450
451impl Default for LLMRequest {
452    fn default() -> Self {
453        Self {
454            system: None,
455            messages: Vec::new(),
456            // Matches `LLMRequestBuilder::build()`'s `unwrap_or(0.7)` so the two
457            // construction paths agree. Keep these coupled.
458            temperature: 0.7,
459            max_tokens: None,
460            model: None,
461            response_format: None,
462            tools: None,
463            reasoning: None,
464            verbosity: None,
465            extra_body: None,
466            observability: None,
467        }
468    }
469}
470
471impl LLMRequest {
472    /// Create a new builder for constructing an `LLMRequest`.
473    pub fn builder() -> LLMRequestBuilder {
474        LLMRequestBuilder::default()
475    }
476
477    /// Convert into OpenAI-format messages, consuming the request.
478    ///
479    /// Prepends a system message if `self.system` is set.
480    pub(crate) fn into_openai_messages(self) -> Vec<(String, String)> {
481        let mut out = Vec::with_capacity(self.messages.len() + 1);
482        if let Some(system) = self.system {
483            out.push(("system".into(), system));
484        }
485        for msg in self.messages {
486            out.push((msg.role.to_string(), msg.text_content()));
487        }
488        out
489    }
490
491    /// Convert into Anthropic-format messages, consuming the request.
492    ///
493    /// Returns only user/assistant messages (system is handled separately by Anthropic API).
494    pub(crate) fn into_anthropic_messages(self) -> Vec<(String, String)> {
495        self.messages
496            .into_iter()
497            .map(|m| (m.role.to_string(), m.text_content()))
498            .collect()
499    }
500}
501
502/// Builder for constructing `LLMRequest` instances with a fluent API.
503///
504/// # Example
505///
506/// ```no_run
507/// use llm_kernel::llm::LLMRequest;
508///
509/// let request = LLMRequest::builder()
510///     .system("You are concise.")
511///     .user_message("Summarise Rust ownership in one line.")
512///     .temperature(0.0)
513///     .build();
514/// ```
515#[derive(Debug, Clone, Default)]
516pub struct LLMRequestBuilder {
517    system: Option<String>,
518    messages: Vec<ChatMessage>,
519    temperature: Option<f32>,
520    max_tokens: Option<u32>,
521    model: Option<String>,
522    response_format: Option<ResponseFormat>,
523    tools: Option<Vec<crate::llm::ToolDefinition>>,
524    reasoning: Option<ReasoningConfig>,
525    verbosity: Option<Verbosity>,
526    extra_body: Option<serde_json::Map<String, serde_json::Value>>,
527    observability: Option<ObservabilityContext>,
528}
529
530impl LLMRequestBuilder {
531    /// Set the system prompt.
532    pub fn system(mut self, prompt: impl Into<String>) -> Self {
533        self.system = Some(prompt.into());
534        self
535    }
536
537    /// Append a user message.
538    pub fn user_message(mut self, content: impl Into<String>) -> Self {
539        self.messages.push(ChatMessage::user(content));
540        self
541    }
542
543    /// Append an assistant message.
544    pub fn assistant_message(mut self, content: impl Into<String>) -> Self {
545        self.messages.push(ChatMessage::assistant(content));
546        self
547    }
548
549    /// Append a raw `ChatMessage`.
550    pub fn message(mut self, msg: ChatMessage) -> Self {
551        self.messages.push(msg);
552        self
553    }
554
555    /// Replace the message list with the provided messages.
556    ///
557    /// Convenience for callers that already hold a `Vec<ChatMessage>` (e.g. a
558    /// pre-built conversation), avoiding repeated `.message()` calls.
559    pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
560        self.messages = messages;
561        self
562    }
563
564    /// Set the sampling temperature.
565    pub fn temperature(mut self, temp: f32) -> Self {
566        self.temperature = Some(temp);
567        self
568    }
569
570    /// Set the maximum tokens to generate.
571    pub fn max_tokens(mut self, tokens: u32) -> Self {
572        self.max_tokens = Some(tokens);
573        self
574    }
575
576    /// Set the maximum tokens to generate, or `None` to use the provider default.
577    ///
578    /// Convenience for callers that already hold an `Option<u32>` (e.g. a
579    /// config field), avoiding a conditional chain.
580    pub fn maybe_max_tokens(mut self, tokens: Option<u32>) -> Self {
581        self.max_tokens = tokens;
582        self
583    }
584
585    /// Override the model for this request.
586    pub fn model(mut self, model: impl Into<String>) -> Self {
587        self.model = Some(model.into());
588        self
589    }
590
591    /// Set the desired response format.
592    pub fn response_format(mut self, format: ResponseFormat) -> Self {
593        self.response_format = Some(format);
594        self
595    }
596
597    /// Set the tool definitions available to the model.
598    pub fn tools(mut self, tools: Vec<crate::llm::ToolDefinition>) -> Self {
599        self.tools = Some(tools);
600        self
601    }
602
603    /// Set reasoning-model controls (effort / on-off switch / summary).
604    pub fn reasoning(mut self, cfg: ReasoningConfig) -> Self {
605        self.reasoning = Some(cfg);
606        self
607    }
608
609    /// Set the response verbosity (OpenAI `verbosity`).
610    pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
611        self.verbosity = Some(verbosity);
612        self
613    }
614
615    /// Merge extra provider parameters into the request body verbatim
616    /// (last-write-wins). Any official spec parameter or provider extension.
617    pub fn extra_body(mut self, extra: serde_json::Map<String, serde_json::Value>) -> Self {
618        self.extra_body = Some(extra);
619        self
620    }
621
622    /// Attach an observability context for middleware (kernel-opaque).
623    pub fn observability(mut self, context: ObservabilityContext) -> Self {
624        self.observability = Some(context);
625        self
626    }
627
628    /// Convenience: set just the session id, creating the context if
629    /// absent and preserving other fields otherwise.
630    pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
631        let context = self.observability.get_or_insert_with(Default::default);
632        context.session_id = Some(session_id.into());
633        self
634    }
635
636    /// Build the `LLMRequest`.
637    pub fn build(self) -> LLMRequest {
638        LLMRequest {
639            system: self.system,
640            messages: self.messages,
641            temperature: self.temperature.unwrap_or(0.7),
642            max_tokens: self.max_tokens,
643            model: self.model,
644            response_format: self.response_format,
645            tools: self.tools,
646            reasoning: self.reasoning,
647            verbosity: self.verbosity,
648            extra_body: self.extra_body,
649            observability: self.observability,
650        }
651    }
652}
653
654/// A chat completion response from an LLM provider.
655///
656/// Implements [`Default`] for forward-compatible struct-update syntax
657/// (`LLMResponse { ..LLMResponse::default() }`).
658#[derive(Debug, Clone, Default, Serialize, Deserialize)]
659pub struct LLMResponse {
660    /// Generated text content.
661    pub content: String,
662    /// Reasoning model's chain-of-thought (GLM-4.5+/z.ai, OpenAI o1, DeepSeek-R1).
663    ///
664    /// When the provider leaves `content` empty and returns the final answer in
665    /// `reasoning_content`, the client promotes the reasoning into `content` and
666    /// still preserves the original here. `None` for non-reasoning models and for
667    /// cache entries written before this field existed (serde default).
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub reasoning: Option<String>,
670    /// Model that produced this response.
671    pub model: String,
672    /// Token usage statistics.
673    pub usage: TokenUsage,
674    /// Tool calls the model requested this turn.
675    ///
676    /// Empty unless the request supplied [`LLMRequest::tools`] and the model
677    /// chose to call one. Each entry carries the provider-assigned call `id`,
678    /// tool `name`, and JSON-encoded `arguments`.
679    #[serde(default, skip_serializing_if = "Vec::is_empty")]
680    pub tool_calls: Vec<crate::llm::ToolCall>,
681    /// Reason the generation stopped (e.g. `"stop"`, `"length"`, `"tool_calls"`).
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub finish_reason: Option<String>,
684    /// Provider-assigned response ID (useful for logging and deduplication).
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub id: Option<String>,
687    /// Unix timestamp (seconds) when the response was created.
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub created: Option<u64>,
690}
691
692/// Token usage statistics from an LLM response.
693#[derive(Debug, Clone, Default, Serialize, Deserialize)]
694pub struct TokenUsage {
695    /// Number of tokens in the prompt.
696    pub prompt_tokens: u32,
697    /// Number of tokens in the completion.
698    pub completion_tokens: u32,
699    /// Total tokens (prompt + completion).
700    pub total_tokens: u32,
701    /// Reasoning-only tokens (o1 / GLM-4.7 `completion_tokens_details.reasoning_tokens`).
702    /// `None` when the provider does not report it.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub reasoning_tokens: Option<u32>,
705}
706
707/// A single event in an LLM streaming response.
708///
709/// `#[non_exhaustive]`: new variants (e.g. reasoning/tool-call streaming) may be
710/// added in a minor release without breaking downstream `match` arms — external
711/// consumers must include a `_ =>` arm. Within this crate, exhaustive matching is
712/// still permitted.
713#[derive(Debug, Clone)]
714#[non_exhaustive]
715pub enum StreamEvent {
716    /// Partial text content arrived.
717    Delta {
718        /// The partial text chunk.
719        content: String,
720    },
721    /// Partial reasoning content arrived (GLM `delta.reasoning_content`,
722    /// Anthropic `thinking_delta`).
723    ///
724    /// **Streaming/reasoning asymmetry (important):** the non-streaming
725    /// `LLMClient::complete` path promotes reasoning into `content` when the
726    /// provider leaves `content` empty (notably GLM-4.7), so non-streaming callers
727    /// transparently receive the final answer. The streaming path does **not**
728    /// perform this promotion — it emits `ReasoningDelta` and `Delta` as separate
729    /// events and leaves accumulation to the consumer. For reasoning-only models
730    /// that put the answer in `reasoning_content` (GLM-4.7), streaming consumers
731    /// **must** accumulate both `ReasoningDelta` and `Delta` chunks to reconstruct
732    /// the full answer; accumulating `Delta` alone yields an empty result.
733    ReasoningDelta {
734        /// The partial reasoning chunk.
735        content: String,
736    },
737    /// Final token usage statistics.
738    Usage(TokenUsage),
739    /// Stream has ended.
740    Done,
741}
742
743/// Type alias for a boxed streaming response.
744#[cfg(feature = "client-async")]
745pub type LLMStream =
746    Pin<Box<dyn futures_core::Stream<Item = crate::error::Result<StreamEvent>> + Send>>;
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn message_role_display() {
754        assert_eq!(MessageRole::System.to_string(), "system");
755        assert_eq!(MessageRole::User.to_string(), "user");
756        assert_eq!(MessageRole::Assistant.to_string(), "assistant");
757        assert_eq!(MessageRole::Tool.to_string(), "tool");
758    }
759
760    #[test]
761    fn message_role_serde_roundtrip() {
762        let json = serde_json::to_string(&MessageRole::User).unwrap();
763        assert_eq!(json, "\"user\"");
764        let back: MessageRole = serde_json::from_str(&json).unwrap();
765        assert_eq!(back, MessageRole::User);
766    }
767
768    #[test]
769    fn chat_message_constructors() {
770        let sys = ChatMessage::system("instructions");
771        assert_eq!(sys.role, MessageRole::System);
772
773        let user = ChatMessage::user("hello");
774        assert_eq!(user.role, MessageRole::User);
775
776        let asst = ChatMessage::assistant("hi there");
777        assert_eq!(asst.role, MessageRole::Assistant);
778
779        let tool = ChatMessage::tool("result");
780        assert_eq!(tool.role, MessageRole::Tool);
781    }
782
783    #[test]
784    fn single_text_serializes_as_string() {
785        let msg = ChatMessage::user("hello");
786        let json = serde_json::to_string(&msg).unwrap();
787        assert!(json.contains("\"content\":\"hello\""), "got: {json}");
788    }
789
790    #[test]
791    fn multipart_serializes_as_array() {
792        let msg = ChatMessage::user_multimodal(vec![
793            ContentPart::text("describe this"),
794            ContentPart::image_url("https://example.com/img.png"),
795        ]);
796        let json = serde_json::to_string(&msg).unwrap();
797        assert!(
798            json.contains("\"content\":["),
799            "expected array serialization, got: {json}"
800        );
801    }
802
803    #[test]
804    fn single_text_deserialize_from_string() {
805        let json = r#"{"role":"user","content":"hello"}"#;
806        let msg: ChatMessage = serde_json::from_str(json).unwrap();
807        assert_eq!(msg.role, MessageRole::User);
808        assert_eq!(msg.content.len(), 1);
809        assert_eq!(msg.text_content(), "hello");
810    }
811
812    #[test]
813    fn multipart_deserialize_from_array() {
814        let json = r#"{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","url":"https://x.com/img.png"}]}"#;
815        let msg: ChatMessage = serde_json::from_str(json).unwrap();
816        assert_eq!(msg.content.len(), 2);
817    }
818
819    #[test]
820    fn content_part_text_helper() {
821        let p = ContentPart::text("hello");
822        assert_eq!(p.as_text(), Some("hello"));
823    }
824
825    #[test]
826    fn response_format_json_serialization() {
827        let fmt = ResponseFormat::Json;
828        let json = serde_json::to_string(&fmt).unwrap();
829        assert!(json.contains("\"type\":\"json\""), "got: {json}");
830    }
831
832    #[test]
833    fn response_format_text_serialization() {
834        let fmt = ResponseFormat::Text;
835        let json = serde_json::to_string(&fmt).unwrap();
836        assert!(json.contains("\"type\":\"text\""), "got: {json}");
837    }
838
839    #[test]
840    fn response_format_json_schema() {
841        let fmt = ResponseFormat::JsonSchema {
842            schema: serde_json::json!({"type": "object"}),
843        };
844        let json = serde_json::to_string(&fmt).unwrap();
845        assert!(json.contains("json_schema"), "got: {json}");
846    }
847
848    #[test]
849    fn builder_basic() {
850        let req = LLMRequest::builder()
851            .system("you are helpful")
852            .user_message("hello")
853            .temperature(0.5)
854            .build();
855        assert_eq!(req.system.as_deref(), Some("you are helpful"));
856        assert_eq!(req.messages.len(), 1);
857        assert_eq!(req.temperature, 0.5);
858    }
859
860    #[test]
861    fn builder_with_model_and_format() {
862        let req = LLMRequest::builder()
863            .user_message("test")
864            .model("gpt-4o-mini")
865            .response_format(ResponseFormat::Json)
866            .max_tokens(100)
867            .build();
868        assert_eq!(req.model.as_deref(), Some("gpt-4o-mini"));
869        assert!(matches!(req.response_format, Some(ResponseFormat::Json)));
870        assert_eq!(req.max_tokens, Some(100));
871    }
872
873    #[test]
874    fn builder_with_tools() {
875        use crate::llm::ToolDefinition;
876        let req = LLMRequest::builder()
877            .user_message("what's the weather?")
878            .tools(vec![ToolDefinition {
879                name: "get_weather".into(),
880                description: "Get weather".into(),
881                input_schema: serde_json::json!({"type": "object"}),
882            }])
883            .build();
884        assert!(req.tools.is_some());
885        assert_eq!(req.tools.unwrap().len(), 1);
886    }
887
888    /// `LLMRequest::default()` and `LLMRequest::builder().build()` must agree on
889    /// every field. The temperature default (0.7) in particular is duplicated
890    /// between the manual `Default` impl and the builder's `unwrap_or(0.7)`;
891    /// this test couples them so a future edit to one without the other is caught.
892    #[test]
893    fn default_matches_builder_default() {
894        let from_default = LLMRequest::default();
895        let from_builder = LLMRequest::builder().build();
896        assert_eq!(from_default.temperature, from_builder.temperature);
897        assert_eq!(from_default.temperature, 0.7);
898        assert!(from_default.system.is_none());
899        assert!(from_default.messages.is_empty());
900        assert!(from_default.max_tokens.is_none());
901        assert!(from_default.model.is_none());
902        assert!(from_default.response_format.is_none());
903        assert!(from_default.tools.is_none());
904        assert!(from_default.reasoning.is_none());
905        assert!(from_default.verbosity.is_none());
906        assert!(from_default.extra_body.is_none());
907    }
908
909    #[test]
910    fn reasoning_config_disabled_serializes_enabled_only() {
911        let json = serde_json::to_value(ReasoningConfig::disabled()).unwrap();
912        assert_eq!(json, serde_json::json!({"enabled": false}));
913    }
914
915    #[test]
916    fn reasoning_config_effort_serializes_effort_only() {
917        let json = serde_json::to_value(ReasoningConfig::effort(ReasoningEffort::Medium)).unwrap();
918        assert_eq!(json, serde_json::json!({"effort": "medium"}));
919        // Empty config serializes to an empty object (no phantom keys).
920        assert_eq!(
921            serde_json::to_value(ReasoningConfig::default()).unwrap(),
922            serde_json::json!({})
923        );
924    }
925
926    #[test]
927    fn observability_context_roundtrip() {
928        let mut metadata = std::collections::BTreeMap::new();
929        metadata.insert("source".to_string(), "router".to_string());
930        let req = LLMRequest::builder()
931            .user_message("hi")
932            .observability(ObservabilityContext {
933                traceparent: Some(
934                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
935                ),
936                session_id: Some("s-1".to_string()),
937                name: Some("generate-reply".to_string()),
938                tags: vec!["prod".to_string()],
939                metadata,
940            })
941            .build();
942        let ctx = req.observability.as_ref().unwrap();
943        assert_eq!(ctx.name.as_deref(), Some("generate-reply"));
944        assert_eq!(
945            ctx.metadata.get("source").map(String::as_str),
946            Some("router")
947        );
948
949        // Serializes nested; absent when None; absent key deserializes to None.
950        let json = serde_json::to_value(&req).unwrap();
951        assert_eq!(json["observability"]["session_id"], "s-1");
952        let back: LLMRequest = serde_json::from_value(json).unwrap();
953        assert_eq!(back.observability, req.observability);
954        let legacy = serde_json::json!({"messages": [], "temperature": 0.5});
955        let plain: LLMRequest = serde_json::from_value(legacy).unwrap();
956        assert!(plain.observability.is_none());
957    }
958
959    #[test]
960    fn with_session_preserves_other_context_fields() {
961        let req = LLMRequest::builder()
962            .observability(ObservabilityContext {
963                name: Some("generate-reply".to_string()),
964                ..Default::default()
965            })
966            .with_session("s-2")
967            .build();
968        let ctx = req.observability.as_ref().unwrap();
969        assert_eq!(ctx.session_id.as_deref(), Some("s-2"));
970        assert_eq!(ctx.name.as_deref(), Some("generate-reply"));
971        // Absent context is created on demand.
972        let fresh = LLMRequest::builder().with_session("s-3").build();
973        assert_eq!(
974            fresh.observability.unwrap().session_id.as_deref(),
975            Some("s-3")
976        );
977    }
978
979    #[test]
980    fn builder_reasoning_roundtrip() {
981        let req = LLMRequest::builder()
982            .user_message("hi")
983            .reasoning(ReasoningConfig::disabled())
984            .build();
985        assert_eq!(req.reasoning.as_ref().unwrap().enabled, Some(false));
986
987        // Serialized request nests under `reasoning`; absent when None.
988        let with = serde_json::to_value(&req).unwrap();
989        assert_eq!(with["reasoning"]["enabled"], false);
990        let without = serde_json::to_value(LLMRequest::default()).unwrap();
991        assert!(without.get("reasoning").is_none());
992    }
993
994    #[test]
995    fn builder_verbosity_roundtrip() {
996        let req = LLMRequest::builder()
997            .user_message("hi")
998            .verbosity(Verbosity::Low)
999            .build();
1000        assert_eq!(req.verbosity, Some(Verbosity::Low));
1001        let json = serde_json::to_value(&req).unwrap();
1002        assert_eq!(json["verbosity"], "low");
1003        // Absent when None.
1004        assert!(
1005            serde_json::to_value(LLMRequest::default())
1006                .unwrap()
1007                .get("verbosity")
1008                .is_none()
1009        );
1010    }
1011
1012    #[test]
1013    fn builder_extra_body_roundtrip() {
1014        let mut extra = serde_json::Map::new();
1015        extra.insert("seed".into(), 42.into());
1016        extra.insert("stop".into(), ["\n\nUser:"].into());
1017        let req = LLMRequest::builder()
1018            .user_message("hi")
1019            .extra_body(extra)
1020            .build();
1021        let json = serde_json::to_value(&req).unwrap();
1022        assert_eq!(json["extra_body"]["seed"], 42);
1023        assert!(
1024            serde_json::to_value(LLMRequest::default())
1025                .unwrap()
1026                .get("extra_body")
1027                .is_none()
1028        );
1029    }
1030
1031    #[test]
1032    fn llm_request_deserializes_without_reasoning_field() {
1033        // Payloads written before `reasoning` existed must still deserialize.
1034        let json =
1035            r#"{"system":null,"messages":[],"temperature":0.7,"max_tokens":null,"model":null}"#;
1036        let req: LLMRequest = serde_json::from_str(json).unwrap();
1037        assert!(req.reasoning.is_none());
1038        assert!(req.verbosity.is_none());
1039    }
1040
1041    #[test]
1042    fn builder_messages_setter_replaces_list() {
1043        let conv = vec![ChatMessage::user("first"), ChatMessage::assistant("second")];
1044        let req = LLMRequest::builder().messages(conv).build();
1045        assert_eq!(req.messages.len(), 2);
1046        assert_eq!(req.messages[0].role, MessageRole::User);
1047        assert_eq!(req.messages[1].role, MessageRole::Assistant);
1048    }
1049
1050    #[test]
1051    fn builder_maybe_max_tokens_accepts_option() {
1052        // Some — sets the value
1053        let req = LLMRequest::builder().maybe_max_tokens(Some(512)).build();
1054        assert_eq!(req.max_tokens, Some(512));
1055        // None — explicitly defers to provider default
1056        let req = LLMRequest::builder().maybe_max_tokens(None).build();
1057        assert_eq!(req.max_tokens, None);
1058    }
1059
1060    #[test]
1061    fn into_openai_messages_with_system() {
1062        let req = LLMRequest::builder()
1063            .system("be helpful")
1064            .user_message("hi")
1065            .assistant_message("hello")
1066            .build();
1067        let msgs = req.into_openai_messages();
1068        assert_eq!(msgs.len(), 3);
1069        assert_eq!(msgs[0].0, "system");
1070        assert_eq!(msgs[1].0, "user");
1071        assert_eq!(msgs[2].0, "assistant");
1072    }
1073
1074    #[test]
1075    fn into_anthropic_messages_excludes_system() {
1076        let req = LLMRequest::builder()
1077            .system("be helpful")
1078            .user_message("hi")
1079            .build();
1080        let msgs = req.into_anthropic_messages();
1081        assert_eq!(msgs.len(), 1);
1082        assert_eq!(msgs[0].0, "user");
1083    }
1084
1085    #[test]
1086    fn text_content_extracts_text() {
1087        let msg = ChatMessage::user_multimodal(vec![
1088            ContentPart::text("hello "),
1089            ContentPart::image_url("http://x.com/i.png"),
1090            ContentPart::text("world"),
1091        ]);
1092        assert_eq!(msg.text_content(), "hello world");
1093    }
1094
1095    #[test]
1096    fn llm_response_back_compat_without_reasoning_field() {
1097        // Cache entries written before `reasoning` existed must still deserialize.
1098        let json = r#"{"content":"hi","model":"m","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
1099        let r: LLMResponse = serde_json::from_str(json).unwrap();
1100        assert_eq!(r.content, "hi");
1101        assert!(r.reasoning.is_none());
1102    }
1103
1104    #[test]
1105    fn token_usage_back_compat_without_reasoning_tokens() {
1106        let json = r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#;
1107        let u: TokenUsage = serde_json::from_str(json).unwrap();
1108        assert_eq!(u.total_tokens, 3);
1109        assert!(u.reasoning_tokens.is_none());
1110    }
1111}