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
+34 -122

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
# 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 }
+33 -116
View File
@@ -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<String>)>
.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<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 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<dyn rhema_provider::ModelListing>,
) -> std::sync::Arc<dyn Fn() -> 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.<id>` holds a [`ProviderParams`]
/// 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
/// registry-injected, never constructed.
pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, BackendError> {
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) = &params.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;