Skip to main content

embacle/
types.rs

1// ABOUTME: Core types for CLI LLM runners — standalone definitions independent of pierre-core
2// ABOUTME: Provides LlmProvider trait, ChatRequest/Response, error types, and capability flags
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! # Core Types
8//!
9//! Self-contained type definitions for the CLI LLM runners library.
10//! These types mirror the LLM provider contract without requiring
11//! any external platform dependency.
12
13use std::error::Error;
14use std::fmt;
15use std::pin::Pin;
16
17use async_trait::async_trait;
18use serde::{Deserialize, Serialize};
19use tokio_stream::Stream;
20
21use crate::turn::ConversationTurnId;
22
23// ============================================================================
24// Error Type
25// ============================================================================
26
27/// Error type for CLI LLM runner operations
28#[derive(Debug, Clone)]
29#[must_use]
30pub struct RunnerError {
31    /// Error category
32    pub kind: ErrorKind,
33    /// Human-readable error message
34    pub message: String,
35}
36
37/// Categories of errors produced by CLI runners
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ErrorKind {
40    /// Internal runner error (bug, unexpected state)
41    Internal,
42    /// External service error (CLI tool failure, bad response)
43    ExternalService,
44    /// CLI command exceeded its configured timeout
45    Timeout,
46    /// Binary not found or not executable
47    BinaryNotFound,
48    /// Authentication or authorization failure
49    AuthFailure,
50    /// Configuration error
51    Config,
52    /// Guardrail policy violation (request or response rejected)
53    Guardrail,
54    /// Input prompt exceeds the underlying model's context window.
55    ///
56    /// A permanent, caller-side error: retrying the identical prompt cannot
57    /// succeed (contrast `ExternalService`, which is transient). Surfaced as an
58    /// HTTP 400 `context_length_exceeded` so OpenAI-compatible clients classify
59    /// it as non-retryable instead of hammering the same oversized request.
60    ContextLength,
61    /// Requested model is not available on the underlying provider
62    ///
63    /// Typically means the model has been rotated out or the account is not
64    /// entitled to it. Transient in the sense that a retry with a different
65    /// model may succeed.
66    ModelUnavailable,
67}
68
69impl ErrorKind {
70    /// Whether this error category represents a transient failure worth retrying.
71    ///
72    /// Transient errors (timeouts, external service issues) may succeed on a
73    /// subsequent attempt. Permanent errors (config, auth, missing binary) will
74    /// not benefit from retries.
75    #[must_use]
76    pub const fn is_transient(self) -> bool {
77        matches!(self, Self::Timeout | Self::ExternalService)
78    }
79}
80
81impl RunnerError {
82    /// Create an internal error
83    pub fn internal(message: impl Into<String>) -> Self {
84        Self {
85            kind: ErrorKind::Internal,
86            message: message.into(),
87        }
88    }
89
90    /// Create an external service error
91    pub fn external_service(service: impl Into<String>, message: impl Into<String>) -> Self {
92        Self {
93            kind: ErrorKind::ExternalService,
94            message: format!("{}: {}", service.into(), message.into()),
95        }
96    }
97
98    /// Create a binary-not-found error
99    pub fn binary_not_found(binary: impl Into<String>) -> Self {
100        Self {
101            kind: ErrorKind::BinaryNotFound,
102            message: format!("Binary not found: {}", binary.into()),
103        }
104    }
105
106    /// Create an auth failure error
107    pub fn auth_failure(message: impl Into<String>) -> Self {
108        Self {
109            kind: ErrorKind::AuthFailure,
110            message: message.into(),
111        }
112    }
113
114    /// Create a config error
115    pub fn config(message: impl Into<String>) -> Self {
116        Self {
117            kind: ErrorKind::Config,
118            message: message.into(),
119        }
120    }
121
122    /// Create a timeout error
123    pub fn timeout(message: impl Into<String>) -> Self {
124        Self {
125            kind: ErrorKind::Timeout,
126            message: message.into(),
127        }
128    }
129
130    /// Create a guardrail violation error
131    pub fn guardrail(message: impl Into<String>) -> Self {
132        Self {
133            kind: ErrorKind::Guardrail,
134            message: message.into(),
135        }
136    }
137
138    /// Create a context-length-exceeded error (permanent, caller-side).
139    pub fn context_length(message: impl Into<String>) -> Self {
140        Self {
141            kind: ErrorKind::ContextLength,
142            message: message.into(),
143        }
144    }
145
146    /// Create a model-unavailable error
147    pub fn model_unavailable(model: impl Into<String>) -> Self {
148        let model = model.into();
149        Self {
150            kind: ErrorKind::ModelUnavailable,
151            message: format!("Model {model:?} is not available"),
152        }
153    }
154}
155
156impl fmt::Display for RunnerError {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write!(f, "{:?}: {}", self.kind, self.message)
159    }
160}
161
162impl Error for RunnerError {}
163
164// ============================================================================
165// Capability Flags
166// ============================================================================
167
168bitflags::bitflags! {
169    /// LLM provider capability flags using bitflags for efficient storage
170    ///
171    /// Indicates which features a provider supports. Used by the system to
172    /// select appropriate providers and configure request handling.
173    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
174    pub struct LlmCapabilities: u16 {
175        /// Provider supports streaming responses
176        const STREAMING         = 0b0000_0000_0001;
177        /// Provider supports function/tool calling
178        const FUNCTION_CALLING  = 0b0000_0000_0010;
179        /// Provider supports vision/image input
180        const VISION            = 0b0000_0000_0100;
181        /// Provider supports JSON mode output
182        const JSON_MODE         = 0b0000_0000_1000;
183        /// Provider supports system messages
184        const SYSTEM_MESSAGES   = 0b0000_0001_0000;
185        /// Provider supports SDK-managed tool calling (tool loop handled by SDK, not by caller)
186        const SDK_TOOL_CALLING  = 0b0000_0010_0000;
187        /// Provider supports temperature parameter
188        const TEMPERATURE       = 0b0000_0100_0000;
189        /// Provider supports max_tokens parameter
190        const MAX_TOKENS        = 0b0000_1000_0000;
191        /// Provider supports top_p (nucleus sampling) parameter
192        const TOP_P             = 0b0001_0000_0000;
193        /// Provider supports stop sequences parameter
194        const STOP_SEQUENCES    = 0b0010_0000_0000;
195        /// Provider supports response format control (JSON mode, JSON Schema)
196        const RESPONSE_FORMAT   = 0b0100_0000_0000;
197    }
198}
199
200impl LlmCapabilities {
201    /// Create capabilities for a basic text-only provider
202    #[must_use]
203    pub const fn text_only() -> Self {
204        Self::STREAMING.union(Self::SYSTEM_MESSAGES)
205    }
206
207    /// Create capabilities for a full-featured provider (like Gemini Pro)
208    #[must_use]
209    pub const fn full_featured() -> Self {
210        Self::STREAMING
211            .union(Self::FUNCTION_CALLING)
212            .union(Self::VISION)
213            .union(Self::JSON_MODE)
214            .union(Self::SYSTEM_MESSAGES)
215    }
216
217    /// Check if streaming is supported
218    #[must_use]
219    pub const fn supports_streaming(&self) -> bool {
220        self.contains(Self::STREAMING)
221    }
222
223    /// Check if function calling is supported
224    #[must_use]
225    pub const fn supports_function_calling(&self) -> bool {
226        self.contains(Self::FUNCTION_CALLING)
227    }
228
229    /// Check if vision is supported
230    #[must_use]
231    pub const fn supports_vision(&self) -> bool {
232        self.contains(Self::VISION)
233    }
234
235    /// Check if JSON mode is supported
236    #[must_use]
237    pub const fn supports_json_mode(&self) -> bool {
238        self.contains(Self::JSON_MODE)
239    }
240
241    /// Check if system messages are supported
242    #[must_use]
243    pub const fn supports_system_messages(&self) -> bool {
244        self.contains(Self::SYSTEM_MESSAGES)
245    }
246
247    /// Check if SDK-managed tool calling is supported
248    #[must_use]
249    pub const fn supports_sdk_tool_calling(&self) -> bool {
250        self.contains(Self::SDK_TOOL_CALLING)
251    }
252
253    /// Check if temperature parameter is supported
254    #[must_use]
255    pub const fn supports_temperature(&self) -> bool {
256        self.contains(Self::TEMPERATURE)
257    }
258
259    /// Check if `max_tokens` parameter is supported
260    #[must_use]
261    pub const fn supports_max_tokens(&self) -> bool {
262        self.contains(Self::MAX_TOKENS)
263    }
264
265    /// Check if `top_p` parameter is supported
266    #[must_use]
267    pub const fn supports_top_p(&self) -> bool {
268        self.contains(Self::TOP_P)
269    }
270
271    /// Check if stop sequences parameter is supported
272    #[must_use]
273    pub const fn supports_stop_sequences(&self) -> bool {
274        self.contains(Self::STOP_SEQUENCES)
275    }
276
277    /// Check if response format control is supported
278    #[must_use]
279    pub const fn supports_response_format(&self) -> bool {
280        self.contains(Self::RESPONSE_FORMAT)
281    }
282}
283
284// ============================================================================
285// Message Types
286// ============================================================================
287
288/// Role of a message in the conversation
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "lowercase")]
291pub enum MessageRole {
292    /// System instruction message
293    System,
294    /// User input message
295    User,
296    /// Assistant response message
297    Assistant,
298    /// Tool result message
299    Tool,
300}
301
302impl MessageRole {
303    /// Convert to string representation for API calls
304    #[must_use]
305    pub const fn as_str(&self) -> &'static str {
306        match self {
307            Self::System => "system",
308            Self::User => "user",
309            Self::Assistant => "assistant",
310            Self::Tool => "tool",
311        }
312    }
313}
314
315/// Supported MIME types for image content
316const VALID_IMAGE_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif"];
317
318/// An image attached to a chat message
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320pub struct ImagePart {
321    /// Base64-encoded image data
322    pub data: String,
323    /// MIME type (e.g., "image/png", "image/jpeg")
324    pub mime_type: String,
325}
326
327impl ImagePart {
328    /// Create a new image part, validating the MIME type.
329    ///
330    /// Accepted MIME types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`RunnerError`] if the MIME type is not supported.
335    pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Result<Self, RunnerError> {
336        let mime_type = mime_type.into();
337        if !VALID_IMAGE_MIME_TYPES.contains(&mime_type.as_str()) {
338            return Err(RunnerError::config(format!(
339                "Unsupported image MIME type '{mime_type}'; expected one of: {}",
340                VALID_IMAGE_MIME_TYPES.join(", ")
341            )));
342        }
343        Ok(Self {
344            data: data.into(),
345            mime_type,
346        })
347    }
348}
349
350/// A single message in a chat conversation
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct ChatMessage {
353    /// Role of the message sender
354    pub role: MessageRole,
355    /// Content of the message
356    pub content: String,
357    /// Images attached to the message (only meaningful for `User` role)
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub images: Option<Vec<ImagePart>>,
360    /// Tool calls requested by the assistant (only for `Assistant` role)
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub tool_calls: Option<Vec<ToolCallRequest>>,
363    /// ID of the tool call this message responds to (only for `Tool` role)
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub tool_call_id: Option<String>,
366    /// Function name for tool result messages
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub name: Option<String>,
369}
370
371impl ChatMessage {
372    /// Create a new chat message
373    #[must_use]
374    pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
375        Self {
376            role,
377            content: content.into(),
378            images: None,
379            tool_calls: None,
380            tool_call_id: None,
381            name: None,
382        }
383    }
384
385    /// Create a system message
386    #[must_use]
387    pub fn system(content: impl Into<String>) -> Self {
388        Self::new(MessageRole::System, content)
389    }
390
391    /// Create a user message
392    #[must_use]
393    pub fn user(content: impl Into<String>) -> Self {
394        Self::new(MessageRole::User, content)
395    }
396
397    /// Create a user message with attached images
398    #[must_use]
399    pub fn user_with_images(content: impl Into<String>, images: Vec<ImagePart>) -> Self {
400        Self {
401            role: MessageRole::User,
402            content: content.into(),
403            images: Some(images),
404            tool_calls: None,
405            tool_call_id: None,
406            name: None,
407        }
408    }
409
410    /// Create an assistant message
411    #[must_use]
412    pub fn assistant(content: impl Into<String>) -> Self {
413        Self::new(MessageRole::Assistant, content)
414    }
415
416    /// Create a tool result message
417    #[must_use]
418    pub fn tool(
419        name: impl Into<String>,
420        tool_call_id: impl Into<String>,
421        content: impl Into<String>,
422    ) -> Self {
423        Self {
424            role: MessageRole::Tool,
425            content: content.into(),
426            images: None,
427            tool_calls: None,
428            tool_call_id: Some(tool_call_id.into()),
429            name: Some(name.into()),
430        }
431    }
432}
433
434// ============================================================================
435// Tool Calling Types
436// ============================================================================
437
438/// A tool call requested by the assistant
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct ToolCallRequest {
441    /// Unique identifier for this tool call
442    pub id: String,
443    /// Name of the function to call
444    pub function_name: String,
445    /// JSON-encoded arguments for the function
446    pub arguments: serde_json::Value,
447}
448
449/// Definition of a tool that can be called by the model
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct ToolDefinition {
452    /// Name of the function
453    pub name: String,
454    /// Description of what the function does
455    pub description: String,
456    /// JSON Schema describing the function parameters
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub parameters: Option<serde_json::Value>,
459}
460
461/// Controls which tools the model may call
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub enum ToolChoice {
464    /// Model decides whether to call tools
465    Auto,
466    /// Model will not call any tools
467    None,
468    /// Model must call at least one tool
469    Required,
470    /// Model must call the specified function
471    Specific {
472        /// Name of the function to call
473        name: String,
474    },
475}
476
477/// Controls the response format from the model
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub enum ResponseFormat {
480    /// Default text response
481    Text,
482    /// Force JSON object output
483    JsonObject,
484    /// Force JSON output conforming to a specific schema
485    JsonSchema {
486        /// Schema name for identification
487        name: String,
488        /// JSON Schema the response must conform to
489        schema: serde_json::Value,
490    },
491}
492
493// ============================================================================
494// Request/Response Types
495// ============================================================================
496
497/// A name/value pair for an MCP HTTP header or stdio environment variable.
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
499pub struct McpHeader {
500    /// Header or environment-variable name.
501    pub name: String,
502    /// Header or environment-variable value.
503    pub value: String,
504}
505
506/// Transport an ACP-managed agent uses to reach an [`McpServerConfig`].
507///
508/// Mirrors the transports in the Agent Client Protocol `McpServer` schema.
509/// `Stdio` is mandatory for all ACP agents; `Http`/`Sse` are available only
510/// when the agent advertises the matching `mcpCapabilities` at initialize.
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
512pub enum McpTransport {
513    /// Streamable HTTP transport.
514    Http {
515        /// URL of the MCP server.
516        url: String,
517        /// HTTP headers sent on every request (e.g. `Authorization`).
518        headers: Vec<McpHeader>,
519    },
520    /// Server-Sent Events transport.
521    Sse {
522        /// URL of the MCP server.
523        url: String,
524        /// HTTP headers sent on every request.
525        headers: Vec<McpHeader>,
526    },
527    /// Stdio subprocess transport.
528    Stdio {
529        /// Path to the MCP server executable.
530        command: String,
531        /// Command-line arguments.
532        args: Vec<String>,
533        /// Environment variables set when launching the server.
534        env: Vec<McpHeader>,
535    },
536}
537
538/// An MCP server an ACP-managed provider (Copilot Headless) should connect to,
539/// exposing its tools to the model for native tool calling.
540///
541/// Providers without SDK tool calling ignore this; only the ACP `converse()`
542/// path forwards it into `session/new`.
543#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
544pub struct McpServerConfig {
545    /// Human-readable identifier for the server.
546    pub name: String,
547    /// Transport the agent uses to reach the server.
548    pub transport: McpTransport,
549}
550
551/// Configuration for a chat completion request
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ChatRequest {
554    /// Conversation messages
555    pub messages: Vec<ChatMessage>,
556    /// Model identifier (provider-specific)
557    pub model: Option<String>,
558    /// Temperature for response randomness (0.0 - 2.0).
559    ///
560    /// Support depends on each provider's [`LlmCapabilities::TEMPERATURE`] flag.
561    /// Use [`validate_capabilities`](crate::validate_capabilities) to check
562    /// before dispatch.
563    pub temperature: Option<f32>,
564    /// Maximum tokens to generate.
565    ///
566    /// Support depends on each provider's [`LlmCapabilities::MAX_TOKENS`] flag.
567    /// Use [`validate_capabilities`](crate::validate_capabilities) to check
568    /// before dispatch.
569    pub max_tokens: Option<u32>,
570    /// Whether to stream the response
571    pub stream: bool,
572    /// Tool definitions available for the model to call
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub tools: Option<Vec<ToolDefinition>>,
575    /// Controls which tools the model may call
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub tool_choice: Option<ToolChoice>,
578    /// Nucleus sampling parameter (0.0 - 1.0)
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub top_p: Option<f32>,
581    /// Stop sequences that halt generation
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub stop: Option<Vec<String>>,
584    /// Control over the response format (text, JSON, or schema-validated JSON)
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub response_format: Option<ResponseFormat>,
587    /// Conversation-turn correlation identifier.
588    ///
589    /// When present, decorators such as [`MetricsProvider`](crate::metrics::MetricsProvider)
590    /// can emit per-turn records in addition to their usual aggregation. The
591    /// identifier must be propagated from the inbound boundary; LLM providers
592    /// never generate it themselves.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub turn_id: Option<ConversationTurnId>,
595    /// MCP servers an ACP-managed provider should expose to the model for
596    /// native tool calling.
597    ///
598    /// Only the Copilot Headless `converse()` path forwards these into the ACP
599    /// `session/new` request; providers without SDK tool calling ignore them.
600    #[serde(default, skip_serializing_if = "Vec::is_empty")]
601    pub mcp_servers: Vec<McpServerConfig>,
602}
603
604impl ChatRequest {
605    /// Create a new chat request with messages
606    #[must_use]
607    pub const fn new(messages: Vec<ChatMessage>) -> Self {
608        Self {
609            messages,
610            model: None,
611            temperature: None,
612            max_tokens: None,
613            stream: false,
614            tools: None,
615            tool_choice: None,
616            top_p: None,
617            stop: None,
618            response_format: None,
619            turn_id: None,
620            mcp_servers: Vec::new(),
621        }
622    }
623
624    /// Set the MCP servers an ACP-managed provider exposes to the model.
625    #[must_use]
626    pub fn with_mcp_servers(mut self, mcp_servers: Vec<McpServerConfig>) -> Self {
627        self.mcp_servers = mcp_servers;
628        self
629    }
630
631    /// Set the model to use
632    #[must_use]
633    pub fn with_model(mut self, model: impl Into<String>) -> Self {
634        self.model = Some(model.into());
635        self
636    }
637
638    /// Set the temperature
639    #[must_use]
640    pub const fn with_temperature(mut self, temperature: f32) -> Self {
641        self.temperature = Some(temperature);
642        self
643    }
644
645    /// Set the maximum tokens
646    #[must_use]
647    pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
648        self.max_tokens = Some(max_tokens);
649        self
650    }
651
652    /// Enable streaming
653    #[must_use]
654    pub const fn with_streaming(mut self) -> Self {
655        self.stream = true;
656        self
657    }
658
659    /// Set the tool definitions
660    #[must_use]
661    pub fn with_tools(mut self, tools: Vec<ToolDefinition>) -> Self {
662        self.tools = Some(tools);
663        self
664    }
665
666    /// Set the tool choice
667    #[must_use]
668    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
669        self.tool_choice = Some(tool_choice);
670        self
671    }
672
673    /// Set the `top_p` (nucleus sampling) parameter
674    #[must_use]
675    pub const fn with_top_p(mut self, top_p: f32) -> Self {
676        self.top_p = Some(top_p);
677        self
678    }
679
680    /// Set stop sequences
681    #[must_use]
682    pub fn with_stop(mut self, stop: Vec<String>) -> Self {
683        self.stop = Some(stop);
684        self
685    }
686
687    /// Set the response format
688    #[must_use]
689    pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
690        self.response_format = Some(response_format);
691        self
692    }
693
694    /// Attach a conversation-turn correlation identifier.
695    ///
696    /// The identifier is expected to have been generated at the inbound
697    /// boundary. Calling this method on a downstream request propagates the
698    /// same identifier — it must not be freshly generated here.
699    #[must_use]
700    pub const fn with_turn_id(mut self, turn_id: ConversationTurnId) -> Self {
701        self.turn_id = Some(turn_id);
702        self
703    }
704
705    /// Check whether any message in this request contains images
706    #[must_use]
707    pub fn has_images(&self) -> bool {
708        self.messages
709            .iter()
710            .any(|m| m.images.as_ref().is_some_and(|imgs| !imgs.is_empty()))
711    }
712}
713
714/// Response from a chat completion
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct ChatResponse {
717    /// Generated message content
718    pub content: String,
719    /// Model used for generation
720    pub model: String,
721    /// Token usage statistics
722    pub usage: Option<TokenUsage>,
723    /// Finish reason (stop, length, etc.)
724    pub finish_reason: Option<String>,
725    /// Warnings about unsupported request parameters
726    #[serde(skip_serializing_if = "Option::is_none")]
727    pub warnings: Option<Vec<String>>,
728    /// Tool calls requested by the model (populated by providers with native function calling)
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub tool_calls: Option<Vec<ToolCallRequest>>,
731}
732
733/// Token usage statistics
734#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct TokenUsage {
736    /// Number of tokens in the prompt
737    pub prompt_tokens: u32,
738    /// Number of tokens in the completion
739    pub completion_tokens: u32,
740    /// Total tokens used
741    pub total_tokens: u32,
742}
743
744/// A chunk of a streaming response
745#[derive(Debug, Clone, Serialize, Deserialize)]
746pub struct StreamChunk {
747    /// Content delta for this chunk
748    pub delta: String,
749    /// Whether this is the final chunk
750    pub is_final: bool,
751    /// Finish reason if final
752    pub finish_reason: Option<String>,
753}
754
755/// Stream type for chat completion responses
756pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamChunk, RunnerError>> + Send>>;
757
758// ============================================================================
759// Provider Trait
760// ============================================================================
761
762/// LLM provider trait for chat completion
763///
764/// Implement this trait to add a new LLM runner. Each runner wraps
765/// a CLI tool and translates between the chat protocol and the
766/// tool's native interface.
767#[async_trait]
768pub trait LlmProvider: Send + Sync {
769    /// Unique provider identifier (e.g., `claude_code`, `copilot`)
770    fn name(&self) -> &'static str;
771
772    /// Human-readable display name for the provider
773    fn display_name(&self) -> &str;
774
775    /// Provider capabilities (streaming, function calling, etc.)
776    fn capabilities(&self) -> LlmCapabilities;
777
778    /// Default model to use if not specified in request
779    fn default_model(&self) -> &str;
780
781    /// Available models for this provider
782    fn available_models(&self) -> &[String];
783
784    /// Perform a chat completion (non-streaming)
785    async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError>;
786
787    /// Perform a streaming chat completion
788    ///
789    /// Returns a stream of chunks that can be consumed incrementally.
790    /// Falls back to non-streaming if not supported.
791    async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError>;
792
793    /// Check if the provider is healthy and ready to serve requests
794    async fn health_check(&self) -> Result<bool, RunnerError>;
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use serde_json::json;
801
802    #[test]
803    fn is_transient_classification() {
804        assert!(ErrorKind::Timeout.is_transient());
805        assert!(ErrorKind::ExternalService.is_transient());
806        assert!(!ErrorKind::Internal.is_transient());
807        assert!(!ErrorKind::BinaryNotFound.is_transient());
808        assert!(!ErrorKind::AuthFailure.is_transient());
809        assert!(!ErrorKind::Config.is_transient());
810        assert!(!ErrorKind::Guardrail.is_transient());
811        assert!(!ErrorKind::ContextLength.is_transient());
812        assert!(!ErrorKind::ModelUnavailable.is_transient());
813    }
814
815    #[test]
816    fn model_unavailable_constructor() {
817        let err = RunnerError::model_unavailable("claude-opus-4.6-fast");
818        assert_eq!(err.kind, ErrorKind::ModelUnavailable);
819        assert!(err.message.contains("claude-opus-4.6-fast"));
820    }
821
822    #[test]
823    fn tool_call_request_serde_round_trip() {
824        let tc = ToolCallRequest {
825            id: "call_1".to_owned(),
826            function_name: "get_weather".to_owned(),
827            arguments: json!({"city": "Paris"}),
828        };
829        let json = serde_json::to_string(&tc).unwrap(); // Safe: test assertion
830        let deserialized: ToolCallRequest = serde_json::from_str(&json).unwrap(); // Safe: test assertion
831        assert_eq!(deserialized.id, "call_1");
832        assert_eq!(deserialized.function_name, "get_weather");
833        assert_eq!(deserialized.arguments["city"], "Paris");
834    }
835
836    #[test]
837    fn tool_definition_serde_round_trip() {
838        let td = ToolDefinition {
839            name: "search".to_owned(),
840            description: "Search the web".to_owned(),
841            parameters: Some(json!({"type": "object", "properties": {"q": {"type": "string"}}})),
842        };
843        let json = serde_json::to_string(&td).unwrap(); // Safe: test assertion
844        let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap(); // Safe: test assertion
845        assert_eq!(deserialized.name, "search");
846        assert!(deserialized.parameters.is_some());
847    }
848
849    #[test]
850    fn tool_definition_without_parameters() {
851        let td = ToolDefinition {
852            name: "ping".to_owned(),
853            description: "Check connectivity".to_owned(),
854            parameters: None,
855        };
856        let json = serde_json::to_string(&td).unwrap(); // Safe: test assertion
857        assert!(!json.contains("parameters"));
858        let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap(); // Safe: test assertion
859        assert!(deserialized.parameters.is_none());
860    }
861
862    #[test]
863    fn tool_choice_serde_variants() {
864        let auto = ToolChoice::Auto;
865        let json = serde_json::to_string(&auto).unwrap(); // Safe: test assertion
866        let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); // Safe: test assertion
867        assert!(matches!(deserialized, ToolChoice::Auto));
868
869        let none = ToolChoice::None;
870        let json = serde_json::to_string(&none).unwrap(); // Safe: test assertion
871        let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); // Safe: test assertion
872        assert!(matches!(deserialized, ToolChoice::None));
873
874        let required = ToolChoice::Required;
875        let json = serde_json::to_string(&required).unwrap(); // Safe: test assertion
876        let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); // Safe: test assertion
877        assert!(matches!(deserialized, ToolChoice::Required));
878
879        let specific = ToolChoice::Specific {
880            name: "get_weather".to_owned(),
881        };
882        let json = serde_json::to_string(&specific).unwrap(); // Safe: test assertion
883        let deserialized: ToolChoice = serde_json::from_str(&json).unwrap(); // Safe: test assertion
884        assert!(matches!(deserialized, ToolChoice::Specific { name } if name == "get_weather"));
885    }
886
887    #[test]
888    fn response_format_serde_variants() {
889        let text = ResponseFormat::Text;
890        let json = serde_json::to_string(&text).unwrap(); // Safe: test assertion
891        let deserialized: ResponseFormat = serde_json::from_str(&json).unwrap(); // Safe: test assertion
892        assert!(matches!(deserialized, ResponseFormat::Text));
893
894        let json_obj = ResponseFormat::JsonObject;
895        let json = serde_json::to_string(&json_obj).unwrap(); // Safe: test assertion
896        let deserialized: ResponseFormat = serde_json::from_str(&json).unwrap(); // Safe: test assertion
897        assert!(matches!(deserialized, ResponseFormat::JsonObject));
898
899        let json_schema = ResponseFormat::JsonSchema {
900            name: "person".to_owned(),
901            schema: json!({"type": "object", "properties": {"name": {"type": "string"}}}),
902        };
903        let json = serde_json::to_string(&json_schema).unwrap(); // Safe: test assertion
904        let deserialized: ResponseFormat = serde_json::from_str(&json).unwrap(); // Safe: test assertion
905        assert!(
906            matches!(deserialized, ResponseFormat::JsonSchema { name, .. } if name == "person")
907        );
908    }
909
910    #[test]
911    fn chat_message_tool_constructor() {
912        let msg = ChatMessage::tool("get_weather", "call_1", r#"{"temp": 72}"#);
913        assert_eq!(msg.role, MessageRole::Tool);
914        assert_eq!(msg.content, r#"{"temp": 72}"#);
915        assert_eq!(msg.tool_call_id.as_deref(), Some("call_1"));
916        assert_eq!(msg.name.as_deref(), Some("get_weather"));
917        assert!(msg.tool_calls.is_none());
918    }
919
920    #[test]
921    fn chat_message_regular_constructors_have_none_tool_fields() {
922        let user = ChatMessage::user("hello");
923        assert!(user.tool_calls.is_none());
924        assert!(user.tool_call_id.is_none());
925        assert!(user.name.is_none());
926        assert!(user.images.is_none());
927    }
928
929    #[test]
930    fn image_part_valid_mime_types() {
931        for mime in &["image/png", "image/jpeg", "image/webp", "image/gif"] {
932            let part = ImagePart::new("base64data", *mime);
933            assert!(part.is_ok(), "Expected {mime} to be valid");
934        }
935    }
936
937    #[test]
938    fn image_part_invalid_mime_type() {
939        let err = ImagePart::new("data", "image/bmp").unwrap_err();
940        assert_eq!(err.kind, ErrorKind::Config);
941        assert!(err.message.contains("image/bmp"));
942    }
943
944    #[test]
945    fn user_with_images_constructor() {
946        let img = ImagePart::new("aGVsbG8=", "image/png").unwrap(); // Safe: test assertion
947        let msg = ChatMessage::user_with_images("describe this", vec![img]);
948        assert_eq!(msg.role, MessageRole::User);
949        assert_eq!(msg.content, "describe this");
950        let images = msg.images.as_ref().unwrap(); // Safe: test assertion
951        assert_eq!(images.len(), 1);
952        assert_eq!(images[0].mime_type, "image/png");
953    }
954
955    #[test]
956    fn chat_request_has_images() {
957        let img = ImagePart::new("data", "image/jpeg").unwrap(); // Safe: test assertion
958        let with = ChatRequest::new(vec![ChatMessage::user_with_images("x", vec![img])]);
959        assert!(with.has_images());
960
961        let without = ChatRequest::new(vec![ChatMessage::user("text only")]);
962        assert!(!without.has_images());
963    }
964
965    #[test]
966    fn chat_request_has_images_empty_vec() {
967        let msg = ChatMessage::user_with_images("x", vec![]);
968        let req = ChatRequest::new(vec![msg]);
969        assert!(!req.has_images());
970    }
971
972    #[test]
973    fn image_part_serde_round_trip() {
974        let img = ImagePart::new("aGVsbG8=", "image/png").unwrap(); // Safe: test assertion
975        let json = serde_json::to_string(&img).unwrap(); // Safe: test assertion
976        let deserialized: ImagePart = serde_json::from_str(&json).unwrap(); // Safe: test assertion
977        assert_eq!(deserialized, img);
978    }
979
980    #[test]
981    fn chat_message_with_images_serde_round_trip() {
982        let img = ImagePart::new("data", "image/jpeg").unwrap(); // Safe: test assertion
983        let msg = ChatMessage::user_with_images("describe", vec![img]);
984        let json = serde_json::to_string(&msg).unwrap(); // Safe: test assertion
985        let deserialized: ChatMessage = serde_json::from_str(&json).unwrap(); // Safe: test assertion
986        assert_eq!(deserialized.images.as_ref().unwrap().len(), 1); // Safe: test assertion
987        assert_eq!(deserialized.images.unwrap()[0].mime_type, "image/jpeg"); // Safe: test assertion
988    }
989
990    #[test]
991    fn chat_message_without_images_backward_compat() {
992        let json = r#"{"role":"user","content":"hello"}"#;
993        let msg: ChatMessage = serde_json::from_str(json).unwrap(); // Safe: test assertion
994        assert!(msg.images.is_none());
995        assert_eq!(msg.content, "hello");
996    }
997
998    #[test]
999    fn chat_message_images_not_serialized_when_none() {
1000        let msg = ChatMessage::user("hello");
1001        let json = serde_json::to_string(&msg).unwrap(); // Safe: test assertion
1002        assert!(!json.contains("images"));
1003    }
1004
1005    #[test]
1006    fn chat_request_builder_methods() {
1007        let req = ChatRequest::new(vec![ChatMessage::user("hi")])
1008            .with_tools(vec![ToolDefinition {
1009                name: "test".to_owned(),
1010                description: "test fn".to_owned(),
1011                parameters: None,
1012            }])
1013            .with_tool_choice(ToolChoice::Required)
1014            .with_top_p(0.9)
1015            .with_stop(vec!["END".to_owned()])
1016            .with_response_format(ResponseFormat::JsonObject);
1017
1018        assert!(req.tools.is_some());
1019        assert!(matches!(req.tool_choice, Some(ToolChoice::Required)));
1020        assert_eq!(req.top_p, Some(0.9));
1021        assert_eq!(req.stop.as_ref().unwrap()[0], "END"); // Safe: test assertion
1022        assert!(matches!(
1023            req.response_format,
1024            Some(ResponseFormat::JsonObject)
1025        ));
1026    }
1027
1028    #[test]
1029    fn message_role_tool_as_str() {
1030        assert_eq!(MessageRole::Tool.as_str(), "tool");
1031    }
1032
1033    #[test]
1034    fn capability_flags_new_fields() {
1035        let caps = LlmCapabilities::TOP_P
1036            | LlmCapabilities::STOP_SEQUENCES
1037            | LlmCapabilities::RESPONSE_FORMAT;
1038        assert!(caps.supports_top_p());
1039        assert!(caps.supports_stop_sequences());
1040        assert!(caps.supports_response_format());
1041
1042        let empty = LlmCapabilities::empty();
1043        assert!(!empty.supports_top_p());
1044        assert!(!empty.supports_stop_sequences());
1045        assert!(!empty.supports_response_format());
1046    }
1047}