mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
f95fdc1a10
commit
ab55fee818
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1959
-4
File diff suppressed because it is too large
Load Diff
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-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.
|
||||
|
||||
// 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": "",
|
||||
|
||||
Reference in New Issue
Block a user