diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index ec600cd7..60e6cdeb 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -5216,26 +5216,85 @@ impl OpenFangKernel { } /// Collect every provider ID the operator has actually referenced in - /// their effective config — the default model, every fallback chain - /// entry, every `[provider_urls]` key, and every registered agent's - /// manifest provider (including per-agent `fallback_models`). Used by - /// the local provider probe loop so that we don't spam `WARN Local - /// provider offline` for providers the user never asked about (#1031). + /// their effective config. Walks the surfaces below so the local + /// provider probe loop does not spam `WARN Local provider offline` + /// for providers the user never asked about (#1031, #1188): + /// + /// 1. Default model (boot config + hot-reload override). + /// 2. Global fallback chain (boot config + hot-reload override). + /// 3. Explicit `[provider_urls]` keys. + /// 4. Every registered agent manifest provider + per-agent + /// `fallback_models`. + /// 5. Catalog-resolved aliases — model names on default/fallback/manifest + /// that resolve to a different concrete provider via the catalog. + /// 6. Per-channel `overrides.model` for every enabled channel adapter, + /// resolved through the model catalog. + /// 7. Bundled and user-installed skills — tags that match a known + /// provider ID, and `config` variables whose `env` matches a known + /// provider's `api_key_env`. + /// 8. MCP server configs — `env` entries that match a known provider's + /// `api_key_env`. fn referenced_providers(&self) -> std::collections::HashSet { let mut set: std::collections::HashSet = std::collections::HashSet::new(); + // Snapshot catalog lookups up front so we don't keep the lock across + // long iterations. Provider IDs are lowercased model-side already. + let (provider_ids, env_to_provider) = { + let catalog = self + .model_catalog + .read() + .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()); + let ids: std::collections::HashSet = catalog + .list_providers() + .iter() + .map(|p| p.id.clone()) + .collect(); + let env_map: std::collections::HashMap = catalog + .list_providers() + .iter() + .filter(|p| !p.api_key_env.is_empty()) + .map(|p| (p.api_key_env.to_ascii_uppercase(), p.id.clone())) + .collect(); + (ids, env_map) + }; + + // Resolve a model name through the catalog and add the concrete + // provider it lives on. Lets us catch alias-only references where + // the surrounding `provider` field is "default" or empty (#1188). + let add_model = |set: &mut std::collections::HashSet, name: &str| { + if name.is_empty() || name == "default" { + return; + } + let catalog = self + .model_catalog + .read() + .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()); + if let Some(entry) = catalog.find_model(name) { + let p = &entry.provider; + if !p.is_empty() && p != "default" { + set.insert(p.clone()); + } + } + }; + // Default model — respect hot-reloaded override. let override_guard = self .default_model_override .read() .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()); - let dm_provider = override_guard + let (dm_provider, dm_model) = override_guard .as_ref() - .map(|dm| dm.provider.clone()) - .unwrap_or_else(|| self.config.default_model.provider.clone()); + .map(|dm| (dm.provider.clone(), dm.model.clone())) + .unwrap_or_else(|| { + ( + self.config.default_model.provider.clone(), + self.config.default_model.model.clone(), + ) + }); if !dm_provider.is_empty() && dm_provider != "default" { set.insert(dm_provider); } + add_model(&mut set, &dm_model); drop(override_guard); // Global fallback chain — respect hot-reloaded override. @@ -5250,6 +5309,7 @@ impl OpenFangKernel { if !fb.provider.is_empty() && fb.provider != "default" { set.insert(fb.provider.clone()); } + add_model(&mut set, &fb.model); } drop(fb_override); @@ -5264,10 +5324,109 @@ impl OpenFangKernel { if !p.is_empty() && p != "default" { set.insert(p.clone()); } + add_model(&mut set, &entry.manifest.model.model); for fb in &entry.manifest.fallback_models { if !fb.provider.is_empty() && fb.provider != "default" { set.insert(fb.provider.clone()); } + add_model(&mut set, &fb.model); + } + } + + // Channel adapters — each enabled channel may pin `overrides.model` + // to a specific model, which resolves to a concrete provider through + // the catalog. Skip when no override is set. + let ch = &self.config.channels; + let channel_overrides: [Option<&openfang_types::config::ChannelOverrides>; 43] = [ + ch.telegram.as_ref().map(|c| &c.overrides), + ch.discord.as_ref().map(|c| &c.overrides), + ch.slack.as_ref().map(|c| &c.overrides), + ch.whatsapp.as_ref().map(|c| &c.overrides), + ch.signal.as_ref().map(|c| &c.overrides), + ch.matrix.as_ref().map(|c| &c.overrides), + ch.email.as_ref().map(|c| &c.overrides), + ch.teams.as_ref().map(|c| &c.overrides), + ch.mattermost.as_ref().map(|c| &c.overrides), + ch.irc.as_ref().map(|c| &c.overrides), + ch.google_chat.as_ref().map(|c| &c.overrides), + ch.twitch.as_ref().map(|c| &c.overrides), + ch.rocketchat.as_ref().map(|c| &c.overrides), + ch.zulip.as_ref().map(|c| &c.overrides), + ch.xmpp.as_ref().map(|c| &c.overrides), + ch.line.as_ref().map(|c| &c.overrides), + ch.viber.as_ref().map(|c| &c.overrides), + ch.messenger.as_ref().map(|c| &c.overrides), + ch.reddit.as_ref().map(|c| &c.overrides), + ch.mastodon.as_ref().map(|c| &c.overrides), + ch.bluesky.as_ref().map(|c| &c.overrides), + ch.feishu.as_ref().map(|c| &c.overrides), + ch.revolt.as_ref().map(|c| &c.overrides), + ch.nextcloud.as_ref().map(|c| &c.overrides), + ch.guilded.as_ref().map(|c| &c.overrides), + ch.keybase.as_ref().map(|c| &c.overrides), + ch.threema.as_ref().map(|c| &c.overrides), + ch.nostr.as_ref().map(|c| &c.overrides), + ch.webex.as_ref().map(|c| &c.overrides), + ch.pumble.as_ref().map(|c| &c.overrides), + ch.flock.as_ref().map(|c| &c.overrides), + ch.twist.as_ref().map(|c| &c.overrides), + ch.mumble.as_ref().map(|c| &c.overrides), + ch.dingtalk.as_ref().map(|c| &c.overrides), + ch.dingtalk_stream.as_ref().map(|c| &c.overrides), + ch.discourse.as_ref().map(|c| &c.overrides), + ch.gitter.as_ref().map(|c| &c.overrides), + ch.ntfy.as_ref().map(|c| &c.overrides), + ch.gotify.as_ref().map(|c| &c.overrides), + ch.webhook.as_ref().map(|c| &c.overrides), + ch.linkedin.as_ref().map(|c| &c.overrides), + ch.wecom.as_ref().map(|c| &c.overrides), + ch.mqtt.as_ref().map(|c| &c.overrides), + ]; + for overrides in channel_overrides.iter().flatten() { + if let Some(model) = overrides.model.as_deref() { + add_model(&mut set, model); + } + } + + // Skills — bundled + user-installed. Two indirect provider hints: + // 1. Tag matching a known provider ID (e.g. tag "openai" on a + // skill that drives the OpenAI API). + // 2. A declared config variable whose `env` matches a known + // provider's `api_key_env` (e.g. env = "OPENAI_API_KEY" + // → openai). + let skill_registry = self + .skill_registry + .read() + .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()); + for skill in skill_registry.list() { + for tag in &skill.manifest.skill.tags { + let lower = tag.to_ascii_lowercase(); + if provider_ids.contains(&lower) { + set.insert(lower); + } + } + for var in skill.manifest.config.values() { + if let Some(env_name) = var.env.as_deref() { + if let Some(provider) = + env_to_provider.get(&env_name.to_ascii_uppercase()) + { + set.insert(provider.clone()); + } + } + } + } + drop(skill_registry); + + // MCP server configs — each entry's `env` allowlist may include a + // provider's API key env var, which is enough evidence the operator + // wired that provider into their MCP server. + for server in &self.config.mcp_servers { + for env_name in &server.env { + if let Some(provider) = + env_to_provider.get(&env_name.to_ascii_uppercase()) + { + set.insert(provider.clone()); + } } } @@ -8791,6 +8950,187 @@ mod tests { kernel.shutdown(); } + // ---------------------------------------------------------------------- + // Issue #1188: referenced_providers() must also walk MCP server configs, + // skill manifests, channel adapters, and catalog aliases. Otherwise the + // probe loop still spams "Local provider offline" for providers that are + // referenced indirectly. Each block below pins one new surface so a + // regression on any single surface fails its own assertion. + // ---------------------------------------------------------------------- + + #[test] + fn test_1188_referenced_providers_resolves_alias_to_provider() { + use openfang_types::config::DefaultModelConfig; + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-1188-alias"); + std::fs::create_dir_all(&home_dir).unwrap(); + + // Operator sets provider = "default" and picks the model by its + // builtin catalog alias "opus", which resolves to provider + // "anthropic". The literal provider field is "default", so the + // pre-fix walker would not have added anthropic. + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + default_model: DefaultModelConfig { + provider: "default".to_string(), + model: "opus".to_string(), + api_key_env: "ANTHROPIC_API_KEY".to_string(), + base_url: None, + subprocess_timeout_secs: None, + }, + ..KernelConfig::default() + }; + + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + let referenced = kernel.referenced_providers(); + assert!( + referenced.contains("anthropic"), + "alias 'opus' must resolve to anthropic ({referenced:?})" + ); + kernel.shutdown(); + } + + #[test] + fn test_1188_referenced_providers_walks_channel_overrides() { + use openfang_types::config::{ + ChannelOverrides, ChannelsConfig, DefaultModelConfig, TelegramConfig, + }; + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-1188-channel"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let overrides = ChannelOverrides { + model: Some("opus".to_string()), + ..ChannelOverrides::default() + }; + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + default_model: DefaultModelConfig { + provider: "groq".to_string(), + model: "llama-3.1-70b".to_string(), + api_key_env: "GROQ_API_KEY".to_string(), + base_url: None, + subprocess_timeout_secs: None, + }, + channels: ChannelsConfig { + telegram: Some(TelegramConfig { + overrides, + ..TelegramConfig::default() + }), + ..ChannelsConfig::default() + }, + ..KernelConfig::default() + }; + + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + let referenced = kernel.referenced_providers(); + assert!( + referenced.contains("anthropic"), + "channel override 'opus' must pull in anthropic ({referenced:?})" + ); + kernel.shutdown(); + } + + #[test] + fn test_1188_referenced_providers_walks_mcp_env() { + use openfang_types::config::{ + DefaultModelConfig, McpServerConfigEntry, McpTransportEntry, + }; + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-1188-mcp"); + std::fs::create_dir_all(&home_dir).unwrap(); + + // MCP server passes through OPENAI_API_KEY, which is the api_key_env + // for the openai provider, so openai must be considered referenced. + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + default_model: DefaultModelConfig { + provider: "groq".to_string(), + model: "llama-3.1-70b".to_string(), + api_key_env: "GROQ_API_KEY".to_string(), + base_url: None, + subprocess_timeout_secs: None, + }, + mcp_servers: vec![McpServerConfigEntry { + name: "openai-proxy".to_string(), + transport: McpTransportEntry::Stdio { + command: "node".to_string(), + args: vec!["proxy.js".to_string()], + }, + timeout_secs: 30, + env: vec!["OPENAI_API_KEY".to_string()], + headers: vec![], + }], + ..KernelConfig::default() + }; + + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + let referenced = kernel.referenced_providers(); + assert!( + referenced.contains("openai"), + "MCP env OPENAI_API_KEY must pull in openai ({referenced:?})" + ); + kernel.shutdown(); + } + + #[test] + fn test_1188_referenced_providers_walks_skill_tags() { + use openfang_types::config::DefaultModelConfig; + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-1188-skill"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + default_model: DefaultModelConfig { + provider: "groq".to_string(), + model: "llama-3.1-70b".to_string(), + api_key_env: "GROQ_API_KEY".to_string(), + base_url: None, + subprocess_timeout_secs: None, + }, + ..KernelConfig::default() + }; + + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + + // Drop a minimal skill manifest with a tag matching a known + // provider ID, then load it through the kernel's registry. + let skill_dir = home_dir.join("skills").join("openai-helper"); + std::fs::create_dir_all(&skill_dir).unwrap(); + let manifest_toml = r#" +[skill] +name = "openai-helper" +version = "0.1.0" +description = "test skill" +tags = ["openai"] + +[runtime] +type = "promptonly" +"#; + std::fs::write(skill_dir.join("skill.toml"), manifest_toml).unwrap(); + { + let mut reg = kernel.skill_registry.write().unwrap(); + reg.load_skill(&skill_dir).expect("skill loads"); + } + + let referenced = kernel.referenced_providers(); + assert!( + referenced.contains("openai"), + "skill tag 'openai' must pull in openai ({referenced:?})" + ); + kernel.shutdown(); + } + // ---------------------------------------------------------------------- // Issue #1140: agents placed at ~/.openfang/agents//agent.toml // must auto-spawn on boot so they appear in the chat tab.