Provider Architecture Guide
Architecture Overview
Providers come in two tiers behind one implementation trait:
Tier 1 — Catalog-only (zero code). An OpenAI-compatible endpoint that differs only in base URL, auth env var, and advertised capabilities/models. It has no Rust module: one static entry in src/core/providers/registry/catalog.rs fully describes it, and the factory builds an OpenAILikeProvider from that data at runtime. This is the primary path for new integrations.
Tier 2 — Code-based. A provider needing custom request/response transformation, custom auth signing, non-standard streaming, or rich model metadata lives in src/core/providers/<name>/, implements LLMProvider, and is wired into routing.
Routing does not use trait objects. Router deployments store the closed Provider enum (src/core/providers/mod.rs), which dispatches to concrete provider structs. LLMProvider (src/core/traits/provider/llmprovider/traitdefinition.rs) is the interface every variant implements — implementing the trait alone does not make a provider routeable; enum variant, dispatch arm, and factory wiring are crate-level changes. Trait objects appear only at the edges: the error mapper (Box<dyn ErrorMapper<ProviderError>>) and the streaming return type (Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>).
Enumerating Current Providers
Do not rely on memorized counts — they change often. Count from source:
# Tier 1: one def_chat()/def_local_chat() call per entry
# (each count includes the helper fn definition itself, so subtract 1)
grep -c 'def_chat(' src/core/providers/registry/catalog.rs
grep -c 'def_local_chat(' src/core/providers/registry/catalog.rs
# Tier 2: code-based provider modules (base/factory/macros/registry are infrastructure)
ls -d src/core/providers/*/ | grep -vE '/(base|factory|macros|registry)/'
Adding a Tier 1 Provider (Primary Path)
Two edits, nothing else:
// src/core/providers/registry/catalog.rs
def_chat(
"myprovider",
"My Provider",
"https://api.myprovider.com/v1",
"MYPROVIDER_API_KEY",
),
// src/core/providers/mod.rs — annotation comment alongside the other Tier 1 notes
// myprovider: Tier 1 -> registry/catalog.rs
At runtime createprovider (src/core/providers/factory/mod.rs) matches the selector against the catalog, builds an OpenAILikeConfig via ProviderDefinition::toopenailikeconfig, and constructs openailike::OpenAILikeProvider::newforcatalog(oaiconfig, def.capabilities) into the Provider::OpenAILike variant.
Keyless local servers use deflocalchat (AuthType::None, skipapikey = true):
def_local_chat("myrunner", "My Runner", "http://localhost:1234/v1"),
Each entry is a ProviderDefinition (src/core/providers/registry/definition.rs). Defaults from def_chat can be overridden with struct-update syntax:
alternateauthenvvars — env vars checked after authenv_var (see together)
model_prefix: Some("xai/") — selector/model prefix stripping (see xai)
- custom
capabilities profile replacing the default
OPENAILIKECATALOG_CAPABILITIES (ChatCompletion, ChatCompletionStream, ToolCalling, FunctionCalling)
- name aliases resolve via
canonicalcatalogname() in catalog.rs (e.g.
"zhipuai" -> "zhipu")
Registry API (src/core/providers/registry/, re-exported in registry/mod.rs): istier1provider(name), getdefinition(name), canonicalcatalogname(name), PROVIDERCATALOG.
Core Trait Definition: LLMProvider
There are no associated types. Every fallible method returns the unified ProviderError directly.
// src/core/traits/provider/llm_provider/trait_definition.rs
pub trait LLMProvider: Send + Sync + Debug + 'static {
// ===== Required =====
fn name(&self) -> &str;
fn capabilities(&self) -> &'static [ProviderCapability];
fn models(&self) -> &[ModelInfo];
fn get_supported_openai_params(&self, model: &str) -> &'static [&'static str];
async fn map_openai_params(
&self,
params: HashMap<String, Value>,
model: &str,
) -> Result<HashMap<String, Value>, ProviderError>;
async fn transform_request(
&self,
request: ChatRequest,
context: RequestContext,
) -> Result<Value, ProviderError>;
async fn transform_response(
&self,
raw_response: &[u8],
model: &str,
request_id: &str,
) -> Result<ChatResponse, ProviderError>;
fn get_error_mapper(&self) -> Box<dyn ErrorMapper<ProviderError>>;
async fn chat_completion(
&self,
request: ChatRequest,
context: RequestContext,
) -> Result<ChatResponse, ProviderError>;
async fn health_check(&self) -> HealthStatus;
async fn calculate_cost(
&self,
model: &str,
input_tokens: u32,
output_tokens: u32,
) -> Result<f64, ProviderError>;
// ===== Provided (default = not_supported / trivial) =====
fn error_provider_name(&self) -> &'static str { "provider" }
fn supports_capability(&self, capability: &ProviderCapability) -> bool {
self.capabilities().contains(capability)
}
fn supports_model(&self, model: &str) -> bool {
self.models().iter().any(|m| m.id == model)
}
// supports_tools / supports_streaming / supports_embeddings /
// supports_image_generation delegate to supports_capability();
// supports_vision() currently returns false.
async fn chat_completion_stream(&self, request: ChatRequest, context: RequestContext)
-> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>, ProviderError>;
async fn embeddings(&self, request: EmbeddingRequest, context: RequestContext)
-> Result<EmbeddingResponse, ProviderError>;
async fn image_generation(&self, request: ImageGenerationRequest, context: RequestContext)
-> Result<ImageGenerationResponse, ProviderError>;
async fn audio_transcription(&self, request: TranscriptionRequest, context: RequestContext)
-> Result<TranscriptionResponse, ProviderError>;
async fn audio_translation(&self, request: TranslationRequest, context: RequestContext)
-> Result<TranslationResponse, ProviderError>;
async fn text_to_speech(&self, request: SpeechRequest, context: RequestContext)
-> Result<SpeechResponse, ProviderError>;
async fn get_average_latency(&self) -> Result<std::time::Duration, ProviderError>; // default 100ms
async fn get_success_rate(&self) -> Result<f32, ProviderError>; // default 0.99
async fn estimate_tokens(&self, text: &str) -> Result<u32, ProviderError>; // len()/4
}
Optional dispatch methods (streaming, embeddings, images, audio) must still be gated by the matching ProviderCapability — route selection checks supports_capability() before calling them.
Tier 2: Code-Based Provider Pattern
Directory Structure
Real examples: src/core/providers/cloudflare/ (small) and src/core/providers/openai_like/.
src/core/providers/my_provider/
├── mod.rs # Module exports, LLMProvider impl may live here too
├── config.rs # Config struct (often via define_provider_config!)
├── provider.rs # Provider struct + LLMProvider impl
├── model_info.rs # Static model metadata
├── streaming.rs # SSE parsing (optional)
└── error.rs # Error helpers/mappers (optional; legacy name = ProviderError alias)
Configuration
BaseConfig (src/core/providers/base/config.rs) carries apikey, apibase, endpointaccess, timeout (secs), maxretries, headers, organization, apiversion, with env fallbacks ({PROVIDER}APIKEY, {PROVIDER}APIBASE, ...) via fromenv(provider) / for_provider(provider).
Implement the ProviderConfig trait (src/core/traits/provider/config.rs) — note the accessor names differ from BaseConfig's helpers:
pub trait ProviderConfig: Send + Sync + Clone + Debug + 'static {
fn validate(&self) -> Result<(), String>;
fn api_key(&self) -> Option<&str>;
fn api_base(&self) -> Option<&str>;
fn timeout(&self) -> std::time::Duration;
fn max_retries(&self) -> u32;
fn endpoint_access(&self) -> ProviderEndpointAccess { ProviderEndpointAccess::PublicOnly }
fn use_ssrf_safe_client(&self) -> bool { false }
// validate_standard(provider_name): shared key/timeout/retries checks
}
The defineproviderconfig! macro (exported from base/config.rs) generates the struct, builders (withapikey, withbaseurl, withtimeout), fromenv(), getapikey()/getapibase(), and the ProviderConfig impl in one call.
Provider Implementation
Modeled on src/core/providers/cloudflare/provider.rs:
use std::sync::Arc;
use crate::core::providers::base::{header, BaseConfig, GlobalPoolManager, HttpMethod};
use crate::core::providers::ProviderError;
use crate::core::traits::error_mapper::trait_def::ErrorMapper;
use crate::core::traits::error_mapper::DefaultErrorMapper;
use crate::core::traits::provider::llm_provider::trait_definition::LLMProvider;
use crate::core::traits::provider::ProviderConfig;
pub struct MyProvider {
config: MyProviderConfig,
pool_manager: Arc<GlobalPoolManager>,
models: Vec<ModelInfo>,
}
impl MyProvider {
pub fn new(config: MyProviderConfig) -> Result<Self, ProviderError> {
config.validate()
.map_err(|e| ProviderError::configuration(PROVIDER_NAME, e))?;
let http_config = BaseConfig {
api_key: config.api_key().map(str::to_owned),
api_base: config.api_base().map(str::to_owned),
endpoint_access: config.endpoint_access(),
timeout: config.timeout().as_secs(),
max_retries: config.max_retries(),
..BaseConfig::default()
};
Ok(Self {
config,
pool_manager: Arc::new(GlobalPoolManager::new_for_provider(
PROVIDER_NAME,
http_config,
)?),
models: load_models(),
})
}
}
impl LLMProvider for MyProvider {
fn name(&self) -> &'static str { PROVIDER_NAME }
fn error_provider_name(&self) -> &'static str { PROVIDER_NAME }
async fn chat_completion(
&self,
request: ChatRequest,
context: RequestContext,
) -> Result<ChatResponse, ProviderError> {
let api_key = self.config.api_key()
.ok_or_else(|| ProviderError::authentication(PROVIDER_NAME, "API key required"))?;
let mut headers = vec![header("Authorization", format!("Bearer {api_key}"))];
headers.push(header("Content-Type", "application/json".to_string()));
// url/model/request_id derived from config + request (elided)
let body = self.transform_request(request, context).await?;
let response = self.pool_manager
.execute_request(&url, HttpMethod::POST, headers, Some(body))
.await?;
let status = response.status();
if !status.is_success() {
let body_text = response.text().await
.map_err(|e| ProviderError::network(PROVIDER_NAME, e.to_string()))?;
return Err(self.get_error_mapper().map_http_error(status.as_u16(), &body_text));
}
let raw = response.bytes().await
.map_err(|e| ProviderError::network(PROVIDER_NAME, e.to_string()))?;
self.transform_response(&raw, &model, &request_id).await
}
fn get_error_mapper(&self) -> Box<dyn ErrorMapper<ProviderError>> {
Box::new(DefaultErrorMapper)
}
// ... remaining required methods
}
Registration (routing) Requirements
Adding a Tier 2 provider means crate-level wiring, in this order of authority:
- Add
src/core/providers/<name>/, its module declaration, and a closed Provider
enum variant under the same feature gate in src/core/providers/mod.rs.
- Add the variant to all four
dispatch_provider! @expand arms (sync,
asyncerr, value, asyncdirect) and to Provider::name() and provider_type().
- Add a
ProviderType variant and allnoncustomprovidertypes() entry in
provider_type.rs.
- Add canonical name, aliases,
catalog_backed and the correct
ProviderDispatchKind to PROVIDERTYPEREGISTRY in registry/types.rs. For a feature-gated native implementation, use or add a cfg-sensitive dispatch-kind helper that reports the correct enabled and disabled modes; registry entries themselves do not have a feature field.
- Add the config builder in
factory/builder.rs and match branch in
factory/registry.rs, with matching feature gates. Keep cfg gates synchronized across module, Provider, dispatch and factory wiring.
- Update provider-type/registry lifecycle, factory support and feature-on/off
tests so aliases, support state and construction stay in sync.
Connection Pooling
The recommended newforprovider path runs requests through BaseHttpClient and ProviderHttpClient. ProviderHttpClient keeps a process-wide client cache keyed by endpoint policy, request timeout, and mode (ordinary, streaming, or no-redirect). Those clients use HttpClientPoolConfig::default() (src/utils/net/http.rs): 100 maximum idle connections per host, 90-second idle timeout, 10-second connect timeout, and 60-second TCP keepalive.
PoolConfig in src/core/providers/base/connection_pool.rs (there is no pool.rs) instead configures the legacy global ConnectionPool used by the unbound new() / shared() path. These constants do not tune the policy-bound clients above:
pub struct PoolConfig;
impl PoolConfig {
pub const TIMEOUT_SECS: u64 = 600;
pub const POOL_SIZE: usize = 80; // pool_max_idle_per_host
pub const KEEPALIVE_SECS: u64 = 90; // pool_idle_timeout
}
// Provider implementations use new_for_provider(provider, BaseConfig) so
// endpoint-access and timeout policy are installed. new()/shared() omit that policy.
pub async fn execute_request(
&self,
url: &str,
method: HttpMethod, // GET | POST | PUT | DELETE
headers: Vec<HeaderPair>, // (Cow<'static, str>, Cow<'static, str>)
body: Option<serde_json::Value>, // serialized as JSON; not generic
) -> Result<reqwest::Response, ProviderError>;
pub async fn execute_streaming_request(
&self,
url: &str,
headers: Vec<HeaderPair>,
body: serde_json::Value,
legacy_provider: &'static str,
) -> Result<reqwest::Response, ProviderError>;
Build header pairs zero-copy where possible: header(key, value) (static key, owned value), headerstatic(key, value), headerowned(key, value). Streaming callers get a separate client without a total-body timeout via streamingunboundedclient(); header phase bounded by STREAMINGHEADERTIMEOUTSECS, error bodies by readstreamingerrorbody (10s / 64KiB caps).
Model Information
ModelInfo (src/core/types/model.rs) is a plain serializable struct with Default:
pub struct ModelInfo {
pub id: String,
pub name: String,
pub provider: String,
pub max_context_length: u32,
pub max_output_length: Option<u32>,
pub supports_streaming: bool,
pub supports_tools: bool,
pub supports_multimodal: bool,
pub input_cost_per_1k_tokens: Option<f64>,
pub output_cost_per_1k_tokens: Option<f64>,
pub currency: String,
pub capabilities: Vec<ProviderCapability>,
pub created_at: Option<SystemTime>,
pub updated_at: Option<SystemTime>,
pub metadata: HashMap<String, serde_json::Value>,
}
Costs are per 1K tokens (catalog pricing constants in registry/catalog.rs are per million and divided by 1000 when converted). Return &self.models from LLMProvider::models(); supportsmodel() scans that slice.
Provider Capabilities
ProviderCapability (src/core/types/model.rs) — the full current variant list:
pub enum ProviderCapability {
ChatCompletion,
ChatCompletionStream,
Embeddings,
ImageGeneration,
ImageEdit,
ImageVariation,
AudioTranscription,
AudioTranslation,
TextToSpeech,
Moderation,
Rerank,
ToolCalling,
FunctionCalling,
CodeExecution,
FileUpload,
FineTuning,
BatchProcessing,
RealtimeApi,
GeminiGenerateContent,
}
Return a static slice (a temporary array literal cannot back &'static [...]):
const MY_CAPABILITIES: &[ProviderCapability] = &[
ProviderCapability::ChatCompletion,
ProviderCapability::ChatCompletionStream,
ProviderCapability::ToolCalling,
];
fn capabilities(&self) -> &'static [ProviderCapability] {
MY_CAPABILITIES
}
Unified Error Handling
ProviderError (src/core/providers/unifiedprovidererror.rs, re-exported as crate::core::providers::ProviderError and unifiedprovider::ProviderError) is the single error type across the trait. Construct with factory methods (src/core/providers/unifiedprovider_methods.rs) rather than struct literals — some variants carry extra optional fields (e.g. RateLimit rpm/tpm limits):
ProviderError::authentication(provider, msg)
ProviderError::rate_limit(provider, retry_after: Option<u64>)
ProviderError::rate_limit_with_retry(provider, msg, retry_after)
ProviderError::model_not_found(provider, model)
ProviderError::invalid_request(provider, msg)
ProviderError::network(provider, msg)
ProviderError::timeout(provider, msg)
ProviderError::api_error(provider, status: u16, msg)
ProviderError::provider_unavailable(provider, msg)
ProviderError::not_supported(provider, feature)
ProviderError::configuration(provider, msg)
ProviderError::serialization(provider, msg)
ErrorMapper<E> (src/core/traits/errormapper/traitdef.rs) converts HTTP statuses and JSON error bodies into errors: required maphttperror(u16, &str); defaulted mapjsonerror, mapnetworkerror, mapparsingerror, maptimeouterror. Ready-made mappers:
GenericErrorMapper — core::traits::error_mapper::types, aliased
DefaultErrorMapper at core::traits::errormapper (what most providers return from geterror_mapper())
OpenAIErrorMapper, AnthropicErrorMapper — core::traits::error_mapper::implementations
Legacy per-provider error enums were removed; surviving names like AnthropicError or GeminiError are pub type ... = ProviderError aliases (see [reference/migration-from-legacy-errors.md](reference/migration-from-legacy-errors.md)).
References
- [reference/best-practices-and-checklist.md](reference/best-practices-and-checklist.md) — error factory conventions, coding practices, and new-provider checklist (Tier 1 and Tier 2)
- [reference/migration-from-legacy-errors.md](reference/migration-from-legacy-errors.md) — how legacy per-provider error types map onto unified ProviderError