diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index a2504db451..056beae05d 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -168,6 +168,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF | | Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder | | OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route | +| Together AI | `openai` | `POST {base}/chat/completions` | moonshotai/Kimi-K2.6, zai-org/GLM-5.2 | | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | | Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 | @@ -178,7 +179,17 @@ If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or ` By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway). -`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`. +Some providers appear in Buzz's own provider picker under their own name while still running over this transport. **Together AI** and **Buzz shared compute** are both presets: the desktop stores `together` / `relay-mesh` as the provider and rewrites the env to `BUZZ_AGENT_PROVIDER=openai` plus the preset's base URL when it spawns the agent, so the agent itself never sees those ids. Together's key is `TOGETHER_API_KEY` in the picker and is mapped onto `OPENAI_COMPAT_API_KEY` at spawn. Running `buzz-agent` standalone against Together is the same thing done by hand: + +```sh +BUZZ_AGENT_PROVIDER=openai \ +OPENAI_COMPAT_BASE_URL=https://api.together.ai/v1 \ +OPENAI_COMPAT_API_KEY="$TOGETHER_API_KEY" \ +OPENAI_COMPAT_MODEL=moonshotai/Kimi-K2.6 \ +buzz-agent +``` + +`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs` — but a provider that already speaks an existing dialect needs no arm at all, only a preset like the two above. ## MCP Servers diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 55bf334f28..669987023b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -173,7 +173,12 @@ const overrides = new Map([ // Windows Doctor install fix: cli_install_commands_windows field added to test stubs. // team-instructions-first-class: ManagedAgentRecord fixture gains the new // team_id field (+1 line). - ["src-tauri/src/managed_agents/readiness.rs", 1863], + // +74 (1863 -> 1937): Together AI provider — the buzz_agent_requirements and + // goose_requirements credential arms (TOGETHER_API_KEY, not the OPENAI_COMPAT + // name it is mapped onto at spawn) plus three readiness tests pinning that + // distinction. Provider-specific credential requirements are the purpose of + // this file; the split remains queued with the rest of the list. + ["src-tauri/src/managed_agents/readiness.rs", 1937], // Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering // .cmd shim rejection, .bat shim rejection, and .exe acceptance for // configure_runtime_cli (fix #2397). Test-only growth; queued to split. @@ -188,7 +193,11 @@ const overrides = new Map([ // definition-authoritative resolver comments grew it to 982, and this PR's // typed harness-descriptor resolution in spawn_agent_child (+38) lands on // top. Queued to shrink with the next runtime split pass (#2974 follow-up). - ["src-tauri/src/managed_agents/runtime.rs", 1020], + // +23 (1020 -> 1043): Together AI provider — the spawn-time env derivation + // block beside the existing relay-mesh one. The preset logic itself lives in + // managed_agents/together.rs; only the call site (which must run after the + // user env is written, and scrubs both OpenAI keys first) belongs here. + ["src-tauri/src/managed_agents/runtime.rs", 1043], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -552,7 +561,12 @@ const overrides = new Map([ // test-bindable seam (struct + helper + docs) so the linked-agent // regression test kills the stale-record mutation at get_agent_models' // consumption point (review finding, Wren + Dawn). - ["src-tauri/src/commands/agent_models.rs", 1152], + // +50 (1152 -> 1202): Together AI provider — OpenAiModelListResponse became + // an untagged enum because Together answers /v1/models with a bare array, + // OpenAiModelListItem gained its `type` and `display_name` fields, and + // discovery routes Together to its own base URL and credential name. This is + // the model-discovery boundary; queued to split with the modules above. + ["src-tauri/src/commands/agent_models.rs", 1202], // global-agent-config: get_agent_config_surface / write_agent_config_field / // put_agent_session_config commands + GlobalAgentConfig serde types. New file // in this PR; queued to split with the command module refactor. diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ca1fe9bdf6..273234c292 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -376,9 +376,22 @@ pub async fn discover_agent_models( run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await } +/// A `GET /v1/models` payload. OpenAI wraps the list in `{"data": [...]}`; +/// Together returns the bare array, so both shapes are accepted. #[derive(Debug, Deserialize)] -struct OpenAiModelListResponse { - data: Vec, +#[serde(untagged)] +enum OpenAiModelListResponse { + Wrapped { data: Vec }, + Bare(Vec), +} + +impl OpenAiModelListResponse { + fn into_items(self) -> Vec { + match self { + Self::Wrapped { data } => data, + Self::Bare(items) => items, + } + } } #[derive(Debug, Deserialize)] @@ -386,6 +399,17 @@ struct OpenAiModelListItem { id: String, #[serde(default)] created: Option, + /// Together-only: the endpoint's modality. Its catalog mixes chat with + /// image, audio, embedding, and rerank entries in one list. + #[serde(default, rename = "type")] + kind: Option, + /// Together-only: a readable name ("Kimi K2.6" for `moonshotai/Kimi-K2.6`). + #[serde(default)] + display_name: Option, +} + +fn is_together_provider(provider: Option<&str>) -> bool { + provider.map(str::trim) == Some(crate::managed_agents::TOGETHER_PROVIDER_ID) } fn is_openai_compatible_provider(provider: Option<&str>) -> bool { @@ -394,7 +418,7 @@ fn is_openai_compatible_provider(provider: Option<&str>) -> bool { .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), - Some("openai" | "openai-compat") + Some("openai" | "openai-compat" | "together") ) } @@ -492,7 +516,7 @@ fn normalize_openai_compatible_models( provider: Option<&str>, ) -> Vec { let mut seen = HashSet::new(); - let mut items = response.data; + let mut items = response.into_items(); let filter_to_openai_text_models = matches!( provider .map(str::trim) @@ -500,6 +524,13 @@ fn normalize_openai_compatible_models( .as_deref(), Some("openai") ); + // Together publishes every modality it serves — roughly two thousand + // endpoints — under one list. Only `chat` ones can back an agent, and the + // id-shape heuristic used for OpenAI does not apply to its namespaced ids + // (`moonshotai/Kimi-K2.6`), so filter on the declared type instead. + if is_together_provider(provider) { + items.retain(|item| item.kind.as_deref() == Some("chat")); + } let all_ids = items .iter() .map(|item| item.id.clone()) @@ -520,7 +551,14 @@ fn normalize_openai_compatible_models( }) .filter(|item| seen.insert(item.id.clone())) .map(|item| AgentModelInfo { - name: Some(openai_model_display_name(&item.id)), + name: Some( + item.display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| openai_model_display_name(&item.id)), + ), id: item.id, description: None, }) @@ -535,21 +573,33 @@ async fn discover_openai_compatible_models( ) -> Result, String> { let relay_mesh = provider.as_deref().map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); + let together = is_together_provider(provider.as_deref()); if !relay_mesh && !is_openai_compatible_provider(provider.as_deref()) { return Ok(None); } + // Each native preset keeps its own credential name. Discovery runs before + // spawn, so the OPENAI_COMPAT_* mapping in `apply_together_env` has not + // happened yet — asking for the mapped name would miss the key the user + // actually typed. + let api_key_env = if together { + crate::managed_agents::TOGETHER_API_KEY_ENV + } else { + "OPENAI_COMPAT_API_KEY" + }; let api_key = if relay_mesh { crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() } else { - match provider.required_env(env, "OPENAI_COMPAT_API_KEY")? { + match provider.required_env(env, api_key_env)? { Some(api_key) => api_key, None => return Ok(None), } }; - let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); + let redaction_env = redaction_env_with_value(env, api_key_env, &api_key); let url = if relay_mesh { format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL) + } else if together { + format!("{}/models", crate::managed_agents::TOGETHER_API_BASE_URL) } else { openai_compatible_models_url_for_discovery(env) }; diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d98460109f..da322850f7 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -1,38 +1,38 @@ use super::*; +/// An entry as OpenAI-shaped providers return it: id + creation time only. +fn model_item(id: &str, created: i64) -> OpenAiModelListItem { + OpenAiModelListItem { + id: id.to_string(), + created: Some(created), + kind: None, + display_name: None, + } +} + +/// An entry as Together returns it, with the modality and readable name that +/// only its catalog carries. +fn together_item(id: &str, created: i64, kind: &str, display_name: &str) -> OpenAiModelListItem { + OpenAiModelListItem { + id: id.to_string(), + created: Some(created), + kind: Some(kind.to_string()), + display_name: Some(display_name.to_string()), + } +} + #[test] fn openai_model_normalization_keeps_agent_text_models() { let models = normalize_openai_compatible_models( - OpenAiModelListResponse { + OpenAiModelListResponse::Wrapped { data: vec![ - OpenAiModelListItem { - id: "text-embedding-3-large".to_string(), - created: Some(4), - }, - OpenAiModelListItem { - id: "gpt-image-2".to_string(), - created: Some(5), - }, - OpenAiModelListItem { - id: "chatgpt-5.5-pro-2026-04-23".to_string(), - created: Some(7), - }, - OpenAiModelListItem { - id: "chatgpt-5.5-pro".to_string(), - created: Some(6), - }, - OpenAiModelListItem { - id: "gpt-5.4-mini".to_string(), - created: Some(2), - }, - OpenAiModelListItem { - id: "o4-mini".to_string(), - created: Some(3), - }, - OpenAiModelListItem { - id: "gpt-5.4-mini".to_string(), - created: Some(1), - }, + model_item("text-embedding-3-large", 4), + model_item("gpt-image-2", 5), + model_item("chatgpt-5.5-pro-2026-04-23", 7), + model_item("chatgpt-5.5-pro", 6), + model_item("gpt-5.4-mini", 2), + model_item("o4-mini", 3), + model_item("gpt-5.4-mini", 1), ], }, Some("openai"), @@ -58,28 +58,13 @@ fn openai_model_normalization_keeps_agent_text_models() { #[test] fn openai_compat_model_normalization_preserves_provider_specific_ids() { let models = normalize_openai_compatible_models( - OpenAiModelListResponse { + OpenAiModelListResponse::Wrapped { data: vec![ - OpenAiModelListItem { - id: "meta-llama/Llama-3.3-70B-Instruct".to_string(), - created: Some(5), - }, - OpenAiModelListItem { - id: "mistral-large-latest".to_string(), - created: Some(4), - }, - OpenAiModelListItem { - id: "anthropic/claude-sonnet-4-6".to_string(), - created: Some(3), - }, - OpenAiModelListItem { - id: "text-embedding-compatible".to_string(), - created: Some(2), - }, - OpenAiModelListItem { - id: "meta-llama/Llama-3.3-70B-Instruct".to_string(), - created: Some(1), - }, + model_item("meta-llama/Llama-3.3-70B-Instruct", 5), + model_item("mistral-large-latest", 4), + model_item("anthropic/claude-sonnet-4-6", 3), + model_item("text-embedding-compatible", 2), + model_item("meta-llama/Llama-3.3-70B-Instruct", 1), ], }, Some("openai-compat"), @@ -97,6 +82,58 @@ fn openai_compat_model_normalization_preserves_provider_specific_ids() { ); } +#[test] +fn together_model_list_is_parsed_from_a_bare_array() { + // Together answers `GET /v1/models` with the array itself rather than + // OpenAI's `{"data": [...]}` envelope. + let response: OpenAiModelListResponse = serde_json::from_str( + r#"[ + {"id":"zai-org/GLM-5.2","object":"model","created":1785026761,"type":"chat","display_name":"GLM 5.2"}, + {"id":"BAAI/bge-large-en-v1.5","object":"model","created":0,"type":"embedding","display_name":"BAAI-Bge-Large-1p5"} + ]"#, + ) + .expect("Together's bare array must parse"); + + assert_eq!(response.into_items().len(), 2); +} + +#[test] +fn together_model_normalization_keeps_only_chat_endpoints() { + // Together serves ~2k endpoints across every modality from one list, and + // its namespaced ids defeat the id-shape heuristic used for OpenAI. + let models = normalize_openai_compatible_models( + OpenAiModelListResponse::Bare(vec![ + together_item("zai-org/GLM-5.2", 3, "chat", "GLM 5.2"), + together_item("BAAI/bge-large-en-v1.5", 2, "embedding", "BAAI Bge Large"), + together_item("black-forest-labs/FLUX.2", 4, "image", "FLUX.2"), + together_item("moonshotai/Kimi-K2.6", 1, "chat", "Kimi K2.6 Fp4"), + ]), + Some("together"), + ); + + let ids_and_names = models + .into_iter() + .map(|model| (model.id, model.name)) + .collect::>(); + assert_eq!( + ids_and_names, + vec![ + ("zai-org/GLM-5.2".to_string(), Some("GLM 5.2".to_string())), + ( + "moonshotai/Kimi-K2.6".to_string(), + Some("Kimi K2.6 Fp4".to_string()), + ), + ] + ); +} + +#[test] +fn together_is_discovered_over_the_openai_compatible_transport() { + assert!(is_together_provider(Some("together"))); + assert!(is_openai_compatible_provider(Some("together"))); + assert!(!is_together_provider(Some("openai"))); +} + #[test] fn openai_models_url_uses_openai_default_base_url() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..d3551a6023 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -34,6 +34,7 @@ pub(crate) mod storage; pub(crate) mod team_events; mod team_repair; mod teams; +mod together; mod types; // Shared guard for tests that mutate or read process-global PATH. @@ -75,6 +76,7 @@ pub use runtime_commands::*; pub use runtime_types::*; pub use storage::*; pub use teams::*; +pub use together::*; pub use types::*; /// Returns the Buzz nest directory (`~/.buzz`) if it exists as a real diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c5480b2479..8f32ab9c95 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -515,6 +515,16 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "OPENAI_COMPAT_API_KEY".to_string(), }); } + // Together is an OpenAI-compatible preset, but the credential the user + // owns is TOGETHER_API_KEY — `apply_together_env` maps it onto + // OPENAI_COMPAT_API_KEY at spawn, so requiring the mapped name here + // would ask for a key the dialog never offers. + Some(crate::managed_agents::TOGETHER_PROVIDER_ID) + if env_key_missing(crate::managed_agents::TOGETHER_API_KEY_ENV) => { + missing.push(Requirement::EnvKey { + key: crate::managed_agents::TOGETHER_API_KEY_ENV.to_string(), + }); + } Some("databricks") | Some("databricks_v2") | Some("databricks-v2") // DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional // (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143). @@ -623,6 +633,14 @@ fn goose_requirements( key: "OPENAI_COMPAT_API_KEY".to_string(), }); } + Some(crate::managed_agents::TOGETHER_PROVIDER_ID) + if env_key_missing(crate::managed_agents::TOGETHER_API_KEY_ENV) + && !file_key_present(crate::managed_agents::TOGETHER_API_KEY_ENV) => + { + missing.push(Requirement::EnvKey { + key: crate::managed_agents::TOGETHER_API_KEY_ENV.to_string(), + }); + } Some("databricks") | Some("databricks_v2") | Some("databricks-v2") if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") => { @@ -734,6 +752,64 @@ mod tests { })); } + #[test] + fn buzz_agent_missing_together_key_asks_for_together_not_the_mapped_openai_key() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "together"), + ("BUZZ_AGENT_MODEL", "moonshotai/Kimi-K2.6"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + let reqs = result.requirements(); + assert!(reqs.contains(&Requirement::EnvKey { + key: "TOGETHER_API_KEY".to_string() + })); + // Asking for OPENAI_COMPAT_API_KEY would name a key the dialog never + // offers for this provider — the spawn-time mapping owns that name. + assert!( + !reqs.contains(&Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string() + }), + "together must not surface the mapped OpenAI key; got {reqs:?}" + ); + } + + #[test] + fn buzz_agent_together_with_key_and_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "together"), + ("BUZZ_AGENT_MODEL", "moonshotai/Kimi-K2.6"), + ("TOGETHER_API_KEY", "tgp-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + #[test] + fn buzz_agent_empty_string_together_key_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "together"), + ("BUZZ_AGENT_MODEL", "moonshotai/Kimi-K2.6"), + ("TOGETHER_API_KEY", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string TOGETHER_API_KEY must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "TOGETHER_API_KEY".to_string() + })); + } + #[test] fn buzz_agent_anthropic_with_all_fields_is_ready() { let env = make_env( diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 32b3b328e9..8bf83f3960 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -868,6 +868,29 @@ pub fn spawn_agent_child( } } + // Together AI is stored as a native provider for the same reason: derive the + // OpenAI-compatible transport at spawn time. Both OpenAI credentials are + // scrubbed first so an unrelated key left in the env or inherited from the + // shell can never be sent to Together's ingress — only the key + // `apply_together_env` writes back from TOGETHER_API_KEY survives. + if effective_provider.as_deref().map(str::trim) == Some(super::TOGETHER_PROVIDER_ID) { + let mut together_env = std::collections::BTreeMap::new(); + super::apply_together_env( + &mut together_env, + effective_provider.as_deref(), + effective_model.as_deref(), + descriptor + .env + .get(super::TOGETHER_API_KEY_ENV) + .map(String::as_str), + ); + command.env_remove("OPENAI_API_KEY"); + command.env_remove("OPENAI_COMPAT_API_KEY"); + for (key, value) in together_env { + command.env(key, value); + } + } + // Stamp desktop ownership and an unpredictable harness-generation identity. let start_nonce = uuid::Uuid::new_v4().simple().to_string(); command diff --git a/desktop/src-tauri/src/managed_agents/together.rs b/desktop/src-tauri/src/managed_agents/together.rs new file mode 100644 index 0000000000..949f4e9b62 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/together.rs @@ -0,0 +1,120 @@ +/// Provider id persisted on agent and global config, and shown in the picker. +pub const TOGETHER_PROVIDER_ID: &str = "together"; +/// Together's OpenAI-compatible ingress. Chat Completions only — Together has +/// no Responses endpoint, so `OPENAI_COMPAT_API` is pinned rather than left on +/// `auto`. +pub const TOGETHER_API_BASE_URL: &str = "https://api.together.ai/v1"; +/// The single user-owned input for this provider. Everything else about the +/// transport is derived. +pub const TOGETHER_API_KEY_ENV: &str = "TOGETHER_API_KEY"; + +/// Translate the Together AI provider into the OpenAI-compatible transport +/// understood by buzz-agent. Base URL, wire dialect, and the `OPENAI_COMPAT_*` +/// key names are derived runtime details, not user-owned agent configuration — +/// users only ever supply `TOGETHER_API_KEY` and a model. +/// +/// `OPENAI_COMPAT_API_KEY` is written only when Together supplied a key, so a +/// caller that scrubs it first cannot leak an unrelated OpenAI credential to +/// Together's ingress. +pub fn apply_together_env( + env: &mut std::collections::BTreeMap, + provider: Option<&str>, + model: Option<&str>, + api_key: Option<&str>, +) { + if provider.map(str::trim) != Some(TOGETHER_PROVIDER_ID) { + return; + } + env.insert("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()); + env.insert( + "OPENAI_COMPAT_BASE_URL".to_string(), + TOGETHER_API_BASE_URL.to_string(), + ); + env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); + if let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) { + env.insert("BUZZ_AGENT_MODEL".to_string(), model.to_string()); + env.insert("OPENAI_COMPAT_MODEL".to_string(), model.to_string()); + } + if let Some(api_key) = api_key.map(str::trim).filter(|value| !value.is_empty()) { + env.insert("OPENAI_COMPAT_API_KEY".to_string(), api_key.to_string()); + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn applied(model: Option<&str>, api_key: Option<&str>) -> BTreeMap { + let mut env = BTreeMap::new(); + apply_together_env(&mut env, Some(TOGETHER_PROVIDER_ID), model, api_key); + env + } + + #[test] + fn native_provider_maps_to_the_openai_chat_transport() { + let env = applied(Some("moonshotai/Kimi-K2.6"), Some("tgp-secret")); + + assert_eq!( + env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("openai") + ); + assert_eq!( + env.get("OPENAI_COMPAT_BASE_URL").map(String::as_str), + Some(TOGETHER_API_BASE_URL) + ); + assert_eq!( + env.get("OPENAI_COMPAT_API").map(String::as_str), + Some("chat") + ); + assert_eq!( + env.get("OPENAI_COMPAT_API_KEY").map(String::as_str), + Some("tgp-secret") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("moonshotai/Kimi-K2.6") + ); + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("moonshotai/Kimi-K2.6") + ); + } + + #[test] + fn other_providers_are_left_alone() { + let mut env = BTreeMap::new(); + apply_together_env(&mut env, Some("openai"), Some("gpt-5"), Some("sk-openai")); + assert!(env.is_empty()); + + apply_together_env(&mut env, None, Some("gpt-5"), Some("sk-openai")); + assert!(env.is_empty()); + } + + #[test] + fn a_missing_key_writes_no_credential_so_a_scrubbed_one_stays_scrubbed() { + for api_key in [None, Some(""), Some(" ")] { + let env = applied(Some("zai-org/GLM-5.2"), api_key); + assert!( + !env.contains_key("OPENAI_COMPAT_API_KEY"), + "api_key {api_key:?} must not produce a credential" + ); + // The rest of the transport is still derived, so the agent fails on + // the missing key rather than on an api.openai.com base URL. + assert_eq!( + env.get("OPENAI_COMPAT_BASE_URL").map(String::as_str), + Some(TOGETHER_API_BASE_URL) + ); + } + } + + #[test] + fn a_blank_model_leaves_model_selection_to_the_agents_own_resolution() { + for model in [None, Some(""), Some(" ")] { + let env = applied(model, Some("tgp-secret")); + assert!(!env.contains_key("OPENAI_COMPAT_MODEL")); + assert!(!env.contains_key("BUZZ_AGENT_MODEL")); + } + } +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..6492046846 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -35,6 +35,7 @@ import { CUSTOM_PROVIDER_DROPDOWN_VALUE, getPersonaProviderOptions, getProviderApiKeyEnvVar, + providerApiKeyFieldLabel, runtimeSupportsLlmProviderSelection, } from "@/features/agents/ui/agentConfigOptions"; import { @@ -754,11 +755,7 @@ export function AgentConfigFields({ } isInherited={apiKeyInherited} isRequired={!apiKeyInherited && apiKeyValue.length === 0} - label={ - effectiveProvider === "anthropic" - ? "Anthropic API Key" - : "OpenAI API Key" - } + label={providerApiKeyFieldLabel(effectiveProvider)} onValueChange={(value) => onConfigChange({ ...config, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 601d57f95d..70a6f9beda 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -75,7 +75,10 @@ import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; +import { + getProviderApiKeyEnvVar, + providerApiKeyFieldLabel, +} from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -1036,11 +1039,7 @@ export function AgentInstanceEditDialog({ isInherited={apiKeyIsInherited} inheritedLabel={apiKeyInheritedLabel} isRequired={apiKeyIsRequired} - label={ - effectiveProvider === "anthropic" - ? "Anthropic API Key" - : "OpenAI API Key" - } + label={providerApiKeyFieldLabel(effectiveProvider)} onValueChange={(next) => { setEnvVars((prev) => ({ ...prev, diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..f0bb406e87 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -23,6 +23,14 @@ export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet = new Set([ "databricks", ]); +/** + * Providers Buzz derives at spawn time for buzz-agent only, by rewriting + * `BUZZ_AGENT_PROVIDER` to an underlying transport. Nothing rewrites + * `GOOSE_PROVIDER`, and goose has no provider under these ids, so offering + * them for a goose agent would persist a provider goose rejects at startup. + */ +const BUZZ_AGENT_ONLY_PROVIDER_IDS: ReadonlySet = new Set(["together"]); + export const PERSONA_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; export const PERSONA_FIELD_CONTROL_CLASS = @@ -42,6 +50,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "databricks_v2", "openai", "openai-compat", + "together", ] as const; type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number]; @@ -95,6 +104,13 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"], secretEnvVar: "OPENAI_COMPAT_API_KEY", }, + // Together rides the OpenAI-compatible transport, but the key the user owns + // is TOGETHER_API_KEY — the backend maps it onto OPENAI_COMPAT_API_KEY at + // spawn (see managed_agents/together.rs). + together: { + requiredEnvKeys: ["TOGETHER_API_KEY"], + secretEnvVar: "TOGETHER_API_KEY", + }, databricks: { // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. requiredEnvKeys: ["DATABRICKS_HOST"], @@ -120,6 +136,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "anthropic", label: "Anthropic" }, { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, + { id: "together", label: "Together AI" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, @@ -279,15 +296,37 @@ export function providerRequiresExplicitModel( return ( trimmedProvider === "anthropic" || trimmedProvider === "openai" || - trimmedProvider === "openai-compat" + trimmedProvider === "openai-compat" || + // Together serves well over a thousand chat models and picks no default, + // so there is nothing sensible to fall back to. + trimmedProvider === "together" ); } +const PROVIDER_DISPLAY_LABELS: Readonly> = { + "relay-mesh": "Buzz shared compute", + together: "Together AI", +}; + export function providerDisplayLabel(providerId: string) { const trimmedProvider = providerId.trim(); - return trimmedProvider === "relay-mesh" - ? "Buzz shared compute" - : trimmedProvider; + return PROVIDER_DISPLAY_LABELS[trimmedProvider] ?? trimmedProvider; +} + +/** + * Label for the top-level API key field. Providers that ride the + * OpenAI-compatible transport still name their own credential, so the label + * follows the provider rather than the env var it is mapped onto. + */ +export function providerApiKeyFieldLabel(providerId: string) { + switch (providerId.trim()) { + case "anthropic": + return "Anthropic API Key"; + case "together": + return "Together AI API Key"; + default: + return "OpenAI API Key"; + } } export function getDefaultLlmProviderLabel( @@ -356,6 +395,11 @@ export function buildTemplateModelDropdownOptions( * Internal Block builds pass `BLOCK_BUILD_HIDDEN_PROVIDER_IDS` to hide the * legacy Databricks v1 option (the boot migration rewrites v1→v2 on those * builds). OSS builds pass an empty Set so v1 remains visible. + * + * `BUZZ_AGENT_ONLY_PROVIDER_IDS` is suppressed for provider-selecting runtimes + * other than buzz-agent, which is derived from `runtimeId` rather than passed + * in — those ids are meaningless to any runtime that misses the spawn-time + * rewrite, so no caller should have to remember to exclude them. */ export function getPersonaProviderOptions( currentProvider: string, @@ -367,9 +411,16 @@ export function getPersonaProviderOptions( const defaultProviderOptions = [ { id: "", label: getDefaultLlmProviderLabel(runtimeId, globalProvider) }, ]; - const filteredOptions = hideProviderIds?.size - ? PERSONA_LLM_PROVIDER_OPTIONS.filter((o) => !hideProviderIds.has(o.id)) - : PERSONA_LLM_PROVIDER_OPTIONS; + // A runtime that selects providers but is not buzz-agent (goose today) never + // receives the spawn-time rewrite these ids depend on. + const hidesBuzzAgentOnly = + runtimeSupportsLlmProviderSelection(runtimeId) && + runtimeId !== "buzz-agent"; + const filteredOptions = PERSONA_LLM_PROVIDER_OPTIONS.filter( + (o) => + !hideProviderIds?.has(o.id) && + !(hidesBuzzAgentOnly && BUZZ_AGENT_ONLY_PROVIDER_IDS.has(o.id)), + ); const options = [...defaultProviderOptions, ...filteredOptions]; if ( trimmedProvider.length === 0 || diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 6b797db1cf..361645c404 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -4,6 +4,9 @@ import test from "node:test"; import { runtimeSupportsLlmProviderSelection, getPersonaProviderOptions, + providerApiKeyFieldLabel, + providerDisplayLabel, + providerRequiresExplicitModel, requiredCredentialEnvKeys, isMissingRequiredDropdownField, } from "./agentConfigOptions.tsx"; @@ -111,6 +114,68 @@ test("editAgent_providerOptions_includesDatabricksV1AsCurrentEvenWhenHidden", () ); }); +// ── Together AI ───────────────────────────────────────────────────────────── +// +// Together is offered as a first-class option even though it runs over the +// OpenAI-compatible transport, so the credential it asks for and the labels it +// shows must follow Together rather than the env var it is mapped onto at +// spawn (see managed_agents/together.rs). + +test("editAgent_providerOptions_includesTogetherAi", () => { + const options = getPersonaProviderOptions("", "buzz-agent"); + const together = options.find((o) => o.id === "together"); + assert.ok(together, "together must be offered in the provider dropdown"); + assert.equal(together.label, "Together AI"); +}); + +test("together_usesItsOwnCredentialNotTheMappedOpenAiKey", () => { + assert.deepEqual(requiredCredentialEnvKeys("buzz-agent", "together"), [ + "TOGETHER_API_KEY", + ]); + assert.deepEqual(requiredCredentialEnvKeys("goose", "together"), [ + "TOGETHER_API_KEY", + ]); +}); + +test("together_labelsFollowTheProviderNotTheTransport", () => { + assert.equal(providerApiKeyFieldLabel("together"), "Together AI API Key"); + assert.equal(providerDisplayLabel("together"), "Together AI"); + // The refactor to a label table must not change the existing providers. + assert.equal(providerApiKeyFieldLabel("anthropic"), "Anthropic API Key"); + assert.equal(providerApiKeyFieldLabel("openai-compat"), "OpenAI API Key"); + assert.equal(providerDisplayLabel("relay-mesh"), "Buzz shared compute"); + assert.equal(providerDisplayLabel("databricks_v2"), "databricks_v2"); +}); + +test("together_requiresAnExplicitModel", () => { + // Together serves well over a thousand chat models and picks no default, so + // there is no "Default model" option to inherit. + assert.equal(providerRequiresExplicitModel("together"), true); +}); + +test("together_isNotOfferedToGooseWhichCannotReceiveTheRewrite", () => { + // Only buzz-agent gets BUZZ_AGENT_PROVIDER rewritten to the OpenAI transport + // at spawn. A goose agent would persist GOOSE_PROVIDER=together, which goose + // has no provider for, so the option must not be offered there. + const gooseIds = getPersonaProviderOptions("", "goose").map((o) => o.id); + assert.ok( + !gooseIds.includes("together"), + "together must be hidden for goose", + ); + // Providers goose really does support are unaffected. + for (const id of ["anthropic", "openai", "databricks_v2"]) { + assert.ok(gooseIds.includes(id), `${id} must remain available to goose`); + } +}); + +test("together_stillRendersAsCurrentOnAnAgentThatAlreadySavedIt", () => { + // Hiding an option must not blank out an agent already persisted with it. + const options = getPersonaProviderOptions("together", "goose"); + const tail = options.at(-1); + assert.equal(tail?.id, "together"); + assert.equal(tail?.label, "together (current)"); +}); + test("editAgent_providerOptions_includesDefaultEntry", () => { const options = getPersonaProviderOptions("", "buzz-agent"); // The first entry is the default (empty id) — clearing back to runtime default. diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index c6991fdd01..9f3f15b4d7 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -25,6 +25,8 @@ function providerObjectLabel(provider: string): string { return "OpenAI"; case "openai-compat": return "OpenAI-compatible"; + case "together": + return "Together AI"; default: return provider.trim() || "this provider"; } @@ -115,6 +117,13 @@ export function formatModelDiscoveryErrorStatus( }; } + if (message.includes("TOGETHER_API_KEY required")) { + return { + message: "Enter a Together AI API key to load Together AI models.", + tone: "warning", + }; + } + if ( message.includes("DATABRICKS_HOST required") || message.includes("DATABRICKS_MODEL required") ||