llm: named-provider registry — judges & topology nodes on GLM/Kimi

Adds a multi-provider registry so judges and topology execution can run on
providers beyond the default. cm-config gains [[llm.providers]] (name, base_url,
api_key_env) — all OpenAI-compatible (GLM, Kimi/Moonshot). The server builds an
Arc<dyn LlmProvider> per entry (OpenAiCompatProvider) keyed by name; a missing
key is skipped with a warning, not a boot failure. cm-runtime RuntimeConfig
carries a ProviderRegistry; Runtime::resolve_provider("<name>:<model>") selects a
registry provider (else the default). The door governor (Runtime::judge) and the
topology compare endpoint both resolve through it, so CLAWMATES_JUDGE_MODEL and
CLAWMATES_TOPOLOGY_EXEC_MODEL accept "glm:glm-4.6" / "kimi:kimi-k2".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-17 04:17:14 -07:00
co-authored by Claude Opus 4.8
parent 325038e806
commit 2ec8832ef6
5 changed files with 88 additions and 10 deletions
+25
View File
@@ -51,6 +51,29 @@ fn build_provider(config: &AppConfig) -> Result<Arc<dyn LlmProvider>, String> {
} }
} }
/// Builds the extra named-provider registry (GLM, Kimi, …) from
/// `[[llm.providers]]`. Each is OpenAI-compatible; its key comes from the named
/// env var. A provider whose key env is unset is skipped with a warning, so a
/// missing key degrades that one selector rather than failing boot.
fn build_provider_registry(config: &AppConfig) -> cm_runtime::ProviderRegistry {
let mut map = std::collections::HashMap::new();
for p in &config.llm.providers {
match std::env::var(&p.api_key_env) {
Ok(key) if !key.is_empty() => {
let provider: Arc<dyn LlmProvider> =
Arc::new(OpenAiCompatProvider::new(p.base_url.clone(), Some(key)));
map.insert(p.name.clone(), provider);
println!("clawmates-server: registered LLM provider '{}'", p.name);
}
_ => eprintln!(
"clawmates-server: provider '{}' skipped — {} is unset",
p.name, p.api_key_env
),
}
}
cm_runtime::ProviderRegistry(map)
}
async fn run() -> Result<(), String> { async fn run() -> Result<(), String> {
let config_path = PathBuf::from( let config_path = PathBuf::from(
std::env::var("CLAWMATES_CONFIG").unwrap_or_else(|_| "clawmates.toml".into()), std::env::var("CLAWMATES_CONFIG").unwrap_or_else(|_| "clawmates.toml".into()),
@@ -98,6 +121,7 @@ async fn run() -> Result<(), String> {
} }
let provider = build_provider(&config)?; let provider = build_provider(&config)?;
let provider_registry = build_provider_registry(&config);
let blob: std::sync::Arc<dyn cm_files::BlobStore> = match config.storage.backend { let blob: std::sync::Arc<dyn cm_files::BlobStore> = match config.storage.backend {
cm_config::StorageBackend::Local => std::sync::Arc::new(cm_files::LocalBlobStore::new( cm_config::StorageBackend::Local => std::sync::Arc::new(cm_files::LocalBlobStore::new(
PathBuf::from(&config.storage.data_dir), PathBuf::from(&config.storage.data_dir),
@@ -154,6 +178,7 @@ async fn run() -> Result<(), String> {
slack_base_url: config.slack.base_url.clone(), slack_base_url: config.slack.base_url.clone(),
sandboxes, sandboxes,
browser, browser,
providers: provider_registry,
}, },
blob, blob,
); );
+11 -7
View File
@@ -90,15 +90,19 @@ pub async fn compare_topologies(
Authed(user): Authed, Authed(user): Authed,
Json(req): Json<CompareRequest>, Json(req): Json<CompareRequest>,
) -> Result<Json<Comparison>, ApiError> { ) -> Result<Json<Comparison>, ApiError> {
let provider = state.runtime.provider(); // Execution turns run on the exec model (default = configured model, e.g.
// Execution turns run on the configured default model (sonnet); the judge // sonnet); the judge uses the judge model (default claude-opus-4-8). Either
// uses the stronger judge model (default claude-opus-4-8). // can name a registry provider as "<name>:<model>" (e.g. "glm:glm-4.6",
let model = state.runtime.model().to_string(); // "kimi:kimi-k2") to run on GLM/Kimi instead.
let judge_model = let exec_spec = std::env::var("CLAWMATES_TOPOLOGY_EXEC_MODEL")
.unwrap_or_else(|_| state.runtime.model().to_string());
let (exec_provider, exec_model) = state.runtime.resolve_provider(&exec_spec);
let judge_spec =
std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string()); std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string());
let (judge_provider, judge_model) = state.runtime.resolve_provider(&judge_spec);
let executor = let executor =
ProviderExecutor::new(provider.clone(), model, state.runtime.max_tokens()); ProviderExecutor::new(exec_provider, exec_model, state.runtime.max_tokens());
let scorer = JudgeScorer::new(provider, judge_model, 16); let scorer = JudgeScorer::new(judge_provider, judge_model, 16);
let cmp = compare(&req.graphs, &req.task, &executor, &scorer) let cmp = compare(&req.graphs, &req.task, &executor, &scorer)
.await .await
.map_err(|_| ApiError::Internal)?; .map_err(|_| ApiError::Internal)?;
+18
View File
@@ -62,6 +62,24 @@ pub struct LlmConfig {
pub model: String, pub model: String,
/// Scenario file for the deterministic `scripted` provider. /// Scenario file for the deterministic `scripted` provider.
pub scenario_path: Option<String>, pub scenario_path: Option<String>,
/// Additional named providers exposed alongside the default — selectable as
/// `"<name>:<model>"` by judges (`CLAWMATES_JUDGE_MODEL`) and topology nodes.
/// All are OpenAI-compatible (GLM, Kimi/Moonshot, etc.).
#[serde(default)]
pub providers: Vec<NamedProvider>,
}
/// An extra OpenAI-compatible provider in the registry (e.g. GLM or Kimi).
#[derive(Debug, Clone, Deserialize)]
pub struct NamedProvider {
/// Selector prefix, e.g. `glm` or `kimi` (used as `"glm:glm-4.6"`).
pub name: String,
/// OpenAI-compatible base URL incl. version, e.g.
/// `https://open.bigmodel.cn/api/paas/v4` (GLM) or
/// `https://api.moonshot.ai/v1` (Kimi).
pub base_url: String,
/// Env var holding this provider's API key, e.g. `GLM_API_KEY`.
pub api_key_env: String,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
+1 -1
View File
@@ -9,6 +9,6 @@ pub mod scheduling;
mod tools; mod tools;
pub use events::{RunEventBody, RunEventEnvelope}; pub use events::{RunEventBody, RunEventEnvelope};
pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun}; pub use runtime::{ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun};
pub use sandboxes::SandboxManager; pub use sandboxes::SandboxManager;
pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry}; pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
+33 -2
View File
@@ -34,6 +34,20 @@ pub fn judge_model() -> String {
std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string()) std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string())
} }
/// Extra named LLM providers (GLM, Kimi, …) selectable as `"<name>:<model>"`
/// alongside the default provider. `Arc<dyn LlmProvider>` isn't `Debug`, so this
/// newtype carries a manual `Debug` that lists only the provider names.
#[derive(Clone, Default)]
pub struct ProviderRegistry(pub HashMap<String, Arc<dyn LlmProvider>>);
impl std::fmt::Debug for ProviderRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderRegistry")
.field("names", &self.0.keys().collect::<Vec<_>>())
.finish()
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RuntimeConfig { pub struct RuntimeConfig {
pub model: String, pub model: String,
@@ -46,6 +60,8 @@ pub struct RuntimeConfig {
pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>, pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>,
/// Egress-enabled browser containers for browser.goto. /// Egress-enabled browser containers for browser.goto.
pub browser: Option<std::sync::Arc<crate::SandboxManager>>, pub browser: Option<std::sync::Arc<crate::SandboxManager>>,
/// Extra named providers (GLM/Kimi/…) for judges and topology nodes.
pub providers: ProviderRegistry,
} }
impl RuntimeConfig { impl RuntimeConfig {
@@ -71,6 +87,7 @@ impl RuntimeConfig {
slack_base_url: "https://slack.com/api".into(), slack_base_url: "https://slack.com/api".into(),
sandboxes: None, sandboxes: None,
browser: None, browser: None,
providers: ProviderRegistry::default(),
} }
} }
} }
@@ -191,6 +208,19 @@ impl Runtime {
self.inner.provider.clone() self.inner.provider.clone()
} }
/// Resolve a model spec to `(provider, model)`. A spec like `"glm:glm-4.6"`
/// or `"kimi:kimi-k2"` selects a named registry provider; anything else
/// (e.g. `"claude-opus-4-8"`) uses the default provider. This is how judges
/// and topology nodes pick a different LLM.
pub fn resolve_provider(&self, spec: &str) -> (Arc<dyn LlmProvider>, String) {
if let Some((name, model)) = spec.split_once(':') {
if let Some(provider) = self.inner.config.providers.0.get(name) {
return (provider.clone(), model.to_string());
}
}
(self.inner.provider.clone(), spec.to_string())
}
/// The configured default model id. /// The configured default model id.
pub fn model(&self) -> &str { pub fn model(&self) -> &str {
&self.inner.config.model &self.inner.config.model
@@ -252,9 +282,10 @@ impl Runtime {
/// ([`judge_model`], default `claude-opus-4-8`); everything else runs on the /// ([`judge_model`], default `claude-opus-4-8`); everything else runs on the
/// configured default model. /// configured default model.
pub async fn judge(&self, system: &str, user: &str) -> (bool, String) { pub async fn judge(&self, system: &str, user: &str) -> (bool, String) {
let (provider, model) = self.resolve_provider(&judge_model());
let request = ChatRequest { let request = ChatRequest {
system: system.to_string(), system: system.to_string(),
model: judge_model(), model,
messages: vec![ChatMessage { messages: vec![ChatMessage {
role: ChatRole::User, role: ChatRole::User,
parts: vec![ContentPart::text(user)], parts: vec![ContentPart::text(user)],
@@ -263,7 +294,7 @@ impl Runtime {
max_tokens: 256, max_tokens: 256,
}; };
let mut text = String::new(); let mut text = String::new();
match self.inner.provider.stream(request).await { match provider.stream(request).await {
Ok(mut stream) => { Ok(mut stream) => {
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
if let Ok(LlmEvent::TextDelta(t)) = event { if let Ok(LlmEvent::TextDelta(t)) = event {