feat: add first-class OpenRouter provider support (#1975)

## Summary

First-class `Provider::OpenRouter` support joining the existing
anthropic/openai/databricks providers. Reuses the Chat Completions path
with targeted mutations for OpenRouter's routing contract.

**Core (`crates/buzz-agent`):**
- `Provider::OpenRouter` enum variant with `OPENROUTER_API_KEY`,
`BUZZ_AGENT_MODEL` → `OPENROUTER_MODEL` fallback, `OPENROUTER_BASE_URL`
env convention
- Body mutator: `reasoning: {effort}` when effort is configured, and
`max_completion_tokens` translated to OpenRouter's `max_tokens`
spelling; no `provider.require_parameters` filter (it routes only to
endpoints advertising every parameter in the body, which hard-404s a
valid model id); summaries get neither. `openai_body` is always called
with `effort=None` on the OpenRouter path — the `reasoning` object is
added by the mutator directly, so `reasoning_effort` is structurally
absent.
- Attribution headers: `HTTP-Referer: https://github.com/block/buzz`,
`X-OpenRouter-Title: Buzz`
- Error-inside-200 check in shared `parse_openai` (`finish_reason ==
"error"`)
- 401 auth handling: static API keys (`refresh_now` returns the same
token) fail terminal immediately with one wire request; PKCE/minting
sources get one retry with the fresh token.
- Status+`error_type` retry matrix (4-arm collapsed form): 429 (honor
`Retry-After`), 502 (retry), 503/`provider_overloaded` (honor
`Retry-After`), everything else including untyped 503 (bounded retries →
actionable routing message). 499 included matching shared `post()`
(#2175) for turn-timeout stall surfacing. Terminal failures wrapped in
`terminal_llm_error` for duration+attempt-count context.
- `anthropic/*` `cache_control` injection (model-gated, mixed-content
safe)
- Provider-agnostic `reasoning_details` opaque round-trip on
`HistoryItem::Assistant` for tool-call continuations — captured verbatim
in `parse_openai_with_reasoning_details`, replayed verbatim in
`openai_body`, byte-accounting charged. `provider_extra` passthrough
from `make_tool_call` composes independently.

**Desktop:**
- Readiness arms checking `OPENROUTER_API_KEY` + `OPENROUTER_MODEL`
- Model discovery via `{OPENROUTER_BASE_URL}/models` filtered on
`supported_parameters` contains `tools`
- Picker entry, credential config, effort table 3-file sync

**`desktop/src/features/agents/AGENTS.md`: no rules changed** — the
scoped rule requiring an explicit note is satisfied here.

Implements the gate-cleared plan from
`PLANS/OPENROUTER_PROVIDER_PLAN.md` (rev 3).

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-07-29 23:08:35 +00:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent f95fdc1a10
commit ab55fee818
15 changed files with 2958 additions and 304 deletions
+23 -7
View File
@@ -21,9 +21,9 @@
HTTPS
Anthropic Messages API
or any OpenAI-compat
(vLLM, llama.cpp, OpenRouter,
Anthropic Messages API,
OpenRouter, or any OpenAI-compat
(vLLM, llama.cpp, Databricks,
Block Gateway, Ollama, …)
```
@@ -50,6 +50,12 @@ OPENAI_COMPAT_MODEL=gpt-5 \
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
./target/release/buzz-agent
# Or OpenRouter
BUZZ_AGENT_PROVIDER=openrouter \
OPENROUTER_API_KEY=sk-or-v1-... \
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \
./target/release/buzz-agent
# Or Databricks model serving via OAuth 2.0 PKCE
BUZZ_AGENT_PROVIDER=databricks \
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
@@ -129,15 +135,18 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| Variable | Default | Notes |
|---|---|---|
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, Ollama, etc. |
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
| `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` | |
| `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. |
@@ -167,17 +176,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| vLLM | `openai` | `POST {base}/chat/completions` | any tool-calling model |
| llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF |
| Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder |
| OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route |
| 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) |
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 |
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent 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`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_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).
By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway).
`provider=openrouter` is first-class, not routed through `provider=openai`: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top —
- `reasoning.effort` is set on the request when reasoning effort is configured. The request deliberately carries no `provider.require_parameters` filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise `reasoning`, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead.
- The response's `reasoning_details` array (opaque extended-thinking payload) is captured and replayed byte-for-byte on the next turn's assistant message, so multi-turn tool use keeps the model's chain-of-thought.
- `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` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.
## MCP Servers
+76
View File
@@ -256,6 +256,7 @@ impl RunCtx<'_> {
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: Vec::new(),
reasoning_details: response.reasoning_details.clone(),
});
let stop = map_stop(response.stop);
// Only gate genuine end_turn — don't override max_tokens/refusal.
@@ -292,6 +293,7 @@ impl RunCtx<'_> {
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: calls.clone(),
reasoning_details: response.reasoning_details,
});
if let Some(stop) = self.execute_calls(&calls).await {
@@ -726,6 +728,7 @@ pub(crate) fn push_hook_outputs_as_tool_results(
// preserve.
provider_extra: Default::default(),
}],
reasoning_details: None,
});
history.push(HistoryItem::ToolResult(ToolResult {
provider_id,
@@ -790,3 +793,76 @@ fn map_stop(p: ProviderStop) -> StopReason {
ProviderStop::Refusal => StopReason::Refusal,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A9 regression: `reasoning_details` contributes real bytes to
/// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a
/// history item carrying a large opaque reasoning array must actually
/// drive `truncate_history` eviction — not be silently invisible to the
/// sizing gate that decides what survives a turn.
#[test]
fn truncate_history_evicts_oldest_turn_with_reasoning_details() {
let big_reasoning = json!([{ "type": "reasoning.text", "text": "x".repeat(400) }]);
let mut history = vec![
HistoryItem::User("first question".into()),
HistoryItem::Assistant {
text: "first answer".into(),
tool_calls: vec![],
reasoning_details: Some(big_reasoning),
},
HistoryItem::User("second question".into()),
HistoryItem::Assistant {
text: "second answer".into(),
tool_calls: vec![],
reasoning_details: None,
},
];
let total_before: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
// Budget below the total but above the second (smaller) turn alone,
// so only the oldest user+assistant pair — the one carrying
// reasoning_details — must be dropped.
let max_bytes = total_before - 100;
assert!(
max_bytes > 0,
"test fixture must leave room to evict only one turn"
);
truncate_history(&mut history, max_bytes);
assert_eq!(
history.len(),
2,
"the oldest user+assistant turn (with reasoning_details) must be evicted"
);
assert!(matches!(&history[0], HistoryItem::User(s) if s == "second question"));
assert!(
matches!(&history[1], HistoryItem::Assistant { text, .. } if text == "second answer")
);
let total_after: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
assert!(total_after <= max_bytes);
}
#[test]
fn truncate_history_noop_when_under_budget() {
let mut history = vec![
HistoryItem::User("hi".into()),
HistoryItem::Assistant {
text: "hello".into(),
tool_calls: vec![],
reasoning_details: None,
},
];
let original_len = history.len();
truncate_history(&mut history, 1_000_000);
assert_eq!(
history.len(),
original_len,
"under budget must not evict anything"
);
}
}
+47 -13
View File
@@ -671,6 +671,8 @@ pub enum Provider {
/// Databricks AI Gateway v2. Routes by model family through the gateway's
/// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths.
DatabricksV2,
/// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible.
OpenRouter,
}
/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
@@ -740,10 +742,11 @@ pub struct Config {
pub thinking_effort: Option<ThinkingEffort>,
/// 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`. Only consulted on Anthropic
/// Messages routes (first-party Anthropic and the DatabricksV2 Claude
/// route) — the Databricks gateway does not auto-cache, so without this the
/// surfaced `cache_read_input_tokens` is structurally always 0.
/// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Consulted on every route that
/// speaks the Anthropic caching dialect: first-party Anthropic, the
/// DatabricksV2 Claude route, and OpenRouter's `anthropic/*` models. The
/// Databricks gateway does not auto-cache, so without this the surfaced
/// `cache_read_input_tokens` is structurally always 0.
pub prompt_caching: bool,
}
@@ -755,6 +758,7 @@ impl Config {
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(),
)?;
// Universal model override — takes priority over provider-specific model
@@ -797,6 +801,16 @@ impl Config {
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 system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) {
(Some(_), Some(_)) => return Err(
@@ -1002,6 +1016,7 @@ fn resolve_provider(
requested: Option<&str>,
anthropic_key: Option<&str>,
openai_key: Option<&str>,
openrouter_key: Option<&str>,
) -> Result<Provider, String> {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
@@ -1017,6 +1032,8 @@ fn resolve_provider(
),
"databricks" => Ok(Provider::Databricks),
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
"openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
"openrouter" => Err("config: OPENROUTER_API_KEY required".into()),
_ => Err(format!(
"config: BUZZ_AGENT_PROVIDER={raw} not supported"
)),
@@ -1234,11 +1251,11 @@ mod tests {
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(),
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(),
resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(),
Provider::OpenAi
);
}
@@ -1246,17 +1263,17 @@ mod tests {
#[test]
fn resolve_provider_errors_when_requested_provider_key_missing() {
// No fallback — missing key returns an error regardless of Databricks availability.
let err = resolve_provider(Some("anthropic"), None, None).unwrap_err();
let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err();
assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}");
let err = resolve_provider(Some("openai-compat"), None, Some(" ")).unwrap_err();
let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
}
#[test]
fn resolve_provider_errors_when_provider_env_absent() {
// No implicit inference — absent BUZZ_AGENT_PROVIDER is an error.
let err = resolve_provider(None, None, None).unwrap_err();
let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
@@ -1266,19 +1283,19 @@ mod tests {
// When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless
// of DATABRICKS_HOST/MODEL (those are validated later in from_env()).
assert_eq!(
resolve_provider(Some("databricks"), None, None).unwrap(),
resolve_provider(Some("databricks"), None, None, None).unwrap(),
Provider::Databricks
);
// Missing key for other providers still errors — no Databricks fallback.
let err = resolve_provider(Some("openai"), None, None).unwrap_err();
let err = resolve_provider(Some("openai"), None, None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
let err = resolve_provider(None, None, None).unwrap_err();
let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_unsupported_error_preserves_user_casing() {
let err = resolve_provider(Some("OpenAIish"), None, None).unwrap_err();
let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}
@@ -2666,6 +2683,9 @@ mod tests {
if p == "databricks" {
return openai_result(&m);
}
if p == "openrouter" {
return (ALL_7.to_vec(), Some("medium"));
}
// openai-compat, unknown, empty → all-7, default medium.
(ALL_7.to_vec(), Some("medium"))
}
@@ -2717,4 +2737,18 @@ mod tests {
);
}
}
#[test]
fn resolve_provider_openrouter_with_key() {
assert_eq!(
resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(),
Provider::OpenRouter
);
}
#[test]
fn resolve_provider_openrouter_missing_key() {
let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err();
assert!(err.contains("OPENROUTER_API_KEY"));
}
}
+5 -1
View File
@@ -259,7 +259,11 @@ fn push_history_snippet(out: &mut String, item: &HistoryItem) {
out.push_str(s);
out.push('\n');
}
HistoryItem::Assistant { text, tool_calls } => {
HistoryItem::Assistant {
text,
tool_calls,
reasoning_details: _,
} => {
out.push_str("[assistant] ");
if !text.is_empty() {
out.push_str(text);
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -62,6 +62,7 @@ pub enum HistoryItem {
Assistant {
text: String,
tool_calls: Vec<ToolCall>,
reasoning_details: Option<Value>,
},
ToolResult(ToolResult),
}
@@ -83,7 +84,11 @@ impl HistoryItem {
fn size_with(&self, content_size: fn(&ToolResultContent) -> usize) -> usize {
match self {
Self::User(s) => s.len(),
Self::Assistant { text, tool_calls } => {
Self::Assistant {
text,
tool_calls,
reasoning_details,
} => {
text.len()
+ tool_calls
.iter()
@@ -102,6 +107,11 @@ impl HistoryItem {
.unwrap_or(0)
})
.sum::<usize>()
+ reasoning_details
.as_ref()
.and_then(|v| serde_json::to_vec(v).ok())
.map(|b| b.len())
.unwrap_or(0)
}
Self::ToolResult(r) => {
r.provider_id.len() + r.content.iter().map(content_size).sum::<usize>()
@@ -188,6 +198,10 @@ pub struct LlmResponse {
///
/// Empty string when the provider returned no reasoning content.
pub reasoning: String,
/// Raw `reasoning_details` array from an OpenRouter response, if present.
/// Replayed on subsequent turns so the model can continue its chain-of-thought.
/// `None` for all non-OpenRouter providers.
pub reasoning_details: Option<Value>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -481,6 +495,7 @@ mod tests {
arguments: Value::Null,
provider_extra: extra,
}],
reasoning_details: None,
};
let without_extra = HistoryItem::Assistant {
text: String::new(),
@@ -490,6 +505,7 @@ mod tests {
arguments: Value::Null,
provider_extra: Map::new(),
}],
reasoning_details: None,
};
assert!(with_extra.estimated_bytes() > without_extra.estimated_bytes() + 500);
assert_eq!(
+38 -88
View File
@@ -99,6 +99,17 @@ pub async fn get_agent_models(
// so a build-provided provider still gets live discovery.
let effective_provider =
effective_discovery_provider(saved_provider.as_deref(), provider_env_var, &merged_env);
if let Some(models) = discover_openrouter_models(
&state.http_client,
&effective_provider,
&merged_env,
persisted_model.clone(),
)
.await?
{
return Ok(models);
}
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
&effective_provider,
@@ -154,69 +165,11 @@ fn model_discovery_error(pubkey: &str, error: &str) -> String {
)
}
/// Everything `get_agent_models` needs from the record + context, resolved in
/// one pure step so the linked-agent regression test can bind the exact values
/// the command consumes.
#[derive(Debug, PartialEq, Eq)]
struct AgentModelDiscoveryConfig {
/// Effective harness command (descriptor-resolved), for `resolve_command`.
command: String,
/// Effective harness args (descriptor-resolved).
args: Vec<String>,
/// Model from the authoritative resolver spawn uses — linked instances
/// read their definition, never stale `record.model` bytes.
model: Option<String>,
/// Provider from the same authoritative resolver — never stale
/// `record.provider` bytes for linked instances.
provider: Option<String>,
/// The runtime's provider env var (e.g. `GOOSE_PROVIDER`), so discovery
/// can recover the provider from the env when the resolver yields none.
/// `None` for runtimes that do not take a provider, or an unknown command.
provider_env_var: Option<&'static str>,
/// The descriptor's fully layered env (definition/persona/global/agent).
env: BTreeMap<String, String>,
}
/// Resolve the model-discovery config for a saved agent — the descriptor-backed
/// successor to the old `saved_agent_model_discovery_config`.
///
/// Command/args/env come from `resolve_effective_harness_descriptor` (the same
/// resolver as `spawn_agent_child`); model/provider come from
/// `resolve_effective_model_provider` (#1968's definition-authoritative
/// contract) — linked instances read their definition, never a stale
/// materialized `record.model`/`record.provider`, so discovery cannot query a
/// provider this agent will not actually launch with. Definition-less
/// instances keep their own record values, matching spawn's
/// `resolve_definition_less` arm. When the resolver yields no provider,
/// `effective_discovery_provider` recovers the provider the agent will
/// actually launch with from the runtime's own provider env var, read out of
/// the descriptor env (which already layers definition/persona/global values
/// the same way spawn does).
///
/// Returns `Err("DANGLING_HARNESS_ID:<id>")` from the descriptor resolver when
/// the harness id no longer exists; the caller routes it through
/// `model_discovery_error`.
fn agent_model_discovery_config(
record: &crate::managed_agents::ManagedAgentRecord,
personas: &[crate::managed_agents::AgentDefinition],
global: &crate::managed_agents::GlobalAgentConfig,
) -> Result<AgentModelDiscoveryConfig, String> {
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)?;
let (model, provider) =
crate::managed_agents::resolve_effective_model_provider(record, personas, global);
let provider_env_var =
known_acp_runtime(&descriptor.command).and_then(|meta| meta.provider_env_var);
Ok(AgentModelDiscoveryConfig {
command: descriptor.command,
args: descriptor.args,
model,
provider,
provider_env_var,
env: descriptor.env,
})
}
#[path = "agent_models_discovery_config.rs"]
mod discovery_config;
use discovery_config::{
agent_model_discovery_config, draft_agent_model_discovery_env, AgentModelDiscoveryConfig,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -269,31 +222,12 @@ pub async fn discover_agent_models(
.unwrap_or_else(|| agent_command.to_string());
let runtime_meta = known_acp_runtime(agent_command);
let mut derived_env = BTreeMap::new();
if let Some(meta) = runtime_meta {
let provider = input
.provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if !meta.provider_locked {
if let (Some(env_key), Some(provider)) = (meta.provider_env_var, provider) {
derived_env.insert(env_key.to_string(), provider.to_string());
}
}
}
// Layer definition_env below user env_vars so user overrides always win.
// Reserved keys are stripped, matching the same filter applied at spawn.
let mut filtered_definition_env = BTreeMap::new();
for (key, value) in &input.definition_env {
if !crate::managed_agents::is_reserved_env_key(key) {
filtered_definition_env.insert(key.clone(), value.clone());
}
}
// Merge: derived (metadata) → definition env → user env_vars.
let merged_with_def =
crate::managed_agents::merged_user_env(&derived_env, &filtered_definition_env);
let merged_env = crate::managed_agents::merged_user_env(&merged_with_def, &input.env_vars);
let merged_env = draft_agent_model_discovery_env(
agent_command,
input.provider.as_deref(),
&input.definition_env,
&input.env_vars,
);
let merged_env = discovery_env_with_baked_floor(merged_env);
// Recover a build-provided provider when the form has none, so the create
// dialog discovers live models instead of falling through to the subprocess.
@@ -348,6 +282,13 @@ pub async fn discover_agent_models(
return Err("Buzz shared compute is not available in this build".to_string());
}
if let Some(models) =
discover_openrouter_models(&state.http_client, &effective_provider, &merged_env, None)
.await?
{
return Ok(models);
}
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
&effective_provider,
@@ -388,6 +329,15 @@ struct OpenAiModelListItem {
created: Option<i64>,
}
#[path = "agent_models_openrouter.rs"]
mod openrouter;
use openrouter::discover_openrouter_models;
#[cfg(test)]
use openrouter::{
filter_openrouter_models, is_openrouter_provider, openrouter_models_url,
OpenRouterModelListItem, OpenRouterModelListResponse,
};
fn is_openai_compatible_provider(provider: Option<&str>) -> bool {
matches!(
provider
@@ -0,0 +1,112 @@
//! Model-discovery configuration resolution for the agent-models commands.
//!
//! Two entry points, one per call shape: [`agent_model_discovery_config`]
//! resolves a *saved* agent through the same descriptor/model resolvers spawn
//! uses, and [`draft_agent_model_discovery_env`] derives the env for an unsaved
//! form. Both are pure so the regression tests can bind the exact values the
//! commands consume.
//!
//! Included from `agent_models.rs` via `#[path]`, so `super::*` resolves
//! against that module (the `agent_models_tests.rs` convention).
use std::collections::BTreeMap;
use crate::managed_agents::known_acp_runtime;
/// Everything `get_agent_models` needs from the record + context, resolved in
/// one pure step so the linked-agent regression test can bind the exact values
/// the command consumes.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct AgentModelDiscoveryConfig {
/// Effective harness command (descriptor-resolved), for `resolve_command`.
pub(super) command: String,
/// Effective harness args (descriptor-resolved).
pub(super) args: Vec<String>,
/// Model from the authoritative resolver spawn uses — linked instances
/// read their definition, never stale `record.model` bytes.
pub(super) model: Option<String>,
/// Provider from the same authoritative resolver — never stale
/// `record.provider` bytes for linked instances.
pub(super) provider: Option<String>,
/// The runtime's provider env var (e.g. `GOOSE_PROVIDER`), so discovery
/// can recover the provider from the env when the resolver yields none.
/// `None` for runtimes that do not take a provider, or an unknown command.
pub(super) provider_env_var: Option<&'static str>,
/// The descriptor's fully layered env (definition/persona/global/agent).
pub(super) env: BTreeMap<String, String>,
}
/// Resolve the model-discovery config for a saved agent — the descriptor-backed
/// successor to the old `saved_agent_model_discovery_config`.
///
/// Command/args/env come from `resolve_effective_harness_descriptor` (the same
/// resolver as `spawn_agent_child`); model/provider come from
/// `resolve_effective_model_provider` (#1968's definition-authoritative
/// contract) — linked instances read their definition, never a stale
/// materialized `record.model`/`record.provider`, so discovery cannot query a
/// provider this agent will not actually launch with. Definition-less
/// instances keep their own record values, matching spawn's
/// `resolve_definition_less` arm. When the resolver yields no provider,
/// `effective_discovery_provider` recovers the provider the agent will
/// actually launch with from the runtime's own provider env var, read out of
/// the descriptor env (which already layers definition/persona/global values
/// the same way spawn does).
///
/// Returns `Err("DANGLING_HARNESS_ID:<id>")` from the descriptor resolver when
/// the harness id no longer exists; the caller routes it through
/// `model_discovery_error`.
pub(super) fn agent_model_discovery_config(
record: &crate::managed_agents::ManagedAgentRecord,
personas: &[crate::managed_agents::AgentDefinition],
global: &crate::managed_agents::GlobalAgentConfig,
) -> Result<AgentModelDiscoveryConfig, String> {
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)?;
let (model, provider) =
crate::managed_agents::resolve_effective_model_provider(record, personas, global);
let provider_env_var =
known_acp_runtime(&descriptor.command).and_then(|meta| meta.provider_env_var);
Ok(AgentModelDiscoveryConfig {
command: descriptor.command,
args: descriptor.args,
model,
provider,
provider_env_var,
env: descriptor.env,
})
}
/// Derive the discovery env for an unsaved ("draft") agent configuration.
///
/// Mirrors the layering `agent_model_discovery_config` takes from the harness
/// descriptor, but sources the provider from form input: runtime-derived
/// provider env var → definition env → user env vars, so user overrides always
/// win. Extracted so the draft path has the same tested seam as the saved one.
pub(super) fn draft_agent_model_discovery_env(
agent_command: &str,
provider: Option<&str>,
definition_env: &BTreeMap<String, String>,
env_vars: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut derived_env = BTreeMap::new();
if let Some(meta) = known_acp_runtime(agent_command) {
let provider = provider.map(str::trim).filter(|value| !value.is_empty());
if !meta.provider_locked {
if let (Some(env_key), Some(provider)) = (meta.provider_env_var, provider) {
derived_env.insert(env_key.to_string(), provider.to_string());
}
}
}
// Reserved keys are stripped from definition env, matching the same filter
// applied at spawn.
let mut filtered_definition_env = BTreeMap::new();
for (key, value) in definition_env {
if !crate::managed_agents::is_reserved_env_key(key) {
filtered_definition_env.insert(key.clone(), value.clone());
}
}
let merged_with_def =
crate::managed_agents::merged_user_env(&derived_env, &filtered_definition_env);
crate::managed_agents::merged_user_env(&merged_with_def, env_vars)
}
@@ -0,0 +1,112 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use crate::managed_agents::{AgentModelInfo, AgentModelsResponse};
#[cfg(test)]
use super::env_value;
use super::{env_or_process_value, redaction_env_with_value, DiscoveryProvider};
#[derive(Debug, Deserialize)]
#[cfg_attr(test, derive(Clone))]
pub(super) struct OpenRouterModelListResponse {
pub data: Vec<OpenRouterModelListItem>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(test, derive(Clone))]
pub(super) struct OpenRouterModelListItem {
pub id: String,
#[serde(default)]
pub supported_parameters: Vec<String>,
}
pub(super) fn is_openrouter_provider(provider: Option<&str>) -> bool {
matches!(
provider
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("openrouter")
)
}
#[cfg(test)]
pub(super) fn openrouter_models_url(env: &BTreeMap<String, String>) -> String {
let base_url = env_value(env, "OPENROUTER_BASE_URL")
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
format!("{}/models", base_url.trim_end_matches('/'))
}
fn openrouter_models_url_for_discovery(env: &BTreeMap<String, String>) -> String {
let base_url = env_or_process_value(env, "OPENROUTER_BASE_URL")
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
format!("{}/models", base_url.trim_end_matches('/'))
}
pub(super) async fn discover_openrouter_models(
client: &reqwest::Client,
provider: &DiscoveryProvider,
env: &BTreeMap<String, String>,
selected_model: Option<String>,
) -> Result<Option<AgentModelsResponse>, String> {
if !is_openrouter_provider(provider.as_deref()) {
return Ok(None);
}
let api_key = match provider.required_env(env, "OPENROUTER_API_KEY")? {
Some(api_key) => api_key,
None => return Ok(None),
};
let redaction_env = redaction_env_with_value(env, "OPENROUTER_API_KEY", &api_key);
let url = openrouter_models_url_for_discovery(env);
let response = client
.get(&url)
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("OpenRouter model discovery request failed: {error}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let body = crate::managed_agents::redact_env_values_in(&body, &redaction_env);
return Err(format!("OpenRouter model discovery HTTP {status}: {body}"));
}
let response = response
.json::<OpenRouterModelListResponse>()
.await
.map_err(|error| format!("OpenRouter model discovery response parse failed: {error}"))?;
filter_openrouter_models(response, selected_model)
}
pub(super) fn filter_openrouter_models(
response: OpenRouterModelListResponse,
selected_model: Option<String>,
) -> Result<Option<AgentModelsResponse>, String> {
let models: Vec<AgentModelInfo> = response
.data
.into_iter()
.filter(|m| m.supported_parameters.iter().any(|p| p == "tools"))
.map(|m| AgentModelInfo {
id: m.id.clone(),
name: Some(m.id),
description: None,
})
.collect();
if models.is_empty() {
return Err("OpenRouter model discovery returned no tools-capable models".to_string());
}
Ok(Some(AgentModelsResponse {
agent_name: "openrouter".to_string(),
agent_version: "models-api".to_string(),
models,
agent_default_model: None,
selected_model,
supports_switching: true,
}))
}
@@ -590,3 +590,294 @@ fn model_discovery_error_converts_dangling_sentinel_to_sentence() {
let plain = model_discovery_error("agent-pk", "plain failure");
assert_eq!(plain, "cannot discover models for agent-pk: plain failure");
}
// ---------------------------------------------------------------------------
// OpenRouter provider
// ---------------------------------------------------------------------------
#[test]
fn is_openrouter_provider_matches() {
assert!(is_openrouter_provider(Some("openrouter")));
assert!(is_openrouter_provider(Some(" OpenRouter ")));
assert!(!is_openrouter_provider(Some("openai")));
assert!(!is_openrouter_provider(Some("anthropic")));
assert!(!is_openrouter_provider(None));
}
#[test]
fn openrouter_models_url_uses_default_base_url() {
assert_eq!(
openrouter_models_url(&BTreeMap::new()),
"https://openrouter.ai/api/v1/models"
);
}
#[test]
fn openrouter_models_url_respects_custom_base_url() {
let env = BTreeMap::from([(
"OPENROUTER_BASE_URL".to_string(),
"https://eu.openrouter.ai/api/v1".to_string(),
)]);
assert_eq!(
openrouter_models_url(&env),
"https://eu.openrouter.ai/api/v1/models"
);
}
#[test]
fn openrouter_models_url_strips_trailing_slash() {
let env = BTreeMap::from([(
"OPENROUTER_BASE_URL".to_string(),
"https://proxy.example.com/api/v1/".to_string(),
)]);
assert_eq!(
openrouter_models_url(&env),
"https://proxy.example.com/api/v1/models"
);
}
#[test]
fn openrouter_filter_keeps_tools_capable_models() {
let response = OpenRouterModelListResponse {
data: vec![
OpenRouterModelListItem {
id: "anthropic/claude-opus-4-7".to_string(),
supported_parameters: vec!["tools".to_string(), "reasoning".to_string()],
},
OpenRouterModelListItem {
id: "openai/gpt-5.5-pro".to_string(),
supported_parameters: vec!["tools".to_string()],
},
OpenRouterModelListItem {
id: "meta-llama/llama-no-tools".to_string(),
supported_parameters: vec!["temperature".to_string()],
},
],
};
let result = filter_openrouter_models(response, None).unwrap().unwrap();
let ids: Vec<_> = result.models.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, vec!["anthropic/claude-opus-4-7", "openai/gpt-5.5-pro"]);
}
#[test]
fn openrouter_filter_excludes_absent_supported_parameters() {
let response: OpenRouterModelListResponse =
serde_json::from_str(r#"{"data": [{"id": "model-no-params"}]}"#).unwrap();
assert!(
response.data[0].supported_parameters.is_empty(),
"absent supported_parameters must default to empty vec"
);
let result = filter_openrouter_models(response, None);
assert!(
result.is_err(),
"models with no supported_parameters must be excluded"
);
assert!(
result.unwrap_err().contains("no tools-capable models"),
"error must indicate no tools-capable models"
);
}
#[test]
fn openrouter_filter_excludes_empty_supported_parameters() {
let response = OpenRouterModelListResponse {
data: vec![OpenRouterModelListItem {
id: "model-empty-params".to_string(),
supported_parameters: Vec::new(),
}],
};
let result = filter_openrouter_models(response, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no tools-capable models"));
}
#[test]
fn openrouter_filter_empty_result_returns_error() {
let response = OpenRouterModelListResponse { data: Vec::new() };
let result = filter_openrouter_models(response, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no tools-capable models"));
}
#[test]
fn openrouter_filter_preserves_selected_model() {
let response = OpenRouterModelListResponse {
data: vec![OpenRouterModelListItem {
id: "openai/gpt-5.5-pro".to_string(),
supported_parameters: vec!["tools".to_string()],
}],
};
let result = filter_openrouter_models(response, Some("openai/gpt-5.5-pro".to_string()))
.unwrap()
.unwrap();
assert_eq!(result.selected_model.as_deref(), Some("openai/gpt-5.5-pro"));
}
#[test]
fn openrouter_credential_redaction_env_records_key() {
let env = BTreeMap::from([(
"OPENROUTER_API_KEY".to_string(),
"sk-or-v1-secret-key-12345".to_string(),
)]);
let redaction =
redaction_env_with_value(&env, "OPENROUTER_API_KEY", "sk-or-v1-secret-key-12345");
assert_eq!(
redaction.get("OPENROUTER_API_KEY").map(String::as_str),
Some("sk-or-v1-secret-key-12345"),
"redaction env must record the API key for error body redaction"
);
}
#[test]
fn openrouter_saved_agent_model_discovery_resolves_provider() {
let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str(
r#"{
"pubkey": "abcd1234",
"name": "test-agent",
"private_key_nsec": "nsec1fake",
"relay_url": "wss://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "buzz-agent",
"agent_command_override": "buzz-agent",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"system_prompt": null,
"model": "anthropic/claude-sonnet-4",
"provider": "openrouter",
"env_vars": {
"OPENROUTER_API_KEY": "sk-or-test-key",
"BUZZ_PRIVATE_KEY": "must-not-leak"
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_started_at": null,
"last_stopped_at": null,
"last_exit_code": null,
"last_error": null
}"#,
)
.expect("sample openrouter managed agent record");
let discovery = agent_model_discovery_config(
&record,
&[],
&crate::managed_agents::GlobalAgentConfig::default(),
)
.expect("discovery config should resolve for an openrouter record");
assert_eq!(discovery.provider.as_deref(), Some("openrouter"));
assert_eq!(
discovery.model.as_deref(),
Some("anthropic/claude-sonnet-4")
);
assert_eq!(
discovery.env.get("OPENROUTER_API_KEY").map(String::as_str),
Some("sk-or-test-key")
);
assert!(!discovery.env.contains_key("BUZZ_PRIVATE_KEY"));
}
/// B5/T4: unsaved-agent ("draft") discovery mirrors the saved-agent path —
/// `draft_agent_model_discovery_env` must derive the provider env var from
/// form input the same way `agent_model_discovery_config` derives it from a
/// persisted record's harness descriptor, and preserve caller-supplied env
/// (including the OpenRouter API key) unmodified.
#[test]
fn openrouter_draft_agent_model_discovery_derives_provider_env() {
let env_vars = BTreeMap::from([(
"OPENROUTER_API_KEY".to_string(),
"sk-or-draft-key".to_string(),
)]);
let merged = draft_agent_model_discovery_env(
"buzz-agent",
Some("openrouter"),
&BTreeMap::new(),
&env_vars,
);
assert_eq!(
merged.get("BUZZ_AGENT_PROVIDER").map(String::as_str),
Some("openrouter"),
"provider env var must be derived from form input for a known ACP runtime"
);
assert_eq!(
merged.get("OPENROUTER_API_KEY").map(String::as_str),
Some("sk-or-draft-key"),
"caller-supplied env vars must survive the merge"
);
}
#[test]
fn draft_agent_model_discovery_env_omits_provider_when_absent() {
let merged =
draft_agent_model_discovery_env("buzz-agent", None, &BTreeMap::new(), &BTreeMap::new());
assert!(
!merged.contains_key("BUZZ_AGENT_PROVIDER"),
"no provider must be derived when the caller supplies none"
);
}
/// The three-tier precedence this merge exists to preserve: main's inline
/// `derived → definition_env → env_vars` layering was folded into
/// `draft_agent_model_discovery_env`, so pin the order at every collision
/// boundary rather than trusting the two single-tier tests above.
///
/// `SHARED` collides across all three tiers, so the user value proves the
/// full chain; the pairwise keys prove each adjacent boundary independently
/// (a merge that dropped only the middle tier would still satisfy `SHARED`).
/// `BUZZ_PRIVATE_KEY` proves a reserved key cannot ride in on a harness
/// definition, which is the tier a user never types.
#[test]
fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() {
// Tier 2 (middle): harness definition env — overlays the runtime-derived
// floor, loses to user env.
let definition_env = BTreeMap::from([
("SHARED".to_string(), "from-definition".to_string()),
// Collides with tier 1: `buzz-agent`'s own provider env var, which the
// `provider` argument derives below.
("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()),
("USER_OVER_DEF".to_string(), "from-definition".to_string()),
("DEFINITION_ONLY".to_string(), "from-definition".to_string()),
// Reserved: must never reach the child, even from a definition.
("BUZZ_PRIVATE_KEY".to_string(), "must-not-leak".to_string()),
]);
// Tier 3 (top): user-entered env — wins over everything.
let env_vars = BTreeMap::from([
("SHARED".to_string(), "from-user".to_string()),
("USER_OVER_DEF".to_string(), "from-user".to_string()),
("USER_ONLY".to_string(), "from-user".to_string()),
]);
// Tier 1 (floor): `Some("openrouter")` derives BUZZ_AGENT_PROVIDER.
let merged = draft_agent_model_discovery_env(
"buzz-agent",
Some("openrouter"),
&definition_env,
&env_vars,
);
let expected: &[(&str, Option<&str>)] = &[
// Collides in all three tiers — the top tier wins.
("SHARED", Some("from-user")),
// Tier 2 over tier 1: the definition's value survives, proving the
// derived provider is the floor and not layered on top.
("BUZZ_AGENT_PROVIDER", Some("openai")),
// Tier 3 over tier 2.
("USER_OVER_DEF", Some("from-user")),
// Single-tier keys pass through untouched.
("DEFINITION_ONLY", Some("from-definition")),
("USER_ONLY", Some("from-user")),
// Reserved keys never survive the definition tier. Doubly enforced —
// the explicit `is_reserved_env_key` filter here and `merged_user_env`'s
// own `retain` — so this pins the contract, not either mechanism.
("BUZZ_PRIVATE_KEY", None),
];
for (key, want) in expected {
assert_eq!(
merged.get(*key).map(String::as_str),
*want,
"env key `{key}` must resolve to {want:?} after three-tier layering"
);
}
}
+70 -189
View File
@@ -481,6 +481,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
}
Some("anthropic") => Some("ANTHROPIC_MODEL"),
Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"),
Some("openrouter") => Some("OPENROUTER_MODEL"),
_ => None,
};
let model_present = effective
@@ -523,6 +524,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
key: "DATABRICKS_HOST".to_string(),
});
}
Some("openrouter")
if env_key_missing("OPENROUTER_API_KEY") => {
missing.push(Requirement::EnvKey {
key: "OPENROUTER_API_KEY".to_string(),
});
}
_ => {
// Unknown provider or no provider yet — only the NormalizedField
// requirement above captures this gap.
@@ -630,6 +637,13 @@ fn goose_requirements(
key: "DATABRICKS_HOST".to_string(),
});
}
Some("openrouter")
if env_key_missing("OPENROUTER_API_KEY") && !file_key_present("OPENROUTER_API_KEY") =>
{
missing.push(Requirement::EnvKey {
key: "OPENROUTER_API_KEY".to_string(),
});
}
_ => {}
}
@@ -1668,195 +1682,62 @@ 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-configaware requirement tests ─────────────────────────────
//
// These tests call `goose_requirements` directly, injecting a synthetic
// `RuntimeFileConfig` so there is no disk I/O and tests are deterministic.
// Goose file-config-aware requirement tests live in a sibling file so this
// module stays under the desktop file-size ratchet.
#[cfg(test)]
mod goose_file_config_tests {
use std::collections::BTreeMap;
use super::*;
use crate::managed_agents::config_bridge::RuntimeFileConfig;
fn empty_env() -> EffectiveAgentEnv {
EffectiveAgentEnv {
env: BTreeMap::new(),
config_file_path: Some("~/.config/goose/config.yaml"),
effective_command: "goose".to_string(),
}
}
fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv {
EffectiveAgentEnv {
env: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
config_file_path: Some("~/.config/goose/config.yaml"),
effective_command: "goose".to_string(),
}
}
fn databricks_file_config() -> RuntimeFileConfig {
let mut extra = BTreeMap::new();
extra.insert(
"DATABRICKS_HOST".to_string(),
"https://dbc.example.com".to_string(),
);
RuntimeFileConfig {
provider: Some("databricks_v2".to_string()),
model: Some("goose-claude-4-6-opus".to_string()),
extra,
..Default::default()
}
}
#[test]
fn goose_file_config_silences_databricks_host_requirement() {
// File has provider, model, and DATABRICKS_HOST — all requirements silenced.
let env = empty_env();
let cfg = databricks_file_config();
let result = goose_requirements(&env, Some(&cfg));
assert!(
result.is_empty(),
"all requirements should be silenced by goose file config; \
got: {:?}",
result
);
}
#[test]
fn goose_env_empty_file_absent_still_not_ready() {
// No env, no file config → provider and model both required.
let env = empty_env();
let result = goose_requirements(&env, None);
assert!(
result.contains(&Requirement::NormalizedField {
field: "provider".to_string()
}),
"provider must be required when absent from both env and file"
);
assert!(
result.contains(&Requirement::NormalizedField {
field: "model".to_string()
}),
"model must be required when absent from both env and file"
);
}
#[test]
fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() {
// File has provider=anthropic and model, but ANTHROPIC_API_KEY is not
// in the file's `extra` map — it must still be required.
let cfg = RuntimeFileConfig {
provider: Some("anthropic".to_string()),
model: Some("claude-opus-4-5".to_string()),
extra: BTreeMap::new(),
..Default::default()
};
let env = empty_env();
let result = goose_requirements(&env, Some(&cfg));
// Provider and model silenced.
assert!(
!result.contains(&Requirement::NormalizedField {
field: "provider".to_string()
}),
"provider silenced by file config"
);
assert!(
!result.contains(&Requirement::NormalizedField {
field: "model".to_string()
}),
"model silenced by file config"
);
// ANTHROPIC_API_KEY not in file extra → still required.
assert!(
result.contains(&Requirement::EnvKey {
key: "ANTHROPIC_API_KEY".to_string()
}),
"ANTHROPIC_API_KEY must remain required when not in file extra"
);
}
#[test]
fn goose_env_provider_wins_over_file_provider_for_cred_check() {
// Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2).
// The env provider must win for credential checking.
let env = env_with(&[
("GOOSE_PROVIDER", "anthropic"),
("GOOSE_MODEL", "claude-opus-4-5"),
]);
let cfg = databricks_file_config(); // has provider=databricks_v2
let result = goose_requirements(&env, Some(&cfg));
// anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST.
assert!(
result.contains(&Requirement::EnvKey {
key: "ANTHROPIC_API_KEY".to_string()
}),
"env provider=anthropic must require ANTHROPIC_API_KEY"
);
assert!(
!result.contains(&Requirement::EnvKey {
key: "DATABRICKS_HOST".to_string()
}),
"env provider=anthropic must NOT require DATABRICKS_HOST"
);
}
#[test]
fn goose_flat_databricks_host_in_file_config_silences_requirement() {
// Will's typical goose config: flat DATABRICKS_HOST at the top level,
// no active_provider — provider inferred as "databricks".
// The parser must store extra["DATABRICKS_HOST"] = value (canonical key),
// and goose_requirements must then silence the DATABRICKS_HOST requirement.
let mut extra = BTreeMap::new();
extra.insert(
"DATABRICKS_HOST".to_string(),
"https://block.cloud.databricks.com".to_string(),
);
let cfg = RuntimeFileConfig {
provider: Some("databricks".to_string()),
model: Some("goose-claude-4-5".to_string()),
extra,
..Default::default()
};
let env = empty_env();
let result = goose_requirements(&env, Some(&cfg));
// All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file).
assert!(
result.is_empty(),
"flat DATABRICKS_HOST in file config must silence all requirements; \
got: {:?}",
result
);
}
#[test]
fn goose_goose_provider_databricks_flat_host_silences_databricks_host() {
// GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST.
// The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it.
let mut extra = BTreeMap::new();
extra.insert(
"DATABRICKS_HOST".to_string(),
"https://dbc.example.com".to_string(),
);
let cfg = RuntimeFileConfig {
provider: Some("databricks".to_string()),
model: Some("some-model".to_string()),
extra,
..Default::default()
};
let env = empty_env();
let result = goose_requirements(&env, Some(&cfg));
assert!(
!result.contains(&Requirement::EnvKey {
key: "DATABRICKS_HOST".to_string()
}),
"DATABRICKS_HOST must be silenced when canonical key is in file extra"
);
}
}
#[path = "readiness_goose_file_config_tests.rs"]
mod goose_file_config_tests;
@@ -0,0 +1,190 @@
//! Goose file-config-aware requirement tests.
//!
//! These tests call `goose_requirements` directly, injecting a synthetic
//! `RuntimeFileConfig` so there is no disk I/O and tests are deterministic.
//!
//! Included from `readiness.rs` via `#[path]`; `super::*` therefore resolves
//! against that module, matching the `storage_tests.rs` convention.
use std::collections::BTreeMap;
use super::*;
use crate::managed_agents::config_bridge::RuntimeFileConfig;
fn empty_env() -> EffectiveAgentEnv {
EffectiveAgentEnv {
env: BTreeMap::new(),
config_file_path: Some("~/.config/goose/config.yaml"),
effective_command: "goose".to_string(),
}
}
fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv {
EffectiveAgentEnv {
env: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
config_file_path: Some("~/.config/goose/config.yaml"),
effective_command: "goose".to_string(),
}
}
fn databricks_file_config() -> RuntimeFileConfig {
let mut extra = BTreeMap::new();
extra.insert(
"DATABRICKS_HOST".to_string(),
"https://dbc.example.com".to_string(),
);
RuntimeFileConfig {
provider: Some("databricks_v2".to_string()),
model: Some("goose-claude-4-6-opus".to_string()),
extra,
..Default::default()
}
}
#[test]
fn goose_file_config_silences_databricks_host_requirement() {
// File has provider, model, and DATABRICKS_HOST — all requirements silenced.
let env = empty_env();
let cfg = databricks_file_config();
let result = goose_requirements(&env, Some(&cfg));
assert!(
result.is_empty(),
"all requirements should be silenced by goose file config; \
got: {:?}",
result
);
}
#[test]
fn goose_env_empty_file_absent_still_not_ready() {
// No env, no file config → provider and model both required.
let env = empty_env();
let result = goose_requirements(&env, None);
assert!(
result.contains(&Requirement::NormalizedField {
field: "provider".to_string()
}),
"provider must be required when absent from both env and file"
);
assert!(
result.contains(&Requirement::NormalizedField {
field: "model".to_string()
}),
"model must be required when absent from both env and file"
);
}
#[test]
fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() {
// File has provider=anthropic and model, but ANTHROPIC_API_KEY is not
// in the file's `extra` map — it must still be required.
let cfg = RuntimeFileConfig {
provider: Some("anthropic".to_string()),
model: Some("claude-opus-4-5".to_string()),
extra: BTreeMap::new(),
..Default::default()
};
let env = empty_env();
let result = goose_requirements(&env, Some(&cfg));
// Provider and model silenced.
assert!(
!result.contains(&Requirement::NormalizedField {
field: "provider".to_string()
}),
"provider silenced by file config"
);
assert!(
!result.contains(&Requirement::NormalizedField {
field: "model".to_string()
}),
"model silenced by file config"
);
// ANTHROPIC_API_KEY not in file extra → still required.
assert!(
result.contains(&Requirement::EnvKey {
key: "ANTHROPIC_API_KEY".to_string()
}),
"ANTHROPIC_API_KEY must remain required when not in file extra"
);
}
#[test]
fn goose_env_provider_wins_over_file_provider_for_cred_check() {
// Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2).
// The env provider must win for credential checking.
let env = env_with(&[
("GOOSE_PROVIDER", "anthropic"),
("GOOSE_MODEL", "claude-opus-4-5"),
]);
let cfg = databricks_file_config(); // has provider=databricks_v2
let result = goose_requirements(&env, Some(&cfg));
// anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST.
assert!(
result.contains(&Requirement::EnvKey {
key: "ANTHROPIC_API_KEY".to_string()
}),
"env provider=anthropic must require ANTHROPIC_API_KEY"
);
assert!(
!result.contains(&Requirement::EnvKey {
key: "DATABRICKS_HOST".to_string()
}),
"env provider=anthropic must NOT require DATABRICKS_HOST"
);
}
#[test]
fn goose_flat_databricks_host_in_file_config_silences_requirement() {
// Will's typical goose config: flat DATABRICKS_HOST at the top level,
// no active_provider — provider inferred as "databricks".
// The parser must store extra["DATABRICKS_HOST"] = value (canonical key),
// and goose_requirements must then silence the DATABRICKS_HOST requirement.
let mut extra = BTreeMap::new();
extra.insert(
"DATABRICKS_HOST".to_string(),
"https://block.cloud.databricks.com".to_string(),
);
let cfg = RuntimeFileConfig {
provider: Some("databricks".to_string()),
model: Some("goose-claude-4-5".to_string()),
extra,
..Default::default()
};
let env = empty_env();
let result = goose_requirements(&env, Some(&cfg));
// All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file).
assert!(
result.is_empty(),
"flat DATABRICKS_HOST in file config must silence all requirements; \
got: {:?}",
result
);
}
#[test]
fn goose_goose_provider_databricks_flat_host_silences_databricks_host() {
// GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST.
// The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it.
let mut extra = BTreeMap::new();
extra.insert(
"DATABRICKS_HOST".to_string(),
"https://dbc.example.com".to_string(),
);
let cfg = RuntimeFileConfig {
provider: Some("databricks".to_string()),
model: Some("some-model".to_string()),
extra,
..Default::default()
};
let env = empty_env();
let result = goose_requirements(&env, Some(&cfg));
assert!(
!result.contains(&Requirement::EnvKey {
key: "DATABRICKS_HOST".to_string()
}),
"DATABRICKS_HOST must be silenced when canonical key is in file extra"
);
}
@@ -42,6 +42,7 @@ const KNOWN_LLM_PROVIDER_IDS = [
"databricks_v2",
"openai",
"openai-compat",
"openrouter",
] as const;
type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number];
@@ -109,6 +110,10 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
"databricks-v2": {
requiredEnvKeys: ["DATABRICKS_HOST"],
},
openrouter: {
requiredEnvKeys: ["OPENROUTER_API_KEY"],
secretEnvVar: "OPENROUTER_API_KEY",
},
};
const DEFAULT_MODEL_OPTION: PersonaModelOption = {
@@ -120,6 +125,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [
{ id: "anthropic", label: "Anthropic" },
{ id: "openai", label: "OpenAI" },
{ id: "openai-compat", label: "OpenAI-compatible" },
{ id: "openrouter", label: "OpenRouter" },
{ id: "relay-mesh", label: "Buzz shared compute" },
{ id: "databricks", label: "Databricks" },
{ id: "databricks_v2", label: "Databricks v2" },
@@ -279,7 +285,8 @@ export function providerRequiresExplicitModel(
return (
trimmedProvider === "anthropic" ||
trimmedProvider === "openai" ||
trimmedProvider === "openai-compat"
trimmedProvider === "openai-compat" ||
trimmedProvider === "openrouter"
);
}
@@ -128,6 +128,9 @@ export function getProviderEffortConfig(
// databricks v1 uses OpenAI Chat Completions wire format.
return openaiConfig(m);
}
if (provider === "openrouter") {
return { validValues: ALL_VALUES, defaultValue: "medium" };
}
// openai-compat, unknown, empty — all values, default medium.
return { validValues: ALL_VALUES, defaultValue: "medium" };
}
@@ -209,6 +209,13 @@
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
"defaultValue": "medium"
},
{
"note": "openrouter: all-7 with medium default",
"provider": "openrouter",
"model": "",
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
"defaultValue": "medium"
},
{
"note": "empty provider: all-7 with medium default",
"provider": "",