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