Skip to main content

flashmind_types/
llm.rs

1//! LLM wire types — request / response structures and the [`LlmProvider`] trait.
2//!
3//! This module defines everything needed to communicate with an LLM backend:
4//! the provider trait, completion requests and responses, streaming events,
5//! model capabilities, and multimodal support (audio, images, video).
6//!
7//! # Key types
8//!
9//! | Type | Role |
10//! |------|------|
11//! | [`LlmProvider`] | Trait implemented by each LLM backend (OpenRouter, Anthropic, Ollama…) |
12//! | [`CompletionRequest`] | Single completion request sent to a provider |
13//! | [`CompletionResponse`] | Non-streaming response wrapper |
14//! | [`StreamEvent`] | Incremental events from the provider's stream |
15//! | [`CompletionStream`] | Alias for `Pin<Box<dyn Stream<Item = Result<StreamEvent>>>>` |
16//! | [`FinishReason`] | Why generation stopped (stop, length, tool_calls, content_filter) |
17//! | [`ToolDefinition`] | OpenAI-compatible JSON schema for tool calling |
18//! | [`ModelCapabilities`] | Feature flags (tool_calling, images, reasoning, audio…) |
19//! | [`TokenUsage`] | Token consumption reported by the provider |
20//! | [`Modality`] | Output modality: text, audio, or image |
21//! | [`TtsRequest`], [`SttRequest`], [`VideoGenRequest`] | Multimodal operation requests |
22
23use std::collections::{BTreeMap, HashMap};
24use std::pin::Pin;
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use chrono::{DateTime, Utc};
29use futures_core::Stream;
30use serde::{Deserialize, Serialize};
31
32use rust_decimal::Decimal;
33
34use crate::event::TurnUsage;
35use crate::message::Message;
36use crate::model::{
37    AgentLlmConfig, Model, Provider, ProviderPreferences, ReasoningLevel, SamplingParams,
38};
39use crate::session::SessionId;
40
41/// OpenAI-compatible JSON schema for a tool exposed to the LLM.
42///
43/// Mirrors the structure sent in the `tools` array of chat completion requests.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ToolDefinition {
46    /// Tool name — must match the [`Tool::name`](crate::tool::Tool::name) implementation.
47    pub name: String,
48    /// Description shown to the model to help it decide when to call this tool.
49    pub description: String,
50    /// JSON Schema object describing the parameters this tool accepts.
51    pub parameters: serde_json::Value,
52}
53
54/// A single completion request sent to an LLM provider.
55///
56/// This is the primary input type for [`LlmProvider::complete`]. The agent runtime
57/// assembles this from the conversation history, active tools, and LLM config.
58#[derive(Debug)]
59pub struct CompletionRequest {
60    /// Model to use (includes provider prefix).
61    pub model: Model,
62    /// Message history in wire format.
63    pub messages: Vec<Message>,
64    /// Tools available to the model in this turn.
65    pub tools: Vec<ToolDefinition>,
66    /// Maximum output tokens. If `None`, the provider decides.
67    pub max_tokens: Option<u32>,
68    /// Whether reasoning/thinking mode is enabled.
69    pub reasoning: ReasoningLevel,
70    /// Sampling parameters (temperature, top_p, top_k, min_p, penalties).
71    pub sampling: SamplingParams,
72    /// Output modalities (e.g. text+audio, image). Empty means text-only.
73    pub modalities: Vec<Modality>,
74    /// Audio output configuration. Only used when modalities includes [`Modality::Audio`].
75    pub audio_config: Option<AudioOutputConfig>,
76    /// Image generation configuration. Only used when modalities includes [`Modality::Image`].
77    pub image_config: Option<ImageGenConfig>,
78    /// Opaque user identifier for per-user tracking in provider dashboards.
79    pub user: Option<String>,
80    /// Provider routing preferences (used by OpenRouter).
81    pub provider_preferences: Option<ProviderPreferences>,
82    /// Stable conversation identifier for provider cache routing.
83    pub session_id: Option<SessionId>,
84}
85
86impl CompletionRequest {
87    /// Build a request with default settings and the supplied messages.
88    pub fn new(model: Model, messages: Vec<Message>) -> Self {
89        Self::from_config(&AgentLlmConfig::new(model), messages)
90    }
91
92    /// Keep the agent's session ID, routing, user and generation settings.
93    ///
94    /// ```
95    /// use flashmind_types::{AgentLlmConfig, CompletionRequest, Message, SessionId};
96    ///
97    /// let session = SessionId::new();
98    /// let llm = AgentLlmConfig::new("openrouter:example/model".parse()?)
99    ///     .with_session_id(session);
100    /// let request = CompletionRequest::from_config(&llm, vec![Message::user("Hello")]);
101    /// assert_eq!(request.session_id, llm.session_id);
102    /// # Ok::<(), anyhow::Error>(())
103    /// ```
104    pub fn from_config(config: &AgentLlmConfig, messages: Vec<Message>) -> Self {
105        Self {
106            model: config.model.clone(),
107            messages,
108            tools: Vec::new(),
109            max_tokens: config.max_tokens,
110            reasoning: config.reasoning,
111            sampling: config.sampling.clone(),
112            modalities: Vec::new(),
113            audio_config: None,
114            image_config: None,
115            user: config.user.clone(),
116            provider_preferences: config.provider_preferences.clone(),
117            session_id: config.session_id.clone(),
118        }
119    }
120
121    /// Use an existing session identity for this request.
122    pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
123        self.session_id = Some(session_id.into());
124        self
125    }
126}
127
128/// Why the LLM stopped generating (used in stream and non-stream responses).
129#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::EnumString)]
130#[strum(serialize_all = "snake_case")]
131pub enum FinishReason {
132    /// Natural stop token or end of response.
133    Stop,
134    /// Model requested one or more tool calls.
135    ToolCalls,
136    /// Hit `max_tokens` limit without natural stopping point.
137    Length,
138    /// Output was filtered by content policy.
139    ContentFilter,
140}
141
142/// High-level category for filtering models by use-case.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString)]
144#[strum(serialize_all = "snake_case")]
145pub enum ModelCategory {
146    /// General text chat / instruction-following.
147    Chat,
148    /// Advanced reasoning / chain-of-thought.
149    Reasoning,
150    /// Can accept images as input.
151    Vision,
152    /// Generates images from text.
153    ImageGeneration,
154    /// Edits existing images.
155    ImageEditing,
156    /// Generates audio / text-to-speech.
157    Tts,
158    /// Accepts audio input / speech-to-text.
159    Stt,
160    /// Generates video.
161    VideoGeneration,
162    /// Accepts video as input.
163    VideoInput,
164}
165
166/// Pricing information for a model, including input/output token costs and caching discounts.
167///
168/// All costs are per-token in USD. Use [`TokenUsage::cost`] to compute the total
169/// cost of a request from token counts and pricing.
170#[derive(Debug, Clone, Default)]
171pub struct ModelPricing {
172    /// Cost per input token (USD).
173    pub prompt: Option<Decimal>,
174    /// Cost per output token (USD).
175    pub completion: Option<Decimal>,
176    /// Cost per image generated (USD).
177    pub image: Option<Decimal>,
178    /// Cost per cached input token read (USD).
179    pub cache_read: Option<Decimal>,
180    /// Cost per cache creation (write) input token (USD).
181    pub cache_write: Option<Decimal>,
182    /// Published reference rates, which may differ from the provider's charges.
183    pub reference: Option<ReferencePricing>,
184}
185
186/// Reference token rates from a separately identified source.
187#[derive(Debug, Clone)]
188pub struct ReferencePricing {
189    /// Source of the reference rates.
190    pub source: ReferencePricingSource,
191    /// Reference input price per token in USD.
192    pub prompt: Option<Decimal>,
193    /// Reference output price per token in USD.
194    pub completion: Option<Decimal>,
195}
196
197/// Source of a model's reference rates.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
199pub enum ReferencePricingSource {
200    /// Prices published by OpenRouter.
201    OpenRouter,
202    /// Prices published by models.dev.
203    #[strum(to_string = "models.dev")]
204    ModelsDev,
205}
206
207/// Metadata about an available LLM model, including capabilities, pricing, and context window.
208#[derive(Debug, Clone)]
209pub struct ModelInfo {
210    /// Model ID used in API requests.
211    pub id: String,
212    /// Human-readable display name.
213    pub name: Option<String>,
214    /// Model creation time, when supplied by the provider.
215    pub created_at: Option<DateTime<Utc>>,
216    /// Context window size in tokens.
217    pub context_length: Option<u32>,
218    /// Maximum output tokens allowed.
219    pub max_completion_tokens: Option<u32>,
220    /// Capability flags.
221    pub capabilities: ModelCapabilities,
222    /// High-level categories (chat, vision, reasoning, etc.).
223    pub categories: Vec<ModelCategory>,
224    /// Per-token pricing.
225    pub pricing: ModelPricing,
226    /// Image-specific capability metadata from the provider.
227    pub image: ImageModelCapabilities,
228}
229
230/// Image capability metadata, when provided in a model catalog.
231#[derive(Debug, Clone, Default)]
232pub struct ImageModelCapabilities {
233    /// Supports image editing.
234    pub editing: bool,
235    /// Whether style references are supported, if reported.
236    pub style_references: Option<bool>,
237    /// Maximum number of images for an edit, if reported.
238    pub max_edit_images: Option<u32>,
239    /// Provider-reported image options and their allowed values or ranges.
240    pub parameters: BTreeMap<String, serde_json::Value>,
241}
242
243/// Feature flags describing what a model supports.
244///
245/// Defaults to all `false` via `#[derive(Default)]` — nothing is assumed.
246/// Providers override these after inspecting the model's actual capabilities.
247/// Use [`all()`](Self::all()) for a curated "full-featured text model" preset.
248#[derive(Debug, Clone, Copy, Default)]
249pub struct ModelCapabilities {
250    /// Can invoke tools/function calling.
251    pub tool_calling: bool,
252    /// Can accept images as input (vision).
253    pub images: bool,
254    /// Can accept documents (PDFs, etc.) as input.
255    pub documents: bool,
256    /// Can accept video as input.
257    pub video: bool,
258    /// Can accept audio as input (speech-to-text).
259    pub audio: bool,
260    /// Supports extended chain-of-thought / reasoning mode.
261    pub reasoning: bool,
262    // Output capabilities
263    /// Can generate audio output (text-to-speech inline).
264    pub audio_output: bool,
265    /// Can generate images as output.
266    pub image_generation: bool,
267    /// Can generate video as output.
268    pub video_generation: bool,
269}
270
271impl ModelCapabilities {
272    /// Derive high-level categories from capability flags.
273    pub fn categories(&self) -> Vec<ModelCategory> {
274        let mut cats = vec![ModelCategory::Chat];
275        if self.reasoning {
276            cats.push(ModelCategory::Reasoning);
277        }
278        if self.images {
279            cats.push(ModelCategory::Vision);
280        }
281        if self.image_generation {
282            cats.push(ModelCategory::ImageGeneration);
283        }
284        if self.audio_output {
285            cats.push(ModelCategory::Tts);
286        }
287        if self.audio {
288            cats.push(ModelCategory::Stt);
289        }
290        if self.video_generation {
291            cats.push(ModelCategory::VideoGeneration);
292        }
293        if self.video {
294            cats.push(ModelCategory::VideoInput);
295        }
296        cats
297    }
298
299    /// Curated "full-featured text model" capability preset.
300    ///
301    /// Enables common features (tool calling, vision, reasoning, audio input)
302    /// but keeps output-generation capabilities (`audio_output`, `image_generation`,
303    /// `video_generation`) disabled, as those require dedicated models.
304    pub fn all() -> Self {
305        Self {
306            tool_calling: true,
307            images: true,
308            documents: true,
309            video: false,
310            audio: true,
311            reasoning: true,
312            audio_output: false,
313            image_generation: false,
314            video_generation: false,
315        }
316    }
317}
318
319/// Token usage counts for a single completion request or turn.
320#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
321pub struct TokenUsage {
322    /// Tokens consumed by the prompt (input messages).
323    pub prompt_tokens: u32,
324    /// Tokens generated by the model (output/response).
325    pub completion_tokens: u32,
326    /// Total tokens (prompt + completion).
327    pub total_tokens: u32,
328    /// Prompt tokens served from the provider's cache.
329    pub cache_read_tokens: u32,
330    /// Prompt tokens written to the provider's cache.
331    pub cache_creation_tokens: u32,
332}
333
334impl TokenUsage {
335    /// Compute the cost of this usage given per-token pricing.
336    ///
337    /// Non-cached input tokens are charged at the `prompt` rate, cached reads at
338    /// `cache_read` (falling back to `prompt`), and cache writes at `cache_write`
339    /// (falling back to `prompt`). Returns `None` if pricing has no prompt or
340    /// completion prices.
341    pub fn cost(&self, pricing: &ModelPricing) -> Option<Decimal> {
342        let prompt_price = pricing.prompt?;
343        let completion_price = pricing.completion?;
344        let cache_read_price = pricing.cache_read.unwrap_or(prompt_price);
345        let cache_write_price = pricing.cache_write.unwrap_or(prompt_price);
346
347        let non_cached = self
348            .prompt_tokens
349            .saturating_sub(self.cache_read_tokens + self.cache_creation_tokens);
350
351        Some(
352            Decimal::from(non_cached) * prompt_price
353                + Decimal::from(self.completion_tokens) * completion_price
354                + Decimal::from(self.cache_read_tokens) * cache_read_price
355                + Decimal::from(self.cache_creation_tokens) * cache_write_price,
356        )
357    }
358}
359
360impl From<TurnUsage> for TokenUsage {
361    fn from(u: TurnUsage) -> Self {
362        Self {
363            prompt_tokens: u.prompt_tokens,
364            completion_tokens: u.completion_tokens,
365            total_tokens: u.prompt_tokens + u.completion_tokens,
366            cache_read_tokens: u.cache_read_tokens,
367            cache_creation_tokens: u.cache_creation_tokens,
368        }
369    }
370}
371
372/// Non-streaming completion response wrapper.
373#[derive(Debug, Clone)]
374pub struct CompletionResponse {
375    /// The assistant's response message.
376    pub message: Message,
377    /// Token usage for this completion.
378    pub usage: TokenUsage,
379    /// Why generation stopped.
380    pub finish_reason: FinishReason,
381}
382
383/// Events emitted incrementally by the provider's stream.
384///
385/// Each variant corresponds to a server-side SSE/data-line event type.
386/// Consumers assemble these into higher-level [`AgentEvent`](crate::event::AgentEvent)s.
387#[derive(Debug, Clone)]
388pub enum StreamEvent {
389    /// Incremental reasoning/chain-of-thought token.
390    ReasoningDelta(String),
391    /// Incremental assistant text token.
392    ContentDelta(String),
393    /// The LLM started requesting a tool call at `index`.
394    ToolCallStart {
395        index: usize,
396        id: String,
397        name: String,
398    },
399    /// Incremental JSON argument string for the tool at `index`.
400    ToolCallDelta { index: usize, arguments: String },
401    /// Token usage snapshot mid-stream (some providers emit this).
402    Usage(TokenUsage),
403    /// Incremental audio output chunk (raw PCM/MP3 bytes).
404    AudioDelta { data: Vec<u8>, format: String },
405    /// Out-of-band file delivered by the provider (e.g. generated image).
406    FileAttachment {
407        filename: String,
408        media_type: String,
409        data: Vec<u8>,
410    },
411    /// Stream has ended; payload is the final finish reason.
412    Finished(FinishReason),
413}
414
415/// Type alias for a stream of completion events from an LLM provider.
416///
417/// The stream yields `StreamEvent`s (content deltas, tool calls, finish reasons) and is consumed by the agent loop.
418pub type CompletionStream =
419    Pin<Box<dyn Stream<Item = anyhow::Result<StreamEvent>> + Send + 'static>>;
420
421/// A voice available for text-to-speech generation.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct Voice {
424    /// Unique voice identifier (e.g., `"alloy"`).
425    pub id: String,
426    /// Human-readable display name.
427    pub name: String,
428}
429
430/// Parameters for a text-to-speech request.
431pub struct TtsRequest {
432    /// TTS model identifier (e.g., `"tts-1"`).
433    pub model: String,
434    /// Text to synthesize into speech.
435    pub input: String,
436    /// Voice preset ID (e.g., `"alloy"`, `"echo"`).
437    pub voice: String,
438    /// Desired output audio format.
439    pub response_format: AudioFormat,
440}
441
442/// Parameters for a speech-to-text (transcription) request.
443pub struct SttRequest {
444    /// STT model identifier (e.g., `"whisper-1"`).
445    pub model: String,
446    /// Raw audio bytes.
447    pub audio: Vec<u8>,
448    /// MIME type of the audio (e.g. "audio/mp3", "audio/wav").
449    pub media_type: String,
450    /// Optional language hint (ISO 639-1, e.g. "en").
451    pub language: Option<String>,
452}
453
454/// Output audio format for TTS responses.
455///
456/// Serialises to lowercase strings matching common audio file extensions.
457#[derive(
458    Debug, Clone, Copy, Default, strum::EnumString, strum::Display, Serialize, Deserialize,
459)]
460#[strum(serialize_all = "lowercase")]
461#[serde(rename_all = "lowercase")]
462pub enum AudioFormat {
463    /// MP3 — most widely supported, good compression.
464    #[default]
465    Mp3,
466    /// WAV — uncompressed PCM, highest quality.
467    Wav,
468    /// Opus — efficient codec optimized for speech.
469    Opus,
470    /// AAC — Advanced Audio Coding, common in mobile ecosystems.
471    Aac,
472    /// FLAC — lossless compression.
473    Flac,
474}
475
476/// Output modality requested in a completion.
477///
478/// Multiple modalities can be requested simultaneously (e.g., text + audio).
479/// An empty list means text-only output.
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
481#[serde(rename_all = "lowercase")]
482#[strum(serialize_all = "lowercase")]
483pub enum Modality {
484    /// Plain text output (default when no modalities specified).
485    Text,
486    /// Inline audio output (real-time voice).
487    Audio,
488    /// Image output (generated during completion).
489    Image,
490}
491
492/// Configuration for audio output in multimodal completions.
493#[derive(Debug, Clone)]
494pub struct AudioOutputConfig {
495    /// Voice preset ID (e.g., `"alloy"`).
496    pub voice: String,
497    /// Output audio format.
498    pub format: AudioFormat,
499}
500
501/// Configuration for image generation requests.
502#[derive(Debug, Clone, Default)]
503pub struct ImageGenConfig {
504    /// Aspect ratio for the generated image (e.g., `"16:9"`).
505    pub aspect_ratio: Option<String>,
506    /// Absolute dimensions (e.g., `"1024x1024"`).
507    pub size: Option<String>,
508}
509
510impl ImageGenConfig {
511    /// Read an image file and return it as a `data:<mime>;base64,...` URI.
512    ///
513    /// # Arguments
514    ///
515    /// * `path` - Path to the image file on disk.
516    ///
517    /// # Returns
518    ///
519    /// A data URI string with auto-detected MIME type, or an I/O error if the file cannot be read.
520    pub fn data_uri_from_path(path: &std::path::Path) -> std::io::Result<String> {
521        let bytes = std::fs::read(path)?;
522        let mime = mime_from_extension(path);
523        Ok(Self::data_uri_from_bytes(&bytes, mime))
524    }
525
526    /// Encode raw bytes as a `data:<mime>;base64,...` URI.
527    ///
528    /// # Arguments
529    ///
530    /// * `bytes` - Raw binary data (e.g., image file contents).
531    /// * `mime` - MIME type string (e.g., `"image/png"`).
532    ///
533    /// # Returns
534    ///
535    /// A data URI string with the provided MIME type and base64-encoded payload.
536    pub fn data_uri_from_bytes(bytes: &[u8], mime: &str) -> String {
537        use base64::Engine;
538        let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
539        format!("data:{mime};base64,{b64}")
540    }
541}
542
543/// Infer MIME type from a file path's extension.
544///
545/// # Arguments
546///
547/// * `path` - File path whose extension is inspected.
548///
549/// # Returns
550///
551/// A static MIME type string corresponding to the extension, or `"application/octet-stream"`
552/// if the extension is unrecognized or missing.
553pub fn mime_from_extension(path: &std::path::Path) -> &'static str {
554    match path
555        .extension()
556        .and_then(|e| e.to_str())
557        .map(|e| e.to_ascii_lowercase())
558        .as_deref()
559    {
560        Some("png") => "image/png",
561        Some("jpg" | "jpeg") => "image/jpeg",
562        Some("gif") => "image/gif",
563        Some("webp") => "image/webp",
564        Some("bmp") => "image/bmp",
565        Some("svg") => "image/svg+xml",
566        _ => "application/octet-stream",
567    }
568}
569
570/// Image format requested from a provider.
571#[derive(
572    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::Display, strum::EnumString,
573)]
574#[serde(rename_all = "lowercase")]
575#[strum(serialize_all = "lowercase")]
576pub enum ImageOutputFormat {
577    /// PNG image.
578    Png,
579    /// JPEG image.
580    Jpeg,
581    /// WebP image.
582    Webp,
583}
584
585impl ImageOutputFormat {
586    /// MIME type corresponding to the format.
587    pub fn media_type(self) -> &'static str {
588        match self {
589            Self::Png => "image/png",
590            Self::Jpeg => "image/jpeg",
591            Self::Webp => "image/webp",
592        }
593    }
594}
595
596/// Parameters for generating an image via a generative model.
597#[derive(Debug, Clone)]
598pub struct ImageGenRequest {
599    /// Image generation model identifier.
600    pub model: String,
601    /// Text description of the desired image.
602    pub prompt: String,
603    /// Image dimensions (e.g., `"1024x1024"`).
604    pub size: Option<String>,
605    /// Aspect ratio (e.g., `"16:9"`).
606    pub aspect_ratio: Option<String>,
607    /// Quality preset (e.g., `"standard"`, `"hd"`).
608    pub quality: Option<String>,
609    /// Style preset (e.g., `"natural"`, `"vivid"`).
610    pub style: Option<String>,
611    /// Number of images to generate.
612    pub n: Option<u32>,
613    /// Reference images as URLs or data URIs.
614    pub input_references: Vec<String>,
615    /// Requested output format, if supported by the provider.
616    pub output_format: Option<ImageOutputFormat>,
617}
618
619/// Parameters for editing one or more images.
620#[derive(Debug, Clone)]
621pub struct ImageEditRequest {
622    /// Provider model identifier.
623    pub model: String,
624    /// Instructions describing the edit.
625    pub prompt: String,
626    /// Input images as URLs or data URIs, beginning with the base image.
627    pub images: Vec<String>,
628    /// Output resolution tier.
629    pub resolution: Option<String>,
630    /// Output aspect ratio.
631    pub aspect_ratio: Option<String>,
632    /// Provider-specific quality tier.
633    pub quality: Option<String>,
634    /// Mask as a URL or data URI. Transparent pixels indicate editable areas.
635    pub mask: Option<String>,
636    /// Requested output format, if supported by the provider.
637    pub output_format: Option<ImageOutputFormat>,
638}
639
640/// Parameters for generating a video from text prompts or image frames.
641#[derive(Debug, Clone)]
642pub struct VideoGenRequest {
643    /// Video generation model identifier.
644    pub model: String,
645    /// Text description of the desired video.
646    pub description: String,
647    /// Video resolution (e.g., `"720p"`, `"1080p"`).
648    pub resolution: Option<String>,
649    /// Aspect ratio (e.g., `"16:9"`).
650    pub aspect_ratio: Option<String>,
651    /// Duration in seconds.
652    pub duration: Option<u32>,
653    /// Whether to generate accompanying audio.
654    pub generate_audio: Option<bool>,
655    /// Image URLs for first/last frame (image-to-video).
656    pub frame_images: Vec<FrameImage>,
657    /// Reference image URLs for style guidance.
658    pub input_references: Vec<String>,
659    /// Reference videos as URLs or data URIs.
660    pub video_references: Vec<String>,
661}
662
663/// An image used as a frame reference in video generation.
664#[derive(Debug, Clone)]
665pub struct FrameImage {
666    /// URL or data URI of the frame image.
667    pub url: String,
668    /// Frame position — `"first_frame"` or `"last_frame"`.
669    pub frame_type: String,
670}
671
672/// Trait implemented by each LLM backend (OpenRouter, Anthropic, Ollama, etc.).
673///
674/// Providers are registered in a [`ProviderRegistry`] at startup and selected
675/// per-turn based on the active model's [`Model::provider`](crate::model::Model::provider) field.
676///
677/// # Required method
678///
679/// Only [`complete`](Self::complete) is required — it returns a stream of [`StreamEvent`]s.
680/// All other methods have default implementations suitable for simple providers.
681///
682/// # Concurrency
683///
684/// Implementations must be `Send + Sync` since they're shared via `Arc` across async tasks.
685#[async_trait]
686pub trait LlmProvider: Send + Sync {
687    /// Human-readable name of this provider (for logs and error messages).
688    fn name(&self) -> &str;
689
690    /// Which [`crate::model::Provider`] variant this instance implements.
691    fn provider(&self) -> Provider;
692
693    /// Query the provider for the context window size of `model`. Return `None`
694    /// if the provider doesn't expose this information.
695    async fn context_window(&self, _model: &Model) -> Option<u32> {
696        None
697    }
698
699    /// Probe or return hard-coded capabilities for `model`. Used to detect features like reasoning or multimodal support.
700    async fn capabilities(&self, _model: &Model) -> ModelCapabilities {
701        ModelCapabilities {
702            tool_calling: true,
703            images: true,
704            documents: true,
705            video: false,
706            audio: false,
707            reasoning: false,
708            ..Default::default()
709        }
710    }
711
712    /// List models available from this provider. Return `None` if unsupported.
713    async fn list_models(&self) -> Option<Vec<ModelInfo>> {
714        None
715    }
716
717    /// Issue a completion request and return a stream of [`StreamEvent`]s.
718    ///
719    /// This is the core method that every provider must implement. The returned
720    /// stream should yield incremental events (text deltas, tool calls, usage)
721    /// and terminate with a [`StreamEvent::Finished`] event.
722    fn complete(&self, request: CompletionRequest) -> CompletionStream;
723
724    /// Synthesise speech from text as a stream of [`StreamEvent::AudioDelta`] chunks,
725    /// ending with [`StreamEvent::Finished`]. Consumers can play chunks as they arrive
726    /// for real-time playback.
727    fn text_to_speech(&self, _request: TtsRequest) -> CompletionStream {
728        let name = self.name().to_string();
729        Box::pin(futures_util::stream::once(async move {
730            Err(anyhow::anyhow!(
731                "Provider '{name}' does not support text-to-speech"
732            ))
733        }))
734    }
735
736    /// Transcribe audio to text. Returns a stream of [`StreamEvent::ContentDelta`] chunks,
737    /// ending with [`StreamEvent::Finished`].
738    fn transcribe(&self, _request: SttRequest) -> CompletionStream {
739        let name = self.name().to_string();
740        Box::pin(futures_util::stream::once(async move {
741            Err(anyhow::anyhow!(
742                "Provider '{name}' does not support speech-to-text"
743            ))
744        }))
745    }
746
747    /// List available voices for TTS. Return `None` if unsupported.
748    async fn list_voices(&self, _model: &str) -> Option<Vec<Voice>> {
749        None
750    }
751
752    /// Generate an image via a dedicated image API (e.g. DALL-E).
753    /// Default implementation returns an error.
754    fn generate_image(&self, _request: ImageGenRequest) -> CompletionStream {
755        Box::pin(futures_util::stream::once(async move {
756            Err(anyhow::anyhow!(
757                "Provider does not support image generation"
758            ))
759        }))
760    }
761
762    /// Edit an existing image using a provider's image editing API.
763    fn edit_image(&self, _request: ImageEditRequest) -> CompletionStream {
764        Box::pin(futures_util::stream::once(async move {
765            Err(anyhow::anyhow!("Provider does not support image editing"))
766        }))
767    }
768
769    /// Generate a video. Returns a stream that yields progress updates as
770    /// [`StreamEvent::ContentDelta`] and the final video as [`StreamEvent::FileAttachment`].
771    /// Default implementation returns an error.
772    fn generate_video(&self, _request: VideoGenRequest) -> CompletionStream {
773        Box::pin(futures_util::stream::once(async move {
774            Err(anyhow::anyhow!(
775                "Provider does not support video generation"
776            ))
777        }))
778    }
779
780    /// Update URL routing table (for providers like Ollama with multiple backends).
781    fn update_routing(&self, _routing: &HashMap<String, String>) {}
782
783    /// Wrap `self` in an `Arc<dyn LlmProvider>` for use with [`Agent`](crate) builders.
784    fn into_arc(self) -> Arc<dyn LlmProvider>
785    where
786        Self: Sized + 'static,
787    {
788        Arc::new(self)
789    }
790}
791
792/// Shared registry mapping each [`crate::model::Provider`] to its concrete instance.
793///
794/// Created once at startup via the CLI; cloned into every agent so they can
795/// switch providers without reconnecting.
796pub type ProviderRegistry = Arc<HashMap<Provider, Arc<dyn LlmProvider>>>;
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn requests_keep_the_sessions_config_and_identity() {
804        use uuid::Uuid;
805
806        let uuid = Uuid::new_v4();
807        let mut config = AgentLlmConfig::new("openrouter:example/model".parse().unwrap())
808            .with_session_id(uuid)
809            .with_user("example-user")
810            .with_reasoning(ReasoningLevel::Low)
811            .with_provider_preferences(ProviderPreferences {
812                zdr: Some(true),
813                ..Default::default()
814            });
815        config.max_tokens = Some(100);
816        config.sampling.temperature = Some(Decimal::ONE);
817        let first = CompletionRequest::from_config(&config, vec![Message::user("hello")]);
818        let second = CompletionRequest::from_config(&config, vec![Message::user("continue")]);
819        assert_eq!(first.session_id, second.session_id);
820        assert_eq!(
821            first.session_id.as_ref().map(SessionId::as_str),
822            Some(uuid.to_string().as_str())
823        );
824        assert_eq!(first.user, config.user);
825        assert_eq!(first.reasoning, config.reasoning);
826        assert_eq!(first.max_tokens, config.max_tokens);
827        assert_eq!(first.sampling.temperature, config.sampling.temperature);
828        assert_eq!(first.provider_preferences.unwrap().zdr, Some(true));
829
830        let one_off = CompletionRequest::new(config.model.clone(), vec![Message::user("hello")]);
831        assert!(one_off.session_id.is_none());
832        let one_off = one_off.with_session_id(uuid);
833        assert_eq!(one_off.session_id, second.session_id);
834    }
835    use crate::event::TurnUsage;
836
837    #[test]
838    fn token_usage_from_turn_usage() {
839        let turn = TurnUsage {
840            prompt_tokens: 100,
841            completion_tokens: 50,
842            ..TurnUsage::default()
843        };
844        let token: TokenUsage = turn.into();
845        assert_eq!(token.prompt_tokens, 100);
846        assert_eq!(token.completion_tokens, 50);
847        assert_eq!(token.total_tokens, 150);
848    }
849
850    #[test]
851    fn token_usage_default_is_zero() {
852        let usage = TokenUsage::default();
853        assert_eq!(usage.total_tokens, 0);
854    }
855
856    #[test]
857    fn model_capabilities_all() {
858        let caps = ModelCapabilities::all();
859        assert!(caps.tool_calling);
860        assert!(caps.images);
861        assert!(caps.reasoning);
862    }
863
864    #[test]
865    fn model_capabilities_default_is_conservative() {
866        let caps = ModelCapabilities::default();
867        assert!(!caps.tool_calling);
868        assert!(!caps.reasoning);
869    }
870
871    #[test]
872    fn finish_reason_display() {
873        assert_eq!(FinishReason::Stop.to_string(), "stop");
874        assert_eq!(FinishReason::ToolCalls.to_string(), "tool_calls");
875        assert_eq!(FinishReason::Length.to_string(), "length");
876    }
877
878    #[test]
879    fn finish_reason_from_str() {
880        let stop: FinishReason = "stop".parse().unwrap();
881        assert_eq!(stop, FinishReason::Stop);
882        let tc: FinishReason = "tool_calls".parse().unwrap();
883        assert_eq!(tc, FinishReason::ToolCalls);
884    }
885
886    #[test]
887    fn tool_definition_serde() {
888        let def = ToolDefinition {
889            name: "file_read".into(),
890            description: "Read a file".into(),
891            parameters: serde_json::json!({"type": "object", "properties": {}}),
892        };
893        let json = serde_json::to_string(&def).unwrap();
894        let parsed: ToolDefinition = serde_json::from_str(&json).unwrap();
895        assert_eq!(parsed.name, "file_read");
896    }
897
898    #[test]
899    fn audio_format_default() {
900        assert_eq!(AudioFormat::default().to_string(), "mp3");
901    }
902}