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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"06f65c49-467c-454b-9983-b2949cc6bd25","pid":65452,"procStart":"639213051333836130","acquiredAt":1785726080367}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ identity.key

# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
deploy/charts/*/charts/*.tgz

# Claude Code context
.claude_context_tree
44 changes: 44 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2654,6 +2654,50 @@ mod tests {
assert!(super::extract_model_config_options(&result).is_empty());
}

/// Regression guard for the OpenRouter model-catalog seam.
///
/// `switch_model` (kind:24200) resolves the requested model against the
/// `availableModels` a provider advertises in `session/new`. buzz-agent used to
/// advertise a single-entry catalog for every non-Databricks provider, so an
/// OpenRouter switch could never resolve. The payload below is the real shape
/// buzz-agent now returns for `BUZZ_AGENT_PROVIDER=openrouter` (trimmed): an
/// account-eligible, tools-capable slate.
#[test]
fn openrouter_catalog_resolves_a_switch_and_rejects_an_ineligible_model() {
let session_new = serde_json::json!({
"sessionId": "sess-openrouter",
"models": {
"currentModelId": "openai/gpt-5.6-luna",
"availableModels": [
{ "modelId": "openai/gpt-5.6-luna", "name": "OpenAI: GPT-5.6 Luna" },
{ "modelId": "openai/gpt-5.6-luna-pro", "name": "OpenAI: GPT-5.6 Luna Pro" },
{ "modelId": "z-ai/glm-5.2", "name": "Z.ai: GLM 5.2" },
{ "modelId": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek: DeepSeek V4 Flash 0731" },
]
}
});

// A model in the advertised catalog resolves to a live set_model switch.
let method = super::resolve_model_switch_method(&session_new, "z-ai/glm-5.2")
.expect("a catalog model must resolve");
match method {
super::ModelSwitchMethod::SetModel { model_id } => {
assert_eq!(model_id, "z-ai/glm-5.2");
}
other => panic!("expected SetModel, got {other:?}"),
}

// A model absent from the catalog must NOT resolve. gpt-5.6-terra is the
// real case: it exists in OpenRouter's global catalog but is not on this
// account's eligibility allowlist, so requesting it returns HTTP 404.
// Refusing it here turns that into an up-front unsupported_model rather
// than a confusing mid-request failure.
assert!(
super::resolve_model_switch_method(&session_new, "openai/gpt-5.6-terra").is_none(),
"a model outside the advertised catalog must not resolve"
);
}

#[test]
fn extract_model_state_returns_models_object() {
let result = serde_json::json!({
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod pool;
mod pool_lifecycle;
mod queue;
mod relay;
mod routing;
mod setup_mode;
mod usage;

Expand Down
28 changes: 28 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,34 @@ pub async fn run_prompt_task(
Some(b) => PromptSource::Channel(b.channel_id),
None => PromptSource::Heartbeat,
};

// Per-turn model routing (opt-in via BUZZ_ROUTING_POLICY; see routing.rs).
//
// Deliberately does NOT override a live `switch_model`: if the operator (or
// the desktop ModelPicker) explicitly pinned a model for this agent,
// `model_overridden` is set and a router silently changing it would make the
// UI lie about what is running. Explicit human choice outranks the policy.
//
// The decision is expressed as `desired_model`, which the existing
// session-creation path validates against the agent's advertised catalog and
// applies — so a policy naming a model the provider does not offer degrades
// to the agent default with a warning rather than failing the turn.
if !agent.model_overridden {
if let Some(policy) = crate::routing::Policy::from_env() {
let routed_text = prompt_text.as_deref().unwrap_or_default();
if let Some(decision) = policy.decide(routed_text).await {
if agent.desired_model.as_deref() != Some(decision.model.as_str()) {
tracing::info!(
target: "acp::routing",
model = %decision.model,
reason = ?decision.reason,
"routing selected a model for this turn"
);
agent.desired_model = Some(decision.model);
}
}
}
}
let observer_channel_id = match &source {
PromptSource::Channel(channel_id) => Some(*channel_id),
PromptSource::Heartbeat => None,
Expand Down
Loading