diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7b2cee78a6..4d98d51053 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -101,10 +101,13 @@ pub async fn get_agent_models( // so a build-provided provider still gets live discovery. let mut effective_provider = effective_discovery_provider(saved_provider.as_deref(), provider_env_var, &merged_env); - effective_provider.value = crate::managed_agents::readiness::canonicalize_openai_provider_env( - &mut merged_env, - effective_provider.as_deref(), - ); + if known_acp_runtime(&discovery.command).is_some_and(|runtime| runtime.id == "buzz-agent") { + effective_provider.value = + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut merged_env, + effective_provider.as_deref(), + ); + } if let Some(models) = discover_openrouter_models( &state.http_client, &effective_provider, @@ -243,10 +246,13 @@ pub async fn discover_agent_models( runtime_meta.and_then(|meta| meta.provider_env_var), &merged_env, ); - effective_provider.value = crate::managed_agents::readiness::canonicalize_openai_provider_env( - &mut merged_env, - effective_provider.as_deref(), - ); + if runtime_meta.is_some_and(|runtime| runtime.id == "buzz-agent") { + effective_provider.value = + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut merged_env, + effective_provider.as_deref(), + ); + } // Buzz shared compute discovery must not depend on the local OpenAI ingress: that // client endpoint is started only after a live target is selected. diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 272c03348b..fa20cdbb9b 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; mod nest; +pub(crate) mod openai_env; pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; diff --git a/desktop/src-tauri/src/managed_agents/openai_env.rs b/desktop/src-tauri/src/managed_agents/openai_env.rs new file mode 100644 index 0000000000..2b7fa5769c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/openai_env.rs @@ -0,0 +1,64 @@ +use std::collections::BTreeMap; + +pub(crate) fn effective_runtime_provider( + command: &str, + configured_provider: Option, + env: &BTreeMap, +) -> Option { + if crate::managed_agents::known_acp_runtime(command) + .is_some_and(|runtime| runtime.id == "buzz-agent") + { + env.get("BUZZ_AGENT_PROVIDER") + .cloned() + .or(configured_provider) + } else { + configured_provider + } +} + +pub(crate) fn canonicalize_openai_provider_env( + env: &mut BTreeMap, + provider: Option<&str>, +) -> Option { + let mut provider = provider.map(str::trim).map(str::to_ascii_lowercase); + if provider.as_deref() == Some("openai") { + let official_key = env + .get("OPENAI_API_KEY") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let legacy_key = env + .get("OPENAI_COMPAT_API_KEY") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let has_custom_origin = env + .get("OPENAI_COMPAT_BASE_URL") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value.trim_end_matches('/') != "https://api.openai.com/v1"); + if legacy_key.is_some() + && has_custom_origin + && (official_key.is_none() || official_key == legacy_key) + { + provider = Some("openai-compat".to_string()); + } + } + + match provider.as_deref() { + Some("openai") => { + env.remove("OPENAI_COMPAT_BASE_URL"); + env.remove("OPENAI_COMPAT_API_KEY"); + } + Some("openai-compat") => { + env.remove("OPENAI_API_KEY"); + env.entry("OPENAI_COMPAT_API_KEY".to_string()).or_default(); + } + _ => {} + } + if let (Some(value), Some(slot)) = (provider.as_ref(), env.get_mut("BUZZ_AGENT_PROVIDER")) { + *slot = value.clone(); + } + provider +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 536ea28937..6f96f69d46 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -269,7 +269,14 @@ fn resolve_effective_agent_env_with_def( ); env.extend(user_env); - let _ = canonicalize_openai_provider_env(&mut env, effective_provider.as_deref()); + if crate::managed_agents::known_acp_runtime(&effective_command) + .is_some_and(|runtime| runtime.id == "buzz-agent") + { + let _ = crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut env, + effective_provider.as_deref(), + ); + } // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. @@ -287,46 +294,6 @@ fn resolve_effective_agent_env_with_def( } } -pub(crate) fn canonicalize_openai_provider_env( - env: &mut BTreeMap, - provider: Option<&str>, -) -> Option { - let mut provider = provider.map(str::trim).map(str::to_ascii_lowercase); - if provider.as_deref() == Some("openai") { - let has_official_key = env - .get("OPENAI_API_KEY") - .is_some_and(|value| !value.trim().is_empty()); - let has_legacy_key = env - .get("OPENAI_COMPAT_API_KEY") - .is_some_and(|value| !value.trim().is_empty()); - let has_custom_origin = env - .get("OPENAI_COMPAT_BASE_URL") - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some_and(|value| value.trim_end_matches('/') != "https://api.openai.com/v1"); - if !has_official_key && has_legacy_key && has_custom_origin { - provider = Some("openai-compat".to_string()); - } - } - - match provider.as_deref() { - Some("openai") => { - env.remove("OPENAI_COMPAT_BASE_URL"); - env.remove("OPENAI_COMPAT_API_KEY"); - } - Some("openai-compat") => { - env.remove("OPENAI_API_KEY"); - env.entry("OPENAI_COMPAT_API_KEY".to_string()).or_default(); - } - _ => {} - } - if let (Some(value), Some(slot)) = (provider.as_ref(), env.get_mut("BUZZ_AGENT_PROVIDER")) { - *slot = value.clone(); - } - provider -} - // ── Requirement types ───────────────────────────────────────────────────────── /// A single missing piece of configuration, tagged with the UI surface that /// owns it so the UI can route each gap to the right affordance. diff --git a/desktop/src-tauri/src/managed_agents/readiness_openai_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_openai_tests.rs index 9a47dd5056..4787d9b4f6 100644 --- a/desktop/src-tauri/src/managed_agents/readiness_openai_tests.rs +++ b/desktop/src-tauri/src/managed_agents/readiness_openai_tests.rs @@ -22,7 +22,10 @@ fn provider_env_canonicalization_isolates_consumers() { ), ]); let mut official = shared.clone(); - canonicalize_openai_provider_env(&mut official, Some("openai")); + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut official, + Some("openai"), + ); assert_eq!( official.get("OPENAI_API_KEY").map(String::as_str), Some("official") @@ -31,7 +34,10 @@ fn provider_env_canonicalization_isolates_consumers() { assert!(!official.contains_key("OPENAI_COMPAT_BASE_URL")); let mut compatible = shared; - canonicalize_openai_provider_env(&mut compatible, Some("openai-compat")); + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut compatible, + Some("openai-compat"), + ); assert!(!compatible.contains_key("OPENAI_API_KEY")); assert_eq!( compatible.get("OPENAI_COMPAT_API_KEY").map(String::as_str), @@ -50,7 +56,11 @@ fn inherited_legacy_custom_configuration_normalizes_per_consumer() { ), ]); assert_eq!( - canonicalize_openai_provider_env(&mut env, Some("openai")).as_deref(), + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut env, + Some("openai") + ) + .as_deref(), Some("openai-compat") ); assert_eq!( @@ -60,13 +70,52 @@ fn inherited_legacy_custom_configuration_normalizes_per_consumer() { assert!(!env.contains_key("OPENAI_API_KEY")); } +#[test] +fn copied_official_key_does_not_mask_legacy_custom_identity() { + let mut env = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".into(), "openai".into()), + ("OPENAI_API_KEY".into(), "copied-legacy".into()), + ("OPENAI_COMPAT_API_KEY".into(), "copied-legacy".into()), + ( + "OPENAI_COMPAT_BASE_URL".into(), + "https://gateway.example/v1".into(), + ), + ]); + assert_eq!( + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut env, + Some("openai") + ) + .as_deref(), + Some("openai-compat") + ); + assert!(!env.contains_key("OPENAI_API_KEY")); +} + +#[test] +fn non_buzz_harness_keeps_its_configured_provider() { + let env = BTreeMap::from([("BUZZ_AGENT_PROVIDER".into(), "openai-compat".into())]); + assert_eq!( + crate::managed_agents::openai_env::effective_runtime_provider( + "goose", + Some("openai".into()), + &env, + ) + .as_deref(), + Some("openai") + ); +} + #[test] fn keyless_compatible_provider_gets_explicit_blank_credential() { let mut env = BTreeMap::from([( "OPENAI_COMPAT_BASE_URL".into(), "http://localhost:11434/v1".into(), )]); - canonicalize_openai_provider_env(&mut env, Some("openai-compat")); + crate::managed_agents::openai_env::canonicalize_openai_provider_env( + &mut env, + Some("openai-compat"), + ); assert_eq!( env.get("OPENAI_COMPAT_API_KEY").map(String::as_str), Some("") diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 9652e2a0bf..54d79a4a00 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use tauri::AppHandle; use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env}; - +use super::openai_env::effective_runtime_provider; use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, @@ -204,7 +204,7 @@ pub fn build_managed_agent_summary( personas, global_config, ); - let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg + let (effective_model, configured_provider, effective_prompt, model_source) = match effective_cfg { crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => { let source = cfg.model.source.clone(); @@ -293,6 +293,8 @@ pub fn build_managed_agent_summary( env: Default::default(), } }); + let effective_provider = + effective_runtime_provider(&descriptor.command, configured_provider, &descriptor.env); let effective_mcp_command = known_acp_runtime(&descriptor.command) .and_then(|r| r.mcp_command) .unwrap_or("") @@ -711,11 +713,9 @@ pub fn spawn_agent_child( let mesh_model_id = effective_cfg.relay_mesh_model_id(); let effective_prompt = effective_cfg.system_prompt.value; let effective_model = effective_cfg.model.value; - let effective_provider = descriptor - .env - .get("BUZZ_AGENT_PROVIDER") - .cloned() - .or(effective_cfg.provider.value); + let configured_provider = effective_cfg.provider.value; + let effective_provider = + effective_runtime_provider(&descriptor.command, configured_provider, &descriptor.env); if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 8a6f68a693..0e7d9fc086 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -291,12 +291,18 @@ pub(crate) fn prospective_spawn_config_snapshot( // definition) resolves as if all three were absent: `spawn_agent_child` // refuses to spawn an orphan regardless, and `eligible_restart_diff` // suppresses the badge for one. - let (prompt, model, provider) = match resolve_effective_config(record, personas, global) { - EffectiveConfigResult::Resolved(cfg) => { - (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) - } - EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), - }; + let (prompt, model, configured_provider) = + match resolve_effective_config(record, personas, global) { + EffectiveConfigResult::Resolved(cfg) => { + (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) + } + EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), + }; + let provider = crate::managed_agents::openai_env::effective_runtime_provider( + &descriptor.command, + configured_provider, + &descriptor.env, + ); SpawnConfigSnapshot::from_inputs(SpawnConfigInputs { record, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index b007e0b2ff..75bbc86182 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -34,6 +34,29 @@ fn snapshot( snapshot_with_policy(record, personas, teams, workspace_relay, global, false) } +#[test] +fn legacy_custom_buzz_agent_snapshot_uses_runtime_provider_identity() { + let mut rec = record(); + rec.runtime = Some("buzz-agent".into()); + rec.provider = Some("openai".into()); + rec.env_vars + .insert("OPENAI_COMPAT_API_KEY".into(), "shared-key".into()); + rec.env_vars.insert( + "OPENAI_COMPAT_BASE_URL".into(), + "https://gateway.example/v1".into(), + ); + let global = GlobalAgentConfig { + env_vars: BTreeMap::from([("OPENAI_API_KEY".into(), "shared-key".into())]), + ..Default::default() + }; + + let value = snapshot(&rec, &[], &[], "wss://ws.example", &global); + assert_eq!( + value.get("provider").and_then(serde_json::Value::as_str), + Some("openai-compat") + ); +} + /// `snapshot` with the fixed no-persona/no-team/default-global shape the effort /// tests share, so their call sites read as `snap(&record)` instead of wrapping. fn snap(record: &ManagedAgentRecord) -> serde_json::Value { diff --git a/desktop/src-tauri/src/migration/openai_credentials.rs b/desktop/src-tauri/src/migration/openai_credentials.rs index d9700dd36d..16ccef2c41 100644 --- a/desktop/src-tauri/src/migration/openai_credentials.rs +++ b/desktop/src-tauri/src/migration/openai_credentials.rs @@ -235,56 +235,79 @@ fn classify_endpoint(endpoint: Option<&str>) -> Result { }) } +fn is_buzz_agent_consumer(record: &ManagedAgentRecord, defs: &[AgentDefinition]) -> bool { + crate::managed_agents::known_acp_runtime(&crate::managed_agents::record_agent_command( + record, defs, + )) + .is_some_and(|runtime| runtime.id == "buzz-agent") +} + +fn global_defaults_target_buzz_agent(store: &Store) -> bool { + match store.global.preferred_runtime.as_deref().map(str::trim) { + Some("") | None => true, + Some(runtime_id) => crate::managed_agents::known_acp_runtime_exact(runtime_id) + .is_some_and(|runtime| runtime.id == "buzz-agent"), + } +} + fn collect_consumers(store: &Store) -> Result, String> { let defs = definitions(store); + let has_managed_buzz_agent = store + .records + .iter() + .filter(|record| !record.pubkey.is_empty()) + .any(|record| is_buzz_agent_consumer(record, &defs)); let mut consumers = Vec::new(); - if let Some(provider) = store - .global - .provider - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .filter(|value| { - matches!( - value.to_ascii_lowercase().as_str(), - "openai" | "openai-compat" - ) - }) - { - let endpoint = store + if !has_managed_buzz_agent && global_defaults_target_buzz_agent(store) { + if let Some(provider) = store .global - .env_vars - .get(OPENAI_COMPAT_BASE_URL) - .map(|value| ResolvedValue { - value: value.clone(), - owner: Owner::Global, + .provider + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "openai" | "openai-compat" + ) + }) + { + let endpoint = store + .global + .env_vars + .get(OPENAI_COMPAT_BASE_URL) + .map(|value| ResolvedValue { + value: value.clone(), + owner: Owner::Global, + }); + consumers.push(Consumer { + label: "global defaults".into(), + record_index: None, + provider: provider.to_ascii_lowercase(), + provider_owner: Owner::Global, + identity: classify_endpoint(endpoint.as_ref().map(|value| value.value.as_str()))?, + endpoint, + legacy_key: store + .global + .env_vars + .get(OPENAI_COMPAT_API_KEY) + .map(|value| ResolvedValue { + value: value.clone(), + owner: Owner::Global, + }), + official_key: store.global.env_vars.get(OPENAI_API_KEY).map(|value| { + ResolvedValue { + value: value.clone(), + owner: Owner::Global, + } + }), }); - consumers.push(Consumer { - label: "global defaults".into(), - record_index: None, - provider: provider.to_ascii_lowercase(), - provider_owner: Owner::Global, - identity: classify_endpoint(endpoint.as_ref().map(|value| value.value.as_str()))?, - endpoint, - legacy_key: store - .global - .env_vars - .get(OPENAI_COMPAT_API_KEY) - .map(|value| ResolvedValue { - value: value.clone(), - owner: Owner::Global, - }), - official_key: store - .global - .env_vars - .get(OPENAI_API_KEY) - .map(|value| ResolvedValue { - value: value.clone(), - owner: Owner::Global, - }), - }); + } } for (index, record) in store.records.iter().enumerate() { + if record.pubkey.is_empty() || !is_buzz_agent_consumer(record, &defs) { + continue; + } let def = selected_definition(record, &defs); if record.persona_id.is_some() && def.is_none() { return Err(format!( @@ -354,6 +377,7 @@ fn collect_consumers(store: &Store) -> Result, String> { }); } if consumers.is_empty() + && global_defaults_target_buzz_agent(store) && [OPENAI_COMPAT_API_KEY, OPENAI_COMPAT_BASE_URL] .iter() .any(|key| store.global.env_vars.contains_key(*key)) diff --git a/desktop/src-tauri/src/migration/openai_credentials_tests.rs b/desktop/src-tauri/src/migration/openai_credentials_tests.rs index f73cfebb9b..60d39c3f9b 100644 --- a/desktop/src-tauri/src/migration/openai_credentials_tests.rs +++ b/desktop/src-tauri/src/migration/openai_credentials_tests.rs @@ -239,6 +239,75 @@ fn unrelated_consumer_does_not_claim_global_openai_credential() { ); } +#[test] +fn shared_global_legacy_key_supports_official_and_custom_buzz_agents() { + let global = GlobalAgentConfig { + provider: Some("openai".into()), + env_vars: BTreeMap::from([(OPENAI_COMPAT_API_KEY.into(), "shared-key".into())]), + ..Default::default() + }; + let official = record("official", "official"); + let mut custom = record("custom", "custom"); + custom.env_vars.insert( + OPENAI_COMPAT_BASE_URL.into(), + "https://gateway.example/v1".into(), + ); + + let (store, before) = plan_store(Store { + global, + records: vec![official, custom], + }) + .unwrap(); + assert_eq!( + store + .global + .env_vars + .get(OPENAI_API_KEY) + .map(String::as_str), + Some("shared-key") + ); + verify_post_split(&before, &store).unwrap(); + + let defs = definitions(&store); + let custom_descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &store.records[1], + &defs, + &store.global, + ) + .unwrap(); + assert_eq!( + custom_descriptor + .env + .get("BUZZ_AGENT_PROVIDER") + .map(String::as_str), + Some("openai-compat") + ); + assert!(!custom_descriptor.env.contains_key(OPENAI_API_KEY)); +} + +#[test] +fn goose_records_are_not_openai_migration_consumers() { + let global = GlobalAgentConfig { + provider: Some("openai".into()), + preferred_runtime: Some("goose".into()), + env_vars: BTreeMap::from([ + (OPENAI_COMPAT_API_KEY.into(), "goose-owned".into()), + ( + OPENAI_COMPAT_BASE_URL.into(), + "https://gateway.example/v1".into(), + ), + ]), + ..Default::default() + }; + let mut goose = record("goose", "goose"); + goose.runtime = Some("goose".into()); + goose.provider = Some("openai".into()); + + let store = planned(global.clone(), vec![goose.clone()]).unwrap(); + assert_eq!(store.global, global); + assert_eq!(store.records[0].provider, goose.provider); +} + #[test] fn linked_provider_snapshot_is_ignored_and_definition_is_mutated() { let mut def = definition("shared");