feat(desktop): provider-agnostic model selection + databricks discovery (#1307)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-29 22:41:07 -04:00
committed by GitHub
co-authored by npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw
parent 2561cbd069
commit eacbbe880a
19 changed files with 1046 additions and 361 deletions
+6 -6
View File
@@ -129,17 +129,17 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| Variable | Default | Notes |
|---|---|---|
| `BUZZ_AGENT_PROVIDER` | — | `anthropic`, `openai`, `databricks`, or `databricks_v2`. If unset, or if `anthropic`/`openai` is selected but its API key is missing, Databricks is auto-selected when `DATABRICKS_HOST` + `DATABRICKS_MODEL` are set. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic unless Databricks fallback is configured. |
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai unless Databricks fallback is configured. |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
| `DATABRICKS_HOST` | — | Required when provider=databricks or when using Databricks fallback. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or when using Databricks fallback. |
| `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
| `BUZZ_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
| `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
@@ -172,7 +172,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| 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 |
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent automatically falls back to Databricks OAuth when `DATABRICKS_HOST` and `DATABRICKS_MODEL` are set. The same Databricks fallback applies when `BUZZ_AGENT_PROVIDER` is unset. Explicit Anthropic/OpenAI API keys always win.
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.
`provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format).
+116
View File
@@ -44,6 +44,15 @@ const BROWSER_AUTH_TIMEOUT: Duration = Duration::from_secs(60);
pub trait TokenSource: Send + Sync {
async fn bearer(&self) -> Result<String, AgentError>;
/// Return a bearer token from cache or refresh, **never** opening a browser.
///
/// The default delegates to [`bearer`](Self::bearer) — correct for token
/// sources (e.g. static API keys) that can never trigger a browser flow.
/// [`PkceOAuthTokenSource`] overrides this to stop before the browser step.
async fn bearer_no_browser(&self) -> Result<String, AgentError> {
self.bearer().await
}
/// Force a fresh bearer after the server rejected the current one (401).
///
/// `rejected` is the exact access token that just got the 401. Unlike
@@ -287,6 +296,10 @@ impl TokenSource for PkceOAuthTokenSource {
Ok(bearer)
}
async fn bearer_no_browser(&self) -> Result<String, AgentError> {
self.try_bearer_no_browser().await
}
/// Force-refresh after a 401, never touching the browser flow.
///
/// `rejected` is the access token the server just 401'd. Coalescing keys
@@ -344,6 +357,68 @@ impl TokenSource for PkceOAuthTokenSource {
}
}
impl PkceOAuthTokenSource {
/// Return a bearer token from cache or refresh, **never** opening a browser.
///
/// Follows the same steps as [`bearer`](TokenSource::bearer) but stops at
/// step 4 — if no usable token is available after cache + refresh attempts,
/// returns `Err(LlmAuth(...))` instead of launching the browser PKCE flow.
/// Used by model-discovery paths that must not block on user interaction.
pub(crate) async fn try_bearer_no_browser(&self) -> Result<String, AgentError> {
let mut state = self.state.lock().await;
// 1. In-memory cache hit, still fresh.
if let Some(tok) = state.as_ref() {
if !is_expired(tok) {
return Ok(tok.access_token.clone());
}
}
// 2. Re-read disk — another process may have refreshed already.
if let Some(disk_tok) = read_cache(&self.cache_path) {
if !is_expired(&disk_tok) {
let bearer = disk_tok.access_token.clone();
*state = Some(disk_tok);
return Ok(bearer);
}
}
// 3. Try refresh if we have a refresh token. Endpoints are discovered
// lazily here — only when a refresh token is actually present — so
// that an unreachable OIDC discovery URL cannot prevent the
// no-token/no-cache path from returning `LlmAuth` (graceful
// fallback) instead of `Llm` (hard error).
let refresh = state.as_ref().and_then(|t| t.refresh_token.clone());
if let Some(rt) = refresh {
let endpoints = self.endpoints().await?;
match self.refresh(&endpoints, &rt).await {
Ok(fresh) => {
let bearer = fresh.access_token.clone();
self.save(&mut state, fresh)?;
return Ok(bearer);
}
Err(e) => {
tracing::warn!(error = %e, "oauth refresh failed during model discovery");
}
}
// 4. Re-read disk after refresh failure.
if let Some(disk_tok) = read_cache(&self.cache_path) {
if !is_expired(&disk_tok) {
let bearer = disk_tok.access_token.clone();
*state = Some(disk_tok);
return Ok(bearer);
}
}
}
// No usable token — return error instead of opening a browser.
Err(AgentError::LlmAuth(
"no cached Databricks token; run `buzz-agent auth databricks` first".into(),
))
}
}
// ---- helpers -------------------------------------------------------------
/// Aborts a spawned task when dropped. Used to guarantee the localhost
@@ -726,4 +801,45 @@ mod tests {
"expected discovery error, got: {err_msg}"
);
}
/// `try_bearer_no_browser` with an empty cache and no refresh token must
/// return `LlmAuth` immediately — it must NOT attempt OIDC discovery even
/// when the `discovery_url` is unreachable/invalid. This guards the
/// regression where `endpoints()` was called unconditionally before the
/// refresh-token check, causing an `Llm` error (hard failure) instead of
/// the intended graceful `LlmAuth` fallback.
#[tokio::test]
async fn test_try_bearer_no_browser_empty_cache_no_refresh_returns_llm_auth_without_discovery()
{
let dir = tempfile::tempdir().unwrap();
// Intentionally invalid/unreachable discovery URL — if endpoints() is
// called, the test will get an `Llm` error and the assertion below fails.
let cfg = PkceOAuthConfig {
discovery_url: "https://invalid.example.test/.well-known/oauth-authorization-server"
.into(),
client_id: "test-client".into(),
scopes: vec!["offline_access".into()],
cache_namespace: "test".into(),
cache_dir_override: Some(dir.path().to_path_buf()),
};
let source = PkceOAuthTokenSource::new(cfg).unwrap();
// Empty in-memory state (no token, no refresh token).
{
let mut state = source.state.lock().await;
*state = None;
}
// No disk cache file either — dir is empty.
let result = source.try_bearer_no_browser().await;
assert!(result.is_err(), "expected Err, got Ok");
match result.unwrap_err() {
AgentError::LlmAuth(_) => {} // correct: graceful fallback
other => panic!(
"expected LlmAuth (no discovery attempted), got: {other:?}\n\
This means endpoints() was called before the refresh-token check."
),
}
}
}
+369
View File
@@ -0,0 +1,369 @@
//! Databricks model catalog discovery.
//!
//! Exposes [`discover_databricks_models`] — an async helper that lists
//! available models for the `databricks` and `databricks_v2` providers
//! without triggering a browser OAuth flow. Auth is acquired in-process via
//! [`build_token_source`](crate::llm::build_token_source):
//!
//! - Static bearer (`DATABRICKS_TOKEN`): returned immediately.
//! - PKCE cache hit: returned from disk without a network round-trip.
//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)` — the
//! caller degrades gracefully; no browser, no hang.
use reqwest::Client;
use crate::{
config::{Config, Provider},
llm::build_token_source,
types::AgentError,
};
/// A discovered model entry: `id` is the picker value, `name` is the display
/// label (same as `id` for Databricks — the API has no separate display name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelEntry {
pub id: String,
pub name: String,
}
/// Known Databricks AI Gateway v2 models — used as a fallback when the
/// `api/ai-gateway/v2/endpoints` call returns an empty list.
/// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`.
pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] =
&["databricks-gpt-5-5", "databricks-claude-opus-4-7"];
/// Discover available models for a Databricks provider.
///
/// Returns a non-empty `Vec<ModelEntry>` on success. Returns
/// `Err(AgentError::LlmAuth)` when no token is available (no static token,
/// no PKCE cache) — callers should degrade gracefully rather than hanging.
///
/// # Panics
/// Never panics.
pub async fn discover_databricks_models(cfg: &Config) -> Result<Vec<ModelEntry>, AgentError> {
let token_source = build_token_source(cfg)?;
let bearer = token_source.bearer_no_browser().await?;
let http = Client::new();
let host = cfg.base_url.trim_end_matches('/');
match cfg.provider {
Provider::Databricks => fetch_v1_models(&http, host, &bearer).await,
Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await,
_ => Err(AgentError::InvalidParams(
"discover_databricks_models called for non-Databricks provider".into(),
)),
}
}
// ---------------------------------------------------------------------------
// v1 — api/2.0/serving-endpoints
// ---------------------------------------------------------------------------
async fn fetch_v1_models(
http: &Client,
host: &str,
bearer: &str,
) -> Result<Vec<ModelEntry>, AgentError> {
let url = format!("{host}/api/2.0/serving-endpoints");
let response = http
.get(&url)
.bearer_auth(bearer)
.send()
.await
.map_err(|e| AgentError::Llm(format!("Databricks model discovery request failed: {e}")))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(AgentError::Llm(format!(
"Databricks model discovery HTTP {status}: {body}"
)));
}
let json: serde_json::Value = response.json().await.map_err(|e| {
AgentError::Llm(format!(
"Databricks model discovery response parse failed: {e}"
))
})?;
parse_v1_endpoints(&json)
}
/// Parse a `GET api/2.0/serving-endpoints` response.
///
/// Filters to endpoints that are READY and serve an LLM chat/completions task.
/// When `state.ready` or `task` is absent the endpoint is included — prefer
/// including over silently dropping, per spec.
pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result<Vec<ModelEntry>, AgentError> {
let endpoints = json
.get("endpoints")
.and_then(|v| v.as_array())
.ok_or_else(|| {
AgentError::Llm(
"Databricks model discovery: unexpected response (missing 'endpoints' array)"
.into(),
)
})?;
let models = endpoints
.iter()
.filter_map(|endpoint| {
let name = endpoint.get("name")?.as_str()?.to_string();
// Require READY state when present; include when absent.
let state_ready = endpoint
.get("state")
.and_then(|s| s.get("ready"))
.and_then(|r| r.as_str())
.map(|r| r == "READY")
.unwrap_or(true);
if !state_ready {
return None;
}
// Require LLM chat or completions task when present.
let task_ok = endpoint
.get("task")
.and_then(|t| t.as_str())
.map(|t| t == "llm/v1/chat" || t == "llm/v1/completions")
.unwrap_or(true);
if !task_ok {
return None;
}
Some(ModelEntry {
id: name.clone(),
name,
})
})
.collect();
Ok(models)
}
// ---------------------------------------------------------------------------
// v2 — api/ai-gateway/v2/endpoints (paginated)
// ---------------------------------------------------------------------------
/// Percent-encode a string for use as a URL query parameter value.
/// Only encodes characters that are not unreserved (RFC 3986).
fn percent_encode(s: &str) -> String {
s.bytes()
.flat_map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![b as char]
}
_ => format!("%{b:02X}").chars().collect(),
})
.collect()
}
async fn fetch_v2_models(
http: &Client,
host: &str,
bearer: &str,
) -> Result<Vec<ModelEntry>, AgentError> {
let mut all_models: Vec<ModelEntry> = Vec::new();
let mut page_token: Option<String> = None;
let base_url = format!("{host}/api/ai-gateway/v2/endpoints");
// Cap at 20 pages (2 000 endpoints) to bound execution time.
for _ in 0..20 {
// Build URL with query params manually — avoids requiring the `query`
// reqwest feature in buzz-agent's Cargo.toml.
let url = match &page_token {
Some(tok) => format!(
"{base_url}?page_size=100&page_token={}",
percent_encode(tok)
),
None => format!("{base_url}?page_size=100"),
};
let response = http
.get(&url)
.bearer_auth(bearer)
.send()
.await
.map_err(|e| {
AgentError::Llm(format!("Databricks v2 model discovery request failed: {e}"))
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(AgentError::Llm(format!(
"Databricks v2 model discovery HTTP {status}: {body}"
)));
}
let json: serde_json::Value = response.json().await.map_err(|e| {
AgentError::Llm(format!(
"Databricks v2 model discovery response parse failed: {e}"
))
})?;
let (page_models, next) = parse_v2_endpoints_page(&json)?;
all_models.extend(page_models);
match next {
Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok),
_ => break,
}
}
// Fall back to known-model list if the API returned nothing.
if all_models.is_empty() {
all_models = DATABRICKS_V2_KNOWN_MODELS
.iter()
.map(|id| ModelEntry {
id: id.to_string(),
name: id.to_string(),
})
.collect();
}
Ok(all_models)
}
/// Parse one page of a `GET api/ai-gateway/v2/endpoints` response.
///
/// Returns `(models, next_page_token)`. An empty or absent `next_page_token`
/// signals the last page.
pub(crate) fn parse_v2_endpoints_page(
json: &serde_json::Value,
) -> Result<(Vec<ModelEntry>, Option<String>), AgentError> {
let endpoints = json
.get("endpoints")
.and_then(|v| v.as_array())
.ok_or_else(|| {
AgentError::Llm(
"Databricks v2 model discovery: unexpected response (missing 'endpoints' array)"
.into(),
)
})?;
let models = endpoints
.iter()
.filter_map(|endpoint| {
let name = endpoint.get("name")?.as_str()?.to_string();
Some(ModelEntry {
id: name.clone(),
name,
})
})
.collect();
let next_page_token = json
.get("next_page_token")
.and_then(|v| v.as_str())
.filter(|token| !token.is_empty())
.map(str::to_string);
Ok((models, next_page_token))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v1_parse_filters_ready_chat_endpoints() {
let json = serde_json::json!({
"endpoints": [
// included: READY + llm/v1/chat
{"name": "my-llm", "state": {"ready": "READY"}, "task": "llm/v1/chat"},
// included: READY + llm/v1/completions
{"name": "my-completions", "state": {"ready": "READY"}, "task": "llm/v1/completions"},
// excluded: NOT_READY
{"name": "dead-endpoint", "state": {"ready": "NOT_READY"}, "task": "llm/v1/chat"},
// excluded: wrong task
{"name": "embedding-ep", "state": {"ready": "READY"}, "task": "llm/v1/embedding"},
// included: no state field → include by default
{"name": "no-state", "task": "llm/v1/chat"},
// included: no task field → include by default
{"name": "no-task", "state": {"ready": "READY"}},
]
});
let models = parse_v1_endpoints(&json).unwrap();
let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, vec!["my-llm", "my-completions", "no-state", "no-task"]);
}
#[test]
fn v1_parse_errors_on_missing_endpoints_array() {
let json = serde_json::json!({"data": []});
let err = parse_v1_endpoints(&json).unwrap_err();
assert!(
err.to_string().contains("missing 'endpoints' array"),
"got: {err}"
);
}
#[test]
fn v1_parse_empty_endpoints_returns_empty_vec() {
let json = serde_json::json!({"endpoints": []});
let models = parse_v1_endpoints(&json).unwrap();
assert!(models.is_empty());
}
#[test]
fn v2_parse_extracts_names_and_page_token() {
let json = serde_json::json!({
"endpoints": [
{"name": "databricks-claude-opus-4-7"},
{"name": "databricks-gpt-5-5"},
{"name": "custom-model"}
],
"next_page_token": "tok123"
});
let (models, next) = parse_v2_endpoints_page(&json).unwrap();
let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec![
"databricks-claude-opus-4-7",
"databricks-gpt-5-5",
"custom-model"
]
);
assert_eq!(next.as_deref(), Some("tok123"));
}
#[test]
fn v2_parse_empty_token_signals_last_page() {
let json = serde_json::json!({
"endpoints": [{"name": "only-model"}],
"next_page_token": ""
});
let (models, next) = parse_v2_endpoints_page(&json).unwrap();
assert_eq!(models.len(), 1);
assert!(
next.is_none(),
"empty token should be treated as no more pages"
);
}
#[test]
fn v2_parse_absent_token_signals_last_page() {
let json = serde_json::json!({"endpoints": [{"name": "only-model"}]});
let (_, next) = parse_v2_endpoints_page(&json).unwrap();
assert!(next.is_none());
}
#[test]
fn v2_parse_errors_on_missing_endpoints_array() {
let json = serde_json::json!({"data": []});
let err = parse_v2_endpoints_page(&json).unwrap_err();
assert!(
err.to_string().contains("missing 'endpoints' array"),
"got: {err}"
);
}
}
+89 -108
View File
@@ -104,13 +104,12 @@ impl Config {
env("BUZZ_AGENT_PROVIDER").as_deref(),
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
databricks_host.as_deref(),
databricks_model.as_deref(),
)?;
// Universal model override — any provider will use this when its own
// model env var is absent. Useful for wrapper scripts that set a single
// var regardless of which provider is active.
// Universal model override — takes priority over provider-specific model
// env vars (ANTHROPIC_MODEL, OPENAI_COMPAT_MODEL, DATABRICKS_MODEL) when
// present. Set by the desktop from the persona/record to express explicit
// user intent; provider-specific vars serve as defaults for CLI/standalone use.
let buzz_agent_model = env("BUZZ_AGENT_MODEL");
// OPENAI_COMPAT_API is only read when provider=openai, so a stray
@@ -123,8 +122,8 @@ impl Config {
Provider::Anthropic => (
req("ANTHROPIC_API_KEY")?,
resolve_model(
env("ANTHROPIC_MODEL").as_deref(),
buzz_agent_model.as_deref(),
env("ANTHROPIC_MODEL").as_deref(),
)
.ok_or_else(|| "config: ANTHROPIC_MODEL required".to_string())?,
env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
@@ -133,8 +132,8 @@ impl Config {
Provider::OpenAi => (
req("OPENAI_COMPAT_API_KEY")?,
resolve_model(
env("OPENAI_COMPAT_MODEL").as_deref(),
buzz_agent_model.as_deref(),
env("OPENAI_COMPAT_MODEL").as_deref(),
)
.ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?,
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
@@ -142,7 +141,7 @@ impl Config {
),
Provider::Databricks | Provider::DatabricksV2 => (
env("DATABRICKS_TOKEN").unwrap_or_default(),
resolve_model(databricks_model.as_deref(), buzz_agent_model.as_deref())
resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref())
.ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?,
databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?,
OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch
@@ -193,6 +192,43 @@ impl Config {
Ok(cfg)
}
/// Construct a minimal `Config` for model-catalog discovery.
///
/// Only the fields used by [`build_token_source`](crate::llm::build_token_source)
/// and the catalog HTTP helpers are meaningful; all others are set to
/// inert defaults. Never call `from_env` for discovery — it requires
/// `DATABRICKS_MODEL` and other fields that are irrelevant here.
pub fn for_discovery(provider: Provider, api_key: String, base_url: String) -> Self {
Self {
provider,
api_key,
base_url,
model: String::new(),
system_prompt: String::new(),
anthropic_api_version: "2023-06-01".into(),
openai_api: OpenAiApi::Chat,
max_rounds: 0,
max_output_tokens: 1,
llm_timeout: Duration::from_secs(30),
tool_timeout: Duration::from_secs(30),
mcp_init_timeout: Duration::from_secs(30),
mcp_max_restart_attempts: 0,
mcp_restart_base_ms: 0,
mcp_restart_max_ms: 0,
max_sessions: 1,
max_line_bytes: 4 * 1024 * 1024,
max_history_bytes: 16 * 1024 * 1024,
max_tool_result_text_bytes: 50 * 1024,
max_context_tokens: 200_001,
max_handoffs: 0,
max_parallel_tools: 1,
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
hook_servers: HookServers::None,
hints_enabled: false,
}
}
fn validate(&self) -> Result<(), String> {
const MIN_HISTORY_BYTES: usize = 4096;
const MIN_LINE_BYTES: usize = 1024;
@@ -271,54 +307,38 @@ fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}
/// Returns the first of `provider_model` or `universal_fallback` that is
/// `Some`, converting to an owned `String`. Returns `None` when both are
/// absent so the caller can supply a provider-specific error message.
fn resolve_model(provider_model: Option<&str>, universal_fallback: Option<&str>) -> Option<String> {
provider_model.or(universal_fallback).map(str::to_owned)
/// Returns the first present value. `explicit_override` (BUZZ_AGENT_MODEL,
/// set by the desktop from the persona/record) wins over `provider_default`
/// (provider-specific env var that may be inherited from the shell).
/// Returns `None` when both are absent so the caller can supply a
/// provider-specific error message.
fn resolve_model(
explicit_override: Option<&str>,
provider_default: Option<&str>,
) -> Option<String> {
explicit_override.or(provider_default).map(str::to_owned)
}
fn present_nonempty(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|s| !s.is_empty())
}
fn databricks_available(host: Option<&str>, model: Option<&str>) -> bool {
present_nonempty(host) && present_nonempty(model)
}
fn resolve_provider(
requested: Option<&str>,
anthropic_key: Option<&str>,
openai_key: Option<&str>,
databricks_host: Option<&str>,
databricks_model: Option<&str>,
) -> Result<Provider, String> {
let databricks_ready = databricks_available(databricks_host, databricks_model);
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
let normalized = raw.to_ascii_lowercase();
match normalized.as_str() {
"anthropic" if present_nonempty(anthropic_key) => Ok(Provider::Anthropic),
"anthropic" if databricks_ready => {
tracing::warn!(
requested = raw,
"API key missing for requested provider; falling back to Databricks OAuth"
);
Ok(Provider::Databricks)
}
"anthropic" => Err(
"config: ANTHROPIC_API_KEY required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
"config: ANTHROPIC_API_KEY required".into(),
),
"openai" | "openai-compat" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
"openai" | "openai-compat" if databricks_ready => {
tracing::warn!(
requested = raw,
"API key missing for requested provider; falling back to Databricks OAuth"
);
Ok(Provider::Databricks)
}
"openai" | "openai-compat" => Err(
"config: OPENAI_COMPAT_API_KEY required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
"config: OPENAI_COMPAT_API_KEY required".into(),
),
"databricks" => Ok(Provider::Databricks),
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
@@ -327,9 +347,8 @@ fn resolve_provider(
)),
}
}
None if databricks_ready => Ok(Provider::Databricks),
None => Err(
"config: BUZZ_AGENT_PROVIDER required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
"config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, databricks)".into(),
),
}
}
@@ -540,89 +559,51 @@ mod tests {
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
resolve_provider(
Some("anthropic"),
Some("sk-ant"),
None,
Some("https://dbc.example"),
Some("db-model")
)
.unwrap(),
resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(
Some("openai"),
None,
Some("sk-openai"),
Some("https://dbc.example"),
Some("db-model")
)
.unwrap(),
resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(),
Provider::OpenAi
);
}
#[test]
fn resolve_provider_falls_back_to_databricks_when_requested_token_missing() {
assert_eq!(
resolve_provider(
Some("anthropic"),
None,
None,
Some("https://dbc.example"),
Some("goose-claude-4-6-sonnet")
)
.unwrap(),
Provider::Databricks
);
assert_eq!(
resolve_provider(
Some("openai-compat"),
None,
Some(" "),
Some("https://dbc.example"),
Some("goose-claude-4-6-sonnet")
)
.unwrap(),
Provider::Databricks
);
fn resolve_provider_errors_when_requested_provider_key_missing() {
// No fallback — missing key returns an error regardless of Databricks availability.
let err = resolve_provider(Some("anthropic"), None, None).unwrap_err();
assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}");
let err = resolve_provider(Some("openai-compat"), None, Some(" ")).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
}
#[test]
fn resolve_provider_can_auto_select_databricks_without_explicit_provider() {
assert_eq!(
resolve_provider(
None,
None,
None,
Some("https://dbc.example"),
Some("goose-claude-4-6-sonnet")
)
.unwrap(),
Provider::Databricks
);
fn resolve_provider_errors_when_provider_env_absent() {
// No implicit inference — absent BUZZ_AGENT_PROVIDER is an error.
let err = resolve_provider(None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_requires_databricks_host_and_model_for_fallback() {
let err = resolve_provider(
Some("openai"),
None,
None,
Some("https://dbc.example"),
None,
)
.unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"));
let err =
resolve_provider(None, None, None, Some("https://dbc.example"), None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER required"));
// Renamed: verify the explicit databricks provider path works correctly.
// When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless
// of DATABRICKS_HOST/MODEL (those are validated later in from_env()).
assert_eq!(
resolve_provider(Some("databricks"), None, None).unwrap(),
Provider::Databricks
);
// Missing key for other providers still errors — no Databricks fallback.
let err = resolve_provider(Some("openai"), None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
let err = resolve_provider(None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_unsupported_error_preserves_user_casing() {
let err = resolve_provider(Some("OpenAIish"), None, None, None, None).unwrap_err();
let err = resolve_provider(Some("OpenAIish"), None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}
@@ -645,15 +626,15 @@ mod tests {
}
#[test]
fn resolve_model_prefers_provider_specific() {
let result = resolve_model(Some("anthropic-model"), Some("universal-model"));
assert_eq!(result.as_deref(), Some("anthropic-model"));
fn resolve_model_prefers_explicit_override() {
let result = resolve_model(Some("override-model"), Some("provider-model"));
assert_eq!(result.as_deref(), Some("override-model"));
}
#[test]
fn resolve_model_falls_back_to_universal() {
let result = resolve_model(None, Some("universal-model"));
assert_eq!(result.as_deref(), Some("universal-model"));
fn resolve_model_falls_back_to_provider_default() {
let result = resolve_model(None, Some("provider-model"));
assert_eq!(result.as_deref(), Some("provider-model"));
}
#[test]
+7 -2
View File
@@ -2,14 +2,19 @@
mod agent;
pub mod auth;
mod builtin;
mod config;
pub mod catalog;
pub mod config;
mod handoff;
mod hints;
mod llm;
mod mcp;
mod types;
pub mod types;
mod wire;
pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS};
pub use config::Provider;
pub use types::AgentError;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
+1 -1
View File
@@ -982,7 +982,7 @@ where
/// Otherwise a `PkceOAuthTokenSource` pointed at the workspace's OIDC
/// discovery URL. First request without a cached token triggers a browser
/// flow; subsequent requests use the cache + refresh transparently.
fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
match cfg.provider {
Provider::Anthropic | Provider::OpenAi => {
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
+5
View File
@@ -132,6 +132,11 @@ const overrides = new Map([
["src/shared/ui/markdown.tsx", 2082],
["src/shared/ui/VideoPlayer.tsx", 2199],
["src/shared/ui/sidebar.tsx", 1042],
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules.
["src-tauri/src/commands/agent_models.rs", 1066],
]);
await runFileSizeCheck({
+56
View File
@@ -535,6 +535,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -553,8 +555,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1 0.10.6",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
"tower-layer",
"tower-service",
@@ -580,6 +584,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "backon"
version = "1.6.0"
@@ -824,6 +839,30 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "buzz-agent"
version = "0.1.0"
dependencies = [
"arc-swap",
"async-trait",
"axum",
"base64 0.22.1",
"getrandom 0.4.2",
"hex",
"nix 0.31.3",
"reqwest 0.13.4",
"rmcp",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"tokio",
"tracing",
"tracing-subscriber",
"urlencoding",
"webbrowser",
]
[[package]]
name = "buzz-core"
version = "0.1.0"
@@ -853,6 +892,7 @@ dependencies = [
"audioadapter-buffers",
"axum",
"base64 0.22.1",
"buzz-agent",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -10471,6 +10511,22 @@ dependencies = [
"string_cache_codegen",
]
[[package]]
name = "webbrowser"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
dependencies = [
"core-foundation 0.10.1",
"jni 0.22.4",
"log",
"ndk-context",
"objc2",
"objc2-foundation",
"url",
"web-sys",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
+2
View File
@@ -24,6 +24,7 @@ mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"]
system-keyring = ["dep:keyring"]
[build-dependencies]
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-build = { version = "2", features = [] }
@@ -82,6 +83,7 @@ url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
base64 = "0.22"
+41 -6
View File
@@ -2,13 +2,16 @@
// so the build-time validation below and the runtime parse cannot drift.
include!("src/commands/reconnect_hook_config.rs");
use base64::Engine as _;
fn main() {
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_HOST");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_MODEL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_PROVIDER");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
@@ -20,12 +23,44 @@ fn main() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_HTTP={relay_http}");
}
if let Ok(host) = std::env::var("BUZZ_BUILD_DATABRICKS_HOST") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DATABRICKS_HOST={host}");
if let Ok(provider) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_PROVIDER") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER={provider}");
}
if let Ok(model) = std::env::var("BUZZ_BUILD_DATABRICKS_MODEL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DATABRICKS_MODEL={model}");
if let Ok(model) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_MODEL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL={model}");
}
// Generic KEY=VALUE pairs to inject into every spawned agent process.
// Newline-delimited; each line must be non-empty and contain exactly one
// `=` separator with a non-empty key. OSS builds leave this unset.
// The validated value is base64-encoded before emitting so the single-line
// Cargo build-script output carries all pairs (Cargo output is line-oriented;
// a raw multiline value would be silently truncated to the first line).
if let Ok(raw) = std::env::var("BUZZ_BUILD_AGENT_ENV") {
for (line_no, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let eq = line.find('=').unwrap_or_else(|| {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: missing '=' separator in {:?}",
line_no + 1,
line
)
});
let key = &line[..eq];
if key.is_empty() {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: key must not be empty in {:?}",
line_no + 1,
line
);
}
}
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}");
}
if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") {
+114 -4
View File
@@ -112,6 +112,17 @@ pub async fn get_agent_models(
return Ok(models);
}
if let Some(models) = discover_databricks_models(
&state.http_client,
effective_provider.as_deref(),
&merged_env,
persisted_model.clone(),
)
.await?
{
return Ok(models);
}
run_agent_models_command(
resolved_acp,
agent_command,
@@ -234,6 +245,17 @@ pub async fn discover_agent_models(
return Ok(models);
}
if let Some(models) = discover_databricks_models(
&state.http_client,
input.provider.as_deref(),
&merged_env,
None,
)
.await?
{
return Ok(models);
}
run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await
}
@@ -603,6 +625,96 @@ async fn discover_anthropic_models(
}))
}
// ---------------------------------------------------------------------------
// Databricks model discovery (v1 + v2)
// ---------------------------------------------------------------------------
//
// Delegates to buzz_agent_pkg::catalog::discover_databricks_models, which
// acquires auth in-process via build_token_source:
// - Static bearer (DATABRICKS_TOKEN): returned immediately.
// - PKCE cache hit: returned from disk without a browser flow.
// - No token, no cache: returns Err(LlmAuth) → we return Ok(None) and fall
// through to run_agent_models_command. Never hangs, never opens a browser.
fn is_databricks_provider(provider: Option<&str>) -> bool {
matches!(
provider
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("databricks" | "databricks_v2")
)
}
fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider {
if provider.trim().eq_ignore_ascii_case("databricks_v2") {
buzz_agent_pkg::config::Provider::DatabricksV2
} else {
buzz_agent_pkg::config::Provider::Databricks
}
}
async fn discover_databricks_models(
_client: &reqwest::Client,
provider: Option<&str>,
env: &BTreeMap<String, String>,
selected_model: Option<String>,
) -> Result<Option<AgentModelsResponse>, String> {
let provider_str = match provider {
Some(p) if is_databricks_provider(Some(p)) => p,
_ => return Ok(None),
};
let host = match env_or_process_value(env, "DATABRICKS_HOST") {
Some(h) => h,
None => return Ok(None), // no host → fall through to subprocess
};
// api_key = DATABRICKS_TOKEN (empty string = use PKCE cache).
let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default();
let agent_provider = databricks_agent_provider(provider_str);
let cfg = buzz_agent_pkg::config::Config::for_discovery(agent_provider, api_key, host);
// Build a redaction env so the token never appears in surfaced errors.
let token_for_redact = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default();
let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &token_for_redact);
let entries = match buzz_agent_pkg::discover_databricks_models(&cfg).await {
Ok(e) => e,
Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => {
// No token + no PKCE cache → fall through to subprocess.
return Ok(None);
}
Err(e) => {
let msg = crate::managed_agents::redact_env_values_in(&e.to_string(), &redaction_env);
return Err(format!("Databricks model discovery failed: {msg}"));
}
};
if entries.is_empty() {
return Err("Databricks model discovery returned no models".to_string());
}
let models = entries
.into_iter()
.map(|e| AgentModelInfo {
id: e.id,
name: Some(e.name),
description: None,
})
.collect();
Ok(Some(AgentModelsResponse {
agent_name: provider_str.trim().to_string(),
agent_version: "models-api".to_string(),
models,
agent_default_model: None,
selected_model,
supports_switching: true,
}))
}
async fn run_agent_models_command(
resolved_acp: PathBuf,
agent_command: String,
@@ -637,11 +749,9 @@ async fn run_agent_models_command(
}
}
}
// Mirror runtime spawn: internal builds may bake Databricks host/model
// Mirror runtime spawn: internal builds may bake provider/model
// defaults. User-provided env below still wins.
for (key, value) in crate::managed_agents::build_databricks_defaults() {
cmd.env(key, value);
}
crate::managed_agents::build_buzz_agent_provider_defaults(&mut cmd);
// User env layering — written LAST so it overrides any Buzz-set env above.
for (k, v) in &merged_env {
cmd.env(k, v);
@@ -211,3 +211,19 @@ fn saved_agent_model_discovery_uses_record_snapshot() {
);
assert!(!config.env.contains_key("BUZZ_PRIVATE_KEY"));
}
// ---------------------------------------------------------------------------
// Databricks provider detection
// ---------------------------------------------------------------------------
//
// Parse/filter/pagination tests live in crates/buzz-agent/src/catalog.rs
// (they moved there with the Option C refactor).
#[test]
fn is_databricks_provider_matches_both_variants() {
assert!(is_databricks_provider(Some("databricks")));
assert!(is_databricks_provider(Some("databricks_v2")));
assert!(is_databricks_provider(Some(" DATABRICKS ")));
assert!(!is_databricks_provider(Some("anthropic")));
assert!(!is_databricks_provider(None));
}
@@ -0,0 +1,191 @@
//! Build-time agent env passthrough.
//!
//! Internal builds (buzz-releases) bake arbitrary `KEY=VALUE` pairs into the
//! binary via `BUZZ_BUILD_AGENT_ENV` (base64-encoded, newline-delimited).
//! OSS builds leave the compile-time var unset — nothing is injected.
use base64::Engine as _;
/// Inject baked-in provider/model defaults and generic env pairs onto `cmd`.
///
/// Call this BEFORE writing record/persona metadata env vars so that the
/// record's explicit choices (written after) override the baked defaults.
/// User-supplied `record.env_vars` (written last) always win.
pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command) {
if let Some(provider) = option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER") {
if !provider.is_empty() {
cmd.env("BUZZ_AGENT_PROVIDER", provider);
}
}
if let Some(model) = option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL") {
if !model.is_empty() {
cmd.env("BUZZ_AGENT_MODEL", model);
}
}
if let Some(raw) = option_env!("BUZZ_DESKTOP_BUILD_AGENT_ENV") {
// The value was base64-encoded at build time so the single-line Cargo
// output carries all KEY=VALUE pairs without truncation.
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw.as_bytes()) {
if let Ok(text) = std::str::from_utf8(&decoded) {
for (key, value) in parse_agent_env_lines(text) {
cmd.env(key, value);
}
}
}
}
}
/// Parse newline-delimited `KEY=VALUE` lines from a baked env blob.
/// Blank lines are skipped. Each non-blank line must contain `=`; the key
/// is everything before the first `=`, the value is everything after (values
/// may themselves contain `=`). Lines with an empty key are skipped.
pub(crate) fn parse_agent_env_lines(raw: &str) -> Vec<(&str, &str)> {
raw.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let eq = line.find('=')?;
let key = &line[..eq];
if key.is_empty() {
return None;
}
Some((key, &line[eq + 1..]))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{build_buzz_agent_provider_defaults, parse_agent_env_lines};
#[test]
fn buzz_agent_provider_defaults_empty_in_oss_build() {
// OSS (and normal test) builds set neither BUZZ_BUILD_BUZZ_AGENT_*,
// so nothing is baked in and no BUZZ_AGENT_* is injected on spawn.
let mut cmd = std::process::Command::new("env");
cmd.env_clear();
build_buzz_agent_provider_defaults(&mut cmd);
let output = cmd.output().expect("env should run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("BUZZ_AGENT_PROVIDER="),
"BUZZ_AGENT_PROVIDER should not be injected in OSS builds"
);
assert!(
!stdout.contains("BUZZ_AGENT_MODEL="),
"BUZZ_AGENT_MODEL should not be injected in OSS builds"
);
assert!(
!stdout.contains("DATABRICKS_HOST="),
"DATABRICKS_HOST should not be injected in OSS builds"
);
}
#[test]
fn parse_agent_env_lines_splits_on_first_equals() {
// Value may itself contain `=` — only the first `=` is the separator.
let pairs = parse_agent_env_lines("DATABRICKS_HOST=https://host.example.com/path?a=1");
assert_eq!(
pairs,
vec![("DATABRICKS_HOST", "https://host.example.com/path?a=1")]
);
}
#[test]
fn parse_agent_env_lines_multiple_pairs() {
let raw = "KEY_A=value_a\nKEY_B=value_b";
let pairs = parse_agent_env_lines(raw);
assert_eq!(pairs, vec![("KEY_A", "value_a"), ("KEY_B", "value_b")]);
}
#[test]
fn parse_agent_env_lines_skips_blank_lines() {
let raw = "KEY_A=val_a\n\n \nKEY_B=val_b";
let pairs = parse_agent_env_lines(raw);
assert_eq!(pairs, vec![("KEY_A", "val_a"), ("KEY_B", "val_b")]);
}
#[test]
fn parse_agent_env_lines_skips_line_without_equals() {
// A malformed line (no `=`) is silently skipped — build.rs validates at
// compile time; runtime parsing is defensive.
let raw = "NO_EQUALS_HERE\nGOOD=value";
let pairs = parse_agent_env_lines(raw);
assert_eq!(pairs, vec![("GOOD", "value")]);
}
#[test]
fn parse_agent_env_lines_skips_empty_key() {
// `=value` has an empty key — skip it.
let raw = "=orphan_value\nGOOD=value";
let pairs = parse_agent_env_lines(raw);
assert_eq!(pairs, vec![("GOOD", "value")]);
}
#[test]
fn parse_agent_env_lines_empty_value_is_allowed() {
// `KEY=` is valid — empty value is intentional (clears an env var).
let pairs = parse_agent_env_lines("EMPTY=");
assert_eq!(pairs, vec![("EMPTY", "")]);
}
#[test]
fn parse_agent_env_lines_empty_input_returns_empty() {
assert!(parse_agent_env_lines("").is_empty());
assert!(parse_agent_env_lines(" \n \n").is_empty());
}
// ── base64 round-trip regression ─────────────────────────────────────
//
// Cargo build-script output is line-oriented: a raw multiline value emitted
// via `cargo:rustc-env=KEY=...` would be truncated to the first line.
// build.rs base64-encodes the validated value; runtime.rs decodes it.
// This test verifies that a 2-pair value with a URL containing `=` survives
// the encode→decode→parse round-trip and both pairs land correctly.
#[test]
fn parse_agent_env_lines_base64_round_trip_preserves_all_pairs() {
use base64::Engine as _;
let raw =
"DATABRICKS_HOST=https://host.example.com/path?a=1&b=2\nDATABRICKS_MODEL=some-model";
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let decoded_bytes = base64::engine::general_purpose::STANDARD
.decode(encoded.as_bytes())
.expect("decode should succeed");
let decoded = std::str::from_utf8(&decoded_bytes).expect("utf8 should be valid");
let pairs = parse_agent_env_lines(decoded);
assert_eq!(pairs.len(), 2, "both pairs must survive the round-trip");
assert_eq!(
pairs[0],
("DATABRICKS_HOST", "https://host.example.com/path?a=1&b=2")
);
assert_eq!(pairs[1], ("DATABRICKS_MODEL", "some-model"));
}
// ── baked defaults ordering regression ───────────────────────────────
//
// `build_buzz_agent_provider_defaults` must run BEFORE
// `runtime_metadata_env_vars` writes the record's provider/model so that
// record values win (last-write-wins). This test simulates the ordering by
// writing the baked default first, then overwriting with the record value.
#[test]
fn baked_defaults_do_not_override_record_provider_written_after() {
let mut cmd = std::process::Command::new("env");
cmd.env_clear();
// Simulate what an internal build's baked defaults would inject.
cmd.env("BUZZ_AGENT_PROVIDER", "databricks");
// Simulate what runtime_metadata_env_vars writes from the record (comes after).
cmd.env("BUZZ_AGENT_PROVIDER", "anthropic");
let output = cmd.output().expect("env should run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("BUZZ_AGENT_PROVIDER=anthropic"),
"record provider must win over baked default (last-write-wins)"
);
assert!(
!stdout.contains("BUZZ_AGENT_PROVIDER=databricks"),
"baked default must not survive when record provider is written after"
);
}
}
@@ -1,4 +1,6 @@
mod agent_env;
pub(crate) mod agent_events;
pub(crate) use agent_env::build_buzz_agent_provider_defaults;
mod backend;
pub(crate) mod config_bridge;
mod discovery;
@@ -2,6 +2,8 @@ use std::collections::HashMap;
use tauri::AppHandle;
use super::agent_env::build_buzz_agent_provider_defaults;
use crate::{
managed_agents::{
append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path,
@@ -1796,6 +1798,10 @@ pub fn spawn_agent_child(
} else {
command.env_remove("BUZZ_ACP_MODEL");
}
// Baked-in provider defaults for internal builds (buzz-releases sets
// BUZZ_BUILD_BUZZ_AGENT_* at compile time; OSS builds bake nothing).
// Written FIRST so that record/persona metadata env vars below override them.
build_buzz_agent_provider_defaults(&mut command);
if let Some(meta) = runtime_meta {
for (key, value) in runtime_metadata_env_vars(
meta.model_env_var,
@@ -1870,13 +1876,6 @@ pub fn spawn_agent_child(
);
}
// Baked-in Databricks defaults for internal builds (buzz-releases sets
// BUZZ_BUILD_DATABRICKS_* at compile time; OSS builds bake nothing).
// Written BEFORE user env_vars so a GUI/persona override still wins.
for (key, value) in build_databricks_defaults() {
command.env(key, value);
}
// ── User env vars: the record snapshot ─────────────────────────────
//
// The record's `env_vars` is the complete, pinned env map — persona env
@@ -1953,23 +1952,6 @@ fn child_rust_log_filter() -> String {
}
}
/// Databricks host/model baked in at compile time for internal builds. Empty
/// in OSS builds, where the `BUZZ_BUILD_DATABRICKS_*` env is unset.
pub(crate) fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> {
let mut defaults = Vec::new();
if let Some(host) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_HOST") {
if !host.is_empty() {
defaults.push(("DATABRICKS_HOST", host));
}
}
if let Some(model) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_MODEL") {
if !model.is_empty() {
defaults.push(("DATABRICKS_MODEL", model));
}
}
defaults
}
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
@@ -86,13 +86,6 @@ fn buzz_agent_has_mcp_hooks() {
assert_eq!(p.mcp_command, Some("buzz-dev-mcp"));
}
#[test]
fn databricks_defaults_empty_in_oss_build() {
// OSS (and normal test) builds set neither BUZZ_BUILD_DATABRICKS_*,
// so nothing is baked in and no DATABRICKS_* is injected on spawn.
assert!(super::build_databricks_defaults().is_empty());
}
#[test]
fn buzz_agent_resolved_via_path() {
assert!(known_acp_runtime("/usr/local/bin/buzz-agent").is_some_and(|p| p.mcp_hooks));
@@ -16,6 +16,7 @@ export const NO_RUNTIME_DROPDOWN_VALUE = "__no_runtime__";
const KNOWN_LLM_PROVIDER_IDS = [
"anthropic",
"databricks",
"databricks_v2",
"openai",
"openai-compat",
] as const;
@@ -25,7 +26,6 @@ type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number];
export type PersonaModelOption = {
id: string;
label: string;
providers?: readonly PersonaLlmProviderId[];
};
export type PersonaDropdownOption = {
@@ -45,144 +45,20 @@ const DEFAULT_MODEL_OPTION: PersonaModelOption = {
label: "Default model",
};
const DATABRICKS_DEFAULT_MODEL_ID = "goose-claude-4-8-opus";
const DATABRICKS_DEFAULT_MODEL_LABEL = "Claude Opus 4.8";
const DATABRICKS_DEFAULT_MODEL_OPTION: PersonaModelOption = {
id: "",
label: `${DATABRICKS_DEFAULT_MODEL_LABEL} (default)`,
};
// Databricks IDs are sourced from squareup/goose-releases goose_models.json.
// `goose-claude-4-8-opus` is also the current Buzz internal build default in
// squareup/buzz-releases, though it is ahead of that registry today.
const BUZZ_AGENT_MODEL_OPTIONS: readonly PersonaModelOption[] = [
DATABRICKS_DEFAULT_MODEL_OPTION,
{
id: "goose-claude-4-8-opus",
label: "Claude Opus 4.8",
providers: ["databricks"],
},
{
id: "goose-claude-4-7-opus",
label: "Claude Opus 4.7",
providers: ["databricks"],
},
{
id: "goose-claude-4-6-opus",
label: "Claude Opus 4.6",
providers: ["anthropic", "databricks"],
},
{
id: "goose-claude-4-6-sonnet",
label: "Claude Sonnet 4.6",
providers: ["anthropic", "databricks"],
},
{
id: "goose-claude-4-5-opus",
label: "Claude Opus 4.5",
providers: ["databricks"],
},
{
id: "goose-claude-4-5-sonnet",
label: "Claude Sonnet 4.5",
providers: ["databricks"],
},
{
id: "goose-claude-4-5-haiku",
label: "Claude Haiku 4.5",
providers: ["databricks"],
},
{
id: "goose-gpt-5-2",
label: "GPT-5.2",
providers: ["databricks"],
},
{
id: "databricks-gpt-5-5-pro",
label: "GPT-5.5 Pro",
providers: ["databricks"],
},
{
id: "databricks-gpt-5-5",
label: "GPT-5.5",
providers: ["databricks"],
},
{
id: "gpt-5.5",
label: "GPT-5.5",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5.4",
label: "GPT-5.4",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5.4-mini",
label: "GPT-5.4 mini",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5.4-nano",
label: "GPT-5.4 nano",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5",
label: "GPT-5",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5-mini",
label: "GPT-5 mini",
providers: ["openai", "openai-compat"],
},
{
id: "goose-gemini-3-5-flash",
label: "Gemini 3.5 Flash",
providers: ["databricks"],
},
{
id: "goose-gemini-3-1-pro",
label: "Gemini 3.1 Pro",
providers: ["databricks"],
},
{
id: "goose-gemini-3-1-flash-lite",
label: "Gemini 3.1 Flash Lite",
providers: ["databricks"],
},
{
id: "goose-gemini-2-5-pro",
label: "Gemini 2.5 Pro",
providers: ["databricks"],
},
{
id: "gemini-2.5-pro",
label: "Gemini 2.5 Pro",
providers: ["openai-compat"],
},
{
id: "gemini-2.5-flash",
label: "Gemini 2.5 Flash",
providers: ["openai-compat"],
},
];
const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [
{ id: "anthropic", label: "Anthropic" },
{ id: "openai", label: "OpenAI" },
{ id: "openai-compat", label: "OpenAI-compatible" },
{ id: "databricks", label: "Databricks" },
{ id: "databricks_v2", label: "Databricks v2" },
];
const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record<
string,
readonly PersonaModelOption[]
> = {
goose: BUZZ_AGENT_MODEL_OPTIONS,
"buzz-agent": BUZZ_AGENT_MODEL_OPTIONS,
goose: [DEFAULT_MODEL_OPTION],
"buzz-agent": [DEFAULT_MODEL_OPTION],
claude: [DEFAULT_MODEL_OPTION],
codex: [DEFAULT_MODEL_OPTION],
};
@@ -199,12 +75,8 @@ function isKnownLlmProvider(
return (KNOWN_LLM_PROVIDER_IDS as readonly string[]).includes(providerId);
}
function runtimeDefaultsToDatabricks(runtimeId: string) {
return runtimeId === "buzz-agent" || runtimeId === "goose";
}
export function runtimeSupportsLlmProviderSelection(runtimeId: string) {
return runtimeDefaultsToDatabricks(runtimeId);
return runtimeId === "buzz-agent" || runtimeId === "goose";
}
function effectiveModelProviderForOptions(
@@ -218,11 +90,7 @@ function effectiveModelProviderForOptions(
return "";
}
const trimmedProvider = providerId?.trim() ?? "";
if (trimmedProvider.length === 0 && runtimeDefaultsToDatabricks(runtimeId)) {
return "databricks";
}
return trimmedProvider;
return providerId?.trim() ?? "";
}
export function getPersonaModelOptions(
@@ -243,10 +111,7 @@ export function getPersonaModelOptions(
return options.filter(
(option) =>
(option.id.length === 0 &&
!providerRequiresExplicitModel(trimmedProvider)) ||
(option.id !== DATABRICKS_DEFAULT_MODEL_ID &&
option.providers?.includes(trimmedProvider)),
option.id.length === 0 && !providerRequiresExplicitModel(trimmedProvider),
);
}
@@ -299,10 +164,8 @@ export function providerRequiresExplicitModel(
);
}
export function getDefaultLlmProviderLabel(runtimeId: string) {
return runtimeDefaultsToDatabricks(runtimeId)
? "Databricks (default)"
: "Default";
export function getDefaultLlmProviderLabel(_runtimeId: string) {
return "Default";
}
export function getPersonaProviderOptions(
@@ -310,16 +173,10 @@ export function getPersonaProviderOptions(
runtimeId: string,
): readonly PersonaModelOption[] {
const trimmedProvider = currentProvider.trim();
const providerOptions = runtimeDefaultsToDatabricks(runtimeId)
? PERSONA_LLM_PROVIDER_OPTIONS.filter((option) =>
trimmedProvider === "databricks" ? true : option.id !== "databricks",
)
: PERSONA_LLM_PROVIDER_OPTIONS;
const defaultProviderOptions =
runtimeDefaultsToDatabricks(runtimeId) && trimmedProvider === "databricks"
? []
: [{ id: "", label: getDefaultLlmProviderLabel(runtimeId) }];
const options = [...defaultProviderOptions, ...providerOptions];
const defaultProviderOptions = [
{ id: "", label: getDefaultLlmProviderLabel(runtimeId) },
];
const options = [...defaultProviderOptions, ...PERSONA_LLM_PROVIDER_OPTIONS];
if (
trimmedProvider.length === 0 ||
options.some((option) => option.id === trimmedProvider)
@@ -53,7 +53,7 @@ export function formatModelDiscoveryErrorStatus(
if (
message.includes("DATABRICKS_HOST required") ||
message.includes("DATABRICKS_MODEL required") ||
message.includes("BUZZ_AGENT_PROVIDER required")
message.includes("BUZZ_AGENT_PROVIDER is required")
) {
return null;
}
+11 -46
View File
@@ -296,8 +296,10 @@ test("persona model options follow the selected LLM provider", async ({
await expect(provider).toContainText("Buzz Agent (default)");
await expect(llmProvider).toBeVisible();
await expect(model).toBeVisible();
// Without live discovery, the only static option is "Default model".
await expect(model).toContainText("Default model");
// Switch to OpenAI — the API-key field appears and is labelled correctly.
await llmProvider.click();
await page
.getByRole("menuitemradio", { name: "OpenAI", exact: true })
@@ -307,26 +309,14 @@ test("persona model options follow the selected LLM provider", async ({
await expect(providerApiKey).toBeVisible();
await expect(page.getByTestId("env-vars-editor")).toHaveCount(0);
await expect(model).toBeVisible();
await providerApiKey.fill("sk-openai-test");
// OpenAI requires an explicit model, so "Default model" is filtered out.
// The menu offers only "Custom model..." — verify it is present and selectable.
const openAiModelMenu = await openModelMenu(page, model);
await expect(
openAiModelMenu.getByRole("menuitemradio", {
name: "GPT-5.5",
exact: true,
}),
).toBeVisible();
await expect(
openAiModelMenu.getByRole("menuitemradio", { name: "GPT-5", exact: true }),
).toHaveCount(0);
await expect(
openAiModelMenu.getByRole("menuitemradio", { name: /Claude/ }),
).toHaveCount(0);
await openAiModelMenu
.getByRole("menuitemradio", { name: "GPT-5.5", exact: true })
.getByRole("menuitemradio", { name: "Custom model...", exact: true })
.click();
await expect(model).toContainText("GPT-5.5");
// Switch to Anthropic — API-key field label changes and value clears.
await llmProvider.click();
await page
.getByRole("menuitemradio", { name: "Anthropic", exact: true })
@@ -335,44 +325,19 @@ test("persona model options follow the selected LLM provider", async ({
await expect(providerApiKey).toHaveValue("");
await expect(model).toBeVisible();
// Fill in the Anthropic key and verify the model field is still present.
await providerApiKey.fill("sk-ant-test");
await expect(model).toBeVisible();
const anthropicModelMenu = await openModelMenu(page, model);
await expect(
anthropicModelMenu.getByRole("menuitemradio", {
name: "Claude Sonnet 4.6",
}),
).toBeVisible();
await expect(
anthropicModelMenu.getByRole("menuitemradio", {
name: "GPT-5.5",
exact: true,
}),
).toHaveCount(0);
await anthropicModelMenu
.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" })
.click();
await expect(model).toContainText("Claude Sonnet 4.6");
// Switch to Default (no explicit provider) — model resets to "Default model".
await llmProvider.click();
const llmProviderMenu = page.getByRole("menu").filter({
has: page.getByRole("menuitemradio", { name: "OpenAI", exact: true }),
});
await llmProviderMenu
.last()
.getByRole("menuitemradio", { name: "Databricks (default)", exact: true })
.getByRole("menuitemradio", { name: "Default", exact: true })
.click();
await expect(model).toBeVisible();
await expect(model).toContainText("Claude Sonnet 4.6");
const defaultModelMenu = await openModelMenu(page, model);
await expect(
defaultModelMenu.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }),
).toBeVisible();
await expect(
defaultModelMenu.getByRole("menuitemradio", {
name: "GPT-5.5",
exact: true,
}),
).toBeVisible();
await expect(model).toContainText("Default model");
});