Skip to main content

zeph_llm/
provider.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::future::Future;
5use std::pin::Pin;
6use std::{
7    any::TypeId,
8    collections::HashMap,
9    sync::{LazyLock, Mutex},
10};
11
12use futures_core::Stream;
13use serde::{Deserialize, Serialize};
14
15use zeph_common::ToolName;
16
17pub use zeph_common::ToolDefinition;
18
19use crate::embed::owned_strs;
20use crate::error::LlmError;
21
22static SCHEMA_CACHE: LazyLock<Mutex<HashMap<TypeId, (serde_json::Value, String)>>> =
23    LazyLock::new(|| Mutex::new(HashMap::new()));
24
25/// Return the JSON schema value and pretty-printed string for type `T`, cached by `TypeId`.
26///
27/// # Errors
28///
29/// Returns an error if schema serialization fails.
30pub(crate) fn cached_schema<T: schemars::JsonSchema + 'static>()
31-> Result<(serde_json::Value, String), crate::LlmError> {
32    let type_id = TypeId::of::<T>();
33    if let Ok(cache) = SCHEMA_CACHE.lock()
34        && let Some(entry) = cache.get(&type_id)
35    {
36        return Ok(entry.clone());
37    }
38    let schema = schemars::schema_for!(T);
39    let value = serde_json::to_value(&schema)
40        .map_err(|e| crate::LlmError::StructuredParse(e.to_string()))?;
41    let pretty = serde_json::to_string_pretty(&schema)
42        .map_err(|e| crate::LlmError::StructuredParse(e.to_string()))?;
43    if let Ok(mut cache) = SCHEMA_CACHE.lock() {
44        cache.insert(type_id, (value.clone(), pretty.clone()));
45    }
46    Ok((value, pretty))
47}
48
49/// Extract the short (unqualified) type name for schema prompts and tool names.
50///
51/// Returns the last `::` segment of [`std::any::type_name::<T>()`], which is always
52/// non-empty. The `"Output"` fallback is unreachable in practice (`type_name` never returns
53/// an empty string and `rsplit` on a non-empty string always yields at least one element),
54/// but is kept for defensive clarity.
55///
56/// # Examples
57///
58/// ```
59/// struct MyOutput;
60/// // short_type_name::<MyOutput>() returns "MyOutput"
61/// ```
62pub(crate) fn short_type_name<T: ?Sized>() -> &'static str {
63    std::any::type_name::<T>()
64        .rsplit("::")
65        .next()
66        .unwrap_or("Output")
67}
68
69/// Per-call extras returned alongside the chat response by [`LlmProvider::chat_with_extras`].
70///
71/// Always paired 1:1 with a single response — no shared state, no races possible.
72/// All optional fields default to `None` so providers that do not expose the
73/// underlying API (e.g. Claude, Gemini) can simply return the default.
74///
75/// Marked `#[non_exhaustive]` so future fields (e.g. `cached_tokens`) can be added
76/// without breaking match sites.
77#[non_exhaustive]
78#[derive(Debug, Clone, Default)]
79pub struct ChatExtras {
80    /// Mean negative log-probability of the generated tokens, when the provider
81    /// was configured to request `logprobs` and the API supplied them.
82    ///
83    /// Lower = more confident. Typical range: `[0.0, ~6.0]` for natural-language tokens.
84    pub entropy: Option<f64>,
85}
86
87impl ChatExtras {
88    /// Return a `ChatExtras` with the given entropy value.
89    ///
90    /// Used by `MockProvider` (test-only, enabled via `testing` feature) and OpenAI/Ollama providers.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use zeph_llm::provider::ChatExtras;
96    ///
97    /// let extras = ChatExtras::with_entropy(0.9);
98    /// assert_eq!(extras.entropy, Some(0.9));
99    /// ```
100    #[must_use]
101    pub fn with_entropy(entropy: f64) -> Self {
102        Self {
103            entropy: Some(entropy),
104        }
105    }
106}
107
108/// A chunk from an LLM streaming response.
109///
110/// Consumers should match all variants: future providers may emit non-`Content` chunks
111/// that callers must not silently drop (e.g. thinking blocks that must be echoed back).
112#[non_exhaustive]
113#[derive(Debug, Clone)]
114pub enum StreamChunk {
115    /// Regular response text.
116    Content(String),
117    /// Internal reasoning/thinking token (e.g. Claude extended thinking, `OpenAI` reasoning).
118    Thinking(String),
119    /// Server-side compaction summary (Claude compact-2026-01-12 beta).
120    /// Delivered when the Claude API automatically summarizes conversation history.
121    Compaction(String),
122    /// One or more tool calls from the model received during streaming.
123    ToolUse(Vec<ToolUseRequest>),
124}
125
126/// Boxed stream of typed chunks from an LLM provider.
127///
128/// Obtain via [`LlmProvider::chat_stream`]. Drive the stream with
129/// `futures::StreamExt::next` or `tokio_stream::StreamExt::next`.
130pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamChunk, LlmError>> + Send>>;
131
132/// Structured tool invocation request from the model.
133///
134/// Returned by [`LlmProvider::chat_with_tools`] when the model decides to call one or
135/// more tools. The caller is responsible for executing the tool and returning results
136/// via a [`MessagePart::ToolResult`] in the next turn.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ToolUseRequest {
139    /// Opaque call identifier assigned by the model; must be echoed in `ToolResult.tool_use_id`.
140    pub id: String,
141    /// Name of the tool to invoke, matching a [`ToolDefinition::name`].
142    pub name: ToolName,
143    /// JSON arguments the model wants to pass to the tool.
144    pub input: serde_json::Value,
145}
146
147/// Thinking block returned by Claude when extended or adaptive thinking is enabled.
148///
149/// Both variants must be echoed verbatim in the next turn's `assistant` message so
150/// the API can correctly attribute reasoning across turns. Never modify or discard
151/// these blocks between turns.
152#[non_exhaustive]
153#[derive(Debug, Clone)]
154pub enum ThinkingBlock {
155    /// Visible reasoning token with its cryptographic signature.
156    Thinking { thinking: String, signature: String },
157    /// Redacted reasoning block (API-side privacy redaction). Preserved as opaque data.
158    Redacted { data: String },
159}
160
161/// Marker injected into `ChatResponse::Text` when the LLM response was cut off by the
162/// token limit. Consumers can detect this substring to signal `MaxTokens` stop reason.
163pub const MAX_TOKENS_TRUNCATION_MARKER: &str = "max_tokens limit reached";
164
165/// Response from [`LlmProvider::chat_with_tools`].
166///
167/// When the model returns `ToolUse`, the caller must:
168/// 1. Execute each tool in `tool_calls`.
169/// 2. Append an `assistant` message with the original `tool_calls` and any `thinking_blocks`.
170/// 3. Append a `user` message containing [`MessagePart::ToolResult`] entries.
171/// 4. Call `chat_with_tools` again to continue the conversation.
172#[non_exhaustive]
173#[derive(Debug, Clone)]
174pub enum ChatResponse {
175    /// Model produced text output only.
176    Text(String),
177    /// Model requests one or more tool invocations.
178    ToolUse {
179        /// Any text the model emitted before/alongside tool calls.
180        text: Option<String>,
181        tool_calls: Vec<ToolUseRequest>,
182        /// Thinking blocks from the model (empty when thinking is disabled).
183        /// Must be preserved verbatim in multi-turn requests.
184        thinking_blocks: Vec<ThinkingBlock>,
185    },
186}
187
188/// Boxed future returning an embedding vector, returned by [`EmbedFn`].
189pub type EmbedFuture = Pin<Box<dyn Future<Output = Result<Vec<f32>, LlmError>> + Send>>;
190
191/// A Send + Sync closure that embeds a text slice into a vector.
192///
193/// Obtain a provider-backed `EmbedFn` via [`crate::any::AnyProvider::embed_fn`].
194/// The closure captures an `Arc`-wrapped provider clone, so it is cheap to clone.
195pub type EmbedFn = Box<dyn Fn(&str) -> EmbedFuture + Send + Sync>;
196
197/// Sender for emitting human-readable status events (retries, fallbacks) to the UI layer.
198///
199/// When set on a provider, the provider sends short strings such as
200/// `"Retrying after rate limit…"` or `"Falling back to secondary provider"`.
201/// The TUI consumes these to show real-time activity spinners.
202pub type StatusTx = tokio::sync::mpsc::UnboundedSender<String>;
203
204/// Best-effort fallback for debug dump request payloads when a provider does not expose
205/// its concrete API request body.
206#[must_use]
207pub fn default_debug_request_json(
208    messages: &[Message],
209    tools: &[ToolDefinition],
210) -> serde_json::Value {
211    serde_json::json!({
212        "model": serde_json::Value::Null,
213        "max_tokens": serde_json::Value::Null,
214        "messages": serde_json::to_value(messages).unwrap_or(serde_json::Value::Array(vec![])),
215        "tools": serde_json::to_value(tools).unwrap_or(serde_json::Value::Array(vec![])),
216        "temperature": serde_json::Value::Null,
217        "cache_control": serde_json::Value::Null,
218    })
219}
220
221/// Partial LLM generation parameter overrides for experiment variation injection.
222///
223/// Applied by the experiment engine to clone-and-patch a provider before evaluation,
224/// so each variation is scored with its specific generation parameters.
225///
226/// Only `Some` fields are applied; `None` fields leave the provider's configured
227/// defaults unchanged. Not all providers support all fields — unsupported fields
228/// are silently ignored by each backend.
229#[derive(Debug, Clone, Default)]
230pub struct GenerationOverrides {
231    /// Sampling temperature in `[0.0, 2.0]`. Lower = more deterministic.
232    pub temperature: Option<f64>,
233    /// Nucleus sampling probability in `[0.0, 1.0]`.
234    pub top_p: Option<f64>,
235    /// Top-K sampling cutoff (number of top tokens to consider).
236    pub top_k: Option<usize>,
237    /// Penalty for tokens that have already appeared (OpenAI-compatible providers).
238    pub frequency_penalty: Option<f64>,
239    /// Penalty for topics the model has already covered (OpenAI-compatible providers).
240    pub presence_penalty: Option<f64>,
241}
242
243/// Message role in a conversation.
244///
245/// Determines how each message is presented to the model:
246/// - `System` — global instructions prepended before the conversation
247/// - `User` — human turn input
248/// - `Assistant` — previous model output (used for multi-turn context)
249#[non_exhaustive]
250#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "lowercase")]
252pub enum Role {
253    System,
254    User,
255    Assistant,
256}
257
258/// A typed content part within a [`Message`].
259///
260/// Messages may contain zero or more parts that represent heterogeneous content:
261/// plain text, tool invocations, memory recall fragments, images, and internal
262/// protocol blocks (thinking, compaction). Most providers flatten these into a single
263/// string before sending; Claude encodes them as structured content blocks.
264///
265/// # Ordering invariants
266///
267/// - `ToolUse` parts must precede their corresponding `ToolResult` parts.
268/// - `ThinkingBlock` / `RedactedThinkingBlock` parts must be preserved verbatim in
269///   multi-turn requests so the API can correctly attribute reasoning.
270/// - `Compaction` parts must be preserved verbatim; the API uses them to prune
271///   prior history on subsequent turns (Claude compact-2026-01-12 beta).
272#[non_exhaustive]
273#[derive(Clone, Debug, Serialize, Deserialize)]
274#[serde(tag = "kind", rename_all = "snake_case")]
275pub enum MessagePart {
276    /// Plain assistant or user text.
277    Text { text: String },
278    /// Output from a tool execution, optionally compacted.
279    ToolOutput {
280        tool_name: zeph_common::ToolName,
281        body: String,
282        #[serde(default, skip_serializing_if = "Option::is_none")]
283        compacted_at: Option<i64>,
284    },
285    /// Memory recall fragment injected by the agent's semantic memory layer.
286    Recall { text: String },
287    /// Repository or file code context injected by the code indexing layer.
288    CodeContext { text: String },
289    /// Compaction summary replacing pruned conversation history.
290    Summary { text: String },
291    /// Cross-session memory fragment carried over from a previous conversation.
292    CrossSession { text: String },
293    /// Model-initiated tool invocation. Pairs with a subsequent [`MessagePart::ToolResult`].
294    ToolUse {
295        id: String,
296        name: String,
297        input: serde_json::Value,
298    },
299    /// Tool execution result returned to the model after a [`MessagePart::ToolUse`].
300    ToolResult {
301        tool_use_id: String,
302        content: String,
303        #[serde(default)]
304        is_error: bool,
305    },
306    /// Inline image payload (vision input).
307    Image(Box<ImageData>),
308    /// Claude thinking block — must be preserved verbatim in multi-turn requests.
309    ThinkingBlock { thinking: String, signature: String },
310    /// Claude redacted thinking block — preserved as-is in multi-turn requests.
311    RedactedThinkingBlock { data: String },
312    /// Claude server-side compaction block — must be preserved verbatim in multi-turn requests
313    /// so the API can correctly prune prior history on the next turn.
314    Compaction { summary: String },
315}
316
317impl MessagePart {
318    /// Return the plain text content if this part is a text-like variant (`Text`, `Recall`,
319    /// `CodeContext`, `Summary`, `CrossSession`), `None` otherwise.
320    #[must_use]
321    pub fn as_plain_text(&self) -> Option<&str> {
322        match self {
323            Self::Text { text }
324            | Self::Recall { text }
325            | Self::CodeContext { text }
326            | Self::Summary { text }
327            | Self::CrossSession { text } => Some(text.as_str()),
328            _ => None,
329        }
330    }
331
332    /// Return the image data if this part is an `Image` variant, `None` otherwise.
333    #[must_use]
334    pub fn as_image(&self) -> Option<&ImageData> {
335        if let Self::Image(img) = self {
336            Some(img)
337        } else {
338            None
339        }
340    }
341
342    /// Return a cloned copy of `parts` with every `Image` entry removed.
343    ///
344    /// `Image` parts are ephemeral, current-turn-only vision input (spec-072 §4, C1) and must
345    /// never reach a persistence sink — `SQLite` `parts_json`, the Qdrant embed path, the durable
346    /// JSONL session log, or a sub-agent transcript file. Callers apply this once, immediately
347    /// above the persistence write, leaving the in-memory slice used for the current turn's
348    /// provider request untouched.
349    ///
350    /// # Examples
351    ///
352    /// ```
353    /// use zeph_llm::provider::{ImageData, MessagePart};
354    ///
355    /// let parts = vec![
356    ///     MessagePart::Text {
357    ///         text: "hello".to_owned(),
358    ///     },
359    ///     MessagePart::Image(Box::new(ImageData {
360    ///         data: vec![0xFF, 0xD8, 0xFF, 0xE0],
361    ///         mime_type: "image/jpeg".to_owned(),
362    ///     })),
363    /// ];
364    /// let stripped = MessagePart::strip_images(&parts);
365    /// assert_eq!(stripped.len(), 1);
366    /// assert!(!stripped.iter().any(|p| matches!(p, MessagePart::Image(_))));
367    /// ```
368    #[must_use]
369    pub fn strip_images(parts: &[MessagePart]) -> Vec<MessagePart> {
370        parts
371            .iter()
372            .filter(|p| !matches!(p, MessagePart::Image(_)))
373            .cloned()
374            .collect()
375    }
376}
377
378#[derive(Clone, Serialize, Deserialize)]
379/// Raw image payload for vision-capable providers.
380///
381/// The `data` field is serialized as a Base64 string. `mime_type` must be a valid
382/// image MIME type supported by the target provider (e.g. `"image/png"`, `"image/jpeg"`).
383pub struct ImageData {
384    #[serde(with = "serde_bytes_base64")]
385    pub data: Vec<u8>,
386    pub mime_type: String,
387}
388
389impl std::fmt::Debug for ImageData {
390    /// Redacts the raw image bytes — only the MIME type and byte count are printed.
391    ///
392    /// `data` can carry arbitrary externally-sourced bytes (MCP tool results, user uploads);
393    /// a derived `Debug` would dump the full payload into logs/panics.
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        write!(f, "[image: {}, {} bytes]", self.mime_type, self.data.len())
396    }
397}
398
399mod serde_bytes_base64 {
400    use base64::{Engine, engine::general_purpose::STANDARD};
401    use serde::{Deserialize, Deserializer, Serializer};
402
403    pub fn serialize<S>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error>
404    where
405        S: Serializer,
406    {
407        s.serialize_str(&STANDARD.encode(bytes))
408    }
409
410    pub fn deserialize<'de, D>(d: D) -> Result<Vec<u8>, D::Error>
411    where
412        D: Deserializer<'de>,
413    {
414        let s = String::deserialize(d)?;
415        STANDARD.decode(&s).map_err(serde::de::Error::custom)
416    }
417}
418
419/// Visibility of a message to agent and user.
420///
421/// Replaces the former `(agent_visible: bool, user_visible: bool)` pair, which
422/// allowed the semantically invalid `(false, false)` combination. Every variant
423/// guarantees at least one consumer can see the message.
424///
425/// # Examples
426///
427/// ```
428/// use zeph_llm::provider::MessageVisibility;
429///
430/// let v = MessageVisibility::AgentOnly;
431/// assert!(v.is_agent_visible());
432/// assert!(!v.is_user_visible());
433/// ```
434#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum MessageVisibility {
438    /// Visible to both the agent (LLM context) and the user (conversation log).
439    Both,
440    /// Visible to the agent only (e.g. compaction summaries, internal context).
441    AgentOnly,
442    /// Visible to the user only (e.g. compacted originals shown in history).
443    UserOnly,
444}
445
446impl MessageVisibility {
447    /// Returns `true` if this message should be included in the LLM request context.
448    #[must_use]
449    pub fn is_agent_visible(self) -> bool {
450        matches!(self, MessageVisibility::Both | MessageVisibility::AgentOnly)
451    }
452
453    /// Returns `true` if this message should appear in the user-facing conversation log.
454    #[must_use]
455    pub fn is_user_visible(self) -> bool {
456        matches!(self, MessageVisibility::Both | MessageVisibility::UserOnly)
457    }
458}
459
460impl Default for MessageVisibility {
461    /// Defaults to [`Both`](MessageVisibility::Both) — visible to agent and user.
462    fn default() -> Self {
463        MessageVisibility::Both
464    }
465}
466
467impl MessageVisibility {
468    /// Serialize to the SQLite/PostgreSQL text value stored in the `visibility` column.
469    #[must_use]
470    pub fn as_db_str(self) -> &'static str {
471        match self {
472            MessageVisibility::Both => "both",
473            MessageVisibility::AgentOnly => "agent_only",
474            MessageVisibility::UserOnly => "user_only",
475        }
476    }
477
478    /// Deserialize from the SQLite/PostgreSQL text value stored in the `visibility` column.
479    ///
480    /// Unknown values (e.g. from a future migration) default to `Both` for safety.
481    #[must_use]
482    pub fn from_db_str(s: &str) -> Self {
483        match s {
484            "agent_only" => MessageVisibility::AgentOnly,
485            "user_only" => MessageVisibility::UserOnly,
486            _ => MessageVisibility::Both,
487        }
488    }
489}
490
491/// Per-message visibility and metadata controlling agent context and user display.
492///
493/// Constructors [`agent_only`](Self::agent_only), [`user_only`](Self::user_only),
494/// and [`focus_pinned`](Self::focus_pinned) cover the most common combinations.
495#[derive(Clone, Debug, Serialize, Deserialize)]
496pub struct MessageMetadata {
497    /// Who can see this message.
498    pub visibility: MessageVisibility,
499    /// Unix timestamp (seconds) when this message was compacted, if applicable.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub compacted_at: Option<i64>,
502    /// Pre-computed tool pair summary, applied lazily when context pressure rises.
503    /// Stored on the tool response message; cleared after application.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub deferred_summary: Option<String>,
506    /// When true, this message is excluded from all compaction passes (soft pruning,
507    /// hard summarization, sidequest eviction). Used for the Focus Knowledge block (#1850).
508    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
509    pub focus_pinned: bool,
510    /// Unique marker UUID set when `start_focus` begins a session. Used by `complete_focus`
511    /// to locate the checkpoint without relying on a fragile raw index.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub focus_marker_id: Option<uuid::Uuid>,
514    /// `SQLite` row ID for this message. Populated when loading from DB or after persisting.
515    /// Never serialized — always re-populated from the database on load.
516    #[serde(skip)]
517    pub db_id: Option<i64>,
518    /// Fidelity level assigned by `FidelityScorer` during context assembly.
519    ///
520    /// `None` when fidelity scoring is disabled or the message has not yet been scored.
521    /// Used for debug tracing and compaction input filtering (INV-02).
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub fidelity_tag: Option<zeph_common::ContextFidelity>,
524    /// Cached embedding vector for semantic fidelity scoring.
525    ///
526    /// In-memory only — not serialized or persisted to the database.
527    #[serde(skip)]
528    pub embedding: Option<Vec<f32>>,
529}
530
531impl Default for MessageMetadata {
532    fn default() -> Self {
533        Self {
534            visibility: MessageVisibility::Both,
535            compacted_at: None,
536            deferred_summary: None,
537            focus_pinned: false,
538            focus_marker_id: None,
539            db_id: None,
540            fidelity_tag: None,
541            embedding: None,
542        }
543    }
544}
545
546impl MessageMetadata {
547    /// Message visible only to the agent (e.g. compaction summary).
548    #[must_use]
549    pub fn agent_only() -> Self {
550        Self {
551            visibility: MessageVisibility::AgentOnly,
552            compacted_at: None,
553            deferred_summary: None,
554            focus_pinned: false,
555            focus_marker_id: None,
556            db_id: None,
557            fidelity_tag: None,
558            embedding: None,
559        }
560    }
561
562    /// Message visible only to the user (e.g. compacted original).
563    #[must_use]
564    pub fn user_only() -> Self {
565        Self {
566            visibility: MessageVisibility::UserOnly,
567            compacted_at: None,
568            deferred_summary: None,
569            focus_pinned: false,
570            focus_marker_id: None,
571            db_id: None,
572            fidelity_tag: None,
573            embedding: None,
574        }
575    }
576
577    /// Pinned Knowledge block — excluded from all compaction passes.
578    #[must_use]
579    pub fn focus_pinned() -> Self {
580        Self {
581            visibility: MessageVisibility::AgentOnly,
582            compacted_at: None,
583            deferred_summary: None,
584            focus_pinned: true,
585            focus_marker_id: None,
586            db_id: None,
587            fidelity_tag: None,
588            embedding: None,
589        }
590    }
591}
592
593/// A single message in a conversation.
594///
595/// Each message has a [`Role`], a flat `content` string (used when sending to providers
596/// that do not support structured parts), and an optional list of [`MessagePart`]s for
597/// providers that accept heterogeneous content blocks (e.g. Claude).
598///
599/// The `content` field is kept in sync with `parts` via [`Message::rebuild_content`].
600/// When building messages from structured parts, always use [`Message::from_parts`] —
601/// it populates both `parts` and `content`.
602///
603/// # Examples
604///
605/// ```
606/// use zeph_llm::provider::{Message, MessagePart, Role};
607///
608/// // Simple text-only message
609/// let msg = Message::from_legacy(Role::User, "What is Rust?");
610/// assert_eq!(msg.to_llm_content(), "What is Rust?");
611///
612/// // Structured message with parts
613/// let parts = vec![
614///     MessagePart::Text { text: "Explain this code.".into() },
615/// ];
616/// let msg = Message::from_parts(Role::User, parts);
617/// assert!(!msg.parts.is_empty());
618/// ```
619#[derive(Clone, Debug, Serialize, Deserialize)]
620pub struct Message {
621    pub role: Role,
622    /// Flat text representation of this message, derived from `parts` when structured.
623    pub content: String,
624    #[serde(default)]
625    pub parts: Vec<MessagePart>,
626    #[serde(default)]
627    pub metadata: MessageMetadata,
628}
629
630impl Default for Message {
631    fn default() -> Self {
632        Self {
633            role: Role::User,
634            content: String::new(),
635            parts: vec![],
636            metadata: MessageMetadata::default(),
637        }
638    }
639}
640
641impl Message {
642    /// Create a simple text-only message without structured parts.
643    ///
644    /// Use this constructor for system prompts, plain user turns, and assistant
645    /// messages produced by providers that return a raw string.
646    #[must_use]
647    pub fn from_legacy(role: Role, content: impl Into<String>) -> Self {
648        Self {
649            role,
650            content: content.into(),
651            parts: vec![],
652            metadata: MessageMetadata::default(),
653        }
654    }
655
656    /// Create a message from structured parts, deriving the flat `content` automatically.
657    ///
658    /// Prefer this constructor when the message contains tool invocations, images,
659    /// or other non-text content that providers need to render as separate content blocks.
660    #[must_use]
661    pub fn from_parts(role: Role, parts: Vec<MessagePart>) -> Self {
662        let content = Self::flatten_parts(&parts);
663        Self {
664            role,
665            content,
666            parts,
667            metadata: MessageMetadata::default(),
668        }
669    }
670
671    /// Return the flat text content of this message, suitable for providers that do
672    /// not support structured content blocks.
673    #[must_use]
674    pub fn to_llm_content(&self) -> &str {
675        &self.content
676    }
677
678    /// Re-synchronize `content` from `parts` after in-place mutation.
679    pub fn rebuild_content(&mut self) {
680        if !self.parts.is_empty() {
681            self.content = Self::flatten_parts(&self.parts);
682        }
683    }
684
685    fn flatten_parts(parts: &[MessagePart]) -> String {
686        use std::fmt::Write;
687        let mut out = String::new();
688        for part in parts {
689            match part {
690                MessagePart::Text { text }
691                | MessagePart::Recall { text }
692                | MessagePart::CodeContext { text }
693                | MessagePart::Summary { text }
694                | MessagePart::CrossSession { text } => out.push_str(text),
695                MessagePart::ToolOutput {
696                    tool_name,
697                    body,
698                    compacted_at,
699                } => {
700                    if compacted_at.is_some() {
701                        if body.is_empty() {
702                            let _ = write!(out, "[tool output: {tool_name}] (pruned)");
703                        } else {
704                            let _ = write!(out, "[tool output: {tool_name}] {body}");
705                        }
706                    } else {
707                        let _ = write!(out, "[tool output: {tool_name}]\n```\n{body}\n```");
708                    }
709                }
710                MessagePart::ToolUse { id, name, .. } => {
711                    let _ = write!(out, "[tool_use: {name}({id})]");
712                }
713                MessagePart::ToolResult {
714                    tool_use_id,
715                    content,
716                    ..
717                } => {
718                    let _ = write!(out, "[tool_result: {tool_use_id}]\n{content}");
719                }
720                MessagePart::Image(img) => {
721                    let _ = write!(out, "[image: {}, {} bytes]", img.mime_type, img.data.len());
722                }
723                // Thinking and compaction blocks are internal API metadata — not rendered in text.
724                MessagePart::ThinkingBlock { .. }
725                | MessagePart::RedactedThinkingBlock { .. }
726                | MessagePart::Compaction { .. } => {}
727            }
728        }
729        out
730    }
731}
732
733/// Core abstraction for all LLM inference backends.
734///
735/// Every backend — `Ollama`, `Claude`, `OpenAI`, `Gemini`, `Candle` — implements this trait.
736/// The [`crate::any::AnyProvider`] enum erases the concrete type so callers can
737/// hold any backend behind a single type, and [`crate::router::RouterProvider`]
738/// implements this trait to multiplex across multiple backends.
739///
740/// # Object safety
741///
742/// This trait is **not** object-safe: 6 methods return `impl Future + Send` (RPIT),
743/// and [`chat_typed`](Self::chat_typed) carries a generic type parameter `T`.
744/// The `where Self: Sized` bound on `chat_typed` is a mitigation — it excludes that
745/// method from the vtable — but the RPIT methods remain and prevent `dyn LlmProvider`.
746/// Attempting `Box<dyn LlmProvider>` will produce a compile error.
747///
748/// For dynamic dispatch, use [`Arc<dyn LlmProviderDyn>`](crate::provider_dyn::LlmProviderDyn)
749/// instead — a blanket impl wires every `LlmProvider` implementor automatically.
750/// See the [`provider_dyn`](crate::provider_dyn) module for details.
751///
752/// # Required methods
753///
754/// Implementors must provide: [`chat`](Self::chat), [`chat_stream`](Self::chat_stream),
755/// [`supports_streaming`](Self::supports_streaming), [`embed`](Self::embed),
756/// [`supports_embeddings`](Self::supports_embeddings), and [`name`](Self::name).
757///
758/// # Optional methods
759///
760/// All other methods have default implementations that are safe to accept:
761/// - [`context_window`](Self::context_window) — returns `None`
762/// - [`embed_batch`](Self::embed_batch) — sequential fallback via [`embed`](Self::embed)
763/// - [`chat_with_tools`](Self::chat_with_tools) — falls back to [`chat`](Self::chat)
764/// - [`chat_typed`](Self::chat_typed) — schema-prompt injection + retry
765///   (requires `Self: Sized`; use [`chat_typed_dyn`](crate::provider_dyn::chat_typed_dyn)
766///   for trait objects)
767/// - [`supports_vision`](Self::supports_vision) — returns `false`
768/// - [`supports_tool_use`](Self::supports_tool_use) — returns `false`, matching the
769///   [`chat_with_tools`](Self::chat_with_tools) default fallback which silently drops
770///   tool definitions; providers must override both together to opt into tool use
771///
772/// # Examples
773///
774/// ```rust,no_run
775/// use zeph_llm::provider::{LlmProvider, Message, Role, ChatStream};
776/// use zeph_llm::LlmError;
777///
778/// struct EchoProvider;
779///
780/// impl LlmProvider for EchoProvider {
781///     async fn chat(&self, messages: &[Message]) -> Result<String, LlmError> {
782///         Ok(messages.last().map(|m| m.content.clone()).unwrap_or_default())
783///     }
784///
785///     async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
786///         use zeph_llm::provider::StreamChunk;
787///         let text = self.chat(messages).await?;
788///         Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(text)))))
789///     }
790///
791///     fn supports_streaming(&self) -> bool { true }
792///
793///     async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
794///         Err(LlmError::EmbedUnsupported { provider: "echo".into() })
795///     }
796///
797///     fn supports_embeddings(&self) -> bool { false }
798///
799///     fn name(&self) -> &str { "echo" }
800/// }
801/// ```
802pub trait LlmProvider: Send + Sync {
803    /// Report the model's context window size in tokens.
804    ///
805    /// Returns `None` if unknown. Used for auto-budget calculation.
806    fn context_window(&self) -> Option<usize> {
807        None
808    }
809
810    /// Send messages to the LLM and return the assistant response.
811    ///
812    /// # Errors
813    ///
814    /// Returns an error if the provider fails to communicate or the response is invalid.
815    fn chat(&self, messages: &[Message]) -> impl Future<Output = Result<String, LlmError>> + Send;
816
817    /// Send messages and return a stream of response chunks.
818    ///
819    /// # Errors
820    ///
821    /// Returns an error if the provider fails to communicate or the response is invalid.
822    fn chat_stream(
823        &self,
824        messages: &[Message],
825    ) -> impl Future<Output = Result<ChatStream, LlmError>> + Send;
826
827    /// Whether this provider supports native streaming.
828    fn supports_streaming(&self) -> bool;
829
830    /// Generate an embedding vector from text.
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the provider does not support embeddings or the request fails.
835    fn embed(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, LlmError>> + Send;
836
837    /// Embed multiple texts in a single API call.
838    ///
839    /// Default implementation calls [`embed`][Self::embed] sequentially for each input.
840    /// Providers with native batch APIs should override this.
841    ///
842    /// # Errors
843    ///
844    /// Returns an error if any embedding fails. On native batch backends the entire batch
845    /// fails atomically; on the sequential fallback the first error aborts.
846    fn embed_batch(
847        &self,
848        texts: &[&str],
849    ) -> impl Future<Output = Result<Vec<Vec<f32>>, LlmError>> + Send {
850        let owned = owned_strs(texts);
851        async move {
852            let mut results = Vec::with_capacity(owned.len());
853            for text in &owned {
854                results.push(self.embed(text).await?);
855            }
856            Ok(results)
857        }
858    }
859
860    /// Whether this provider supports embedding generation.
861    fn supports_embeddings(&self) -> bool;
862
863    /// Provider name for logging and identification.
864    fn name(&self) -> &str;
865
866    /// Model identifier string (e.g. `gpt-4o-mini`, `claude-sonnet-5`).
867    /// Used by cost-estimation heuristics. Returns `""` when not applicable.
868    #[allow(clippy::unnecessary_literal_bound)]
869    fn model_identifier(&self) -> &str {
870        ""
871    }
872
873    /// Model identifier that actually served the most recent dispatch.
874    ///
875    /// For a concrete single-model provider (Claude, `OpenAI`, Candle, ...) this is
876    /// identical to [`model_identifier`](Self::model_identifier) — the default
877    /// implementation simply forwards to it. Routing providers (`Router`,
878    /// `TriageRouter`) override it to resolve the sub-provider that served the last
879    /// call, since their own `model_identifier()` returns a stable routing-policy
880    /// label (e.g. `"router"`) rather than a real model id. Callers that need to
881    /// reason about the model that produced a specific response (for example,
882    /// `is_reasoning_model` detection) should call this method instead of
883    /// `model_identifier()`.
884    fn effective_model_identifier(&self) -> &str {
885        self.model_identifier()
886    }
887
888    /// Whether this provider supports image input (vision).
889    fn supports_vision(&self) -> bool {
890        false
891    }
892
893    /// Whether this provider supports native `tool_use` / function calling.
894    ///
895    /// Defaults to `false` because [`chat_with_tools`](Self::chat_with_tools) defaults
896    /// to falling back on [`chat`](Self::chat), which silently discards tool
897    /// definitions. Providers implementing real tool calling must override both
898    /// this method and `chat_with_tools` together.
899    fn supports_tool_use(&self) -> bool {
900        false
901    }
902
903    /// Send messages with tool definitions, returning a structured response.
904    ///
905    /// Default: falls back to `chat()` and wraps the result in `ChatResponse::Text`.
906    ///
907    /// # Errors
908    ///
909    /// Returns an error if the provider fails to communicate or the response is invalid.
910    fn chat_with_tools(
911        &self,
912        messages: &[Message],
913        _tools: &[ToolDefinition],
914    ) -> impl std::future::Future<Output = Result<ChatResponse, LlmError>> + Send {
915        let msgs = messages.to_vec();
916        async move { Ok(ChatResponse::Text(self.chat(&msgs).await?)) }
917    }
918
919    /// Return the cache usage from the last API call, if available.
920    /// Returns `(cache_creation_tokens, cache_read_tokens)`.
921    fn last_cache_usage(&self) -> Option<(u64, u64)> {
922        None
923    }
924
925    /// Return token counts from the last API call, if available.
926    /// Returns `(input_tokens, output_tokens)`.
927    fn last_usage(&self) -> Option<(u64, u64)> {
928        None
929    }
930
931    /// Return reasoning tokens from the last API call, if the provider reports them.
932    ///
933    /// Reasoning tokens are a **subset** of completion tokens (`OpenAI` o-series only).
934    /// Returns `None` for providers that do not expose reasoning token counts.
935    fn last_reasoning_tokens(&self) -> Option<u64> {
936        None
937    }
938
939    /// Return the compaction summary from the most recent API call, if a server-side
940    /// compaction occurred (Claude compact-2026-01-12 beta). Clears the stored value.
941    fn take_compaction_summary(&self) -> Option<String> {
942        None
943    }
944
945    /// Send messages and return the assistant response together with per-call extras.
946    ///
947    /// Default implementation calls [`chat`][Self::chat] and returns [`ChatExtras::default()`],
948    /// keeping every existing implementor source-compatible at zero cost.
949    ///
950    /// Providers that support logprobs (`OpenAI`, `Compatible`, `Ollama`) override this to
951    /// populate [`ChatExtras::entropy`] with the mean negative log-probability.
952    ///
953    /// `CoE` is the only caller of this method; the canonical entry point for the agent
954    /// loop remains [`chat`][Self::chat].
955    ///
956    /// # Errors
957    ///
958    /// Same as [`chat`][Self::chat].
959    fn chat_with_extras(
960        &self,
961        messages: &[Message],
962    ) -> impl Future<Output = Result<(String, ChatExtras), LlmError>> + Send {
963        let msgs = messages.to_vec();
964        async move { Ok((self.chat(&msgs).await?, ChatExtras::default())) }
965    }
966
967    /// Return the request payload that will be sent to the provider, for debug dumps.
968    ///
969    /// Implementations should mirror the provider's request body as closely as practical.
970    #[must_use]
971    fn debug_request_json(
972        &self,
973        messages: &[Message],
974        tools: &[ToolDefinition],
975        _stream: bool,
976    ) -> serde_json::Value {
977        default_debug_request_json(messages, tools)
978    }
979
980    /// Return the list of model identifiers this provider can serve.
981    /// Default: empty (provider does not advertise models).
982    fn list_models(&self) -> Vec<String> {
983        vec![]
984    }
985
986    /// Whether this provider supports native structured output.
987    fn supports_structured_output(&self) -> bool {
988        false
989    }
990
991    /// Send messages and parse the response into a typed value `T`.
992    ///
993    /// Default implementation injects JSON schema into the system prompt and retries once
994    /// on parse failure. Providers with native structured output should override this.
995    ///
996    /// # Object safety
997    ///
998    /// This method requires `Self: Sized` and is therefore unavailable on trait objects.
999    /// Use [`chat_typed_dyn`](crate::provider_dyn::chat_typed_dyn) when working with
1000    /// `Arc<dyn LlmProviderDyn>`.
1001    #[allow(async_fn_in_trait)]
1002    async fn chat_typed<T>(&self, messages: &[Message]) -> Result<T, LlmError>
1003    where
1004        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
1005        Self: Sized,
1006    {
1007        let (_, schema_json) = cached_schema::<T>()?;
1008        let type_name = short_type_name::<T>();
1009
1010        let mut augmented = messages.to_vec();
1011        let instruction = format!(
1012            "Respond with a valid JSON object matching this schema. \
1013             Output ONLY the JSON, no markdown fences or extra text.\n\n\
1014             Type: {type_name}\nSchema:\n```json\n{schema_json}\n```"
1015        );
1016        augmented.insert(0, Message::from_legacy(Role::System, instruction));
1017
1018        let raw = self.chat(&augmented).await?;
1019        let cleaned = strip_json_fences(&raw);
1020        match serde_json::from_str::<T>(cleaned) {
1021            Ok(val) => Ok(val),
1022            Err(first_err) => {
1023                augmented.push(Message::from_legacy(Role::Assistant, &raw));
1024                augmented.push(Message::from_legacy(
1025                    Role::User,
1026                    format!(
1027                        "Your response was not valid JSON. Error: {first_err}. \
1028                         Please output ONLY valid JSON matching the schema."
1029                    ),
1030                ));
1031                let retry_raw = self.chat(&augmented).await?;
1032                let retry_cleaned = strip_json_fences(&retry_raw);
1033                serde_json::from_str::<T>(retry_cleaned).map_err(|e| {
1034                    LlmError::StructuredParse(format!("parse failed after retry: {e}"))
1035                })
1036            }
1037        }
1038    }
1039}
1040
1041/// Strip markdown code fences from LLM output. Only handles outer fences;
1042/// JSON containing trailing triple backticks in string values may be
1043/// incorrectly trimmed (acceptable for MVP — see review R2).
1044fn strip_json_fences(s: &str) -> &str {
1045    s.trim()
1046        .trim_start_matches("```json")
1047        .trim_start_matches("```")
1048        .trim_end_matches("```")
1049        .trim()
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use std::assert_matches;
1055    use tokio_stream::StreamExt;
1056
1057    use super::*;
1058
1059    struct StubProvider {
1060        response: String,
1061    }
1062
1063    impl LlmProvider for StubProvider {
1064        async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1065            Ok(self.response.clone())
1066        }
1067
1068        async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1069            let response = self.chat(messages).await?;
1070            Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1071                response,
1072            )))))
1073        }
1074
1075        fn supports_streaming(&self) -> bool {
1076            false
1077        }
1078
1079        async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1080            Ok(vec![0.1, 0.2, 0.3])
1081        }
1082
1083        fn supports_embeddings(&self) -> bool {
1084            false
1085        }
1086
1087        fn name(&self) -> &'static str {
1088            "stub"
1089        }
1090    }
1091
1092    #[test]
1093    fn test_image_data_debug_redacts_bytes() {
1094        let img = ImageData {
1095            data: vec![0xAB, 0xCD, 0xEF],
1096            mime_type: "image/png".to_owned(),
1097        };
1098        let debug = format!("{img:?}");
1099        assert_eq!(debug, "[image: image/png, 3 bytes]");
1100        assert!(!debug.contains("171") && !debug.contains("205") && !debug.contains("239"));
1101    }
1102
1103    #[test]
1104    fn context_window_default_returns_none() {
1105        let provider = StubProvider {
1106            response: String::new(),
1107        };
1108        assert!(provider.context_window().is_none());
1109    }
1110
1111    #[test]
1112    fn supports_streaming_default_returns_false() {
1113        let provider = StubProvider {
1114            response: String::new(),
1115        };
1116        assert!(!provider.supports_streaming());
1117    }
1118
1119    #[test]
1120    fn supports_tool_use_default_returns_false() {
1121        // StubProvider overrides neither `supports_tool_use` nor `chat_with_tools`,
1122        // mirroring CandleProvider (crates/zeph-llm/src/candle_provider/mod.rs). The
1123        // default must report `false` so callers gating on this method skip providers
1124        // that would otherwise silently drop tool definitions via the `chat_with_tools`
1125        // fallback (issue #5687).
1126        let provider = StubProvider {
1127            response: String::new(),
1128        };
1129        assert!(!provider.supports_tool_use());
1130    }
1131
1132    #[tokio::test]
1133    async fn chat_stream_default_yields_single_chunk() {
1134        let provider = StubProvider {
1135            response: "hello world".into(),
1136        };
1137        let messages = vec![Message {
1138            role: Role::User,
1139            content: "test".into(),
1140            parts: vec![],
1141            metadata: MessageMetadata::default(),
1142        }];
1143
1144        let mut stream = provider.chat_stream(&messages).await.unwrap();
1145        let chunk = stream.next().await.unwrap().unwrap();
1146        assert_matches!(chunk, StreamChunk::Content(s) if s == "hello world");
1147        assert!(stream.next().await.is_none());
1148    }
1149
1150    #[tokio::test]
1151    async fn chat_stream_default_propagates_chat_error() {
1152        struct FailProvider;
1153
1154        impl LlmProvider for FailProvider {
1155            async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1156                Err(LlmError::Unavailable)
1157            }
1158
1159            async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1160                let response = self.chat(messages).await?;
1161                Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1162                    response,
1163                )))))
1164            }
1165
1166            fn supports_streaming(&self) -> bool {
1167                false
1168            }
1169
1170            async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1171                Err(LlmError::Unavailable)
1172            }
1173
1174            fn supports_embeddings(&self) -> bool {
1175                false
1176            }
1177
1178            fn name(&self) -> &'static str {
1179                "fail"
1180            }
1181        }
1182
1183        let provider = FailProvider;
1184        let messages = vec![Message {
1185            role: Role::User,
1186            content: "test".into(),
1187            parts: vec![],
1188            metadata: MessageMetadata::default(),
1189        }];
1190
1191        let result = provider.chat_stream(&messages).await;
1192        assert!(result.is_err());
1193        if let Err(e) = result {
1194            assert!(e.to_string().contains("provider unavailable"));
1195        }
1196    }
1197
1198    #[tokio::test]
1199    async fn stub_provider_embed_returns_vector() {
1200        let provider = StubProvider {
1201            response: String::new(),
1202        };
1203        let embedding = provider.embed("test").await.unwrap();
1204        assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
1205    }
1206
1207    #[tokio::test]
1208    async fn fail_provider_embed_propagates_error() {
1209        struct FailProvider;
1210
1211        impl LlmProvider for FailProvider {
1212            async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1213                Err(LlmError::Unavailable)
1214            }
1215
1216            async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1217                let response = self.chat(messages).await?;
1218                Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1219                    response,
1220                )))))
1221            }
1222
1223            fn supports_streaming(&self) -> bool {
1224                false
1225            }
1226
1227            async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1228                Err(LlmError::EmbedUnsupported {
1229                    provider: "fail".into(),
1230                })
1231            }
1232
1233            fn supports_embeddings(&self) -> bool {
1234                false
1235            }
1236
1237            fn name(&self) -> &'static str {
1238                "fail"
1239            }
1240        }
1241
1242        let provider = FailProvider;
1243        let result = provider.embed("test").await;
1244        assert!(result.is_err());
1245        assert!(
1246            result
1247                .unwrap_err()
1248                .to_string()
1249                .contains("embedding not supported")
1250        );
1251    }
1252
1253    #[test]
1254    fn role_serialization() {
1255        let system = Role::System;
1256        let user = Role::User;
1257        let assistant = Role::Assistant;
1258
1259        assert_eq!(serde_json::to_string(&system).unwrap(), "\"system\"");
1260        assert_eq!(serde_json::to_string(&user).unwrap(), "\"user\"");
1261        assert_eq!(serde_json::to_string(&assistant).unwrap(), "\"assistant\"");
1262    }
1263
1264    #[test]
1265    fn role_deserialization() {
1266        let system: Role = serde_json::from_str("\"system\"").unwrap();
1267        let user: Role = serde_json::from_str("\"user\"").unwrap();
1268        let assistant: Role = serde_json::from_str("\"assistant\"").unwrap();
1269
1270        assert_eq!(system, Role::System);
1271        assert_eq!(user, Role::User);
1272        assert_eq!(assistant, Role::Assistant);
1273    }
1274
1275    #[test]
1276    fn message_clone() {
1277        let msg = Message {
1278            role: Role::User,
1279            content: "test".into(),
1280            parts: vec![],
1281            metadata: MessageMetadata::default(),
1282        };
1283        let cloned = msg.clone();
1284        assert_eq!(cloned.role, msg.role);
1285        assert_eq!(cloned.content, msg.content);
1286    }
1287
1288    #[test]
1289    fn message_debug() {
1290        let msg = Message {
1291            role: Role::Assistant,
1292            content: "response".into(),
1293            parts: vec![],
1294            metadata: MessageMetadata::default(),
1295        };
1296        let debug = format!("{msg:?}");
1297        assert!(debug.contains("Assistant"));
1298        assert!(debug.contains("response"));
1299    }
1300
1301    #[test]
1302    fn message_serialization() {
1303        let msg = Message {
1304            role: Role::User,
1305            content: "hello".into(),
1306            parts: vec![],
1307            metadata: MessageMetadata::default(),
1308        };
1309        let json = serde_json::to_string(&msg).unwrap();
1310        assert!(json.contains("\"role\":\"user\""));
1311        assert!(json.contains("\"content\":\"hello\""));
1312    }
1313
1314    #[test]
1315    fn message_part_serde_round_trip() {
1316        let parts = vec![
1317            MessagePart::Text {
1318                text: "hello".into(),
1319            },
1320            MessagePart::ToolOutput {
1321                tool_name: "bash".into(),
1322                body: "output".into(),
1323                compacted_at: None,
1324            },
1325            MessagePart::Recall {
1326                text: "recall".into(),
1327            },
1328            MessagePart::CodeContext {
1329                text: "code".into(),
1330            },
1331            MessagePart::Summary {
1332                text: "summary".into(),
1333            },
1334        ];
1335        let json = serde_json::to_string(&parts).unwrap();
1336        let deserialized: Vec<MessagePart> = serde_json::from_str(&json).unwrap();
1337        assert_eq!(deserialized.len(), 5);
1338    }
1339
1340    #[test]
1341    fn from_legacy_creates_empty_parts() {
1342        let msg = Message::from_legacy(Role::User, "hello");
1343        assert_eq!(msg.role, Role::User);
1344        assert_eq!(msg.content, "hello");
1345        assert!(msg.parts.is_empty());
1346        assert_eq!(msg.to_llm_content(), "hello");
1347    }
1348
1349    #[test]
1350    fn from_parts_flattens_content() {
1351        let msg = Message::from_parts(
1352            Role::System,
1353            vec![MessagePart::Recall {
1354                text: "recalled data".into(),
1355            }],
1356        );
1357        assert_eq!(msg.content, "recalled data");
1358        assert_eq!(msg.to_llm_content(), "recalled data");
1359        assert_eq!(msg.parts.len(), 1);
1360    }
1361
1362    #[test]
1363    fn from_parts_tool_output_format() {
1364        let msg = Message::from_parts(
1365            Role::User,
1366            vec![MessagePart::ToolOutput {
1367                tool_name: "bash".into(),
1368                body: "hello world".into(),
1369                compacted_at: None,
1370            }],
1371        );
1372        assert!(msg.content.contains("[tool output: bash]"));
1373        assert!(msg.content.contains("hello world"));
1374    }
1375
1376    #[test]
1377    fn message_deserializes_without_parts() {
1378        let json = r#"{"role":"user","content":"hello"}"#;
1379        let msg: Message = serde_json::from_str(json).unwrap();
1380        assert_eq!(msg.content, "hello");
1381        assert!(msg.parts.is_empty());
1382    }
1383
1384    #[test]
1385    fn flatten_skips_compacted_tool_output_empty_body() {
1386        // When compacted_at is set and body is empty, renders "(pruned)".
1387        let msg = Message::from_parts(
1388            Role::User,
1389            vec![
1390                MessagePart::Text {
1391                    text: "prefix ".into(),
1392                },
1393                MessagePart::ToolOutput {
1394                    tool_name: "bash".into(),
1395                    body: String::new(),
1396                    compacted_at: Some(1234),
1397                },
1398                MessagePart::Text {
1399                    text: " suffix".into(),
1400                },
1401            ],
1402        );
1403        assert!(msg.content.contains("(pruned)"));
1404        assert!(msg.content.contains("prefix "));
1405        assert!(msg.content.contains(" suffix"));
1406    }
1407
1408    #[test]
1409    fn flatten_compacted_tool_output_with_reference_renders_body() {
1410        // When compacted_at is set and body contains a reference notice, renders the body.
1411        let ref_notice = "[tool output pruned; full content at /tmp/overflow/big.txt]";
1412        let msg = Message::from_parts(
1413            Role::User,
1414            vec![MessagePart::ToolOutput {
1415                tool_name: "bash".into(),
1416                body: ref_notice.into(),
1417                compacted_at: Some(1234),
1418            }],
1419        );
1420        assert!(msg.content.contains(ref_notice));
1421        assert!(!msg.content.contains("(pruned)"));
1422    }
1423
1424    #[test]
1425    fn rebuild_content_syncs_after_mutation() {
1426        let mut msg = Message::from_parts(
1427            Role::User,
1428            vec![MessagePart::ToolOutput {
1429                tool_name: "bash".into(),
1430                body: "original".into(),
1431                compacted_at: None,
1432            }],
1433        );
1434        assert!(msg.content.contains("original"));
1435
1436        if let MessagePart::ToolOutput {
1437            ref mut compacted_at,
1438            ref mut body,
1439            ..
1440        } = msg.parts[0]
1441        {
1442            *compacted_at = Some(999);
1443            body.clear(); // simulate pruning: body cleared, no overflow notice
1444        }
1445        msg.rebuild_content();
1446
1447        assert!(msg.content.contains("(pruned)"));
1448        assert!(!msg.content.contains("original"));
1449    }
1450
1451    #[test]
1452    fn message_part_tool_use_serde_round_trip() {
1453        let part = MessagePart::ToolUse {
1454            id: "toolu_123".into(),
1455            name: "bash".into(),
1456            input: serde_json::json!({"command": "ls"}),
1457        };
1458        let json = serde_json::to_string(&part).unwrap();
1459        let deserialized: MessagePart = serde_json::from_str(&json).unwrap();
1460        if let MessagePart::ToolUse { id, name, input } = deserialized {
1461            assert_eq!(id, "toolu_123");
1462            assert_eq!(name, "bash");
1463            assert_eq!(input["command"], "ls");
1464        } else {
1465            panic!("expected ToolUse");
1466        }
1467    }
1468
1469    #[test]
1470    fn message_part_tool_result_serde_round_trip() {
1471        let part = MessagePart::ToolResult {
1472            tool_use_id: "toolu_123".into(),
1473            content: "file1.rs\nfile2.rs".into(),
1474            is_error: false,
1475        };
1476        let json = serde_json::to_string(&part).unwrap();
1477        let deserialized: MessagePart = serde_json::from_str(&json).unwrap();
1478        if let MessagePart::ToolResult {
1479            tool_use_id,
1480            content,
1481            is_error,
1482        } = deserialized
1483        {
1484            assert_eq!(tool_use_id, "toolu_123");
1485            assert_eq!(content, "file1.rs\nfile2.rs");
1486            assert!(!is_error);
1487        } else {
1488            panic!("expected ToolResult");
1489        }
1490    }
1491
1492    #[test]
1493    fn message_part_tool_result_is_error_default() {
1494        let json = r#"{"kind":"tool_result","tool_use_id":"id","content":"err"}"#;
1495        let part: MessagePart = serde_json::from_str(json).unwrap();
1496        if let MessagePart::ToolResult { is_error, .. } = part {
1497            assert!(!is_error);
1498        } else {
1499            panic!("expected ToolResult");
1500        }
1501    }
1502
1503    #[test]
1504    fn chat_response_construction() {
1505        let text = ChatResponse::Text("hello".into());
1506        assert_matches!(text, ChatResponse::Text(s) if s == "hello");
1507
1508        let tool_use = ChatResponse::ToolUse {
1509            text: Some("I'll run that".into()),
1510            tool_calls: vec![ToolUseRequest {
1511                id: "1".into(),
1512                name: "bash".into(),
1513                input: serde_json::json!({}),
1514            }],
1515            thinking_blocks: vec![],
1516        };
1517        assert_matches!(tool_use, ChatResponse::ToolUse { .. });
1518    }
1519
1520    #[test]
1521    fn flatten_parts_tool_use() {
1522        let msg = Message::from_parts(
1523            Role::Assistant,
1524            vec![MessagePart::ToolUse {
1525                id: "t1".into(),
1526                name: "bash".into(),
1527                input: serde_json::json!({"command": "ls"}),
1528            }],
1529        );
1530        assert!(msg.content.contains("[tool_use: bash(t1)]"));
1531    }
1532
1533    #[test]
1534    fn flatten_parts_tool_result() {
1535        let msg = Message::from_parts(
1536            Role::User,
1537            vec![MessagePart::ToolResult {
1538                tool_use_id: "t1".into(),
1539                content: "output here".into(),
1540                is_error: false,
1541            }],
1542        );
1543        assert!(msg.content.contains("[tool_result: t1]"));
1544        assert!(msg.content.contains("output here"));
1545    }
1546
1547    #[test]
1548    fn tool_definition_serde_round_trip() {
1549        let def = ToolDefinition {
1550            name: "bash".into(),
1551            description: "Execute a shell command".into(),
1552            parameters: serde_json::json!({"type": "object"}),
1553            output_schema: None,
1554        };
1555        let json = serde_json::to_string(&def).unwrap();
1556        let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap();
1557        assert_eq!(deserialized.name, "bash");
1558        assert_eq!(deserialized.description, "Execute a shell command");
1559    }
1560
1561    #[tokio::test]
1562    async fn chat_with_tools_default_delegates_to_chat() {
1563        let provider = StubProvider {
1564            response: "hello".into(),
1565        };
1566        let messages = vec![Message::from_legacy(Role::User, "test")];
1567        let result = provider.chat_with_tools(&messages, &[]).await.unwrap();
1568        assert_matches!(result, ChatResponse::Text(s) if s == "hello");
1569    }
1570
1571    #[test]
1572    fn tool_output_compacted_at_serde_default() {
1573        let json = r#"{"kind":"tool_output","tool_name":"bash","body":"out"}"#;
1574        let part: MessagePart = serde_json::from_str(json).unwrap();
1575        if let MessagePart::ToolOutput { compacted_at, .. } = part {
1576            assert!(compacted_at.is_none());
1577        } else {
1578            panic!("expected ToolOutput");
1579        }
1580    }
1581
1582    // --- M27: strip_json_fences tests ---
1583
1584    #[test]
1585    fn strip_json_fences_plain_json() {
1586        assert_eq!(strip_json_fences(r#"{"a": 1}"#), r#"{"a": 1}"#);
1587    }
1588
1589    #[test]
1590    fn strip_json_fences_with_json_fence() {
1591        assert_eq!(strip_json_fences("```json\n{\"a\": 1}\n```"), r#"{"a": 1}"#);
1592    }
1593
1594    #[test]
1595    fn strip_json_fences_with_plain_fence() {
1596        assert_eq!(strip_json_fences("```\n{\"a\": 1}\n```"), r#"{"a": 1}"#);
1597    }
1598
1599    #[test]
1600    fn strip_json_fences_whitespace() {
1601        assert_eq!(strip_json_fences("  \n  "), "");
1602    }
1603
1604    #[test]
1605    fn strip_json_fences_empty() {
1606        assert_eq!(strip_json_fences(""), "");
1607    }
1608
1609    #[test]
1610    fn strip_json_fences_outer_whitespace() {
1611        assert_eq!(
1612            strip_json_fences("  ```json\n{\"a\": 1}\n```  "),
1613            r#"{"a": 1}"#
1614        );
1615    }
1616
1617    #[test]
1618    fn strip_json_fences_only_opening_fence() {
1619        assert_eq!(strip_json_fences("```json\n{\"a\": 1}"), r#"{"a": 1}"#);
1620    }
1621
1622    // --- M27: chat_typed tests ---
1623
1624    #[derive(Debug, serde::Deserialize, schemars::JsonSchema, PartialEq)]
1625    struct TestOutput {
1626        value: String,
1627    }
1628
1629    struct SequentialStub {
1630        responses: std::sync::Mutex<Vec<Result<String, LlmError>>>,
1631    }
1632
1633    impl SequentialStub {
1634        fn new(responses: Vec<Result<String, LlmError>>) -> Self {
1635            Self {
1636                responses: std::sync::Mutex::new(responses),
1637            }
1638        }
1639    }
1640
1641    impl LlmProvider for SequentialStub {
1642        async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1643            let mut responses = self.responses.lock().unwrap();
1644            if responses.is_empty() {
1645                return Err(LlmError::Other("no more responses".into()));
1646            }
1647            responses.remove(0)
1648        }
1649
1650        async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1651            let response = self.chat(messages).await?;
1652            Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1653                response,
1654            )))))
1655        }
1656
1657        fn supports_streaming(&self) -> bool {
1658            false
1659        }
1660
1661        async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1662            Err(LlmError::EmbedUnsupported {
1663                provider: "sequential-stub".into(),
1664            })
1665        }
1666
1667        fn supports_embeddings(&self) -> bool {
1668            false
1669        }
1670
1671        fn name(&self) -> &'static str {
1672            "sequential-stub"
1673        }
1674    }
1675
1676    #[tokio::test]
1677    async fn chat_typed_happy_path() {
1678        let provider = StubProvider {
1679            response: r#"{"value": "hello"}"#.into(),
1680        };
1681        let messages = vec![Message::from_legacy(Role::User, "test")];
1682        let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1683        assert_eq!(
1684            result,
1685            TestOutput {
1686                value: "hello".into()
1687            }
1688        );
1689    }
1690
1691    #[tokio::test]
1692    async fn chat_typed_retry_succeeds() {
1693        let provider = SequentialStub::new(vec![
1694            Ok("not valid json".into()),
1695            Ok(r#"{"value": "ok"}"#.into()),
1696        ]);
1697        let messages = vec![Message::from_legacy(Role::User, "test")];
1698        let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1699        assert_eq!(result, TestOutput { value: "ok".into() });
1700    }
1701
1702    #[tokio::test]
1703    async fn chat_typed_both_fail() {
1704        let provider = SequentialStub::new(vec![Ok("bad json".into()), Ok("still bad".into())]);
1705        let messages = vec![Message::from_legacy(Role::User, "test")];
1706        let result = provider.chat_typed::<TestOutput>(&messages).await;
1707        let err = result.unwrap_err();
1708        assert!(err.to_string().contains("parse failed after retry"));
1709    }
1710
1711    #[tokio::test]
1712    async fn chat_typed_chat_error_propagates() {
1713        let provider = SequentialStub::new(vec![Err(LlmError::Unavailable)]);
1714        let messages = vec![Message::from_legacy(Role::User, "test")];
1715        let result = provider.chat_typed::<TestOutput>(&messages).await;
1716        assert_matches!(result, Err(LlmError::Unavailable));
1717    }
1718
1719    #[tokio::test]
1720    async fn chat_typed_strips_fences() {
1721        let provider = StubProvider {
1722            response: "```json\n{\"value\": \"fenced\"}\n```".into(),
1723        };
1724        let messages = vec![Message::from_legacy(Role::User, "test")];
1725        let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1726        assert_eq!(
1727            result,
1728            TestOutput {
1729                value: "fenced".into()
1730            }
1731        );
1732    }
1733
1734    #[test]
1735    fn supports_structured_output_default_false() {
1736        let provider = StubProvider {
1737            response: String::new(),
1738        };
1739        assert!(!provider.supports_structured_output());
1740    }
1741
1742    #[test]
1743    fn structured_parse_error_display() {
1744        let err = LlmError::StructuredParse("test error".into());
1745        assert_eq!(
1746            err.to_string(),
1747            "structured output parse failed: test error"
1748        );
1749    }
1750
1751    #[test]
1752    fn message_part_image_roundtrip_json() {
1753        let part = MessagePart::Image(Box::new(ImageData {
1754            data: vec![1, 2, 3, 4],
1755            mime_type: "image/jpeg".into(),
1756        }));
1757        let json = serde_json::to_string(&part).unwrap();
1758        let decoded: MessagePart = serde_json::from_str(&json).unwrap();
1759        match decoded {
1760            MessagePart::Image(img) => {
1761                assert_eq!(img.data, vec![1, 2, 3, 4]);
1762                assert_eq!(img.mime_type, "image/jpeg");
1763            }
1764            _ => panic!("expected Image variant"),
1765        }
1766    }
1767
1768    #[test]
1769    fn flatten_parts_includes_image_placeholder() {
1770        let msg = Message::from_parts(
1771            Role::User,
1772            vec![
1773                MessagePart::Text {
1774                    text: "see this".into(),
1775                },
1776                MessagePart::Image(Box::new(ImageData {
1777                    data: vec![0u8; 100],
1778                    mime_type: "image/png".into(),
1779                })),
1780            ],
1781        );
1782        let content = msg.to_llm_content();
1783        assert!(content.contains("see this"));
1784        assert!(content.contains("[image: image/png"));
1785    }
1786
1787    #[test]
1788    fn supports_vision_default_false() {
1789        let provider = StubProvider {
1790            response: String::new(),
1791        };
1792        assert!(!provider.supports_vision());
1793    }
1794
1795    #[test]
1796    fn message_metadata_default_both_visible() {
1797        let m = MessageMetadata::default();
1798        assert!(m.visibility.is_agent_visible());
1799        assert!(m.visibility.is_user_visible());
1800        assert_eq!(m.visibility, MessageVisibility::Both);
1801        assert!(m.compacted_at.is_none());
1802    }
1803
1804    #[test]
1805    fn message_metadata_agent_only() {
1806        let m = MessageMetadata::agent_only();
1807        assert!(m.visibility.is_agent_visible());
1808        assert!(!m.visibility.is_user_visible());
1809        assert_eq!(m.visibility, MessageVisibility::AgentOnly);
1810    }
1811
1812    #[test]
1813    fn message_metadata_user_only() {
1814        let m = MessageMetadata::user_only();
1815        assert!(!m.visibility.is_agent_visible());
1816        assert!(m.visibility.is_user_visible());
1817        assert_eq!(m.visibility, MessageVisibility::UserOnly);
1818    }
1819
1820    #[test]
1821    fn message_metadata_serde_default() {
1822        let json = r#"{"role":"user","content":"hello"}"#;
1823        let msg: Message = serde_json::from_str(json).unwrap();
1824        assert!(msg.metadata.visibility.is_agent_visible());
1825        assert!(msg.metadata.visibility.is_user_visible());
1826    }
1827
1828    #[test]
1829    fn message_metadata_round_trip() {
1830        let msg = Message {
1831            role: Role::User,
1832            content: "test".into(),
1833            parts: vec![],
1834            metadata: MessageMetadata::agent_only(),
1835        };
1836        let json = serde_json::to_string(&msg).unwrap();
1837        let decoded: Message = serde_json::from_str(&json).unwrap();
1838        assert!(decoded.metadata.visibility.is_agent_visible());
1839        assert!(!decoded.metadata.visibility.is_user_visible());
1840        assert_eq!(decoded.metadata.visibility, MessageVisibility::AgentOnly);
1841    }
1842
1843    #[test]
1844    fn message_part_compaction_round_trip() {
1845        let part = MessagePart::Compaction {
1846            summary: "Context was summarized.".to_owned(),
1847        };
1848        let json = serde_json::to_string(&part).unwrap();
1849        let decoded: MessagePart = serde_json::from_str(&json).unwrap();
1850        assert!(
1851            matches!(decoded, MessagePart::Compaction { summary } if summary == "Context was summarized.")
1852        );
1853    }
1854
1855    #[test]
1856    fn flatten_parts_compaction_contributes_no_text() {
1857        // MessagePart::Compaction must not appear in the flattened content string
1858        // (it's metadata-only; the summary is stored on the Message separately).
1859        let parts = vec![
1860            MessagePart::Text {
1861                text: "Hello".to_owned(),
1862            },
1863            MessagePart::Compaction {
1864                summary: "Summary".to_owned(),
1865            },
1866        ];
1867        let msg = Message::from_parts(Role::Assistant, parts);
1868        // Only the Text part should appear in content.
1869        assert_eq!(msg.content.trim(), "Hello");
1870    }
1871
1872    #[test]
1873    fn stream_chunk_compaction_variant() {
1874        let chunk = StreamChunk::Compaction("A summary".to_owned());
1875        assert_matches!(chunk, StreamChunk::Compaction(s) if s == "A summary");
1876    }
1877
1878    #[test]
1879    fn short_type_name_extracts_last_segment() {
1880        struct MyOutput;
1881        assert_eq!(short_type_name::<MyOutput>(), "MyOutput");
1882    }
1883
1884    #[test]
1885    fn short_type_name_primitive_returns_full_name() {
1886        // Primitives have no "::" in their type_name — rsplit returns the full name.
1887        assert_eq!(short_type_name::<u32>(), "u32");
1888        assert_eq!(short_type_name::<bool>(), "bool");
1889    }
1890
1891    #[test]
1892    fn short_type_name_nested_path_returns_last() {
1893        // Use a type whose path contains "::" segments.
1894        assert_eq!(
1895            short_type_name::<std::collections::HashMap<u32, u32>>(),
1896            "HashMap<u32, u32>"
1897        );
1898    }
1899
1900    // Regression test for #2257: `MessagePart::Summary` must serialize to the
1901    // internally-tagged format `{"kind":"summary","text":"..."}` and round-trip correctly.
1902    #[test]
1903    fn summary_roundtrip() {
1904        let part = MessagePart::Summary {
1905            text: "hello".to_string(),
1906        };
1907        let json = serde_json::to_string(&part).expect("serialization must not fail");
1908        assert!(
1909            json.contains("\"kind\":\"summary\""),
1910            "must use internally-tagged format, got: {json}"
1911        );
1912        assert!(
1913            !json.contains("\"Summary\""),
1914            "must not use externally-tagged format, got: {json}"
1915        );
1916        let decoded: MessagePart =
1917            serde_json::from_str(&json).expect("deserialization must not fail");
1918        match decoded {
1919            MessagePart::Summary { text } => assert_eq!(text, "hello"),
1920            other => panic!("expected MessagePart::Summary, got {other:?}"),
1921        }
1922    }
1923
1924    #[tokio::test]
1925    async fn embed_batch_default_empty_returns_empty() {
1926        let provider = StubProvider {
1927            response: String::new(),
1928        };
1929        let result = provider.embed_batch(&[]).await.unwrap();
1930        assert!(result.is_empty());
1931    }
1932
1933    #[tokio::test]
1934    async fn embed_batch_default_calls_embed_sequentially() {
1935        let provider = StubProvider {
1936            response: String::new(),
1937        };
1938        let texts = ["hello", "world", "foo"];
1939        let result = provider.embed_batch(&texts).await.unwrap();
1940        assert_eq!(result.len(), 3);
1941        // StubProvider::embed always returns [0.1, 0.2, 0.3]
1942        for vec in &result {
1943            assert_eq!(vec, &[0.1_f32, 0.2, 0.3]);
1944        }
1945    }
1946
1947    #[test]
1948    fn message_visibility_db_roundtrip_both() {
1949        assert_eq!(MessageVisibility::Both.as_db_str(), "both");
1950        assert_eq!(
1951            MessageVisibility::from_db_str("both"),
1952            MessageVisibility::Both
1953        );
1954    }
1955
1956    #[test]
1957    fn message_visibility_db_roundtrip_agent_only() {
1958        assert_eq!(MessageVisibility::AgentOnly.as_db_str(), "agent_only");
1959        assert_eq!(
1960            MessageVisibility::from_db_str("agent_only"),
1961            MessageVisibility::AgentOnly
1962        );
1963    }
1964
1965    #[test]
1966    fn message_visibility_db_roundtrip_user_only() {
1967        assert_eq!(MessageVisibility::UserOnly.as_db_str(), "user_only");
1968        assert_eq!(
1969            MessageVisibility::from_db_str("user_only"),
1970            MessageVisibility::UserOnly
1971        );
1972    }
1973
1974    #[test]
1975    fn message_visibility_from_db_str_unknown_defaults_to_both() {
1976        assert_eq!(
1977            MessageVisibility::from_db_str("unknown_future_value"),
1978            MessageVisibility::Both
1979        );
1980        assert_eq!(MessageVisibility::from_db_str(""), MessageVisibility::Both);
1981    }
1982}