Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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<dyn>`, 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<dyn>`, 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

Expand Down
20 changes: 17 additions & 3 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 57 additions & 7 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,16 +376,40 @@ 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<OpenAiModelListItem>,
#[serde(untagged)]
enum OpenAiModelListResponse {
Wrapped { data: Vec<OpenAiModelListItem> },
Bare(Vec<OpenAiModelListItem>),
}

impl OpenAiModelListResponse {
fn into_items(self) -> Vec<OpenAiModelListItem> {
match self {
Self::Wrapped { data } => data,
Self::Bare(items) => items,
}
}
}

#[derive(Debug, Deserialize)]
struct OpenAiModelListItem {
id: String,
#[serde(default)]
created: Option<i64>,
/// 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<String>,
/// Together-only: a readable name ("Kimi K2.6" for `moonshotai/Kimi-K2.6`).
#[serde(default)]
display_name: Option<String>,
}

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 {
Expand All @@ -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")
)
}

Expand Down Expand Up @@ -492,14 +516,21 @@ fn normalize_openai_compatible_models(
provider: Option<&str>,
) -> Vec<AgentModelInfo> {
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)
.map(str::to_ascii_lowercase)
.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())
Expand All @@ -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,
})
Expand All @@ -535,21 +573,33 @@ async fn discover_openai_compatible_models(
) -> Result<Option<AgentModelsResponse>, 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)
};
Expand Down
137 changes: 87 additions & 50 deletions desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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"),
Expand All @@ -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::<Vec<_>>();
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!(
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading