From c927e7d7c1b3d122570ea355659e4c35b416dd38 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 26 Jul 2026 18:48:36 -0700 Subject: [PATCH 1/2] feat(desktop): add Together AI as a provider option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Together speaks stock OpenAI Chat Completions, so it needs no new wire dialect in buzz-agent — only the preset that relay-mesh already established: store `together` as the provider and derive BUZZ_AGENT_PROVIDER=openai plus Together's base URL at spawn. The credential stays TOGETHER_API_KEY everywhere the user can see it (picker, readiness gate, model discovery) and is mapped onto OPENAI_COMPAT_API_KEY only at spawn, so nothing asks for a key the dialog never offers. Both OpenAI keys are scrubbed before the mapped one is written back, so an unrelated key in the env cannot reach Together. Model discovery needed two accommodations: Together answers /v1/models with a bare array rather than OpenAI's {"data": [...]} envelope, and its catalog mixes ~2k endpoints across every modality, so only entries declaring type "chat" are offered. Signed-off-by: jordan Co-authored-by: Cursor --- crates/buzz-agent/README.md | 13 +- desktop/scripts/check-file-sizes.mjs | 20 ++- .../src-tauri/src/commands/agent_models.rs | 64 +++++++- .../src/commands/agent_models_tests.rs | 137 +++++++++++------- desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/readiness.rs | 76 ++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 23 +++ .../src-tauri/src/managed_agents/together.rs | 120 +++++++++++++++ .../features/agents/ui/AgentConfigFields.tsx | 7 +- .../agents/ui/AgentInstanceEditDialog.tsx | 11 +- .../features/agents/ui/agentConfigOptions.tsx | 39 ++++- .../ui/editAgentProviderDiscovery.test.mjs | 42 ++++++ .../agents/ui/personaModelDiscoveryStatus.ts | 9 ++ 13 files changed, 487 insertions(+), 76 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/together.rs 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..bc81ef0f3b 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -42,6 +42,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "databricks_v2", "openai", "openai-compat", + "together", ] as const; type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number]; @@ -95,6 +96,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 +128,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 +288,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( diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 6b797db1cf..6c50129c80 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,45 @@ 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("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") || From 156ef946e4cc03099e137fa0f031e673a032f7fc Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 26 Jul 2026 19:59:06 -0700 Subject: [PATCH 2/2] fix(desktop): do not offer Together AI to goose agents Only buzz-agent gets BUZZ_AGENT_PROVIDER rewritten to Together's OpenAI-compatible transport at spawn. Nothing rewrites GOOSE_PROVIDER, and goose ships no provider under this id, so a goose agent configured with Together would persist GOOSE_PROVIDER=together and fail at startup. Gate the option on the runtime inside getPersonaProviderOptions rather than at each picker, so none of the four call sites has to remember the exclusion. The (current) tail still renders, so an agent already saved with Together keeps showing its value instead of appearing unset. This mirrors the existing relay-mesh treatment in AgentConfigFields, which hides Buzz shared compute for the same reason. Signed-off-by: jordan Co-authored-by: Cursor --- .../features/agents/ui/agentConfigOptions.tsx | 26 ++++++++++++++++--- .../ui/editAgentProviderDiscovery.test.mjs | 23 ++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index bc81ef0f3b..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 = @@ -387,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, @@ -398,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 6c50129c80..361645c404 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -153,6 +153,29 @@ test("together_requiresAnExplicitModel", () => { 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.