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:
co-authored by
Claude Opus 4.8
parent
325038e806
commit
2ec8832ef6
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user