Watch
1
0
Fork
You've already forked souveraine
0

providers now per-agent: z.ai rides, bifrost stays home

Vanguard gets his own inference pipe. z.ai GLM Coding Plan, /paas/v4.
BifrostClient learned to leave versioned URLs alone. ProviderRegistry
dispatches per agent; every engine asks who it's talking to before
dialing. Annie and Hal none the wiser.
This commit is contained in:
Fimeg 2026-06-15 18:06:23 -04:00
commit 80bfb3a599
15 changed files with 326 additions and 57 deletions

7
.gitignore vendored
View file

@ -58,3 +58,10 @@ souveraine.log.prev
# Config (use souveraine.example.toml as template)
souveraine.toml
# Local-only project scaffolding — kept on disk, never committed to any remote
.superpowers/
saf/
journal/
scripts/
website/

13
Cargo.lock generated
View file

@ -1308,6 +1308,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chord_macro"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "chord_macro"
version = "0.1.0"
@ -8419,8 +8428,10 @@ dependencies = [
"anyhow",
"arboard",
"async-trait",
"axis2d",
"axum",
"base64 0.22.1",
"chord_macro 0.1.0",
"chrono",
"clap 4.6.1",
"clap_complete",
@ -10253,7 +10264,7 @@ version = "0.1.5"
dependencies = [
"axis2d",
"base64-simd",
"chord_macro",
"chord_macro 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"image",
"libc",
"nonmax",

View file

@ -94,6 +94,22 @@ pub struct SouveraineConfig {
pub archivist_enabled: bool,
pub archivist_threshold: f32,
pub sensorium_bandwidth: String,
/// Per-agent model override for the subconscious (N+1) pass. When set,
/// it wins over the global `[subconscious] model`. None falls back to
/// the global setting, then the agent's own primary model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subconscious_model: Option<String>,
/// Per-agent model override for the reflection (N+25) pass.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reflection_model: Option<String>,
/// Per-agent model override for the archivist (N+100) synthesis pass.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archivist_model: Option<String>,
/// Per-agent inference provider name (e.g. "zai"). When set, the agent's
/// requests route through the named provider instead of the global
/// `[inference] provider`. None falls back to the global default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}
#[derive(Debug, Deserialize)]

View file

@ -11,8 +11,12 @@ use crate::bridge::provider::LlmProvider;
/// Bifrost is an OpenAI-compatible API gateway: http://127.0.0.1:3360/v1
#[derive(Debug, Clone)]
pub struct BifrostClient {
/// Base URL including /v1 (e.g. "http://127.0.0.1:3360/v1")
/// Base URL including its version path (e.g. "http://127.0.0.1:3360/v1"
/// or "https://api.z.ai/api/coding/paas/v4").
base_url: String,
/// Provider identity label (logs/catalog). Defaults to "bifrost"; a
/// differently-branded OpenAI-compatible endpoint (e.g. z.ai) sets its own.
id: String,
/// Bearer token for auth
api_key: String,
/// Optional virtual key for x-bf-vk header
@ -428,10 +432,28 @@ enum ErrorClass {
Permanent,
}
/// True if the URL's final path segment is a version tag like `v1`, `v4`.
/// Used to avoid appending `/v1` to an already-versioned base URL.
fn last_segment_is_version(url: &str) -> bool {
match url.rsplit('/').next() {
Some(seg) if seg.len() >= 2 => {
seg.starts_with('v') && seg[1..].chars().all(|c| c.is_ascii_digit())
}
_ => false,
}
}
impl BifrostClient {
pub fn new(base_url: &str, api_key: &str, virtual_key: &str, default_model: &str, timeout_secs: u64) -> Result<Self> {
let base = base_url.trim_end_matches('/').to_string();
let base_url = if base.ends_with("/v1") { base } else { format!("{}/v1", base) };
// Only append "/v1" when the base URL has no version path segment.
// A URL already ending in "/v<digits>" (Bifrost's "/v1", z.ai's
// "/api/coding/paas/v4") is left untouched.
let base_url = if last_segment_is_version(&base) {
base
} else {
format!("{}/v1", base)
};
info!(
"🌉 Bifrost client initialized — model: {}, endpoint: {}, timeout: {}s",
@ -439,6 +461,7 @@ impl BifrostClient {
);
Ok(Self {
base_url,
id: "bifrost".to_string(),
api_key: api_key.to_string(),
virtual_key: virtual_key.to_string(),
client: reqwest::Client::builder()
@ -450,6 +473,14 @@ impl BifrostClient {
})
}
/// Set the provider identity label (logs/catalog merge). Use when
/// constructing this generic OpenAI-compatible client for a non-Bifrost
/// provider such as z.ai.
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = id.into();
self
}
pub fn with_fallbacks(mut self, fallbacks: Vec<String>) -> Self {
self.retry_policy.fallback_models = fallbacks;
self
@ -674,7 +705,7 @@ impl BifrostClient {
#[async_trait]
impl LlmProvider for BifrostClient {
fn id(&self) -> &str {
"bifrost"
&self.id
}
fn default_model(&self) -> &str {

View file

@ -3,7 +3,7 @@
/// Connects Souveraine to inference providers behind the [`LlmProvider`] trait.
/// `BifrostClient` speaks to any OpenAI-compatible gateway; the OAuth-riding
/// ChatGPT provider lives in [`providers`]. [`build_provider`] selects the
/// active one from config.
/// active one from config; [`ProviderRegistry`] resolves a provider per agent.
pub mod bifrost;
pub mod model_router;
pub mod oauth;
@ -13,27 +13,56 @@ pub mod providers;
pub use bifrost::BifrostClient;
pub use provider::LlmProvider;
use std::collections::HashMap;
use std::sync::Arc;
use crate::api::models::AgentState;
use crate::core::config::ConsciousnessConfig;
/// Build the active LLM provider from config.
/// Build the active (global default) LLM provider from config.
///
/// `[inference] provider` selects the implementation:
/// - `"bifrost"` (default) — any OpenAI-compatible gateway via `BifrostClient`.
/// - `"openai-oauth"` — ride the Codex CLI's ChatGPT login and drive
/// `backend-api/codex/responses`.
///
/// Kept for call sites with no agent context (model pickers, CLI probes). For
/// inference, prefer [`build_registry`] + [`ProviderRegistry::for_agent`].
pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result<Arc<dyn LlmProvider>> {
let bf = &config.bifrost;
match config.inference.provider.as_str() {
build_provider_named(&config.inference.provider, config)
}
/// Build a specific named provider. Adding a provider = adding a case here and
/// a config section. Reuses `BifrostClient` (generic OpenAI-compatible) for any
/// gateway-style provider; the OAuth provider is the endpoint-specific special
/// case.
pub fn build_provider_named(
name: &str,
config: &ConsciousnessConfig,
) -> anyhow::Result<Arc<dyn LlmProvider>> {
match name {
"openai-oauth" | "openai-codex" => {
let provider = providers::openai_oauth::OpenAiOAuthProvider::from_codex_login(
bf.primary_model.clone(),
bf.timeout_secs,
config.bifrost.primary_model.clone(),
config.bifrost.timeout_secs,
)?;
Ok(Arc::new(provider))
}
"zai" => {
let z = &config.zai;
let client = BifrostClient::new(
&z.base_url,
&z.api_key,
"", // no virtual-key header for z.ai
&z.primary_model,
z.timeout_secs,
)?
.with_id("zai");
Ok(Arc::new(client))
}
_ => {
// "bifrost" (and any unrecognized name) → OpenAI-compatible gateway.
let bf = &config.bifrost;
let client = BifrostClient::new(
&bf.base_url,
&bf.api_key,
@ -45,3 +74,66 @@ pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result<Arc<dyn Ll
}
}
}
/// Build the per-agent provider registry: the global default provider (from
/// `[inference] provider`) plus every additionally-configured named provider
/// (e.g. `zai` when `[zai]` is set). Engines resolve a provider per agent from
/// this instead of holding one global client.
pub fn build_registry(config: &ConsciousnessConfig) -> anyhow::Result<ProviderRegistry> {
let default_name = config.inference.provider.clone();
let default = build_provider_named(&default_name, config)?;
let mut map = HashMap::new();
map.insert(default_name.clone(), default.clone());
// z.ai (GLM Coding Plan) — register when configured.
if !config.zai.base_url.is_empty() && !config.zai.api_key.is_empty() {
map.insert("zai".to_string(), build_provider_named("zai", config)?);
}
Ok(ProviderRegistry {
map,
default_name,
default,
})
}
/// Maps a provider name to a live [`LlmProvider`], resolving per agent.
#[derive(Clone)]
pub struct ProviderRegistry {
map: HashMap<String, Arc<dyn LlmProvider>>,
default_name: String,
default: 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("providers", &self.map.keys().collect::<Vec<_>>())
.field("default", &self.default_name)
.finish()
}
}
impl ProviderRegistry {
/// Resolve the provider for an agent: its `_souveraine.provider` override,
/// else the global default. An unknown/missing name falls back to the
/// default rather than failing.
pub fn for_agent(&self, agent: &AgentState) -> Arc<dyn LlmProvider> {
match agent.souveraine.provider.as_deref() {
Some(name) if name != self.default_name => {
self.map.get(name).cloned().unwrap_or_else(|| self.default.clone())
}
_ => self.default.clone(),
}
}
/// The global default provider (for contexts with no agent).
pub fn default_provider(&self) -> Arc<dyn LlmProvider> {
self.default.clone()
}
/// Name of the global default provider.
pub fn default_name(&self) -> &str {
&self.default_name
}
}

View file

@ -37,6 +37,7 @@ use serde::{Deserialize, Serialize};
use crate::bridge::bifrost::{ChatCompletionRequest, Message};
use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry;
use crate::core::config::{ArchivistConfig, SynthesisElement};
use crate::core::memory::MemoryRepo;
use crate::server::AgentInventory;
@ -95,7 +96,7 @@ impl SynthesisReport {
pub struct ArchivistEngine {
agents: Arc<AgentInventory>,
bifrost: Arc<dyn LlmProvider>,
providers: Arc<ProviderRegistry>,
rate_delay: Arc<AtomicU64>,
config: ArchivistConfig,
/// Subconscious model handle — used to resolve `compression_model: "auto"`.
@ -105,24 +106,35 @@ pub struct ArchivistEngine {
impl ArchivistEngine {
pub fn new(
agents: Arc<AgentInventory>,
bifrost: Arc<dyn LlmProvider>,
providers: Arc<ProviderRegistry>,
rate_delay: Arc<AtomicU64>,
config: ArchivistConfig,
subconscious_model: Option<String>,
) -> Self {
Self {
agents,
bifrost,
providers,
rate_delay,
config,
subconscious_model,
}
}
/// Resolve the compression model. `"auto"` (or empty) falls back to the
/// subconscious model, then to a sensible default. A real capability-aware
/// auto-selection algorithm is deferred (task Phase 4).
fn resolve_model(&self) -> String {
/// Resolve the compression model. The agent's own
/// `_souveraine.archivist_model` override wins outright. Otherwise the
/// configured `compression_model` is used, with `"auto"` (or empty)
/// falling back to the subconscious model, then a sensible default.
/// A real capability-aware auto-selection algorithm is deferred.
async fn resolve_model(&self, agent_id: &str) -> String {
if let Some(per_agent) = self
.agents
.get(agent_id)
.await
.ok()
.and_then(|a| a.souveraine.archivist_model.clone())
{
return per_agent;
}
let configured = self.config.compression_model.trim();
if configured.is_empty() || configured.eq_ignore_ascii_case("auto") {
self.subconscious_model
@ -197,9 +209,16 @@ impl ArchivistEngine {
let raw = format_journal_input(&entries);
let approx_tokens_compressed = raw.len() / 4;
let model = self.resolve_model();
let model = self.resolve_model(agent_id).await;
let llm: Arc<dyn LlmProvider> = self
.agents
.get(agent_id)
.await
.ok()
.map(|a| self.providers.for_agent(&a))
.unwrap_or_else(|| self.providers.default_provider());
let synthesis = self
.run_synthesis(&model, &raw, start_date, end_date)
.run_synthesis(&llm, &model, &raw, start_date, end_date)
.await?;
let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
@ -229,6 +248,7 @@ impl ArchivistEngine {
/// No tool loop — the Archivist produces a record, it doesn't act.
async fn run_synthesis(
&self,
llm: &Arc<dyn LlmProvider>,
model: &str,
raw_journal: &str,
start: NaiveDate,
@ -253,7 +273,7 @@ impl ArchivistEngine {
};
tracing::info!(model = %model, "archivist synthesis call starting");
let (response, strain) = self.bifrost.chat_completion_with_strain(request).await?;
let (response, strain) = llm.chat_completion_with_strain(request).await?;
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event {

View file

@ -34,6 +34,7 @@ use tokio::sync::RwLock;
use crate::bridge::model_router::TokenCounter;
use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry;
use crate::core::config::ConsciousnessConfig;
use crate::core::memory::MemoryRepo;
use crate::core::session::ConversationMessage;
@ -85,7 +86,7 @@ pub trait CompactionEngine: Send + Sync {
pub struct DefaultCompactionEngine {
pub config: Arc<RwLock<ConsciousnessConfig>>,
pub counter: TokenCounter,
pub bifrost: Option<Arc<dyn LlmProvider>>,
pub providers: Option<Arc<ProviderRegistry>>,
pub model: Option<String>,
pub clock: Arc<dyn Clock>,
pub get_messages: Arc<dyn Fn(&str) -> Option<Vec<ConversationMessage>> + Send + Sync>,
@ -151,8 +152,9 @@ impl CompactionEngine for DefaultCompactionEngine {
// Build strategy and get plan
let plan = match strategy_kind {
CompactionStrategyKind::Summary => match &self.bifrost {
Some(client) => {
CompactionStrategyKind::Summary => match &self.providers {
Some(reg) => {
let client = reg.default_provider();
let model = self
.model
.as_deref()
@ -178,8 +180,9 @@ impl CompactionEngine for DefaultCompactionEngine {
let s = SlidingWindowStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
CompactionStrategyKind::SlidingReflect => match &self.bifrost {
Some(client) => {
CompactionStrategyKind::SlidingReflect => match &self.providers {
Some(reg) => {
let client = reg.default_provider();
let model = self
.model
.as_deref()
@ -208,7 +211,7 @@ impl CompactionEngine for DefaultCompactionEngine {
s.plan(&messages, &cfg, &self.counter).await?
}
None => {
tracing::warn!("[sliding_reflect] no bifrost client, falling back to plain sliding window");
tracing::warn!("[sliding_reflect] no provider registry, falling back to plain sliding window");
SlidingWindowStrategy.plan(&messages, &cfg, &self.counter).await?
}
},
@ -334,7 +337,7 @@ mod tests {
let engine = DefaultCompactionEngine {
counter: TokenCounter::new(),
bifrost: None,
providers: None,
model: None,
clock: Arc::new(TestClock),
config,

View file

@ -15,6 +15,11 @@ pub struct ConsciousnessConfig {
#[serde(default)]
pub bifrost: BifrostConfig,
/// z.ai (GLM Coding Plan) inference config — an OpenAI-compatible
/// provider reachable per-agent via `_souveraine.provider = "zai"`.
#[serde(default)]
pub zai: ZaiConfig,
/// Per-model physics configs
#[serde(default)]
pub models: HashMap<String, ModelConfig>,
@ -219,6 +224,47 @@ pub struct BifrostConfig {
pub timeout_secs: u64,
}
// ── z.ai ──
/// z.ai (GLM Coding Plan) provider configuration. An OpenAI-compatible
/// endpoint served from `https://api.z.ai/api/coding/paas/v4`. Selected
/// per-agent via `_souveraine.provider = "zai"`; otherwise unused. Empty by
/// default so configs without a `[zai]` section still parse.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZaiConfig {
/// Coding Plan endpoint, e.g. "https://api.z.ai/api/coding/paas/v4".
#[serde(default)]
pub base_url: String,
/// Bearer token (Zhipu `{id}.{secret}` key).
#[serde(default)]
pub api_key: String,
/// Default model for z.ai agents (e.g. "glm-5.2").
#[serde(default)]
pub primary_model: String,
/// Per-attempt timeout in seconds. glm-5.2 is a reasoning model and can
/// take a while; default generously.
#[serde(default = "default_zai_timeout")]
pub timeout_secs: u64,
}
impl Default for ZaiConfig {
fn default() -> Self {
Self {
base_url: String::new(),
api_key: String::new(),
primary_model: String::new(),
timeout_secs: default_zai_timeout(),
}
}
}
fn default_zai_timeout() -> u64 {
300
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BifrostModelConfig {
#[serde(default = "default_128k")]
@ -628,6 +674,7 @@ impl Default for ConsciousnessConfig {
Self {
inference: InferenceConfig::default(),
bifrost: BifrostConfig::default(),
zai: ZaiConfig::default(),
models: default_models(),
subconscious: SubconsciousConfig::default(),
reflection: ReflectionConfig::default(),

View file

@ -34,6 +34,7 @@ use crate::bridge::bifrost::{
ChatCompletionRequest, Message, ToolDefinition, ToolFunction,
};
use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::ToolContext;
use crate::server::AgentInventory;
@ -70,7 +71,7 @@ pub struct ReflectionReport {
pub struct ReflectionEngine {
agents: Arc<AgentInventory>,
bifrost: Arc<dyn LlmProvider>,
providers: Arc<ProviderRegistry>,
rate_delay: Arc<AtomicU64>,
/// Model handle for reflection passes. None falls back to the
/// subconscious model, then to a sensible default.
@ -81,14 +82,14 @@ pub struct ReflectionEngine {
impl ReflectionEngine {
pub fn new(
agents: Arc<AgentInventory>,
bifrost: Arc<dyn LlmProvider>,
providers: Arc<ProviderRegistry>,
rate_delay: Arc<AtomicU64>,
model: Option<String>,
max_tokens: Option<u32>,
) -> Self {
Self {
agents,
bifrost,
providers,
rate_delay,
model,
max_tokens,
@ -118,10 +119,21 @@ impl ReflectionEngine {
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
.count();
let model = self
.model
.as_deref()
.unwrap_or("openai/glm-5.1");
// Per-agent reflection model: the agent's own
// `_souveraine.reflection_model` override wins, then the engine's
// configured reflection/subconscious model, then the agent's primary
// model, then a sensible default.
let agent = self.agents.get(agent_id).await.ok();
let model = agent
.as_ref()
.and_then(|a| a.souveraine.reflection_model.clone())
.or_else(|| self.model.clone())
.or_else(|| agent.as_ref().map(|a| a.llm_config.model.clone()))
.unwrap_or_else(|| "openai/glm-5.1".to_string());
let llm: Arc<dyn LlmProvider> = agent
.as_ref()
.map(|a| self.providers.for_agent(a))
.unwrap_or_else(|| self.providers.default_provider());
// ── Tools ───────────────────────────────────────────────────
let all_defs = crate::core::tools::tool_definitions().await;
@ -181,7 +193,7 @@ impl ReflectionEngine {
tools: Some(reflection_tools.clone()),
};
let (response, strain) = self.bifrost.chat_completion_with_strain(request).await?;
let (response, strain) = llm.chat_completion_with_strain(request).await?;
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } =

View file

@ -315,6 +315,10 @@ impl AgentInventory {
archivist_enabled: true,
archivist_threshold: 0.7,
sensorium_bandwidth: "high".to_string(),
subconscious_model: None,
reflection_model: None,
archivist_model: None,
provider: None,
},
};

View file

@ -27,6 +27,7 @@
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry;
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
@ -59,7 +60,7 @@ const SUBCONSCIOUS_INTER_ROUND_DELAY_MS: u64 = 300;
pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>,
sessions: Arc<SessionManager>,
bifrost: Arc<dyn LlmProvider>,
providers: Arc<ProviderRegistry>,
counter: TokenCounter,
/// Optional model override for the subconscious pass (e.g. "openai/glm-5.1").
/// If None, uses the primary agent's model.
@ -156,7 +157,7 @@ impl ConsciousnessEngine {
pub fn new(
agents: Arc<AgentInventory>,
sessions: Arc<SessionManager>,
bifrost: Arc<dyn LlmProvider>,
providers: Arc<ProviderRegistry>,
subconscious_model: Option<String>,
reflection_model: Option<String>,
max_tokens: Option<u32>,
@ -167,14 +168,14 @@ impl ConsciousnessEngine {
) -> Self {
let reflection = Arc::new(crate::core::reflection::ReflectionEngine::new(
agents.clone(),
bifrost.clone(),
providers.clone(),
rate_delay.clone(),
reflection_model.or_else(|| subconscious_model.clone()),
max_tokens,
));
let archivist = Arc::new(crate::core::archivist::ArchivistEngine::new(
agents.clone(),
bifrost.clone(),
providers.clone(),
rate_delay.clone(),
archivist_config,
subconscious_model.clone(),
@ -182,7 +183,7 @@ impl ConsciousnessEngine {
Self {
agents,
sessions,
bifrost,
providers,
counter: TokenCounter::new(),
subconscious_model,
subconscious_system_prompt,
@ -556,10 +557,21 @@ impl ConsciousnessEngine {
occasion: Occasion,
stream_tx: Option<&mpsc::Sender<anyhow::Result<BackendEvent>>>,
) -> anyhow::Result<SubconsciousPassOutcome> {
let model = self
.subconscious_model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
// Resolve the subconscious model per-agent: the agent's own
// `_souveraine.subconscious_model` override wins, then the global
// `[subconscious] model`, then the agent's primary model, then a
// sensible default.
let primary_agent = self.agents.get(primary_id).await.ok();
let model = primary_agent
.as_ref()
.and_then(|a| a.souveraine.subconscious_model.clone())
.or_else(|| self.subconscious_model.clone())
.or_else(|| primary_agent.as_ref().map(|a| a.llm_config.model.clone()))
.unwrap_or_else(|| "openai/kimi-k2.6".to_string());
let llm: Arc<dyn LlmProvider> = primary_agent
.as_ref()
.map(|a| self.providers.for_agent(a))
.unwrap_or_else(|| self.providers.default_provider());
// ── System prompt — load from subconscious agent's own memfs ──
let sub_memory_root = self.agents.subconscious_memory_root(primary_id);
@ -776,7 +788,7 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
tracing::info!(model = %model, round = _round, "subconscious LLM call starting");
let subconscious_start = std::time::Instant::now();
let (response, strain) = self.bifrost.chat_completion_with_strain(request).await?;
let (response, strain) = llm.chat_completion_with_strain(request).await?;
tracing::info!(
elapsed = ?subconscious_start.elapsed(),
tool_calls = response.tool_calls.len(),
@ -941,8 +953,7 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
stream: None,
tools: None,
};
let (final_response, _strain) = self
.bifrost
let (final_response, _strain) = llm
.chat_completion_with_strain(final_request)
.await?;
let content = final_response.content.trim().to_string();

View file

@ -1,4 +1,4 @@
use crate::bridge::{build_provider, LlmProvider};
use crate::bridge::{build_provider, build_registry, ProviderRegistry, LlmProvider};
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
use crate::core::config::ConsciousnessConfig;
use crate::core::identity::SeedId;
@ -41,7 +41,7 @@ pub struct SouveraineServer {
pub sessions: Arc<SessionManager>,
pub consciousness: Arc<ConsciousnessEngine>,
pub compaction_engine: Arc<dyn CompactionEngine>,
pub bifrost: Arc<dyn LlmProvider>,
pub providers: Arc<ProviderRegistry>,
pub config: Arc<RwLock<ServerConfig>>,
pub data_dir: PathBuf,
pub memory: Option<Arc<ServerMemory>>,
@ -133,9 +133,8 @@ impl SouveraineServer {
let event_bus = crate::core::nervous::EventBus::default();
let sessions = Arc::new(SessionManager::with_persistence(data_dir.join("agents")));
// Select the active LLM provider (OpenAI-compatible gateway or the
// OAuth-riding ChatGPT provider) from config.
let bifrost: Arc<dyn LlmProvider> = build_provider(&config)?;
// Build the per-agent provider registry from config. z.ai, bifrost, etc.
let providers = Arc::new(build_registry(&config)?);
let rate_delay = Arc::new(AtomicU64::new(1000));
tracing::info!("rate delay initialized at 1000ms");
@ -188,7 +187,7 @@ impl SouveraineServer {
let compaction_engine: Arc<dyn CompactionEngine> = Arc::new(DefaultCompactionEngine {
config: app_cfg,
counter: crate::bridge::model_router::TokenCounter::new(),
bifrost: Some(bifrost.clone()),
providers: Some(providers.clone()),
model: config.compaction.model.clone().or_else(|| config.subconscious.model.clone()),
clock: Arc::new(UtcClock),
get_messages,
@ -200,7 +199,7 @@ impl SouveraineServer {
let consciousness = Arc::new(ConsciousnessEngine::new(
agents.clone(),
sessions.clone(),
bifrost.clone(),
providers.clone(),
config.subconscious.model.clone(),
config.reflection.model.clone(),
config.subconscious.max_tokens,
@ -295,7 +294,7 @@ impl SouveraineServer {
sessions,
consciousness,
compaction_engine,
bifrost,
providers,
config: Arc::new(RwLock::new(server_config)),
data_dir,
memory,

View file

@ -144,7 +144,7 @@ impl SubagentRunner for ServerSubagentRunner {
tools: Some(bifrost_tools.clone()),
};
let response = self.server.bifrost.chat_completion(req).await.map_err(|e| {
let response = self.server.providers.default_provider().chat_completion(req).await.map_err(|e| {
crate::core::tools::defs::ToolError::invalid_input(&format!(
"Subagent LLM call failed: {e}"
))

View file

@ -350,13 +350,14 @@ pub(crate) async fn run_turn(
// Race the LLM call against cancellation so Esc drops the in-flight
// request without waiting for it to complete.
let llm = server.providers.for_agent(&agent);
let (response, strain) = tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
break;
}
res = server.bifrost.chat_completion_with_strain(req) => res?,
res = llm.chat_completion_with_strain(req) => res?,
};
tracing::info!(

View file

@ -176,6 +176,21 @@ impl DelegateWidget for SettingsScreen {
};
}
// When the model picker sub-page is active, route input to it. The
// picker handles its own navigation, fuzzy filtering, Enter-to-select,
// and 'r' to refresh. Esc with an empty query bubbles back as Rejected
// — close the sub-page here.
if self.active_model_picker_field.is_some() {
let is_esc = matches!(key, Key::Esc);
let result = self.get_delegate_mut().on_input(queue);
if is_esc && matches!(result, InputResult::Rejected) {
queue.next();
self.process_action(SettingsAction::PopPage);
return InputResult::Handled;
}
return result;
}
match key {
Key::Arrow(Direction2D::Up) => {
queue.next();