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