modelplease/provider.rs
1// Copyright 2026 Thomas Santerre and Moderately AI Inc.
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! `LanguageModelProvider` trait — single seam for chat / completion providers.
6//!
7//! Each provider impl (Anthropic, OpenAI, Ollama, Bedrock, ...) holds its
8//! credentials and HTTP client privately and serves any model the
9//! provider supports. The application router routes
10//! `provider/model` model_ids to the right provider based on
11//! [`LanguageModelProvider::name`].
12
13use std::{collections::BTreeMap, pin::Pin};
14
15use async_trait::async_trait;
16use futures::Stream;
17
18use crate::{
19 capabilities::{
20 CapabilityError, MediaKind, MediaSupport, ModelCapabilities, ReasoningCapability,
21 },
22 config::{LanguageModelConfig, ResponseFormat},
23 error::LanguageModelError,
24 identifiers::ModelId,
25 message::Message,
26 response::{LanguageModelResponse, StreamDelta},
27};
28
29/// Mirror of [`ResponseFormat`] without the per-variant payload.
30///
31/// Used in [`ChatModelInfo::supported_response_formats`] to advertise
32/// which formats a model accepts; callers filter their model picker
33/// against this. The corresponding [`ResponseFormat`] still carries
34/// the schema / name / strict fields when invoking generate.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum ResponseFormatKind {
37 Text,
38 JsonObject,
39 JsonSchema,
40}
41
42impl ResponseFormatKind {
43 #[must_use]
44 pub const fn from_format(format: &ResponseFormat) -> Self {
45 match format {
46 ResponseFormat::Text => Self::Text,
47 ResponseFormat::JsonObject => Self::JsonObject,
48 ResponseFormat::JsonSchema { .. } => Self::JsonSchema,
49 }
50 }
51}
52
53/// Per-call inputs for [`LanguageModelProvider::generate`] and
54/// [`LanguageModelProvider::generate_stream`].
55///
56/// Bundling avoids transposition between fields. Because callers may construct
57/// this value with a struct literal, adding fields is a semver-sensitive change.
58pub struct GenerateRequest<'a> {
59 /// Model identifier (provider-specific name, e.g.
60 /// `"claude-sonnet-4-20250514"`). The [`ModelId`] newtype prevents
61 /// passing it where an [`ApiKey`](crate::ApiKey) is expected at
62 /// construction time.
63 pub model: &'a ModelId,
64 /// Conversation messages — the provider extracts the system
65 /// prompt and serializes per its own conventions.
66 pub messages: &'a [Message],
67 /// Generation knobs (temperature, max_tokens, response_format, etc.).
68 pub config: &'a LanguageModelConfig,
69}
70
71/// Catalog metadata for one model offered by a provider.
72///
73/// Populated via the hybrid dynamic-fetch + local-augment pattern:
74/// provider-supplied IDs are merged with a static capability table in
75/// the impl. IDs returned by upstream that have no local entry land
76/// with conservative defaults (`context_window: None`,
77/// `supports_*: false`, empty `media_support`) and emit a warning per
78/// cache fill.
79///
80/// `Eq` is intentionally not derived — `ReasoningCapability` carries an
81/// `Option<RangeInclusive<f64>>` for `top_p` clamping and `f64` is not
82/// `Eq`. Callers needing equality use `PartialEq`.
83#[derive(Debug, Clone, PartialEq)]
84pub struct ChatModelInfo {
85 pub id: ModelId,
86 /// Human-readable name when the upstream provides one (Anthropic
87 /// returns `display_name`, OpenAI doesn't).
88 pub display_name: Option<String>,
89 /// Maximum context length in tokens. `None` when neither the
90 /// upstream catalog nor the local table knows.
91 pub context_window: Option<u32>,
92 /// Whether `generate_stream` is expected to work for this model.
93 pub supports_streaming: bool,
94 /// Response formats the model accepts. Empty means unknown — treat
95 /// as "try and see". Callers selecting a model should filter on
96 /// this rather than discovering at generate time.
97 pub supported_response_formats: Vec<ResponseFormatKind>,
98 /// Media modalities and source kinds the model accepts. Missing
99 /// keys = modality unsupported; empty map = text-only model. See
100 /// [`crate::ModelCapabilities`] for the runtime-checked counterpart
101 /// surfaced by [`LanguageModelProvider::capabilities`].
102 pub media_support: BTreeMap<MediaKind, MediaSupport>,
103 /// Reasoning / extended-thinking surface this model exposes.
104 /// `None` ⇒ no reasoning support; any non-`Off`
105 /// [`crate::ReasoningConfig`] supplied for this model will fail
106 /// validation upstream of the provider call.
107 pub reasoning: Option<ReasoningCapability>,
108}
109
110/// A chat / completion provider — one impl per backend.
111///
112/// Constructed once at boot with credentials baked into the impl;
113/// the application router routes requests to the right impl
114/// based on the provider name parsed from a `provider/model`
115/// model_id.
116#[async_trait]
117pub trait LanguageModelProvider: Send + Sync {
118 /// Stable provider key — used by the application router for
119 /// `model_id.split_once('/')` routing. Examples: `"anthropic"`,
120 /// `"openai"`, `"ollama"`, `"bedrock"`. Must be unique across the
121 /// providers registered for one modality.
122 fn name(&self) -> &'static str;
123
124 /// Catalog of models this provider serves.
125 ///
126 /// Remote impls fetch upstream and merge with a local capability
127 /// table; results are cached (TTL ~1h) so repeat calls don't hit
128 /// the network. Local impls (e.g. a dummy provider for tests)
129 /// return their static set directly.
130 async fn list_models(&self) -> Result<Vec<ChatModelInfo>, LanguageModelError>;
131
132 /// Generate a complete response.
133 async fn generate(
134 &self,
135 request: GenerateRequest<'_>,
136 ) -> Result<LanguageModelResponse, LanguageModelError>;
137
138 /// Generate a streamed response — token deltas as a `Stream`.
139 async fn generate_stream(
140 &self,
141 request: GenerateRequest<'_>,
142 ) -> Result<
143 Pin<Box<dyn Stream<Item = Result<StreamDelta, LanguageModelError>> + Send>>,
144 LanguageModelError,
145 >;
146
147 /// Sync capability lookup against the provider's static
148 /// `MODEL_CAPABILITIES` table. Returns `None` when the model is
149 /// not in the table (treat as conservative-deny for media content;
150 /// pure text calls bypass this lookup).
151 ///
152 /// Synchronous because every provider's table is in-process — no
153 /// network round trip required. The async [`Self::list_models`]
154 /// surface remains for live catalog discovery.
155 fn capabilities(&self, model: &ModelId) -> Option<ModelCapabilities>;
156
157 /// Validate that `request` only carries content parts the model
158 /// actually accepts. Default impl walks `request.messages`, looks
159 /// up the model in the provider's capability table, and returns
160 /// the first [`CapabilityError`] variant that applies — or `Ok(())`
161 /// for pure-text requests against text-only models.
162 ///
163 /// Providers should call this from `generate` /
164 /// `generate_stream` before any network work. Override only if
165 /// extra provider-specific checks need to run before/after the
166 /// default scan.
167 fn validate_request(&self, request: &GenerateRequest<'_>) -> Result<(), CapabilityError> {
168 // Fast path: scan once for non-text parts. Pure-text requests
169 // skip the entire capability lookup so text-only models with
170 // empty `media_support` stay zero-cost.
171 let has_media = request
172 .messages
173 .iter()
174 .any(|m| m.content.iter().any(|p| p.media_kind().is_some()));
175 if !has_media {
176 return Ok(());
177 }
178
179 let caps = self.capabilities(request.model);
180 let model_label = request.model.as_str();
181
182 for msg in request.messages {
183 for part in &msg.content {
184 let Some(kind) = part.media_kind() else {
185 continue;
186 };
187 let Some(source) = part.media_source() else {
188 continue;
189 };
190 let Some(caps_ref) = caps.as_ref() else {
191 return Err(CapabilityError::UnknownModel {
192 model: model_label.to_owned(),
193 kind,
194 });
195 };
196 caps_ref.validate(kind, source)?;
197 }
198 }
199 Ok(())
200 }
201}