Skip to content
Open
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

147 changes: 120 additions & 27 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,64 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option<serde_json::Val
result.get("models").cloned()
}

/// Match a catalog model id against a desired model string.
///
/// Exact (case-sensitive) wins; otherwise case-insensitive equality; otherwise
/// a unique alias hit where `desired` appears as a hyphen-delimited segment of
/// the catalog id (so `sonnet` matches `claude-sonnet-4-20250514` but not an
/// ambiguous pair of sonnet variants).
fn model_id_matches(candidate: &str, desired: &str) -> MatchKind {
if candidate == desired {
return MatchKind::Exact;
}
if candidate.eq_ignore_ascii_case(desired) {
return MatchKind::CaseInsensitive;
}
let c = candidate.to_ascii_lowercase();
let d = desired.to_ascii_lowercase();
if d.len() >= 3 {
let padded = format!("-{c}-");
if padded.contains(&format!("-{d}-")) {
return MatchKind::Alias;
}
}
MatchKind::None
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MatchKind {
Exact,
CaseInsensitive,
Alias,
None,
}

fn pick_matching_model_id<'a>(
candidates: impl IntoIterator<Item = &'a str>,
desired: &str,
) -> Option<String> {
let mut exact = None;
let mut case_insensitive = None;
let mut aliases = Vec::new();
for candidate in candidates {
match model_id_matches(candidate, desired) {
MatchKind::Exact => exact = Some(candidate.to_string()),
MatchKind::CaseInsensitive if case_insensitive.is_none() => {
case_insensitive = Some(candidate.to_string());
}
MatchKind::Alias => aliases.push(candidate.to_string()),
_ => {}
}
}
exact.or(case_insensitive).or_else(|| {
if aliases.len() == 1 {
aliases.pop()
} else {
None
}
})
}

/// Match a desired model ID against a fresh `session/new` response.
///
/// Returns the correct ACP method to call, or `None` if no match.
Expand All @@ -1874,33 +1932,33 @@ pub fn resolve_model_switch_method(
desired_model: &str,
) -> Option<ModelSwitchMethod> {
// 1. Search stable configOptions for a "model"-category entry whose
// options contain a value matching desired_model.
// options contain a value matching desired_model (exact / ci / alias).
for config_opt in extract_model_config_options(session_new_result) {
let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) {
Some(id) => id,
None => continue,
};
if let Some(options) = config_opt.get("options").and_then(|v| v.as_array()) {
for opt in options {
if opt.get("value").and_then(|v| v.as_str()) == Some(desired_model) {
return Some(ModelSwitchMethod::ConfigOption {
config_id: config_id.to_string(),
option_value: desired_model.to_string(),
});
}
let values = options
.iter()
.filter_map(|opt| opt.get("value").and_then(|v| v.as_str()));
if let Some(option_value) = pick_matching_model_id(values, desired_model) {
return Some(ModelSwitchMethod::ConfigOption {
config_id: config_id.to_string(),
option_value,
});
}
}
}

// 2. Search unstable availableModels for a matching modelId.
if let Some(models) = extract_model_state(session_new_result) {
if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) {
for model in available {
if model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model) {
return Some(ModelSwitchMethod::SetModel {
model_id: desired_model.to_string(),
});
}
let ids = available
.iter()
.filter_map(|model| model.get("modelId").and_then(|v| v.as_str()));
if let Some(model_id) = pick_matching_model_id(ids, desired_model) {
return Some(ModelSwitchMethod::SetModel { model_id });
}
}
}
Expand All @@ -1920,28 +1978,25 @@ pub fn model_in_catalog(
available_models: Option<&serde_json::Value>,
desired_model: &str,
) -> bool {
let in_config_options = config_options.iter().any(|config_opt| {
let config_values = config_options.iter().flat_map(|config_opt| {
config_opt
.get("options")
.and_then(|v| v.as_array())
.is_some_and(|options| {
options
.iter()
.any(|opt| opt.get("value").and_then(|v| v.as_str()) == Some(desired_model))
})
.into_iter()
.flatten()
.filter_map(|opt| opt.get("value").and_then(|v| v.as_str()))
});
if in_config_options {
if pick_matching_model_id(config_values, desired_model).is_some() {
return true;
}

available_models
let ids = available_models
.and_then(|models| models.get("availableModels"))
.and_then(|v| v.as_array())
.is_some_and(|available| {
available
.iter()
.any(|model| model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model))
})
.into_iter()
.flatten()
.filter_map(|model| model.get("modelId").and_then(|v| v.as_str()));
pick_matching_model_id(ids, desired_model).is_some()
}

// ─── Drop: kill child process ─────────────────────────────────────────────────
Expand Down Expand Up @@ -2474,6 +2529,44 @@ mod tests {
assert!(super::resolve_model_switch_method(&result, "nonexistent-model").is_none());
}

#[test]
fn resolve_matches_short_alias_to_unique_catalog_id() {
// BUZZ_ACP_MODEL=sonnet should resolve against full Anthropic ids (#2265).
let result = serde_json::json!({
"configOptions": [{
"configId": "model",
"category": "model",
"options": [
{ "value": "claude-sonnet-4-20250514", "displayName": "Sonnet 4" },
{ "value": "claude-opus-4-20250514", "displayName": "Opus 4" }
]
}]
});
let method = super::resolve_model_switch_method(&result, "sonnet");
assert_eq!(
method,
Some(super::ModelSwitchMethod::ConfigOption {
config_id: "model".to_string(),
option_value: "claude-sonnet-4-20250514".to_string(),
})
);
}

#[test]
fn resolve_refuses_ambiguous_alias() {
let result = serde_json::json!({
"configOptions": [{
"configId": "model",
"category": "model",
"options": [
{ "value": "claude-sonnet-4-20250514" },
{ "value": "claude-sonnet-3-7-20250219" }
]
}]
});
assert!(super::resolve_model_switch_method(&result, "sonnet").is_none());
}

#[test]
fn resolve_returns_none_when_no_model_info() {
let result = serde_json::json!({ "sessionId": "sess-1" });
Expand Down
42 changes: 42 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2946,6 +2946,31 @@ fn spawn_failure_notice(
}
}

/// Application errors that will not succeed on requeue (credits, auth).
///
/// Matched on the Display text so we cover both ACP-wrapped
/// (`Agent reported error (code -32603): …`) and bare harness messages.
fn is_non_retryable_application_error(err: &acp::AcpError) -> bool {
let lower = err.to_string().to_ascii_lowercase();
lower.contains("usage credits")
|| lower.contains("/usage-credits")
|| lower.contains("invalid api key")
|| lower.contains("authentication_error")
|| lower.contains("incorrect api key")
|| (lower.contains("unauthorized") && lower.contains("api"))
}

fn non_retryable_failure_notice(err: &acp::AcpError) -> String {
let text = err.to_string();
let lower = text.to_ascii_lowercase();
if lower.contains("usage credits") || lower.contains("/usage-credits") {
return "⚠️ I couldn't complete that turn — the configured Claude model requires usage credits. Switch to a plan-included model (e.g. `sonnet`) in agent settings, or run `/usage-credits` in Claude Code.".to_string();
}
format!(
"⚠️ I couldn't complete that turn ({text}). Please fix the configuration and re-send if it's still needed."
)
}

#[allow(clippy::too_many_arguments)]
fn handle_prompt_result(
pool: &mut AgentPool,
Expand Down Expand Up @@ -3043,6 +3068,23 @@ fn handle_prompt_result(
} else {
hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)");
}
} else if let PromptOutcome::Error(ref err) = result.outcome {
if is_non_retryable_application_error(err) {
// Credits / auth failures will never succeed on retry — tell
// the channel immediately instead of silent backoff (#2265).
tracing::error!(
channel_id = %batch.channel_id,
error = %err,
"dead-lettering batch after non-retryable application error"
);
let content = non_retryable_failure_notice(err);
spawn_failure_notice(rest_client, &batch, content);
} else if let Some(dead) = queue.requeue(batch) {
let content = format!(
"⚠️ I couldn't process the last request after multiple retries ({err}). Please re-send if it's still needed."
);
spawn_failure_notice(rest_client, &dead, content);
}
} else if let Some(dead) = queue.requeue(batch) {
let reason = match &result.outcome {
PromptOutcome::Timeout(TimeoutKind::Idle) => "the turn timed out".to_string(),
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ dirs = "6"
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
buzz-ws-client = { path = "../buzz-ws-client" }

# Workspace builds pull both rustls crypto providers (ring via relay crates,
# aws-lc-rs via reqwest). Install ring at CLI entry so wss:// publish paths
# (agents draft-create, …) don't panic — see #2308 / #2329 / #2457.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }

# Random number generation — full jitter for exponential backoff in with_retry
rand = { workspace = true }

Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
#[tokio::main]
async fn main() {
// Workspace feature unification compiles both rustls providers (ring +
// aws-lc-rs). Without an explicit install, the first wss:// publish panics
// inside tokio-tungstenite (#2308 / #2329 / #2457). Idempotent if another
// path already installed a provider.
let _ = rustls::crypto::ring::default_provider().install_default();

std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
}
12 changes: 12 additions & 0 deletions desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CLI_ACP_INTERNAL_ERROR_COPY,
MODEL_NOT_FOUND_COPY,
RELAY_MESH_DENIED_COPY,
USAGE_CREDITS_COPY,
} from "./friendlyAgentLastError.ts";

test("null lastError → null", () => {
Expand Down Expand Up @@ -127,6 +128,17 @@ test("unknown code falls through to generic", () => {
});
});

test("usage-credits internal error → denied credits copy", () => {
const result = friendlyAgentLastError(
"Agent reported error (code -32603): Internal error: Fable 5 requires usage credits. Run /usage-credits to continue.",
-32603,
);
assert.deepEqual(result, {
severity: "denied",
copy: USAGE_CREDITS_COPY,
});
});

test("friendlyTurnErrorCopy: numeric code -32002 → model-not-found copy", () => {
assert.equal(
friendlyTurnErrorCopy("raw error", -32002),
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/features/agents/lib/friendlyAgentLastError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export const RELAY_MESH_DENIED_COPY =
export const MODEL_NOT_FOUND_COPY =
"The configured model is not available — open agent settings and select a different one from the dropdown.";

export const USAGE_CREDITS_COPY =
"This Claude model requires usage credits — switch to a plan-included model (e.g. sonnet) or run /usage-credits in Claude Code.";

export const CLI_ACP_INTERNAL_ERROR_COPY =
"The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or upgrade the adapter (`brew upgrade codex-acp`).";

Expand Down Expand Up @@ -96,6 +99,9 @@ export function friendlyAgentLastError(
if (remainder === BARE_INTERNAL_ERROR) {
return { severity: "generic", copy: CLI_ACP_INTERNAL_ERROR_COPY };
}
if (/usage credits/i.test(remainder)) {
return { severity: "denied", copy: USAGE_CREDITS_COPY };
}
return { severity: "generic", copy: remainder };
}
}
Expand All @@ -112,6 +118,9 @@ export function friendlyAgentLastError(
) {
return { severity: "denied", copy: RELAY_MESH_DENIED_COPY };
}
if (/usage credits/i.test(trimmed)) {
return { severity: "denied", copy: USAGE_CREDITS_COPY };
}

return { severity: "generic", copy: trimmed };
}
Expand Down