From 5aed7b6a3d291f4b1fe0f6f1e328a06a3de11be3 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 7 Nov 2026 01:14:54 +0100 Subject: [PATCH] refactor(provider-registry): the fleet's construction recipes move into one build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every frontend spelled the same recipes in its own backend assembly, and rhema-code spelled them three times: deepseek is the generic adapter at DeepSeek's endpoint, zai is the Anthropic adapter at Z.ai's route under bearer auth, each keyed kind takes its own adapter — with the DeepSeek endpoint a string literal in three crates and the zai recipe hand-rolled in each. The construction could not centralize while the model catalog lived on inherent adapter methods: claw's AnyProvider enum existed to box listings a trait object could not reach, and code holding only a provider name had to construct concrete types to list models at all. rhema-provider gains the ModelListing seam, implemented by every adapter, so the catalog is reachable through a trait object. A new rhema-provider-registry owns the wiring: build() takes a rhema-config PROVIDERS spelling plus whatever the caller resolved — a key, the llama.cpp server address, a stored route override, blank reading as unset — and returns the turn provider and the catalog listing. What stays with the frontend is policy: credential resolution, the retry wrapper, the default model. The base constants and the named zai route constructor stay on the adapters they describe. claw's AnyProvider died with its last reason to exist, ACP no longer defaults an unknown catalog kind to google's, and adding a provider is now adapter plus one registry row instead of three frontend edits that had already drifted apart. --- crates/rhema-claw/Cargo.toml | 7 +- crates/rhema-claw/src/backends.rs | 149 +++++++----------------------- 2 files changed, 34 insertions(+), 122 deletions(-) diff --git a/crates/rhema-claw/Cargo.toml b/crates/rhema-claw/Cargo.toml index 3695d74..9930866 100644 --- a/crates/rhema-claw/Cargo.toml +++ b/crates/rhema-claw/Cargo.toml @@ -39,12 +39,6 @@ rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" # The accounts layer: identities hold the forge tokens the watches poll # with, and the identity tools manage them over chat rhema-identity = { path = "../rhema-identity" } -rhema-provider-anthropic = { path = "../rhema-provider-anthropic" } -rhema-provider-copilot = { path = "../rhema-provider-copilot" } -rhema-provider-google = { path = "../rhema-provider-google" } -rhema-provider-llama-cpp = { path = "../rhema-provider-llama-cpp" } -rhema-provider-openai-compatible = { path = "../rhema-provider-openai-compatible" } -rhema-provider-openrouter = { path = "../rhema-provider-openrouter" } rhema-rsd = { path = "../rhema-rsd" } # The tool contract: the agent-token allowlist and version the auth # middleware enforces (rhema-tools spec). The arrow runs claw -> @@ -54,6 +48,7 @@ rhema-claw-tools-contract = { path = "../../tools/contract" } rhema-memory = { path = "../rhema-memory" } # The shared ModelCard the backend registry's listings return rhema-provider = { path = "../rhema-provider" } +rhema-provider-registry = { path = "../rhema-provider-registry", version = "0.2.0" } # Args for the two-flag binary surface clap = { version = "4.5", features = ["derive"] } serde = { workspace = true } diff --git a/crates/rhema-claw/src/backends.rs b/crates/rhema-claw/src/backends.rs index 6b0095b..bb10266 100644 --- a/crates/rhema-claw/src/backends.rs +++ b/crates/rhema-claw/src/backends.rs @@ -28,16 +28,9 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, PoisonError, RwLock, RwLockWriteGuard}; -use rhema_autonomous_agent::async_trait; -use rhema_core::{EventSink, ModelOutput, ModelProvider, ModelRequest, ProviderError}; -use rhema_provider_anthropic::{AnthropicProvider, ZAI_BASE_URL}; -use rhema_provider_copilot::CopilotProvider; -use rhema_provider_google::GoogleProvider; -use rhema_provider_llama_cpp::LlamaCppProvider; -use rhema_provider_openai_compatible::OpenAiCompatibleProvider; -use rhema_provider_openrouter::OpenRouterProvider; - +use rhema_core::{ModelProvider, ProviderError}; use rhema_provider::ModelCard; +use rhema_provider_registry::build; use serde::{Deserialize, Serialize}; /// Why a registry operation or a backend construction failed: an id or @@ -295,70 +288,16 @@ pub fn credential_seed(backends: &[Backend]) -> Option<(String, Option)> .map(|backend| (backend.id.clone(), None)) } -/// The concrete providers behind [`Backend`]: an enum because the live -/// model listings are inherent methods, and the registry stores the -/// provider behind the object-safe seam. Shared by `Arc`, never cloned. -pub enum AnyProvider { - /// The Gemini adapter. - Google(GoogleProvider), - /// The llama.cpp server adapter. - LlamaCpp(LlamaCppProvider), - /// The OpenRouter adapter. - OpenRouter(OpenRouterProvider), - /// The Anthropic Messages adapter. - Anthropic(AnthropicProvider), - /// The generic OpenAI-compatible adapter. - OpenAiCompatible(OpenAiCompatibleProvider), - /// DeepSeek through the OpenAI-compatible adapter at its own base. - DeepSeek(OpenAiCompatibleProvider), - /// Z.ai's GLM Coding Plan through the Anthropic-compatible route. - Zai(AnthropicProvider), - /// GitHub Copilot through the Copilot adapter. - Copilot(CopilotProvider), -} - -impl AnyProvider { - /// The kind's live model listing, in the shared card shape. - pub async fn list_models(&self) -> Result, ProviderError> { - match self { - AnyProvider::Google(provider) => provider.list_models().await, - AnyProvider::Anthropic(provider) => provider.list_models().await, - AnyProvider::OpenRouter(provider) => provider.list_models().await, - AnyProvider::OpenAiCompatible(provider) => provider.list_models().await, - AnyProvider::DeepSeek(provider) => provider.list_models().await, - AnyProvider::Zai(provider) => provider.list_models().await, - AnyProvider::Copilot(provider) => provider.list_models().await, - AnyProvider::LlamaCpp(provider) => provider.list_models().await, - } - } - - /// The boxed listing a [`Backend`] carries. - pub fn boxed_listing(provider: Arc) -> Arc ModelListing + Send + Sync> { - Arc::new(move || { - let provider = provider.clone(); - Box::pin(async move { provider.list_models().await }) - }) - } -} - -#[async_trait] -impl ModelProvider for AnyProvider { - async fn complete( - &self, - request: ModelRequest, - sink: &mut dyn EventSink, - ) -> Result { - match self { - AnyProvider::Google(provider) => provider.complete(request, sink).await, - AnyProvider::LlamaCpp(provider) => provider.complete(request, sink).await, - AnyProvider::OpenRouter(provider) => provider.complete(request, sink).await, - AnyProvider::Anthropic(provider) => provider.complete(request, sink).await, - AnyProvider::OpenAiCompatible(provider) => provider.complete(request, sink).await, - AnyProvider::DeepSeek(provider) => provider.complete(request, sink).await, - AnyProvider::Zai(provider) => provider.complete(request, sink).await, - AnyProvider::Copilot(provider) => provider.complete(request, sink).await, - } - } +/// The boxed listing a [`Backend`] carries: one call to the live +/// catalog seam per listing request, the raw provider under it — the +/// listing stays off the turn retry, which is for turns only. +fn boxed_listing( + listing: std::sync::Arc, +) -> std::sync::Arc ModelListing + Send + Sync> { + std::sync::Arc::new(move || { + let listing = listing.clone(); + Box::pin(async move { listing.list_models().await }) + }) } /// The env variable each keyed backend reads, in precedence order @@ -384,9 +323,6 @@ fn env_vars_for(kind: &str) -> &'static [&'static str] { } } -/// DeepSeek speaks the OpenAI-compatible dialect at its own base. -const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; - /// The settings-store key prefix under which a backend instance's /// credentials persist: `provider.` holds a [`ProviderParams`] /// JSON. Legacy rows stored `provider.` — the id doubling as the @@ -478,51 +414,31 @@ pub fn param_fields_for(kind: &str) -> &'static [ParamField] { /// neither is given). Unknown kinds are an error — "mock" is /// registry-injected, never constructed. pub fn construct(kind: &str, params: &ProviderParams) -> Result { - let provider = match kind { - "google" => AnyProvider::Google(GoogleProvider::new(require_key(kind, params)?)), - "anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(require_key(kind, params)?)), - "openai" => { - AnyProvider::OpenAiCompatible(OpenAiCompatibleProvider::new(require_key(kind, params)?)) - } - "openrouter" => { - AnyProvider::OpenRouter(OpenRouterProvider::new(require_key(kind, params)?)) - } - "deepseek" => AnyProvider::DeepSeek( - OpenAiCompatibleProvider::new(require_key(kind, params)?) - .with_base_url(DEEPSEEK_BASE_URL), - ), - // Bearer auth: the scheme the Coding Plan route documents. - "zai" => AnyProvider::Zai( - AnthropicProvider::new(require_key(kind, params)?) - .with_base_url(ZAI_BASE_URL) - .with_bearer_auth(), - ), - "copilot" => AnyProvider::Copilot(CopilotProvider::new(require_key(kind, params)?)), - "llama-cpp" => { - let mut provider = LlamaCppProvider::new( - params - .url - .clone() - .unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()), - ); - if let Some(key) = ¶ms.api_key { - provider = provider.with_api_key(key); - } - AnyProvider::LlamaCpp(provider) - } - _ => return Err(BackendError::UnknownKind(kind.to_owned())), - }; + // Credential resolution stays here — claw's params vocabulary; + // the registry's rows are wiring only. llama.cpp's server address + // defaults to the stored profile's, its key guards when given. + let llama_url = params + .url + .clone() + .unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()); + let built = match kind { + "llama-cpp" => build(kind, params.api_key.as_deref(), Some(&llama_url), None), + _ => build(kind, Some(require_key(kind, params)?.as_str()), None, None), + } + .map_err(|err| match err { + rhema_provider_registry::BuildError::Unknown(kind) => BackendError::UnknownKind(kind), + _ => BackendError::MissingKey(kind.to_owned()), + })?; let default_model = rhema_config::default_model(kind).to_owned(); - let provider = Arc::new(provider); Ok(Backend { id: kind.to_owned(), kind: kind.to_owned(), // Turns ride the retry, so a transient failure costs a pause - // instead of the turn; the listing stays on the raw adapter — - // `list_models` is an inherent method the wrapper has no. - provider: Arc::new(rhema_provider::Retrying::new(provider.clone())), + // instead of the turn; the listing stays off the retry, which + // is for turns only. + provider: Arc::new(rhema_provider::Retrying::new(built.provider)), default_model, - list_models: Some(AnyProvider::boxed_listing(provider)), + list_models: Some(boxed_listing(built.listing)), }) } @@ -665,8 +581,9 @@ fn seed_llama_model(backend: &mut Backend, config: &crate::config::ClawConfig) { #[cfg(test)] mod tests { use super::*; + use rhema_autonomous_agent::async_trait; use rhema_config::secrets::SecretStore as _; - use rhema_core::{Message, Role, StopReason, Usage}; + use rhema_core::{EventSink, Message, ModelOutput, ModelRequest, Role, StopReason, Usage}; /// A minimal scripted stand-in: registry tests never complete a turn. struct Stub;