refactor(provider-registry): the fleet's construction recipes move into one build

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.
This commit is contained in:
vhaudiquet committed 2026-11-08 17:53:04 +01:00
1 parent 12182492ea
commit 5aed7b6a3d
2 files changed
+30 -118

No files matched your search

+1 -6
View File
@@ -39,12 +39,6 @@ rhema-forge-integration = { path = "../../integrations/rhema-forge-integration"
# The accounts layer: identities hold the forge tokens the watches poll # The accounts layer: identities hold the forge tokens the watches poll
# with, and the identity tools manage them over chat # with, and the identity tools manage them over chat
rhema-identity = { path = "../rhema-identity" } 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" } rhema-rsd = { path = "../rhema-rsd" }
# The tool contract: the agent-token allowlist and version the auth # The tool contract: the agent-token allowlist and version the auth
# middleware enforces (rhema-tools spec). The arrow runs claw -> # 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" } rhema-memory = { path = "../rhema-memory" }
# The shared ModelCard the backend registry's listings return # The shared ModelCard the backend registry's listings return
rhema-provider = { path = "../rhema-provider" } rhema-provider = { path = "../rhema-provider" }
rhema-provider-registry = { path = "../rhema-provider-registry", version = "0.2.0" }
# Args for the two-flag binary surface # Args for the two-flag binary surface
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
serde = { workspace = true } serde = { workspace = true }
+29 -112
View File
@@ -28,16 +28,9 @@ use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{Arc, PoisonError, RwLock, RwLockWriteGuard}; use std::sync::{Arc, PoisonError, RwLock, RwLockWriteGuard};
use rhema_autonomous_agent::async_trait; use rhema_core::{ModelProvider, ProviderError};
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_provider::ModelCard; use rhema_provider::ModelCard;
use rhema_provider_registry::build;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Why a registry operation or a backend construction failed: an id or /// Why a registry operation or a backend construction failed: an id or
@@ -295,71 +288,17 @@ pub fn credential_seed(backends: &[Backend]) -> Option<(String, Option<String>)>
.map(|backend| (backend.id.clone(), None)) .map(|backend| (backend.id.clone(), None))
} }
/// The concrete providers behind [`Backend`]: an enum because the live /// The boxed listing a [`Backend`] carries: one call to the live
/// model listings are inherent methods, and the registry stores the /// catalog seam per listing request, the raw provider under it — the
/// provider behind the object-safe seam. Shared by `Arc`, never cloned. /// listing stays off the turn retry, which is for turns only.
pub enum AnyProvider { fn boxed_listing(
/// The Gemini adapter. listing: std::sync::Arc<dyn rhema_provider::ModelListing>,
Google(GoogleProvider), ) -> std::sync::Arc<dyn Fn() -> ModelListing + Send + Sync> {
/// The llama.cpp server adapter. std::sync::Arc::new(move || {
LlamaCpp(LlamaCppProvider), let listing = listing.clone();
/// The OpenRouter adapter. Box::pin(async move { listing.list_models().await })
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<Vec<ModelCard>, 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<Self>) -> Arc<dyn Fn() -> 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<ModelOutput, ProviderError> {
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 env variable each keyed backend reads, in precedence order /// The env variable each keyed backend reads, in precedence order
/// (checked before the stored rhema config's value, mirroring the /// (checked before the stored rhema config's value, mirroring the
@@ -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 /// The settings-store key prefix under which a backend instance's
/// credentials persist: `provider.<id>` holds a [`ProviderParams`] /// credentials persist: `provider.<id>` holds a [`ProviderParams`]
/// JSON. Legacy rows stored `provider.<kind>` — the id doubling as the /// JSON. Legacy rows stored `provider.<kind>` — 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 /// neither is given). Unknown kinds are an error — "mock" is
/// registry-injected, never constructed. /// registry-injected, never constructed.
pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, BackendError> { pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, BackendError> {
let provider = match kind { // Credential resolution stays here — claw's params vocabulary;
"google" => AnyProvider::Google(GoogleProvider::new(require_key(kind, params)?)), // the registry's rows are wiring only. llama.cpp's server address
"anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(require_key(kind, params)?)), // defaults to the stored profile's, its key guards when given.
"openai" => { let llama_url = params
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 .url
.clone() .clone()
.unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()), .unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url());
); let built = match kind {
if let Some(key) = &params.api_key { "llama-cpp" => build(kind, params.api_key.as_deref(), Some(&llama_url), None),
provider = provider.with_api_key(key); _ => build(kind, Some(require_key(kind, params)?.as_str()), None, None),
} }
AnyProvider::LlamaCpp(provider) .map_err(|err| match err {
} rhema_provider_registry::BuildError::Unknown(kind) => BackendError::UnknownKind(kind),
_ => return Err(BackendError::UnknownKind(kind.to_owned())), _ => BackendError::MissingKey(kind.to_owned()),
}; })?;
let default_model = rhema_config::default_model(kind).to_owned(); let default_model = rhema_config::default_model(kind).to_owned();
let provider = Arc::new(provider);
Ok(Backend { Ok(Backend {
id: kind.to_owned(), id: kind.to_owned(),
kind: kind.to_owned(), kind: kind.to_owned(),
// Turns ride the retry, so a transient failure costs a pause // Turns ride the retry, so a transient failure costs a pause
// instead of the turn; the listing stays on the raw adapter — // instead of the turn; the listing stays off the retry, which
// `list_models` is an inherent method the wrapper has no. // is for turns only.
provider: Arc::new(rhema_provider::Retrying::new(provider.clone())), provider: Arc::new(rhema_provider::Retrying::new(built.provider)),
default_model, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rhema_autonomous_agent::async_trait;
use rhema_config::secrets::SecretStore as _; 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. /// A minimal scripted stand-in: registry tests never complete a turn.
struct Stub; struct Stub;