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::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 /// Generates audio / text-to-speech.
155 Tts,
156 /// Accepts audio input / speech-to-text.
157 Stt,
158 /// Generates video.
159 VideoGeneration,
160 /// Accepts video as input.
161 VideoInput,
162}
163
164/// Pricing information for a model, including input/output token costs and caching discounts.
165///
166/// All costs are per-token in USD. Use [`TokenUsage::cost`] to compute the total
167/// cost of a request from token counts and pricing.
168#[derive(Debug, Clone, Default)]
169pub struct ModelPricing {
170 /// Cost per input token (USD).
171 pub prompt: Option<Decimal>,
172 /// Cost per output token (USD).
173 pub completion: Option<Decimal>,
174 /// Cost per image generated (USD).
175 pub image: Option<Decimal>,
176 /// Cost per cached input token read (USD).
177 pub cache_read: Option<Decimal>,
178 /// Cost per cache creation (write) input token (USD).
179 pub cache_write: Option<Decimal>,
180}
181
182/// Metadata about an available LLM model, including capabilities, pricing, and context window.
183#[derive(Debug, Clone)]
184pub struct ModelInfo {
185 /// Model ID used in API requests.
186 pub id: String,
187 /// Human-readable display name.
188 pub name: Option<String>,
189 /// Model creation time, when supplied by the provider.
190 pub created_at: Option<DateTime<Utc>>,
191 /// Context window size in tokens.
192 pub context_length: Option<u32>,
193 /// Maximum output tokens allowed.
194 pub max_completion_tokens: Option<u32>,
195 /// Capability flags.
196 pub capabilities: ModelCapabilities,
197 /// High-level categories (chat, vision, reasoning, etc.).
198 pub categories: Vec<ModelCategory>,
199 /// Per-token pricing.
200 pub pricing: ModelPricing,
201}
202
203/// Feature flags describing what a model supports.
204///
205/// Defaults to all `false` via `#[derive(Default)]` — nothing is assumed.
206/// Providers override these after inspecting the model's actual capabilities.
207/// Use [`all()`](Self::all()) for a curated "full-featured text model" preset.
208#[derive(Debug, Clone, Copy, Default)]
209pub struct ModelCapabilities {
210 /// Can invoke tools/function calling.
211 pub tool_calling: bool,
212 /// Can accept images as input (vision).
213 pub images: bool,
214 /// Can accept documents (PDFs, etc.) as input.
215 pub documents: bool,
216 /// Can accept video as input.
217 pub video: bool,
218 /// Can accept audio as input (speech-to-text).
219 pub audio: bool,
220 /// Supports extended chain-of-thought / reasoning mode.
221 pub reasoning: bool,
222 // Output capabilities
223 /// Can generate audio output (text-to-speech inline).
224 pub audio_output: bool,
225 /// Can generate images as output.
226 pub image_generation: bool,
227 /// Can generate video as output.
228 pub video_generation: bool,
229}
230
231impl ModelCapabilities {
232 /// Derive high-level categories from capability flags.
233 pub fn categories(&self) -> Vec<ModelCategory> {
234 let mut cats = vec![ModelCategory::Chat];
235 if self.reasoning {
236 cats.push(ModelCategory::Reasoning);
237 }
238 if self.images {
239 cats.push(ModelCategory::Vision);
240 }
241 if self.image_generation {
242 cats.push(ModelCategory::ImageGeneration);
243 }
244 if self.audio_output {
245 cats.push(ModelCategory::Tts);
246 }
247 if self.audio {
248 cats.push(ModelCategory::Stt);
249 }
250 if self.video_generation {
251 cats.push(ModelCategory::VideoGeneration);
252 }
253 if self.video {
254 cats.push(ModelCategory::VideoInput);
255 }
256 cats
257 }
258
259 /// Curated "full-featured text model" capability preset.
260 ///
261 /// Enables common features (tool calling, vision, reasoning, audio input)
262 /// but keeps output-generation capabilities (`audio_output`, `image_generation`,
263 /// `video_generation`) disabled, as those require dedicated models.
264 pub fn all() -> Self {
265 Self {
266 tool_calling: true,
267 images: true,
268 documents: true,
269 video: false,
270 audio: true,
271 reasoning: true,
272 audio_output: false,
273 image_generation: false,
274 video_generation: false,
275 }
276 }
277}
278
279/// Token usage counts for a single completion request or turn.
280#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
281pub struct TokenUsage {
282 /// Tokens consumed by the prompt (input messages).
283 pub prompt_tokens: u32,
284 /// Tokens generated by the model (output/response).
285 pub completion_tokens: u32,
286 /// Total tokens (prompt + completion).
287 pub total_tokens: u32,
288 /// Prompt tokens served from the provider's cache.
289 pub cache_read_tokens: u32,
290 /// Prompt tokens written to the provider's cache.
291 pub cache_creation_tokens: u32,
292}
293
294impl TokenUsage {
295 /// Compute the cost of this usage given per-token pricing.
296 ///
297 /// Non-cached input tokens are charged at the `prompt` rate, cached reads at
298 /// `cache_read` (falling back to `prompt`), and cache writes at `cache_write`
299 /// (falling back to `prompt`). Returns `None` if pricing has no prompt or
300 /// completion prices.
301 pub fn cost(&self, pricing: &ModelPricing) -> Option<Decimal> {
302 let prompt_price = pricing.prompt?;
303 let completion_price = pricing.completion?;
304 let cache_read_price = pricing.cache_read.unwrap_or(prompt_price);
305 let cache_write_price = pricing.cache_write.unwrap_or(prompt_price);
306
307 let non_cached = self
308 .prompt_tokens
309 .saturating_sub(self.cache_read_tokens + self.cache_creation_tokens);
310
311 Some(
312 Decimal::from(non_cached) * prompt_price
313 + Decimal::from(self.completion_tokens) * completion_price
314 + Decimal::from(self.cache_read_tokens) * cache_read_price
315 + Decimal::from(self.cache_creation_tokens) * cache_write_price,
316 )
317 }
318}
319
320impl From<TurnUsage> for TokenUsage {
321 fn from(u: TurnUsage) -> Self {
322 Self {
323 prompt_tokens: u.prompt_tokens,
324 completion_tokens: u.completion_tokens,
325 total_tokens: u.prompt_tokens + u.completion_tokens,
326 cache_read_tokens: u.cache_read_tokens,
327 cache_creation_tokens: u.cache_creation_tokens,
328 }
329 }
330}
331
332/// Non-streaming completion response wrapper.
333#[derive(Debug, Clone)]
334pub struct CompletionResponse {
335 /// The assistant's response message.
336 pub message: Message,
337 /// Token usage for this completion.
338 pub usage: TokenUsage,
339 /// Why generation stopped.
340 pub finish_reason: FinishReason,
341}
342
343/// Events emitted incrementally by the provider's stream.
344///
345/// Each variant corresponds to a server-side SSE/data-line event type.
346/// Consumers assemble these into higher-level [`AgentEvent`](crate::event::AgentEvent)s.
347#[derive(Debug, Clone)]
348pub enum StreamEvent {
349 /// Incremental reasoning/chain-of-thought token.
350 ReasoningDelta(String),
351 /// Incremental assistant text token.
352 ContentDelta(String),
353 /// The LLM started requesting a tool call at `index`.
354 ToolCallStart {
355 index: usize,
356 id: String,
357 name: String,
358 },
359 /// Incremental JSON argument string for the tool at `index`.
360 ToolCallDelta { index: usize, arguments: String },
361 /// Token usage snapshot mid-stream (some providers emit this).
362 Usage(TokenUsage),
363 /// Incremental audio output chunk (raw PCM/MP3 bytes).
364 AudioDelta { data: Vec<u8>, format: String },
365 /// Out-of-band file delivered by the provider (e.g. generated image).
366 FileAttachment {
367 filename: String,
368 media_type: String,
369 data: Vec<u8>,
370 },
371 /// Stream has ended; payload is the final finish reason.
372 Finished(FinishReason),
373}
374
375/// Type alias for a stream of completion events from an LLM provider.
376///
377/// The stream yields `StreamEvent`s (content deltas, tool calls, finish reasons) and is consumed by the agent loop.
378pub type CompletionStream =
379 Pin<Box<dyn Stream<Item = anyhow::Result<StreamEvent>> + Send + 'static>>;
380
381/// A voice available for text-to-speech generation.
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct Voice {
384 /// Unique voice identifier (e.g., `"alloy"`).
385 pub id: String,
386 /// Human-readable display name.
387 pub name: String,
388}
389
390/// Parameters for a text-to-speech request.
391pub struct TtsRequest {
392 /// TTS model identifier (e.g., `"tts-1"`).
393 pub model: String,
394 /// Text to synthesize into speech.
395 pub input: String,
396 /// Voice preset ID (e.g., `"alloy"`, `"echo"`).
397 pub voice: String,
398 /// Desired output audio format.
399 pub response_format: AudioFormat,
400}
401
402/// Parameters for a speech-to-text (transcription) request.
403pub struct SttRequest {
404 /// STT model identifier (e.g., `"whisper-1"`).
405 pub model: String,
406 /// Raw audio bytes.
407 pub audio: Vec<u8>,
408 /// MIME type of the audio (e.g. "audio/mp3", "audio/wav").
409 pub media_type: String,
410 /// Optional language hint (ISO 639-1, e.g. "en").
411 pub language: Option<String>,
412}
413
414/// Output audio format for TTS responses.
415///
416/// Serialises to lowercase strings matching common audio file extensions.
417#[derive(
418 Debug, Clone, Copy, Default, strum::EnumString, strum::Display, Serialize, Deserialize,
419)]
420#[strum(serialize_all = "lowercase")]
421#[serde(rename_all = "lowercase")]
422pub enum AudioFormat {
423 /// MP3 — most widely supported, good compression.
424 #[default]
425 Mp3,
426 /// WAV — uncompressed PCM, highest quality.
427 Wav,
428 /// Opus — efficient codec optimized for speech.
429 Opus,
430 /// AAC — Advanced Audio Coding, common in mobile ecosystems.
431 Aac,
432 /// FLAC — lossless compression.
433 Flac,
434}
435
436/// Output modality requested in a completion.
437///
438/// Multiple modalities can be requested simultaneously (e.g., text + audio).
439/// An empty list means text-only output.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
441#[serde(rename_all = "lowercase")]
442#[strum(serialize_all = "lowercase")]
443pub enum Modality {
444 /// Plain text output (default when no modalities specified).
445 Text,
446 /// Inline audio output (real-time voice).
447 Audio,
448 /// Image output (generated during completion).
449 Image,
450}
451
452/// Configuration for audio output in multimodal completions.
453#[derive(Debug, Clone)]
454pub struct AudioOutputConfig {
455 /// Voice preset ID (e.g., `"alloy"`).
456 pub voice: String,
457 /// Output audio format.
458 pub format: AudioFormat,
459}
460
461/// Configuration for image generation requests.
462#[derive(Debug, Clone, Default)]
463pub struct ImageGenConfig {
464 /// Aspect ratio for the generated image (e.g., `"16:9"`).
465 pub aspect_ratio: Option<String>,
466 /// Absolute dimensions (e.g., `"1024x1024"`).
467 pub size: Option<String>,
468}
469
470impl ImageGenConfig {
471 /// Read an image file and return it as a `data:<mime>;base64,...` URI.
472 ///
473 /// # Arguments
474 ///
475 /// * `path` - Path to the image file on disk.
476 ///
477 /// # Returns
478 ///
479 /// A data URI string with auto-detected MIME type, or an I/O error if the file cannot be read.
480 pub fn data_uri_from_path(path: &std::path::Path) -> std::io::Result<String> {
481 let bytes = std::fs::read(path)?;
482 let mime = mime_from_extension(path);
483 Ok(Self::data_uri_from_bytes(&bytes, mime))
484 }
485
486 /// Encode raw bytes as a `data:<mime>;base64,...` URI.
487 ///
488 /// # Arguments
489 ///
490 /// * `bytes` - Raw binary data (e.g., image file contents).
491 /// * `mime` - MIME type string (e.g., `"image/png"`).
492 ///
493 /// # Returns
494 ///
495 /// A data URI string with the provided MIME type and base64-encoded payload.
496 pub fn data_uri_from_bytes(bytes: &[u8], mime: &str) -> String {
497 use base64::Engine;
498 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
499 format!("data:{mime};base64,{b64}")
500 }
501}
502
503/// Infer MIME type from a file path's extension.
504///
505/// # Arguments
506///
507/// * `path` - File path whose extension is inspected.
508///
509/// # Returns
510///
511/// A static MIME type string corresponding to the extension, or `"application/octet-stream"`
512/// if the extension is unrecognized or missing.
513pub fn mime_from_extension(path: &std::path::Path) -> &'static str {
514 match path
515 .extension()
516 .and_then(|e| e.to_str())
517 .map(|e| e.to_ascii_lowercase())
518 .as_deref()
519 {
520 Some("png") => "image/png",
521 Some("jpg" | "jpeg") => "image/jpeg",
522 Some("gif") => "image/gif",
523 Some("webp") => "image/webp",
524 Some("bmp") => "image/bmp",
525 Some("svg") => "image/svg+xml",
526 _ => "application/octet-stream",
527 }
528}
529
530/// Parameters for generating an image via a generative model.
531#[derive(Debug, Clone)]
532pub struct ImageGenRequest {
533 /// Image generation model identifier.
534 pub model: String,
535 /// Text description of the desired image.
536 pub prompt: String,
537 /// Image dimensions (e.g., `"1024x1024"`).
538 pub size: Option<String>,
539 /// Aspect ratio (e.g., `"16:9"`).
540 pub aspect_ratio: Option<String>,
541 /// Quality preset (e.g., `"standard"`, `"hd"`).
542 pub quality: Option<String>,
543 /// Style preset (e.g., `"natural"`, `"vivid"`).
544 pub style: Option<String>,
545 /// Number of images to generate.
546 pub n: Option<u32>,
547}
548
549/// Parameters for generating a video from text prompts or image frames.
550#[derive(Debug, Clone)]
551pub struct VideoGenRequest {
552 /// Video generation model identifier.
553 pub model: String,
554 /// Text description of the desired video.
555 pub description: String,
556 /// Video resolution (e.g., `"720p"`, `"1080p"`).
557 pub resolution: Option<String>,
558 /// Aspect ratio (e.g., `"16:9"`).
559 pub aspect_ratio: Option<String>,
560 /// Duration in seconds.
561 pub duration: Option<u32>,
562 /// Whether to generate accompanying audio.
563 pub generate_audio: Option<bool>,
564 /// Image URLs for first/last frame (image-to-video).
565 pub frame_images: Vec<FrameImage>,
566 /// Reference image URLs for style guidance.
567 pub input_references: Vec<String>,
568}
569
570/// An image used as a frame reference in video generation.
571#[derive(Debug, Clone)]
572pub struct FrameImage {
573 /// URL or data URI of the frame image.
574 pub url: String,
575 /// Frame position — `"first_frame"` or `"last_frame"`.
576 pub frame_type: String,
577}
578
579/// Trait implemented by each LLM backend (OpenRouter, Anthropic, Ollama, etc.).
580///
581/// Providers are registered in a [`ProviderRegistry`] at startup and selected
582/// per-turn based on the active model's [`Model::provider`](crate::model::Model::provider) field.
583///
584/// # Required method
585///
586/// Only [`complete`](Self::complete) is required — it returns a stream of [`StreamEvent`]s.
587/// All other methods have default implementations suitable for simple providers.
588///
589/// # Concurrency
590///
591/// Implementations must be `Send + Sync` since they're shared via `Arc` across async tasks.
592#[async_trait]
593pub trait LlmProvider: Send + Sync {
594 /// Human-readable name of this provider (for logs and error messages).
595 fn name(&self) -> &str;
596
597 /// Which [`crate::model::Provider`] variant this instance implements.
598 fn provider(&self) -> Provider;
599
600 /// Query the provider for the context window size of `model`. Return `None`
601 /// if the provider doesn't expose this information.
602 async fn context_window(&self, _model: &Model) -> Option<u32> {
603 None
604 }
605
606 /// Probe or return hard-coded capabilities for `model`. Used to detect features like reasoning or multimodal support.
607 async fn capabilities(&self, _model: &Model) -> ModelCapabilities {
608 ModelCapabilities {
609 tool_calling: true,
610 images: true,
611 documents: true,
612 video: false,
613 audio: false,
614 reasoning: false,
615 ..Default::default()
616 }
617 }
618
619 /// List models available from this provider. Return `None` if unsupported.
620 async fn list_models(&self) -> Option<Vec<ModelInfo>> {
621 None
622 }
623
624 /// Issue a completion request and return a stream of [`StreamEvent`]s.
625 ///
626 /// This is the core method that every provider must implement. The returned
627 /// stream should yield incremental events (text deltas, tool calls, usage)
628 /// and terminate with a [`StreamEvent::Finished`] event.
629 fn complete(&self, request: CompletionRequest) -> CompletionStream;
630
631 /// Synthesise speech from text as a stream of [`StreamEvent::AudioDelta`] chunks,
632 /// ending with [`StreamEvent::Finished`]. Consumers can play chunks as they arrive
633 /// for real-time playback.
634 fn text_to_speech(&self, _request: TtsRequest) -> CompletionStream {
635 let name = self.name().to_string();
636 Box::pin(futures_util::stream::once(async move {
637 Err(anyhow::anyhow!(
638 "Provider '{name}' does not support text-to-speech"
639 ))
640 }))
641 }
642
643 /// Transcribe audio to text. Returns a stream of [`StreamEvent::ContentDelta`] chunks,
644 /// ending with [`StreamEvent::Finished`].
645 fn transcribe(&self, _request: SttRequest) -> CompletionStream {
646 let name = self.name().to_string();
647 Box::pin(futures_util::stream::once(async move {
648 Err(anyhow::anyhow!(
649 "Provider '{name}' does not support speech-to-text"
650 ))
651 }))
652 }
653
654 /// List available voices for TTS. Return `None` if unsupported.
655 async fn list_voices(&self, _model: &str) -> Option<Vec<Voice>> {
656 None
657 }
658
659 /// Generate an image via a dedicated image API (e.g. DALL-E).
660 /// Default implementation returns an error.
661 fn generate_image(&self, _request: ImageGenRequest) -> CompletionStream {
662 Box::pin(futures_util::stream::once(async move {
663 Err(anyhow::anyhow!(
664 "Provider does not support image generation"
665 ))
666 }))
667 }
668
669 /// Generate a video. Returns a stream that yields progress updates as
670 /// [`StreamEvent::ContentDelta`] and the final video as [`StreamEvent::FileAttachment`].
671 /// Default implementation returns an error.
672 fn generate_video(&self, _request: VideoGenRequest) -> CompletionStream {
673 Box::pin(futures_util::stream::once(async move {
674 Err(anyhow::anyhow!(
675 "Provider does not support video generation"
676 ))
677 }))
678 }
679
680 /// Update URL routing table (for providers like Ollama with multiple backends).
681 fn update_routing(&self, _routing: &HashMap<String, String>) {}
682
683 /// Wrap `self` in an `Arc<dyn LlmProvider>` for use with [`Agent`](crate) builders.
684 fn into_arc(self) -> Arc<dyn LlmProvider>
685 where
686 Self: Sized + 'static,
687 {
688 Arc::new(self)
689 }
690}
691
692/// Shared registry mapping each [`crate::model::Provider`] to its concrete instance.
693///
694/// Created once at startup via the CLI; cloned into every agent so they can
695/// switch providers without reconnecting.
696pub type ProviderRegistry = Arc<HashMap<Provider, Arc<dyn LlmProvider>>>;
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 #[test]
703 fn requests_keep_the_sessions_config_and_identity() {
704 use uuid::Uuid;
705
706 let uuid = Uuid::new_v4();
707 let mut config = AgentLlmConfig::new("openrouter:example/model".parse().unwrap())
708 .with_session_id(uuid)
709 .with_user("example-user")
710 .with_reasoning(ReasoningLevel::Low)
711 .with_provider_preferences(ProviderPreferences {
712 zdr: Some(true),
713 ..Default::default()
714 });
715 config.max_tokens = Some(100);
716 config.sampling.temperature = Some(Decimal::ONE);
717 let first = CompletionRequest::from_config(&config, vec![Message::user("hello")]);
718 let second = CompletionRequest::from_config(&config, vec![Message::user("continue")]);
719 assert_eq!(first.session_id, second.session_id);
720 assert_eq!(
721 first.session_id.as_ref().map(SessionId::as_str),
722 Some(uuid.to_string().as_str())
723 );
724 assert_eq!(first.user, config.user);
725 assert_eq!(first.reasoning, config.reasoning);
726 assert_eq!(first.max_tokens, config.max_tokens);
727 assert_eq!(first.sampling.temperature, config.sampling.temperature);
728 assert_eq!(first.provider_preferences.unwrap().zdr, Some(true));
729
730 let one_off = CompletionRequest::new(config.model.clone(), vec![Message::user("hello")]);
731 assert!(one_off.session_id.is_none());
732 let one_off = one_off.with_session_id(uuid);
733 assert_eq!(one_off.session_id, second.session_id);
734 }
735 use crate::event::TurnUsage;
736
737 #[test]
738 fn token_usage_from_turn_usage() {
739 let turn = TurnUsage {
740 prompt_tokens: 100,
741 completion_tokens: 50,
742 ..TurnUsage::default()
743 };
744 let token: TokenUsage = turn.into();
745 assert_eq!(token.prompt_tokens, 100);
746 assert_eq!(token.completion_tokens, 50);
747 assert_eq!(token.total_tokens, 150);
748 }
749
750 #[test]
751 fn token_usage_default_is_zero() {
752 let usage = TokenUsage::default();
753 assert_eq!(usage.total_tokens, 0);
754 }
755
756 #[test]
757 fn model_capabilities_all() {
758 let caps = ModelCapabilities::all();
759 assert!(caps.tool_calling);
760 assert!(caps.images);
761 assert!(caps.reasoning);
762 }
763
764 #[test]
765 fn model_capabilities_default_is_conservative() {
766 let caps = ModelCapabilities::default();
767 assert!(!caps.tool_calling);
768 assert!(!caps.reasoning);
769 }
770
771 #[test]
772 fn finish_reason_display() {
773 assert_eq!(FinishReason::Stop.to_string(), "stop");
774 assert_eq!(FinishReason::ToolCalls.to_string(), "tool_calls");
775 assert_eq!(FinishReason::Length.to_string(), "length");
776 }
777
778 #[test]
779 fn finish_reason_from_str() {
780 let stop: FinishReason = "stop".parse().unwrap();
781 assert_eq!(stop, FinishReason::Stop);
782 let tc: FinishReason = "tool_calls".parse().unwrap();
783 assert_eq!(tc, FinishReason::ToolCalls);
784 }
785
786 #[test]
787 fn tool_definition_serde() {
788 let def = ToolDefinition {
789 name: "file_read".into(),
790 description: "Read a file".into(),
791 parameters: serde_json::json!({"type": "object", "properties": {}}),
792 };
793 let json = serde_json::to_string(&def).unwrap();
794 let parsed: ToolDefinition = serde_json::from_str(&json).unwrap();
795 assert_eq!(parsed.name, "file_read");
796 }
797
798 #[test]
799 fn audio_format_default() {
800 assert_eq!(AudioFormat::default().to_string(), "mp3");
801 }
802}