From 232f4a3491b4d2d390d70dfd5960c242aa019fce Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 22 Aug 2026 20:26:20 -0500 Subject: [PATCH 1/4] feat(agent): add provider-generic model discovery Model discovery lived in per-provider arms: the agent's session/new response listed live models only for Databricks, and each frontend carried its own per-provider discovery code. A new provider meant a new special case in every frontend. Move the dispatch into the agent, which owns every provider transport. catalog::discover_models is the one dispatch point. The session/new availableModels block consumes it generically, and a new "buzz-agent models" subcommand exposes it to frontends as a JSON array. The subcommand runs before a model is chosen, so provider connection resolution splits out of Config::from_env into a path that does not require a model. Providers without an agent-side catalog report an empty list so callers fall back to the configured model. Co-Authored-By: Claude Fable 5 Signed-off-by: benthecarman --- crates/buzz-agent/README.md | 10 +++ crates/buzz-agent/src/catalog.rs | 51 ++++++++--- crates/buzz-agent/src/config.rs | 146 +++++++++++++++++++------------ crates/buzz-agent/src/lib.rs | 114 +++++++++++++++--------- 4 files changed, 213 insertions(+), 108 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 0bc03db7813..fc7272ce414 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -258,6 +258,16 @@ By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI `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`. +### Model discovery + +`buzz-agent models` prints the provider's live model catalog as a JSON array of `{"id","name"}` objects on stdout. It reads the same provider env vars as the ACP server but does **not** require a model. Providers without a live agent-side catalog (Anthropic, OpenAI, OpenRouter; frontends list those over plain HTTP) print `[]`. Failures exit non-zero with the error on stderr. + +```bash +BUZZ_AGENT_PROVIDER=databricks_v2 DATABRICKS_HOST=https://dbc-...cloud.databricks.com buzz-agent models +``` + +The per-provider dispatch lives in `catalog.rs` (`discover_models`) and also backs the `availableModels` list in the ACP `session/new` response, so a provider added there reaches every frontend, including Buzz Desktop's model picker. + ## MCP Servers The client passes MCP server specs in `session/new`. The agent spawns each one as a stdio subprocess, calls `tools/list`, and merges everything into a single tool catalog the LLM sees. Tool names are namespaced as `server__tool` (double underscore separator). Bare tool names containing `__` are rejected at registration. diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 69714b145c5..c1e2a7c4ecb 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -1,16 +1,15 @@ -//! Databricks model catalog discovery. +//! Live model-catalog discovery, per provider. //! -//! 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): +//! [`discover_models`] is the dispatch point behind ACP `session/new` and +//! the `buzz-agent models` subcommand. //! -//! - 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)`. +//! Databricks ([`discover_databricks_models`]) lists endpoints for the +//! `databricks` and `databricks_v2` providers without opening a browser. +//! Auth comes from [`build_token_source`](crate::llm::build_token_source): //! -//! This helper never opens a browser. Callers choose whether to reject, degrade, -//! or start a separate interactive authentication flow. +//! - Static bearer (`DATABRICKS_TOKEN`): returned immediately. +//! - PKCE cache hit: read from disk, no network round-trip. +//! - PKCE cache empty, no token: `Err(AgentError::LlmAuth)`. use std::sync::Arc; @@ -401,6 +400,24 @@ pub(crate) fn parse_v2_endpoints_page( Ok((models, next_page_token)) } +// --------------------------------------------------------------------------- +// Provider-generic dispatch +// --------------------------------------------------------------------------- + +/// Discover the live model catalog for `cfg.provider`. +/// +/// `Ok(Some(models))` is a non-empty catalog. `Ok(None)` means the provider +/// has no agent-side catalog: the frontend lists its models itself over +/// plain OpenAI-compatible HTTP, or the configured model is the only option. +pub async fn discover_models(cfg: &Config) -> Result>, AgentError> { + match cfg.provider { + Provider::Databricks | Provider::DatabricksV2 => { + discover_databricks_models(cfg).await.map(Some) + } + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => Ok(None), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -728,4 +745,18 @@ mod tests { assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); } + + /// Providers without an agent-side catalog resolve to `Ok(None)`; the + /// dispatch must not error for them and must not return an empty `Some`. + #[tokio::test] + async fn discover_models_returns_none_for_providers_without_live_catalog() { + for provider in [Provider::Anthropic, Provider::OpenAi, Provider::OpenRouter] { + let cfg = + Config::for_discovery(provider, "key".into(), "https://example.invalid".into()); + let discovered = discover_models(&cfg) + .await + .expect("no-catalog providers must not error"); + assert!(discovered.is_none(), "{provider:?} has no live catalog"); + } + } } diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 67d7c593b56..11a58597b6b 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -525,66 +525,22 @@ pub struct Config { impl Config { pub fn from_env() -> Result { - let databricks_host = env("DATABRICKS_HOST"); - let databricks_model = env("DATABRICKS_MODEL"); - let provider = resolve_provider( - env("BUZZ_AGENT_PROVIDER").as_deref(), - env("ANTHROPIC_API_KEY").as_deref(), - env("OPENAI_COMPAT_API_KEY").as_deref(), - env("OPENROUTER_API_KEY").as_deref(), - )?; + let provider = resolve_provider_from_env()?; + let conn = provider_connection_from_env(provider)?; // 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 - // bad value can't break an Anthropic-only deployment. - // - // Databricks borrows api_key as the *optional* `DATABRICKS_TOKEN` escape - // hatch — empty means "use OAuth PKCE." Legacy Databricks encodes the - // model in the URL path; Databricks v2 keeps it in the request body. - let (api_key, model, base_url, openai_api) = match provider { - Provider::Anthropic => ( - req("ANTHROPIC_API_KEY")?, - resolve_model( - 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"), - OpenAiApi::Auto, // unused for Anthropic - ), - Provider::OpenAi => ( - req("OPENAI_COMPAT_API_KEY")?, - resolve_model( - 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"), - parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, - ), - Provider::Databricks | Provider::DatabricksV2 => ( - env("DATABRICKS_TOKEN").unwrap_or_default(), - 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 - ), - Provider::OpenRouter => ( - req("OPENROUTER_API_KEY")?, - resolve_model( - buzz_agent_model.as_deref(), - env("OPENROUTER_MODEL").as_deref(), - ) - .ok_or_else(|| "config: OPENROUTER_MODEL required".to_string())?, - env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), - OpenAiApi::Chat, // OpenRouter uses Chat Completions only - ), - }; + let model = resolve_model(buzz_agent_model.as_deref(), conn.provider_model.as_deref()) + .ok_or_else(|| format!("config: {} required", conn.model_env_var))?; + let ProviderConnection { + api_key, + base_url, + openai_api, + .. + } = conn; let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { (Some(_), Some(_)) => return Err( "config: BUZZ_AGENT_SYSTEM_PROMPT and BUZZ_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()), @@ -637,12 +593,24 @@ impl Config { Ok(cfg) } + /// Same env vars as [`Config::from_env`], but no model is required: + /// `buzz-agent models` runs before a model has been chosen. The ACP + /// server path keeps using `from_env`, since a running agent always has + /// a model. + pub fn discovery_from_env() -> Result { + let provider = resolve_provider_from_env()?; + let conn = provider_connection_from_env(provider)?; + let mut cfg = Self::for_discovery(provider, conn.api_key, conn.base_url); + cfg.openai_api = conn.openai_api; + 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. + /// inert defaults. Never call `from_env` for discovery; use + /// [`Config::discovery_from_env`] instead. pub fn for_discovery(provider: Provider, api_key: String, base_url: String) -> Self { Self { provider, @@ -825,6 +793,72 @@ fn resolve_provider( } } +/// Resolve the provider from the standard env vars (`BUZZ_AGENT_PROVIDER` +/// plus each provider's key). Shared by `from_env` and `discovery_from_env`. +fn resolve_provider_from_env() -> Result { + resolve_provider( + env("BUZZ_AGENT_PROVIDER").as_deref(), + env("ANTHROPIC_API_KEY").as_deref(), + env("OPENAI_COMPAT_API_KEY").as_deref(), + env("OPENROUTER_API_KEY").as_deref(), + ) +} + +/// Per-provider connection settings from env: everything `from_env` needs +/// except the model. Split out so [`Config::discovery_from_env`] can build a +/// connection without one. +struct ProviderConnection { + api_key: String, + base_url: String, + openai_api: OpenAiApi, + /// Provider-specific model env var (e.g. `DATABRICKS_MODEL`), the + /// default when `BUZZ_AGENT_MODEL` is absent. + provider_model: Option, + /// Name of that env var, for the "required" error message. + model_env_var: &'static str, +} + +/// Resolve one provider's connection settings from env. +/// +/// `OPENAI_COMPAT_API` is only read when provider=openai, so a stray bad +/// value can't break an Anthropic-only deployment. Databricks borrows +/// `api_key` as the optional `DATABRICKS_TOKEN` escape hatch; empty means +/// "use OAuth PKCE." Legacy Databricks encodes the model in the URL path, +/// Databricks v2 in the request body. +fn provider_connection_from_env(provider: Provider) -> Result { + Ok(match provider { + Provider::Anthropic => ProviderConnection { + api_key: req("ANTHROPIC_API_KEY")?, + base_url: env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), + openai_api: OpenAiApi::Auto, // unused for Anthropic + provider_model: env("ANTHROPIC_MODEL"), + model_env_var: "ANTHROPIC_MODEL", + }, + Provider::OpenAi => ProviderConnection { + api_key: req("OPENAI_COMPAT_API_KEY")?, + base_url: env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), + openai_api: parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, + provider_model: env("OPENAI_COMPAT_MODEL"), + model_env_var: "OPENAI_COMPAT_MODEL", + }, + Provider::Databricks | Provider::DatabricksV2 => ProviderConnection { + api_key: env("DATABRICKS_TOKEN").unwrap_or_default(), + base_url: env("DATABRICKS_HOST") + .ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?, + openai_api: OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch + provider_model: env("DATABRICKS_MODEL"), + model_env_var: "DATABRICKS_MODEL", + }, + Provider::OpenRouter => ProviderConnection { + api_key: req("OPENROUTER_API_KEY")?, + base_url: env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), + openai_api: OpenAiApi::Chat, // OpenRouter uses Chat Completions only + provider_model: env("OPENROUTER_MODEL"), + model_env_var: "OPENROUTER_MODEL", + }, + }) +} + /// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the /// caller hands in the raw value. fn parse_openai_api(raw: Option<&str>) -> Result { diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 98fa99ca5bf..4cc2e4e2255 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -12,7 +12,7 @@ pub mod model_capabilities; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry}; +pub use catalog::{discover_databricks_models, discover_models, ModelEntry}; pub use config::Provider; pub use types::AgentError; @@ -141,6 +141,12 @@ pub fn run() -> Result<(), Box> { .build()? .block_on(auth_subcommand(&args[2..])); } + if matches!(args.get(1).map(String::as_str), Some("models")) { + return tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(models_subcommand()); + } tokio::runtime::Builder::new_multi_thread() .enable_all() .build()? @@ -154,6 +160,27 @@ pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { .await } +/// `buzz-agent models`: live model discovery for frontends. The desktop's +/// model picker runs it for draft configs. +/// +/// Reads the same provider env vars as the ACP server but needs no model. +/// Prints a JSON array of `{"id","name"}` objects on stdout; providers with +/// no live catalog print `[]` and the caller falls back to its own options. +/// Failures exit non-zero with the error on stderr. +async fn models_subcommand() -> Result<(), Box> { + let cfg = Config::discovery_from_env()?; + let models = catalog::discover_models(&cfg) + .await + .map_err(|error| error.to_string())? + .unwrap_or_default(); + let out: Vec = models + .iter() + .map(|m| json!({ "id": m.id, "name": m.name })) + .collect(); + println!("{}", Value::Array(out)); + Ok(()) +} + /// `buzz-agent auth ` — run the interactive auth flow for a /// provider and persist the result, then exit. Today this supports Databricks /// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the @@ -336,13 +363,13 @@ async fn resolve_models_catalog( cache.get_or_try_init(|| discover).await.cloned() } -/// Return the configured model as a one-entry catalog for this response. +/// Return the configured model as a one-entry catalog. /// -/// This value is never written to `models_cache`; failed discovery must be retried by -/// the next session rather than pinning degraded state for the process lifetime. -/// -/// Only reached from the Databricks provider arm below, so the curated label is -/// looked up from the Databricks manifest; `id` stays the raw configured value. +/// Used as the cached result when `discover_models` returns `Ok(None)` +/// (the configured model is process-constant, so caching it is fine), and +/// as the uncached fallback when live discovery fails, so the next session +/// retries instead of keeping degraded state. The label lookup only hits +/// for Databricks ids; other providers keep the raw model as `name`. fn configured_model_fallback(model: &str) -> Vec { let model = model.trim().to_string(); let name = crate::model_capabilities::databricks_registry_label(&model) @@ -418,42 +445,45 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen // failures and other catalog failures use only the configured model for this // response, without caching, so session/prompt can run the existing PKCE flow. let available_models: Vec = { - use crate::config::Provider; - match app.cfg.provider { - Provider::Databricks | Provider::DatabricksV2 => { - let models = match resolve_models_catalog( - &app.models_cache, - discover_databricks_models(&app.cfg), - ) - .await - { - Ok(models) => models, - Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { - return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()) - .await; - } - Err(error @ AgentError::LlmAuth(_)) => { - tracing::warn!( - error = %error, - "Databricks OAuth model catalog unavailable; using configured model" - ); - configured_model_fallback(&app.cfg.model) - } - Err(error) => { - tracing::warn!( - error = %error, - "Databricks model catalog unavailable; using configured model" - ); - configured_model_fallback(&app.cfg.model) - } - }; - models - .iter() - .map(|m| json!({ "modelId": m.id, "name": m.name })) - .collect() + // catalog::discover_models owns the per-provider dispatch; + // no-catalog providers resolve to the configured model without + // touching the cache. + let models = match resolve_models_catalog(&app.models_cache, async { + catalog::discover_models(&app.cfg).await.map(|discovered| { + discovered.unwrap_or_else(|| configured_model_fallback(&app.cfg.model)) + }) + }) + .await + { + Ok(models) => models, + // A static credential cannot recover interactively; reject so + // the frontend shows the credential error. + Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { + return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()).await; } - _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], - } + // OAuth-recoverable (Databricks PKCE): degrade to the configured + // model; session/prompt can run the interactive flow later. + Err(error @ AgentError::LlmAuth(_)) => { + tracing::warn!( + error = %error, + provider = ?app.cfg.provider, + "model catalog auth unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + Err(error) => { + tracing::warn!( + error = %error, + provider = ?app.cfg.provider, + "model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + }; + models + .iter() + .map(|m| json!({ "modelId": m.id, "name": m.name })) + .collect() }; let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { From 21a21d44b88956fc9706ece05d7cc77a1be5473a Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 22 Aug 2026 20:26:22 -0500 Subject: [PATCH 2/4] feat(desktop): discover models via agent binary The desktop's model picker listed live models only for providers with desktop-side HTTP discovery; anything else fell through to the ACP subprocess, which requires a configured model and therefore fails for draft configs in the create/edit dialog with "config: _MODEL required", shown as "Could not load live models". Run "buzz-agent models" before the ACP fallback when the runtime is buzz-agent. The agent owns every provider transport and needs no configured model, so a provider added to the agent now loads models in the dialog with no desktop changes. Missing API keys match a generic "config: _API_KEY required" pattern instead of per-provider copy. Co-Authored-By: Claude Fable 5 Signed-off-by: benthecarman --- .../src/commands/agent_model_process.rs | 92 ++++++++++++++++++- .../src-tauri/src/commands/agent_models.rs | 27 +++++- .../src/commands/agent_models_tests.rs | 35 +++++++ .../ui/personaModelDiscoveryStatus.test.mjs | 15 +++ .../agents/ui/personaModelDiscoveryStatus.ts | 11 +++ 5 files changed, 177 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..4a310fe8f06 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -2,11 +2,101 @@ use std::{collections::BTreeMap, path::PathBuf}; use crate::managed_agents::{ build_buzz_agent_provider_defaults, default_agent_workdir, known_acp_runtime, - redact_env_values_in, AgentModelsResponse, + redact_env_values_in, AgentModelInfo, AgentModelsResponse, }; use super::agent_models::normalize_agent_models; +/// Live discovery through the agent binary: `buzz-agent models` dispatches +/// on `BUZZ_AGENT_PROVIDER` inside the agent, which owns every provider +/// transport, including ones the desktop cannot probe over plain HTTP. It +/// needs no configured model, so it also serves draft configs in the +/// create/edit dialog. +/// +/// Returns `Ok(None)` when the runtime is not buzz-agent or the catalog is +/// empty, and callers fall through to their next discovery step. Errors +/// propagate so credential failures reach the dialog. +pub(super) async fn run_buzz_agent_native_models( + agent_command: &str, + resolved_agent: &str, + merged_env: BTreeMap, + persisted_model: Option, +) -> Result, String> { + if known_acp_runtime(agent_command).map(|meta| meta.id) != Some("buzz-agent") { + return Ok(None); + } + let env_for_redaction = merged_env.clone(); + let resolved_agent = resolved_agent.to_string(); + let output = tokio::task::spawn_blocking(move || { + let mut cmd = std::process::Command::new(&resolved_agent); + if let Some(home) = default_agent_workdir() { + cmd.current_dir(home); + } + cmd.arg("models"); + // Mirror runtime spawn: internal builds may bake provider/model + // defaults. User-provided env below still wins. + build_buzz_agent_provider_defaults(&mut cmd); + for (k, v) in &merged_env { + cmd.env(k, v); + } + crate::util::configure_no_window(&mut cmd); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("failed to spawn buzz-agent models: {e}")) + }) + .await + .map_err(|e| format!("model discovery task failed: {e}"))? + .map_err(|e: String| e)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Scrub user-supplied env values (API keys) before surfacing stderr. + let stderr_redacted = redact_env_values_in(stderr.as_ref(), &env_for_redaction); + return Err(format!( + "buzz-agent models failed (exit {}): {stderr_redacted}", + output.status.code().unwrap_or(-1) + )); + } + + parse_native_models_output(&output.stdout, persisted_model) +} + +/// Parse `buzz-agent models` stdout: a JSON array of `{"id","name"}` +/// objects. An empty array means the provider has no live catalog, returned +/// as `Ok(None)` so the caller falls through to its next discovery step. +pub(super) fn parse_native_models_output( + stdout: &[u8], + persisted_model: Option, +) -> Result, String> { + #[derive(serde::Deserialize)] + struct NativeModel { + id: String, + name: Option, + } + let raw: Vec = serde_json::from_slice(stdout) + .map_err(|e| format!("failed to parse buzz-agent models JSON: {e}"))?; + if raw.is_empty() { + return Ok(None); + } + Ok(Some(AgentModelsResponse { + agent_name: "buzz-agent".to_string(), + agent_version: "native-models".to_string(), + models: raw + .into_iter() + .map(|m| AgentModelInfo { + id: m.id, + name: m.name, + description: None, + }) + .collect(), + agent_default_model: None, + selected_model: persisted_model, + supports_switching: true, + })) +} + pub(super) async fn run_agent_models_command( resolved_acp: PathBuf, agent_command: String, diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index cb809b6c04a..7b0ac5291f5 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -4,7 +4,7 @@ use nostr::Keys; use serde::Deserialize; use tauri::{AppHandle, State}; -use super::agent_model_process::run_agent_models_command; +use super::agent_model_process::{run_agent_models_command, run_buzz_agent_native_models}; use super::managed_agent_definition::apply_model_provider_prompt_update; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. @@ -92,7 +92,7 @@ pub async fn get_agent_models( provider: saved_provider, provider_env_var, env: merged_env, - command: _, + command: runtime_command, } = discovery; let merged_env = discovery_env_with_baked_floor(merged_env); @@ -145,6 +145,19 @@ pub async fn get_agent_models( return Ok(models); } + // The agent binary owns every provider transport; providers without a + // desktop HTTP helper discover here. + if let Some(models) = run_buzz_agent_native_models( + &runtime_command, + &agent_command, + merged_env.clone(), + persisted_model.clone(), + ) + .await? + { + return Ok(models); + } + run_agent_models_command( resolved_acp, agent_command, @@ -321,6 +334,16 @@ pub async fn discover_agent_models( return Ok(models); } + // `buzz-agent models` needs no configured model, so it serves the draft + // dialog. The ACP path below requires one and would fail with + // "config: _MODEL required". + if let Some(models) = + run_buzz_agent_native_models(agent_command, &resolved_agent, merged_env.clone(), None) + .await? + { + return Ok(models); + } + run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await } diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index df3849de4a4..a71f04455c8 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -962,3 +962,38 @@ fn databricks_static_token_error_redacts_echoed_token() { "error lost its remediation: {error}" ); } + +// ── buzz-agent native model discovery (`buzz-agent models`) ────────────────── + +use crate::commands::agent_model_process::parse_native_models_output; + +#[test] +fn parse_native_models_output_builds_response() { + let stdout = + br#"[{"id":"llama3-3-70b","name":"llama3-3-70b"},{"id":"kimi-k3","name":"kimi-k3"}]"#; + let response = parse_native_models_output(stdout, Some("kimi-k3".to_string())) + .expect("valid JSON parses") + .expect("non-empty catalog yields a response"); + assert_eq!(response.agent_name, "buzz-agent"); + assert!(response.supports_switching); + assert_eq!(response.selected_model.as_deref(), Some("kimi-k3")); + let ids: Vec<&str> = response.models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["llama3-3-70b", "kimi-k3"]); +} + +#[test] +fn parse_native_models_output_empty_catalog_falls_through() { + // `[]` means no live catalog; callers fall through to the ACP + // subprocess instead of an empty dropdown. + let response = parse_native_models_output(b"[]", None).expect("empty array is valid"); + assert!(response.is_none()); +} + +#[test] +fn parse_native_models_output_rejects_malformed_json() { + let error = parse_native_models_output(b"not json", None).unwrap_err(); + assert!( + error.contains("failed to parse buzz-agent models JSON"), + "{error}" + ); +} diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs index dfa086738fb..8e96738bdc5 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs @@ -26,6 +26,21 @@ test("model discovery status names missing OpenAI-compatible credentials", () => assert.match(status?.message ?? "", /OpenAI models/); }); +test("model discovery status handles any provider's missing API key generically", () => { + // A provider without branded copy falls to the generic + // `config: _API_KEY required` matcher. + const status = formatModelDiscoveryErrorStatus( + new Error( + "buzz-agent models failed (exit 1): config: ACME_API_KEY required", + ), + "acme", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /ACME_API_KEY/); + assert.match(status?.message ?? "", /API key/); +}); + test("Buzz shared compute names the empty state and next action", () => { const status = formatModelDiscoveryErrorStatus( new Error("no Buzz shared compute serving members are available"), diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index 0d895264253..6f51ce0e40e 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -116,6 +116,17 @@ export function formatModelDiscoveryErrorStatus( }; } + // Generic provider-key gate: buzz-agent reports a missing credential as + // `config: _API_KEY required`. The branded Anthropic/OpenAI + // cases above stay because their copy names the vendor. + const requiredKey = message.match(/config: ([A-Z0-9_]+_API_KEY) required/); + if (requiredKey) { + return { + message: `Enter an API key (${requiredKey[1]}) to load live models.`, + tone: "warning", + }; + } + if ( message.includes("DATABRICKS_HOST required") || message.includes("DATABRICKS_MODEL required") || From f55a797a7135ae9a51eb73893d9bf8f044555e95 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 22 Aug 2026 20:27:55 -0500 Subject: [PATCH 3/4] feat(agent): add Maple inference provider Add Maple (OpenSecret) as a provider in buzz-agent, selected with BUZZ_AGENT_PROVIDER=maple and configured through MAPLE_API_KEY, MAPLE_MODEL, MAPLE_BASE_URL, and MAPLE_PCR0_ENVIRONMENT. Requests travel through the opensecret SDK instead of a plain HTTPS POST: the SDK verifies the enclave's AWS Nitro attestation document against pinned PCR0 trust roots, performs the key exchange, and end-to-end encrypts request and response bodies. Inside the envelope the wire format is OpenAI Chat Completions, so the existing body builder and parser are reused unchanged. Retries follow post() (retryable statuses, escalating timeout budgets, malformed-body retries) with two deliberate differences: auth rejections fail immediately because a static key cannot mint a different token, and an attestation verification failure is terminal with an explicit "request not sent" message. That guarantee is the reason to use this provider. Model discovery plugs into the provider-generic dispatch and lists the enclave's catalog through the same attested transport; audio, TTS, and embedding models are filtered out. The enclave lists models for any attested session without a credential, so discovery accepts an empty key and builds an attested-but-unauthenticated client. The model picker can fill in before the user enters a key; inference still requires one. Co-Authored-By: Claude Fable 5 Signed-off-by: benthecarman --- Cargo.lock | 500 +++++++++++++++++++++++++++---- crates/buzz-agent/Cargo.toml | 7 + crates/buzz-agent/README.md | 19 +- crates/buzz-agent/src/catalog.rs | 147 +++++++++ crates/buzz-agent/src/config.rs | 103 ++++++- crates/buzz-agent/src/handoff.rs | 1 + crates/buzz-agent/src/llm.rs | 418 +++++++++++++++++++++++++- desktop/src-tauri/Cargo.lock | 403 ++++++++++++++++++++++++- 8 files changed, 1526 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16d86d0206f..c0fea3ffdd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -209,6 +209,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -321,6 +360,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -562,6 +623,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -892,10 +959,14 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bytes", "dirs", + "futures-util", "getrandom 0.4.3", "hex", + "http", "nix 0.31.3", + "opensecret", "reqwest 0.13.4", "rmcp", "serde", @@ -1230,7 +1301,7 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", - "p256", + "p256 0.14.0", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1619,6 +1690,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1712,7 +1810,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1795,6 +1893,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2068,6 +2172,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -2333,7 +2449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2402,17 +2518,42 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2510,6 +2651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2521,7 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2544,7 +2686,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2617,18 +2759,32 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecdsa" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", + "der 0.8.0", "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", "zeroize", ] @@ -2638,9 +2794,9 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2654,7 +2810,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2668,23 +2824,44 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 1.0.0", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", "digest 0.11.3", - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2767,7 +2944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2822,6 +2999,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "evmap" version = "11.0.0" @@ -2891,6 +3079,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.14.0" @@ -3183,7 +3381,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3325,13 +3523,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", ] @@ -3355,6 +3564,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if 1.0.4", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -6028,7 +6248,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6274,6 +6494,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6325,6 +6554,43 @@ dependencies = [ "tracing", ] +[[package]] +name = "opensecret" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d9a35e5dd1ee761d3449d9e2db722eb1ebbab0eef02d3bb8601fd6b23d6bd9" +dependencies = [ + "aes-gcm", + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "bytes", + "chacha20poly1305", + "chrono", + "ciborium", + "eventsource-stream", + "futures", + "hex", + "hkdf 0.12.4", + "http", + "p256 0.13.2", + "percent-encoding", + "pin-project", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "x25519-dalek", + "x509-parser", + "yasna", +] + [[package]] name = "openssl" version = "0.10.80" @@ -6595,16 +6861,28 @@ dependencies = [ "memchr", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p256" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -6720,6 +6998,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6897,14 +7184,24 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -7098,21 +7395,30 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", "zeroize", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "primeorder" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.14.1", "once_cell", "primefield", "serdect", @@ -7501,7 +7807,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8022,13 +8328,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfc6979" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "hmac 0.13.0", ] @@ -8162,6 +8478,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -8172,7 +8497,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8185,7 +8510,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8244,7 +8569,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8391,15 +8716,29 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", + "base16ct 1.0.0", "ctutils", - "der", + "der 0.8.0", "hybrid-array", "subtle", "zeroize", @@ -8527,7 +8866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8690,7 +9029,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -8823,6 +9162,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -9024,7 +9373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9059,6 +9408,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -9066,7 +9425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] [[package]] @@ -9652,7 +10011,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9665,7 +10024,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10435,7 +10794,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11019,7 +11378,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11514,8 +11873,8 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" dependencies = [ - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", ] @@ -11544,6 +11903,35 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -11726,6 +12114,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index fabf75754e1..3fad755a7a4 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -31,6 +31,13 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls", "form"] } rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] } +# Maple (OpenSecret) confidential inference: attested, end-to-end encrypted +# transport to TEE-hosted models. `http`, `bytes`, and `futures-util` are for +# the SDK's request/response types and are already in its dependency tree. +opensecret = "3" +http = "1" +bytes = "1" +futures-util = { workspace = true } arc-swap = "1" getrandom = "0.4" tracing = { workspace = true } diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index fc7272ce414..7b54ec3c83d 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -61,6 +61,12 @@ BUZZ_AGENT_PROVIDER=databricks \ DATABRICKS_HOST=https://dbc-...cloud.databricks.com \ DATABRICKS_MODEL=goose-claude-4-6-sonnet \ ./target/release/buzz-agent + +# Or Maple (OpenSecret) confidential inference, attested and end-to-end encrypted +BUZZ_AGENT_PROVIDER=maple \ +MAPLE_API_KEY=... \ +MAPLE_MODEL=llama3-3-70b \ + ./target/release/buzz-agent ``` That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr. @@ -135,7 +141,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Variable | Default | Notes | |---|---|---| -| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | +| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `maple`, `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` | | @@ -147,6 +153,10 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `OPENROUTER_API_KEY` | — | Required when provider=openrouter. | | `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. | | `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | | +| `MAPLE_API_KEY` | — | Required when provider=maple. | +| `MAPLE_MODEL` | — | Required when provider=maple. | +| `MAPLE_BASE_URL` | `https://enclave.trymaple.ai` | The enclave endpoint the opensecret SDK attests against. | +| `MAPLE_PCR0_ENVIRONMENT` | `production` | `production` \| `development`. Which PCR0 trust roots verify the enclave's attestation document. | | `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. | @@ -240,10 +250,11 @@ lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). | Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder | | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | | OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) | +| Maple (OpenSecret) | `maple` | `POST {base}/v1/chat/completions` (attested + encrypted) | TEE-hosted open models (llama, deepseek, qwen, …) | | 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`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. +If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, or `BUZZ_AGENT_PROVIDER=maple` is selected without `MAPLE_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). @@ -256,12 +267,16 @@ By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI - `anthropic/*` models get Anthropic-style `cache_control` breakpoints injected on the system message and the last two user messages. - Retryable statuses (429 and typed `provider_overloaded` 503) honor the documented `Retry-After` header (clamped to a small ceiling — see `RETRY_AFTER_CAP_SECS` in `llm.rs` — since the sleep happens outside `BUZZ_AGENT_LLM_TIMEOUT_SECS`); 502 and untyped 503 retry with jittered backoff instead. `401` is treated as an expired/invalid key and refreshed once, while `402` (no credits) and `403` (guardrail/moderation/permission) fail immediately without retry. +`provider=maple` speaks OpenAI's Chat Completions wire format, but every request travels through the [opensecret](https://crates.io/crates/opensecret) SDK instead of a plain HTTPS POST: the SDK verifies the enclave's AWS Nitro attestation document against pinned PCR0 trust roots, performs an X25519 key exchange with the attested enclave, and end-to-end encrypts the request and response bodies. The handshake runs lazily on the first request and re-runs transparently when the enclave session goes stale. One divergence from the shared OpenAI path: a `401`/`403` fails immediately without a refresh retry, since a static Maple API key cannot mint a different token. + `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`. ### Model discovery `buzz-agent models` prints the provider's live model catalog as a JSON array of `{"id","name"}` objects on stdout. It reads the same provider env vars as the ACP server but does **not** require a model. Providers without a live agent-side catalog (Anthropic, OpenAI, OpenRouter; frontends list those over plain HTTP) print `[]`. Failures exit non-zero with the error on stderr. +For Maple, `MAPLE_API_KEY` is optional here: the enclave lists models for any attested session, so a picker can fill in before the user has a key. An enclave that still requires a credential answers a keyless call with `config: MAPLE_API_KEY required`. + ```bash BUZZ_AGENT_PROVIDER=databricks_v2 DATABRICKS_HOST=https://dbc-...cloud.databricks.com buzz-agent models ``` diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index c1e2a7c4ecb..aaa5eede402 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -10,6 +10,9 @@ //! - Static bearer (`DATABRICKS_TOKEN`): returned immediately. //! - PKCE cache hit: read from disk, no network round-trip. //! - PKCE cache empty, no token: `Err(AgentError::LlmAuth)`. +//! +//! Maple's catalog is only reachable through the opensecret SDK's attested +//! transport, so its discovery lives here too. use std::sync::Arc; @@ -414,10 +417,99 @@ pub async fn discover_models(cfg: &Config) -> Result>, Ag Provider::Databricks | Provider::DatabricksV2 => { discover_databricks_models(cfg).await.map(Some) } + Provider::Maple => discover_maple_models(cfg).await.map(Some), Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => Ok(None), } } +// --------------------------------------------------------------------------- +// Maple (OpenSecret) +// --------------------------------------------------------------------------- + +/// Cap on one Maple catalog exchange, attestation handshake included. The +/// SDK's HTTP client sets no timeout, and this call sits on the `session/new` +/// path where a hang would block agent startup. +const MAPLE_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Discover the Maple (OpenSecret) model catalog: `GET /v1/models` through +/// the opensecret SDK. +/// +/// The catalog is unreachable over plain HTTPS, so the desktop's picker +/// cannot probe it directly; it calls this via `buzz-agent models` or ACP +/// `session/new`. +/// +/// Auth is the static `MAPLE_API_KEY`. An empty key is allowed: the enclave +/// lists models for any attested session, so the picker can fill in before +/// the user enters a key. An older enclave that still requires a credential +/// answers a keyless call with `Err(AgentError::LlmAuth)`. +async fn discover_maple_models(cfg: &Config) -> Result, AgentError> { + let client = crate::llm::build_maple_client(cfg)?.ok_or_else(|| { + AgentError::InvalidParams("discover_maple_models called for non-Maple provider".into()) + })?; + let keyless = cfg.api_key.trim().is_empty(); + let response = tokio::time::timeout(MAPLE_DISCOVERY_TIMEOUT, client.get_models()) + .await + .map_err(|_| { + AgentError::Llm(format!( + "Maple model discovery timed out after {MAPLE_DISCOVERY_TIMEOUT:?}" + )) + })? + .map_err(|error| maple_discovery_error(error, keyless))?; + let models = filter_maple_models(response.data.into_iter().map(|m| m.id)); + if models.is_empty() { + return Err(AgentError::Llm( + "Maple model discovery returned no chat-capable models".into(), + )); + } + Ok(models) +} + +/// Maple's raw model ids as picker entries, dropping ids that cannot serve +/// chat traffic. The catalog has no display-name or task field, so the id is +/// also the label and the name is the only capability signal, as in +/// [`is_chat_capable_endpoint`]. +fn filter_maple_models(ids: impl Iterator) -> Vec { + ids.filter(|id| is_maple_chat_model(id)) + .map(|id| ModelEntry { + name: id.clone(), + id, + }) + .collect() +} + +fn is_maple_chat_model(id: &str) -> bool { + let lower = id.to_ascii_lowercase(); + if !is_chat_capable_endpoint(id) { + return false; + } + !["embed", "whisper", "tts", "transcribe", "speech"] + .iter() + .any(|needle| lower.contains(needle)) +} + +/// Map an opensecret SDK discovery failure onto `AgentError`. 401/403 become +/// `LlmAuth` so `session/new` rejects with the credential error instead of +/// silently degrading to the configured model. +/// +/// A keyless call rejected for auth means this enclave does not list models +/// without a credential. Word that as the standard `config: MAPLE_API_KEY +/// required` error so the frontend shows its usual key prompt. +fn maple_discovery_error(error: opensecret::Error, keyless: bool) -> AgentError { + match error { + opensecret::Error::Api { + status: status @ (401 | 403), + message, + } if keyless => AgentError::LlmAuth(format!( + "config: MAPLE_API_KEY required — this enclave does not list models without a credential (HTTP {status}: {message})" + )), + opensecret::Error::Api { + status: status @ (401 | 403), + message, + } => AgentError::LlmAuth(format!("Maple model discovery HTTP {status}: {message}")), + other => AgentError::Llm(format!("Maple model discovery failed: {other}")), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -759,4 +851,59 @@ mod tests { assert!(discovered.is_none(), "{provider:?} has no live catalog"); } } + + /// A keyless listing rejected for auth is reported as the standard + /// missing-key config error so the desktop shows its "enter an API key" + /// prompt; the same rejection with a key present names the HTTP status. + #[test] + fn maple_discovery_auth_rejection_wording_depends_on_keyless() { + let rejected = || opensecret::Error::Api { + status: 401, + message: "Invalid JWT".into(), + }; + match maple_discovery_error(rejected(), true) { + AgentError::LlmAuth(s) => assert!(s.contains("config: MAPLE_API_KEY required"), "{s}"), + other => panic!("expected LlmAuth, got {other:?}"), + } + match maple_discovery_error(rejected(), false) { + AgentError::LlmAuth(s) => { + assert!(s.contains("HTTP 401"), "{s}"); + assert!(!s.contains("MAPLE_API_KEY required"), "{s}"); + } + other => panic!("expected LlmAuth, got {other:?}"), + } + // Non-auth failures are plain errors regardless of key presence. + assert!(matches!( + maple_discovery_error(opensecret::Error::Session("stale".into()), true), + AgentError::Llm(_) + )); + } + + /// The catalog is dynamic, so this pins the filter rules, not a + /// snapshot: audio, TTS, and embedding ids drop; everything else stays, + /// including audio-capable chat models. Order is preserved and the id is + /// also the label, since Maple has no name field. + #[test] + fn filter_maple_models_drops_non_chat_families() { + let kept = [ + "llama3-3-70b", + "deepseek-v4-flash", + "gpt-oss-120b", + "voxtral-small-24b", + "some-future-model", + ]; + let dropped = [ + "whisper-large-v3", + "voxtral-tts", + "nomic-embed-text", + "qwen3-embedding-0-6b", + "kokoro-speech", + "parakeet-transcribe", + ]; + let models = + filter_maple_models(kept.iter().chain(dropped.iter()).map(|id| id.to_string())); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, kept); + assert!(models.iter().all(|m| m.id == m.name)); + } } diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 11a58597b6b..09539c577b7 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -427,6 +427,11 @@ pub enum Provider { DatabricksV2, /// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible. OpenRouter, + /// Maple (OpenSecret) confidential inference. Routes to + /// `{base_url}/v1/chat/completions` through the opensecret SDK's attested, + /// encrypted enclave session. Wire format inside the envelope is + /// OpenAI-chat-compatible. + Maple, } /// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API` @@ -513,6 +518,10 @@ pub struct Config { /// Set via `BUZZ_AGENT_THINKING_SUMMARY`. Ignored on Anthropic, Chat /// Completions, and OpenRouter routes. pub thinking_summary: ThinkingSummary, + /// Verify Maple's attestation against the development PCR0 trust roots + /// instead of production. Only read when `provider = Maple`. Set via + /// `MAPLE_PCR0_ENVIRONMENT=development`. + pub maple_pcr0_development: bool, /// Emit Anthropic `cache_control` breakpoints on the stable prefix /// (tools + system prompt) and the rolling conversation tail. Default on; /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Consulted on every route that @@ -526,7 +535,7 @@ pub struct Config { impl Config { pub fn from_env() -> Result { let provider = resolve_provider_from_env()?; - let conn = provider_connection_from_env(provider)?; + let conn = provider_connection_from_env(provider, ConnectionPurpose::Inference)?; // Universal model override — takes priority over provider-specific model // env vars (ANTHROPIC_MODEL, OPENAI_COMPAT_MODEL, DATABRICKS_MODEL) when @@ -539,6 +548,7 @@ impl Config { api_key, base_url, openai_api, + maple_pcr0_development, .. } = conn; let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { @@ -556,6 +566,7 @@ impl Config { base_url, anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"), openai_api, + maple_pcr0_development, max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?, max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 65_536)?, max_token_recoveries: parse_env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", 3u32)?, @@ -599,9 +610,10 @@ impl Config { /// a model. pub fn discovery_from_env() -> Result { let provider = resolve_provider_from_env()?; - let conn = provider_connection_from_env(provider)?; + let conn = provider_connection_from_env(provider, ConnectionPurpose::Discovery)?; let mut cfg = Self::for_discovery(provider, conn.api_key, conn.base_url); cfg.openai_api = conn.openai_api; + cfg.maple_pcr0_development = conn.maple_pcr0_development; Ok(cfg) } @@ -620,6 +632,7 @@ impl Config { system_prompt: String::new(), anthropic_api_version: "2023-06-01".into(), openai_api: OpenAiApi::Chat, + maple_pcr0_development: false, max_rounds: 0, max_output_tokens: 1, max_token_recoveries: 0, @@ -782,6 +795,10 @@ fn resolve_provider( "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter), "openrouter" => Err("config: OPENROUTER_API_KEY required".into()), + // No key check here: Maple may be resolved for keyless model + // discovery. `provider_connection_from_env` enforces the key + // when the purpose is inference. + "maple" => Ok(Provider::Maple), _ => Err(format!( "config: BUZZ_AGENT_PROVIDER={raw} not supported" )), @@ -811,6 +828,8 @@ struct ProviderConnection { api_key: String, base_url: String, openai_api: OpenAiApi, + /// Development PCR0 trust roots. Only read for `Provider::Maple`. + maple_pcr0_development: bool, /// Provider-specific model env var (e.g. `DATABRICKS_MODEL`), the /// default when `BUZZ_AGENT_MODEL` is absent. provider_model: Option, @@ -818,6 +837,14 @@ struct ProviderConnection { model_env_var: &'static str, } +/// Inference always needs a credential. Discovery may run before the user +/// has one: Maple's enclave lists models for any attested session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectionPurpose { + Inference, + Discovery, +} + /// Resolve one provider's connection settings from env. /// /// `OPENAI_COMPAT_API` is only read when provider=openai, so a stray bad @@ -825,12 +852,16 @@ struct ProviderConnection { /// `api_key` as the optional `DATABRICKS_TOKEN` escape hatch; empty means /// "use OAuth PKCE." Legacy Databricks encodes the model in the URL path, /// Databricks v2 in the request body. -fn provider_connection_from_env(provider: Provider) -> Result { +fn provider_connection_from_env( + provider: Provider, + purpose: ConnectionPurpose, +) -> Result { Ok(match provider { Provider::Anthropic => ProviderConnection { api_key: req("ANTHROPIC_API_KEY")?, base_url: env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), openai_api: OpenAiApi::Auto, // unused for Anthropic + maple_pcr0_development: false, provider_model: env("ANTHROPIC_MODEL"), model_env_var: "ANTHROPIC_MODEL", }, @@ -838,6 +869,7 @@ fn provider_connection_from_env(provider: Provider) -> Result Result Result ProviderConnection { + api_key: match purpose { + ConnectionPurpose::Inference => req("MAPLE_API_KEY")?, + // Empty means "attested session, no credential"; see + // `build_maple_client`. + ConnectionPurpose::Discovery => env("MAPLE_API_KEY").unwrap_or_default(), + }, + base_url: env_or("MAPLE_BASE_URL", "https://enclave.trymaple.ai"), + openai_api: OpenAiApi::Chat, // Maple uses Chat Completions only + maple_pcr0_development: parse_maple_pcr0_environment( + env("MAPLE_PCR0_ENVIRONMENT").as_deref(), + )?, + provider_model: env("MAPLE_MODEL"), + model_env_var: "MAPLE_MODEL", + }, }) } +/// Parse `MAPLE_PCR0_ENVIRONMENT`. Returns `true` for the development trust +/// roots. Pure (env-free) for testability; the caller hands in the raw value. +fn parse_maple_pcr0_environment(raw: Option<&str>) -> Result { + match raw + .unwrap_or("production") + .trim() + .to_ascii_lowercase() + .as_str() + { + "production" | "" => Ok(false), + "development" => Ok(true), + other => Err(format!( + "config: MAPLE_PCR0_ENVIRONMENT={other} not supported (use production|development)" + )), + } +} + /// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the /// caller hands in the raw value. fn parse_openai_api(raw: Option<&str>) -> Result { @@ -2194,6 +2260,37 @@ mod tests { assert!(err.contains("OPENROUTER_API_KEY")); } + /// Maple resolves without a key; the inference key requirement lives in + /// `provider_connection_from_env`, not here. + #[test] + fn resolve_provider_maple_does_not_gate_on_key() { + assert_eq!( + resolve_provider(Some("maple"), None, None, None).unwrap(), + Provider::Maple + ); + assert_eq!( + resolve_provider(Some("MAPLE"), None, None, None).unwrap(), + Provider::Maple + ); + } + + #[test] + fn parse_maple_pcr0_environment_values() { + // Absent and explicit production select the production trust roots. + assert_eq!(parse_maple_pcr0_environment(None), Ok(false)); + assert_eq!(parse_maple_pcr0_environment(Some("production")), Ok(false)); + assert_eq!(parse_maple_pcr0_environment(Some("")), Ok(false)); + // Development is case-insensitive and whitespace-tolerant. + assert_eq!(parse_maple_pcr0_environment(Some("development")), Ok(true)); + assert_eq!( + parse_maple_pcr0_environment(Some(" Development ")), + Ok(true) + ); + // Anything else is a hard startup error, matching provider handling. + let err = parse_maple_pcr0_environment(Some("staging")).unwrap_err(); + assert!(err.contains("MAPLE_PCR0_ENVIRONMENT=staging"), "{err}"); + } + // ── pricing_authority: canonical URL → bare-host registry token ────────── #[test] diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs index 869fbe06664..956062f3dac 100644 --- a/crates/buzz-agent/src/handoff.rs +++ b/crates/buzz-agent/src/handoff.rs @@ -571,6 +571,7 @@ mod tests { Provider::OpenAi, Provider::Databricks, Provider::DatabricksV2, + Provider::Maple, ] { assert_eq!( summary_completion_cap(provider, HANDOFF_MAX_OUTPUT_TOKENS), diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 83f642c1239..1ca6e4ae8b6 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2,6 +2,9 @@ use std::collections::BTreeSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use bytes::Bytes; +use futures_util::StreamExt; +use opensecret::{OpenSecretClient, Pcr0Environment}; use reqwest::Client; use serde_json::{json, Map, Value}; @@ -48,6 +51,10 @@ pub struct Llm { /// Databricks otherwise. Anthropic doesn't use this — it always /// reads `cfg.api_key` directly because the API expects `x-api-key`. auth: Arc, + /// OpenSecret client for `Provider::Maple`, `None` for other providers. + /// The SDK owns the enclave session and re-attests on expiry inside + /// `send_inference_request`, so no session state lives here. + maple: Option, } /// Connect-phase timeout applied to every outgoing LLM HTTP request. @@ -68,10 +75,12 @@ impl Llm { .build() .map_err(|e| AgentError::Llm(format!("http: {e}")))?; let auth = build_token_source(cfg)?; + let maple = build_maple_client(cfg)?; Ok(Self { http, auto_upgraded: AtomicBool::new(false), auth, + maple, }) } @@ -114,6 +123,15 @@ impl Llm { .await .and_then(parse_openai_with_reasoning_details) } + Provider::Maple => { + // OpenAI Chat wire format inside the encrypted envelope. + // "maple" has no manifest records yet, so effort + // normalization falls to the `_default` provider fallback. + let e = + effort.map(|ef| normalize_effort_for_provider("maple", effective_model, ef)); + let body = openai_body(cfg, system_prompt, history, tools, effective_model, e); + self.post_maple(cfg, &body).await.and_then(parse_openai) + } Provider::OpenAi | Provider::Databricks => { let provider_str = match cfg.provider { Provider::OpenAi => "openai", @@ -270,6 +288,18 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; Ok(parse_openai(v)?.text) } + Provider::Maple => { + let body = json!({ + "model": effective_model, + "stream": false, + "max_completion_tokens": max_output_tokens, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + }); + Ok(parse_openai(self.post_maple(cfg, &body).await?)?.text) + } Provider::OpenAi | Provider::Databricks => { let r = self .openai_request(cfg, effective_model, |use_responses, request_model| { @@ -541,6 +571,154 @@ impl Llm { } } + /// POST one Chat Completions body through the attested Maple transport. + /// + /// Retries follow `post()`: 429/5xx and transport failures retry with + /// jittered backoff, a timeout raises the next attempt's budget, + /// malformed 2xx bodies retry, everything else is terminal. Auth + /// rejections are terminal immediately: the SDK already retried once + /// through its own credential recovery, and a static API key cannot + /// produce a different token, so a retry would only duplicate the + /// request. + async fn post_maple(&self, cfg: &Config, body: &Value) -> Result { + let client = self.maple.as_ref().ok_or_else(|| { + AgentError::Llm("maple: client not initialized for this provider".into()) + })?; + let body_bytes = + serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; + let call_start = std::time::Instant::now(); + let mut timeout_failures: u32 = 0; + for attempt in 0..MAX_RETRIES { + let per_request_timeout = escalated_timeout(cfg.llm_timeout, timeout_failures); + let last_attempt = attempt + 1 >= MAX_RETRIES; + // The SDK's HTTP client sets no timeout, so the whole + // exchange, attestation handshake included, runs under one + // per-attempt budget. + let outcome = tokio::time::timeout( + per_request_timeout, + maple_chat_completion(client, &body_bytes), + ) + .await; + let (status, response_body) = match outcome { + Err(_elapsed) => { + timeout_failures += 1; + if !last_attempt { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + timeout_failures, + "llm: maple request timeout, retrying with escalated budget" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &timeout_message(false, per_request_timeout, TimeoutPhase::BodyRead), + )); + } + Ok(Err(MapleCallError::Terminal(message))) => { + return Err(AgentError::Llm(message)); + } + Ok(Err(MapleCallError::Sdk(error))) => { + if !last_attempt && is_retryable_maple_sdk_error(&error) { + if matches!(&error, opensecret::Error::Http(e) if e.is_timeout()) { + timeout_failures += 1; + } + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %error, + "llm: maple transport error, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(maple_sdk_terminal_error( + error, + call_start.elapsed(), + attempt + 1, + )); + } + Ok(Ok(exchange)) => exchange, + }; + let code = status.as_u16(); + if code == 401 || code == 403 { + return Err(AgentError::LlmAuth(format!( + "{status}: {} — update key in agent settings", + String::from_utf8_lossy(&response_body) + ))); + } + if status.is_server_error() || code == 429 || code == 499 { + let body_text = String::from_utf8_lossy(&response_body); + if !last_attempt { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + %status, + "llm: maple retryable status, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("exhausted retries: {status}: {body_text}"), + )); + } + if code == 404 { + return Err(AgentError::LlmModelNotFound(format!( + "{status}: {}", + String::from_utf8_lossy(&response_body) + ))); + } + if !status.is_success() { + let body_text = String::from_utf8_lossy(&response_body).into_owned(); + if code == 400 && is_context_length_error(&body_text) { + return Err(AgentError::LlmContextExceeded(format!( + "{status}: {body_text}" + ))); + } + if code == 400 && is_unsupported_image_input_error(&body_text) { + return Err(AgentError::UnsupportedImageInput(body_text)); + } + return Err(AgentError::Llm(format!("{status}: {body_text}"))); + } + match serde_json::from_slice(&response_body) { + Ok(value) => return Ok(value), + Err(e) => { + // As in `post()`: a 2xx body that fails to parse is + // treated as transient and re-sent; no tool call was + // extracted from it. + if !last_attempt { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %e, + "llm: maple malformed response body, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("json: {e}"), + )); + } + } + } + // Unreachable in practice: every iteration returns or continues. + // See the matching tail in `post()`. + Err(terminal_llm_error( + call_start.elapsed(), + MAX_RETRIES, + "exhausted retries", + )) + } + /// If `err` names `/v1/responses` / "use the Responses API", latch a /// sticky upgrade so subsequent OpenAI calls hit Responses. Logged once. fn try_upgrade(&self, err: &AgentError) -> bool { @@ -2071,13 +2249,16 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { /// never read for Anthropic requests (those go through `post_anthropic` with /// `x-api-key`), but Llm holds one to keep the field non-`Option`. /// - `Provider::OpenAi`: a static source over `OPENAI_COMPAT_API_KEY`. +/// - `Provider::Maple`: a static source over `MAPLE_API_KEY`. Never read for +/// Maple requests (the opensecret client carries the key); held to keep +/// the field non-`Option`, as for Anthropic. /// - `Provider::Databricks`: if `DATABRICKS_TOKEN` is set, a static source. /// 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. pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => { + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter | Provider::Maple => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { @@ -2103,9 +2284,11 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A pub(crate) fn summary_completion_cap(provider: Provider, max_output_tokens: u32) -> u32 { match provider { Provider::OpenRouter => max_output_tokens.saturating_mul(2), - Provider::Anthropic | Provider::OpenAi | Provider::Databricks | Provider::DatabricksV2 => { - max_output_tokens - } + Provider::Anthropic + | Provider::OpenAi + | Provider::Databricks + | Provider::DatabricksV2 + | Provider::Maple => max_output_tokens, } } @@ -2475,6 +2658,137 @@ async fn openrouter_post( )) } +/// Build the attested OpenSecret client when the provider is Maple. +/// +/// Construction validates the base URL and picks the PCR0 trust roots; no +/// network I/O. Localhost base URLs switch the SDK to mock attestation (its +/// own rule); anything else requires HTTPS and a verified Nitro attestation +/// document before any request body leaves the process. +/// +/// An empty `api_key` builds a keyless client. The enclave lists models for +/// an attested session without a credential, so discovery can run before the +/// user enters a key. Inference configs always carry a key; an empty one +/// must not become a bogus `Bearer ` header. +pub(crate) fn build_maple_client(cfg: &Config) -> Result, AgentError> { + if cfg.provider != Provider::Maple { + return Ok(None); + } + let environment = if cfg.maple_pcr0_development { + Pcr0Environment::Development + } else { + Pcr0Environment::Production + }; + let client = if cfg.api_key.trim().is_empty() { + OpenSecretClient::new_with_pcr0_environment(cfg.base_url.clone(), environment) + } else { + OpenSecretClient::new_with_api_key_and_pcr0_environment( + cfg.base_url.clone(), + cfg.api_key.clone(), + environment, + ) + }; + client + .map(Some) + .map_err(|e| AgentError::Llm(format!("maple: {e}"))) +} + +/// Failure modes of one Maple exchange: SDK faults (classified by +/// `is_retryable_maple_sdk_error`) versus local hard stops like the response +/// size cap. +enum MapleCallError { + Sdk(opensecret::Error), + Terminal(String), +} + +/// One encrypted Chat Completions exchange through the opensecret SDK. +/// +/// Returns the decrypted status and body bytes; the caller classifies +/// non-2xx statuses and parses the JSON. Success bodies are capped at +/// `MAX_LLM_RESPONSE_BYTES` (a hard error, as in `post()`); error bodies are +/// diagnostics, truncated at `MAX_LLM_ERROR_BODY_BYTES`. +async fn maple_chat_completion( + client: &OpenSecretClient, + body_bytes: &[u8], +) -> Result<(http::StatusCode, Vec), MapleCallError> { + let request = http::Request::builder() + .method(http::Method::POST) + .uri("/v1/chat/completions") + .body(Bytes::copy_from_slice(body_bytes)) + .map_err(|e| MapleCallError::Terminal(format!("maple: build request: {e}")))?; + let response = client + .send_inference_request(request) + .await + .map_err(MapleCallError::Sdk)?; + let status = response.status(); + let mut buf: Vec = Vec::new(); + let mut body = response.into_body(); + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(MapleCallError::Sdk)?; + if status.is_success() { + if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES { + return Err(MapleCallError::Terminal(format!( + "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes" + ))); + } + buf.extend_from_slice(&chunk); + } else { + let room = MAX_LLM_ERROR_BODY_BYTES.saturating_sub(buf.len()); + let take = chunk.len().min(room); + buf.extend_from_slice(&chunk[..take]); + if take < chunk.len() { + break; + } + } + } + Ok((status, buf)) +} + +/// `true` when an identical retry may succeed: transport failures under the +/// SDK, and 429/5xx `Api` errors from the attestation-handshake endpoints. +/// Attestation, session, and crypto failures are terminal; the SDK already +/// retried one re-attestation before surfacing them. +fn is_retryable_maple_sdk_error(error: &opensecret::Error) -> bool { + match error { + // The SDK pins its own reqwest version, so its error type differs + // from the workspace's and cannot flow through + // `is_retryable_transport_error`. + opensecret::Error::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(), + opensecret::Error::Api { status, .. } => { + *status == 429 || *status == 499 || (500..600).contains(status) + } + _ => false, + } +} + +/// Map a terminal opensecret SDK error onto `AgentError`. Attestation +/// failures say the request was never sent. 401/403 map to `LlmAuth` so the +/// desktop shows its credential prompt. +fn maple_sdk_terminal_error( + error: opensecret::Error, + elapsed: std::time::Duration, + attempts: u32, +) -> AgentError { + match error { + opensecret::Error::AttestationVerificationFailed(message) => AgentError::Llm(format!( + "maple: enclave attestation verification failed — request not sent: {message}" + )), + opensecret::Error::Api { + status: status @ (401 | 403), + message, + } => AgentError::LlmAuth(format!("{status}: {message}")), + // The SDK's reqwest error differs from the workspace's type, so + // `classify_transport_error` cannot be reused. Timeouts never + // originate here; the per-attempt budget in `post_maple` fires. + opensecret::Error::Http(e) => { + terminal_llm_error(elapsed, attempts, &format!("transport: {e}")) + } + opensecret::Error::Api { status, message } => { + terminal_llm_error(elapsed, attempts, &format!("maple: {status}: {message}")) + } + other => AgentError::Llm(format!("maple: {other}")), + } +} + fn apply_openrouter_mutations( body: &mut Value, effort: Option, @@ -2618,6 +2932,7 @@ mod tests { base_url: "http://example.invalid".into(), anthropic_api_version: "2023-06-01".into(), openai_api: OpenAiApi::Chat, + maple_pcr0_development: false, hints_enabled: true, thinking_effort: None, thinking_summary: ThinkingSummary::Auto, @@ -5779,6 +6094,7 @@ mod tests { .unwrap(), auto_upgraded: std::sync::atomic::AtomicBool::new(false), auth, + maple: None, } } @@ -6232,6 +6548,100 @@ mod tests { assert!(body.get("max_tokens").is_none()); } + // ---- Maple (OpenSecret) ---- + + #[test] + fn maple_client_is_built_only_for_the_maple_provider() { + assert!(build_maple_client(&cfg(Provider::OpenAi)) + .expect("non-Maple providers skip construction") + .is_none()); + + let mut maple_cfg = cfg(Provider::Maple); + maple_cfg.base_url = "https://enclave.trymaple.ai".into(); + assert!(build_maple_client(&maple_cfg) + .expect("valid HTTPS base URL constructs") + .is_some()); + + // Keyless discovery: an attested but unauthenticated client, not an + // empty bearer. + maple_cfg.api_key = String::new(); + assert!(build_maple_client(&maple_cfg) + .expect("keyless client constructs") + .is_some()); + + // The SDK requires HTTPS for non-loopback hosts; a plain-HTTP remote + // base URL is a startup error, not a first-request surprise. + maple_cfg.base_url = "http://enclave.trymaple.ai".into(); + match build_maple_client(&maple_cfg) { + Err(AgentError::Llm(s)) => assert!(s.starts_with("maple: "), "{s}"), + Err(other) => panic!("expected AgentError::Llm, got: {other:?}"), + Ok(_) => panic!("plain-HTTP remote base URL must be rejected"), + } + } + + /// 429/5xx from the handshake endpoints retry; attestation and crypto + /// failures are terminal. + #[test] + fn maple_sdk_error_retryability() { + for status in [429u16, 499, 500, 503] { + assert!( + is_retryable_maple_sdk_error(&opensecret::Error::Api { + status, + message: "busy".into() + }), + "{status} should be retryable" + ); + } + for error in [ + opensecret::Error::Api { + status: 400, + message: "bad".into(), + }, + opensecret::Error::AttestationVerificationFailed("pcr0 mismatch".into()), + opensecret::Error::Session("stale".into()), + opensecret::Error::Decryption("bad tag".into()), + ] { + assert!(!is_retryable_maple_sdk_error(&error), "{error} is terminal"); + } + } + + #[test] + fn maple_terminal_errors_map_onto_the_agent_taxonomy() { + let auth = maple_sdk_terminal_error( + opensecret::Error::Api { + status: 401, + message: "invalid api key".into(), + }, + Duration::from_secs(1), + 1, + ); + assert!( + matches!(auth, AgentError::LlmAuth(ref s) if s.contains("invalid api key")), + "{auth:?}" + ); + + let attestation = maple_sdk_terminal_error( + opensecret::Error::AttestationVerificationFailed("pcr0 mismatch".into()), + Duration::from_secs(1), + 1, + ); + assert!( + matches!( + attestation, + AgentError::Llm(ref s) + if s.contains("attestation verification failed") && s.contains("pcr0 mismatch") + ), + "{attestation:?}" + ); + } + + /// Maple's summarize path requests exactly the caller's budget, with no + /// separate reasoning allowance like OpenRouter. + #[test] + fn maple_summary_completion_cap_is_the_callers_budget() { + assert_eq!(summary_completion_cap(Provider::Maple, 2048), 2048); + } + // ---- A5: error-inside-200 ---- #[test] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ac982df6f20..e23fe6dc07b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -302,6 +302,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -416,6 +455,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-task" version = "4.7.1" @@ -721,6 +782,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -1040,10 +1107,14 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bytes", "dirs", + "futures-util", "getrandom 0.4.3", "hex", + "http", "nix 0.31.3", + "opensecret", "reqwest 0.13.4", "rmcp", "serde", @@ -1527,6 +1598,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1701,6 +1799,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2002,6 +2106,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2351,17 +2467,42 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2449,6 +2590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2460,7 +2602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -2629,15 +2771,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7984231f8b4c72eb3b88c70040dc1e4ff6803fa9169e93c0ac465942d74fa36a" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2651,7 +2807,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2662,6 +2818,27 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -2810,6 +2987,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom 7.1.3", + "pin-project-lite", +] + [[package]] name = "extended" version = "0.1.0" @@ -2886,6 +3074,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3526,6 +3724,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -6858,6 +7067,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6920,6 +7138,43 @@ dependencies = [ "tracing", ] +[[package]] +name = "opensecret" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d9a35e5dd1ee761d3449d9e2db722eb1ebbab0eef02d3bb8601fd6b23d6bd9" +dependencies = [ + "aes-gcm", + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "bytes", + "chacha20poly1305", + "chrono", + "ciborium", + "eventsource-stream", + "futures", + "hex", + "hkdf", + "http", + "p256", + "percent-encoding", + "pin-project", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "x25519-dalek", + "x509-parser", + "yasna", +] + [[package]] name = "openssl" version = "0.10.81" @@ -7143,6 +7398,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "palette" version = "0.7.6" @@ -7280,6 +7547,15 @@ dependencies = [ "hmac 0.13.0", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -7510,14 +7786,24 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -7760,6 +8046,15 @@ dependencies = [ "num-integer", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -8622,6 +8917,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfd" version = "0.16.0" @@ -8851,6 +9156,15 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -9118,6 +9432,20 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -9440,7 +9768,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -9651,6 +9979,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -9913,6 +10251,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -9920,7 +10268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] @@ -13312,6 +13660,35 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -13498,6 +13875,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" + [[package]] name = "yoke" version = "0.8.3" From a71ff80d483878e5925f8b14c0bf66a5fc129bd6 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 22 Aug 2026 20:29:31 -0500 Subject: [PATCH 4/4] feat(desktop): add Maple provider config UI Show the buzz-agent Maple provider in the desktop app: the persona provider picker gains a "Maple (encrypted)" entry with a Maple API Key credential field, and the readiness mirror requires MAPLE_API_KEY plus a model, accepting MAPLE_MODEL as the provider-specific fallback. No model-discovery code is added here. The picker loads Maple's catalog through the provider-generic "buzz-agent models" path. The buzz-agent per-provider readiness tests (OpenRouter, Maple) move to a sibling file, following the goose file-config convention, so readiness.rs stays under the desktop file-size limit. A Playwright screenshot spec covers the picker entry and the credential field. Co-Authored-By: Claude Fable 5 Signed-off-by: benthecarman --- desktop/playwright.config.ts | 1 + .../src-tauri/src/managed_agents/readiness.rs | 64 ++------- .../readiness_provider_tests.rs | 128 ++++++++++++++++++ .../features/agents/ui/agentConfigOptions.tsx | 7 + desktop/tests/e2e/maple-provider-ui.spec.ts | 126 +++++++++++++++++ 5 files changed, 274 insertions(+), 52 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs create mode 100644 desktop/tests/e2e/maple-provider-ui.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..425ea20bbe9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ "**/voice-settings.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", + "**/maple-provider-ui.spec.ts", "**/edit-agent.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index f7f5d5c5d0e..3044dc16673 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -482,6 +482,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { Some("anthropic") => Some("ANTHROPIC_MODEL"), Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), Some("openrouter") => Some("OPENROUTER_MODEL"), + Some("maple") => Some("MAPLE_MODEL"), _ => None, }; let model_present = effective @@ -530,6 +531,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "OPENROUTER_API_KEY".to_string(), }); } + Some("maple") + if env_key_missing("MAPLE_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "MAPLE_API_KEY".to_string(), + }); + } _ => { // Unknown provider or no provider yet — only the NormalizedField // requirement above captures this gap. @@ -1679,58 +1686,6 @@ mod tests { field: "model".to_string() })); } - - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } } // Goose file-config-aware requirement tests live in a sibling file so this @@ -1738,3 +1693,8 @@ mod tests { #[cfg(test)] #[path = "readiness_goose_file_config_tests.rs"] mod goose_file_config_tests; + +// Per-provider readiness tests (OpenRouter, Maple), same convention. +#[cfg(test)] +#[path = "readiness_provider_tests.rs"] +mod provider_tests; diff --git a/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs new file mode 100644 index 00000000000..09a06301a29 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs @@ -0,0 +1,128 @@ +//! Per-provider readiness tests for buzz-agent (OpenRouter, Maple). +//! +//! Included from `readiness.rs` via `#[path]`, so `super::*` resolves against +//! that module. Sibling file, like `readiness_goose_file_config_tests.rs`, +//! to keep `readiness.rs` under the file-size limit. + +use std::collections::BTreeMap; + +use super::*; +use crate::managed_agents::discovery::known_acp_runtime_exact; + +/// Build a minimal `EffectiveAgentEnv` with the given env map and command. +fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command: command.to_string(), + } +} + +fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +// ── OpenRouter readiness ───────────────────────────────────────────────── + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} + +// ── Maple readiness ────────────────────────────────────────────────────── + +#[test] +fn buzz_agent_maple_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "maple"), + ("BUZZ_AGENT_MODEL", "llama3-3-70b"), + ("MAPLE_API_KEY", "maple-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!(result.is_ready(), "maple with all fields should be ready"); +} + +#[test] +fn buzz_agent_maple_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "maple"), + ("BUZZ_AGENT_MODEL", "llama3-3-70b"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "MAPLE_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_maple_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "maple"), + ("MAPLE_MODEL", "llama3-3-70b"), + ("MAPLE_API_KEY", "maple-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "MAPLE_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5c515a05073..9fb739c7859 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -46,6 +46,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "anthropic", "databricks", "databricks_v2", + "maple", "openai", "openai-compat", "openrouter", @@ -135,6 +136,11 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< secretEnvVar: "OPENROUTER_API_KEY", apiKeyLabel: "OpenRouter API Key", }, + maple: { + requiredEnvKeys: ["MAPLE_API_KEY"], + secretEnvVar: "MAPLE_API_KEY", + apiKeyLabel: "Maple API Key", + }, }; const DEFAULT_MODEL_OPTION: PersonaModelOption = { @@ -147,6 +153,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, { id: "openrouter", label: "OpenRouter" }, + { id: "maple", label: "Maple (encrypted)" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, diff --git a/desktop/tests/e2e/maple-provider-ui.spec.ts b/desktop/tests/e2e/maple-provider-ui.spec.ts new file mode 100644 index 00000000000..15ce3584e13 --- /dev/null +++ b/desktop/tests/e2e/maple-provider-ui.spec.ts @@ -0,0 +1,126 @@ +/** + * Screenshot spec for the Maple (OpenSecret) provider entry in the persona + * provider picker. + * + * 01 – Provider dropdown open, "Maple (encrypted)" listed between + * OpenRouter and Buzz shared compute. + * 02 – Maple selected: the dialog shows the "Maple API Key" credential + * field and the model picker (models arrive through the + * provider-generic "buzz-agent models" discovery path). + */ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const SHOTS = "test-results/screenshots-maple"; + +async function openPersonaEditDialog(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-personas")).toBeVisible({ + timeout: 10_000, + }); + + const actionsBtn = page.getByRole("button", { + name: "Open actions for Encrypted Agent", + }); + await expect(actionsBtn).toBeVisible({ timeout: 8_000 }); + await actionsBtn.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.getByRole("tab", { name: "Customize for this agent" }).click(); + return dialog; +} + +test.describe("maple provider UI screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01-maple-provider-option", async ({ page }) => { + await installMockBridge(page, { + personas: [ + { + displayName: "Encrypted Agent", + systemPrompt: "An agent for capturing the provider picker.", + }, + ], + }); + const dialog = await openPersonaEditDialog(page); + + const providerSelect = dialog.locator("#persona-llm-provider"); + await expect(providerSelect).toBeVisible({ timeout: 8_000 }); + await providerSelect.click(); + + const mapleOption = page.getByRole("menuitemradio", { + name: "Maple (encrypted)", + }); + await expect(mapleOption).toBeVisible({ timeout: 5_000 }); + await expect(mapleOption).toHaveText("Maple (encrypted)"); + + await waitForAnimations(page); + + // The option popover is portaled outside the dialog, so the clip must + // cover the union of the dialog and the popover rectangles. + const dialogBox = await dialog.boundingBox(); + const optionBox = await mapleOption.boundingBox(); + const x = Math.min(dialogBox.x, optionBox.x) - 16; + const y = Math.min(dialogBox.y, optionBox.y) - 16; + await page.screenshot({ + path: `${SHOTS}/01-maple-provider-option.png`, + clip: { + x: Math.max(0, x), + y: Math.max(0, y), + width: + Math.max( + dialogBox.x + dialogBox.width, + optionBox.x + optionBox.width, + ) - + Math.max(0, x) + + 16, + height: + Math.max( + dialogBox.y + dialogBox.height, + optionBox.y + optionBox.height, + ) - + Math.max(0, y) + + 16, + }, + }); + }); + + test("02-maple-api-key-field", async ({ page }) => { + await installMockBridge(page, { + personas: [ + { + displayName: "Encrypted Agent", + systemPrompt: "An agent for capturing the provider picker.", + }, + ], + }); + const dialog = await openPersonaEditDialog(page); + + const providerSelect = dialog.locator("#persona-llm-provider"); + await expect(providerSelect).toBeVisible({ timeout: 8_000 }); + await providerSelect.click(); + + await page + .getByRole("menuitemradio", { name: "Maple (encrypted)" }) + .click(); + await expect(providerSelect).toContainText("Maple (encrypted)", { + timeout: 5_000, + }); + + // Selecting Maple swaps the credential field to the Maple API Key input. + await expect(dialog.getByText("Maple API Key")).toBeVisible({ + timeout: 5_000, + }); + + // Close any popover so the shot captures the dialog's resting state. + await page.keyboard.press("Escape"); + await waitForAnimations(page); + + await dialog.screenshot({ path: `${SHOTS}/02-maple-api-key-field.png` }); + }); +});