Skip to main content

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}