From 905430a378a65c364e7d3539a4ae8f694723f26c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 12 Feb 2026 02:42:18 +1100 Subject: [PATCH 01/16] feat(tetrate): open browser to top-up page when credits are exhausted When the Tetrate provider detects that a user's credits have been exhausted, this change: 1. Adds a new ProviderError::CreditsExhausted variant with details and an optional top_up_url field 2. Detects credit exhaustion in the Tetrate provider via: - HTTP 402 (Payment Required) status codes - Error code 402 in response body - Credit-related keywords in error messages (covers cases where credit exhaustion arrives as 429 or in 200 OK error bodies) 3. Opens the user's default browser to the Tetrate dashboard (https://router.tetrate.ai/dashboard) for easy credit top-up 4. Falls back to printing the URL if the browser can't be opened 5. Shows a clear message explaining what happened and how to continue 6. Does NOT retry on CreditsExhausted (it's not transient) 7. Adds generic HTTP 402 handling in openai_compatible for other providers Uses the existing webbrowser crate (already a dependency for OAuth). --- crates/goose/src/agents/agent.rs | 28 +++++ crates/goose/src/providers/errors.rs | 8 ++ .../goose/src/providers/openai_compatible.rs | 4 + crates/goose/src/providers/tetrate.rs | 112 +++++++++++++++++- 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 309378e19205..4c8b61941043 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1433,6 +1433,34 @@ impl Agent { } } } + Err(ref provider_err @ ProviderError::CreditsExhausted { ref details, ref top_up_url }) => { + crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); + error!("Credits exhausted: {}", details); + + let url = top_up_url.as_deref().unwrap_or("https://router.tetrate.ai/dashboard"); + + // Try to open the user's browser to the top-up page + let browser_msg = match webbrowser::open(url) { + Ok(_) => format!( + "Your credits have been exhausted: {details}\n\n\ + Opening your browser to add more credits: {url}\n\n\ + Once you've topped up, retry your last message to continue." + ), + Err(browser_err) => { + tracing::warn!("Failed to open browser: {}", browser_err); + format!( + "Your credits have been exhausted: {details}\n\n\ + To add more credits, visit: {url}\n\n\ + Once you've topped up, retry your last message to continue." + ) + } + }; + + yield AgentEvent::Message( + Message::assistant().with_text(browser_msg) + ); + break; + } Err(ref provider_err) => { crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); error!("Error: {}", provider_err); diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index 90f6d94b9df5..60c3c837683f 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -30,6 +30,13 @@ pub enum ProviderError { #[error("Unsupported operation: {0}")] NotImplemented(String), + + #[error("Credits exhausted: {details}")] + CreditsExhausted { + details: String, + /// URL where the user can add more credits / top up + top_up_url: Option, + }, } impl ProviderError { @@ -43,6 +50,7 @@ impl ProviderError { ProviderError::ExecutionError(_) => "execution", ProviderError::UsageError(_) => "usage", ProviderError::NotImplemented(_) => "not_implemented", + ProviderError::CreditsExhausted { .. } => "credits_exhausted", } } } diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose/src/providers/openai_compatible.rs index 3e703c54dc8b..221388f4daeb 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -219,6 +219,10 @@ pub fn map_http_error_to_provider_error( StatusCode::NOT_FOUND => { ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message())) } + StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted { + details: extract_message(), + top_up_url: None, + }, StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), StatusCode::BAD_REQUEST => { let payload_str = extract_message(); diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index cb0d0fad790d..0aced647e354 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -34,6 +34,7 @@ pub const TETRATE_KNOWN_MODELS: &[&str] = &[ "gpt-4.1", ]; pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; +pub const TETRATE_DASHBOARD_URL: &str = "https://router.tetrate.ai/dashboard"; #[derive(serde::Serialize)] pub struct TetrateProvider { @@ -67,6 +68,30 @@ impl TetrateProvider { }) } + /// Check if an error message indicates credit/balance exhaustion + fn is_credits_exhausted(message: &str) -> bool { + let lower = message.to_lowercase(); + let credit_phrases = [ + "insufficient credit", + "insufficient balance", + "insufficient fund", + "out of credit", + "credits exhausted", + "credits have been exhausted", + "no credits remaining", + "credit balance", + "balance is zero", + "balance too low", + "payment required", + "billing", + "top up", + "add credits", + "purchase credits", + "exceeded your credit", + ]; + credit_phrases.iter().any(|phrase| lower.contains(phrase)) + } + async fn post( &self, session_id: Option<&str>, @@ -77,6 +102,32 @@ impl TetrateProvider { .response_post(session_id, "v1/chat/completions", payload) .await?; + // Check for HTTP 402 Payment Required before any other handling + let status = response.status(); + if status == reqwest::StatusCode::PAYMENT_REQUIRED { + let body = response.text().await.unwrap_or_default(); + let detail = if body.is_empty() { + "Your Tetrate Agent Router credits have been exhausted.".to_string() + } else { + // Try to extract message from JSON error body + serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| { + "Your Tetrate Agent Router credits have been exhausted.".to_string() + }) + }; + return Err(ProviderError::CreditsExhausted { + details: detail, + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }); + } + // Handle Google-compatible model responses differently if is_google_model(payload) { return handle_response_google_compat(response).await; @@ -85,7 +136,17 @@ impl TetrateProvider { // For OpenAI-compatible models, parse the response body to JSON let response_body = handle_response_openai_compat(response) .await - .map_err(|e| ProviderError::RequestFailed(format!("Failed to parse response: {e}")))?; + .map_err(|e| { + // Check if the underlying error message indicates credit exhaustion + let err_str = e.to_string(); + if Self::is_credits_exhausted(&err_str) { + return ProviderError::CreditsExhausted { + details: err_str, + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }; + } + ProviderError::RequestFailed(format!("Failed to parse response: {e}")) + })?; let _debug = format!( "Tetrate Agent Router Service request with payload: {} and response: {}", @@ -104,6 +165,14 @@ impl TetrateProvider { let error_code = error_obj.get("code").and_then(|c| c.as_u64()).unwrap_or(0); + // Check for credit exhaustion in error messages regardless of error code + if Self::is_credits_exhausted(error_message) || error_code == 402 { + return Err(ProviderError::CreditsExhausted { + details: error_message.to_string(), + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }); + } + // Check for context length errors in the error message if error_code == 400 && error_message.contains("maximum context length") { return Err(ProviderError::ContextLengthExceeded( @@ -115,10 +184,17 @@ impl TetrateProvider { match error_code { 401 | 403 => return Err(ProviderError::Authentication(error_message.to_string())), 429 => { + // A 429 could also be a credits issue disguised as rate limiting + if Self::is_credits_exhausted(error_message) { + return Err(ProviderError::CreditsExhausted { + details: error_message.to_string(), + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }); + } return Err(ProviderError::RateLimitExceeded { details: error_message.to_string(), retry_delay: None, - }) + }); } 500 | 503 => return Err(ProviderError::ServerError(error_message.to_string())), _ => return Err(ProviderError::RequestFailed(error_message.to_string())), @@ -233,7 +309,37 @@ impl Provider for TetrateProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + + // Check for HTTP 402 before delegating to generic handler + if resp.status() == reqwest::StatusCode::PAYMENT_REQUIRED { + let body = resp.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| { + "Your Tetrate Agent Router credits have been exhausted.".to_string() + }); + return Err(ProviderError::CreditsExhausted { + details: detail, + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }); + } + + handle_status_openai_compat(resp).await.map_err(|e| { + // Check if the error message from status handler indicates credit exhaustion + if Self::is_credits_exhausted(&e.to_string()) { + return ProviderError::CreditsExhausted { + details: e.to_string(), + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }; + } + e + }) }) .await .inspect_err(|e| { From 7261b7e27017c84599f47e9b5421ed2f3ac488df Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 12 Feb 2026 07:57:25 +1100 Subject: [PATCH 02/16] feat(tetrate): add tests, generalize credit exhaustion, document 402 assumption Follow-up improvements to the credits-exhausted browser-open feature: 1. Unit tests (35 total): - 29 tests in tetrate.rs covering is_credits_exhausted() positive cases (all 16 phrases), case-insensitivity, embedded matches, negative cases (rate limit, server error, auth, context length, empty, generic, model-not-found), error variant fields, display format, telemetry type, and retry exclusion. - 6 tests in openai_compatible.rs covering map_http_error_to_provider_error for HTTP 402 (with/without payload), 429, 401, 400+context, 500. 2. Verified HTTP 402 assumption: - No documentation found confirming Tetrate's exact error format for credit exhaustion. Added detailed code comment explaining we defensively handle HTTP 402, error code 402 in response bodies, AND keyword-based detection as a safety net. 3. Generalized the design: - Removed hardcoded Tetrate dashboard URL fallback from agent.rs. - When top_up_url is None (generic 402 from unknown provider), shows a provider-agnostic message without attempting to open a browser. - When top_up_url is Some (Tetrate or any future provider), opens the browser as before. 4. Browser opening verification: - Confirmed webbrowser crate v1.0 (same as Tetrate OAuth flow in signup_tetrate). Works on macOS, Linux, Windows. - Added code comment in agent.rs noting the cross-platform mechanism. --- crates/goose/src/agents/agent.rs | 39 ++- .../goose/src/providers/openai_compatible.rs | 95 +++++++ crates/goose/src/providers/tetrate.rs | 243 +++++++++++++++++- 3 files changed, 362 insertions(+), 15 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 4c8b61941043..742c85bc9b4f 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1437,23 +1437,34 @@ impl Agent { crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); error!("Credits exhausted: {}", details); - let url = top_up_url.as_deref().unwrap_or("https://router.tetrate.ai/dashboard"); - - // Try to open the user's browser to the top-up page - let browser_msg = match webbrowser::open(url) { - Ok(_) => format!( - "Your credits have been exhausted: {details}\n\n\ - Opening your browser to add more credits: {url}\n\n\ - Once you've topped up, retry your last message to continue." - ), - Err(browser_err) => { - tracing::warn!("Failed to open browser: {}", browser_err); - format!( + // Build a user-facing message. If the provider supplied a + // top-up URL we try to open it in the default browser (uses + // the `webbrowser` crate — the same cross-platform mechanism + // used by the Tetrate OAuth sign-up flow in signup_tetrate). + // If no URL was provided (generic 402 from an unknown + // provider) we just tell the user what happened. + let browser_msg = if let Some(url) = top_up_url.as_deref() { + match webbrowser::open(url) { + Ok(_) => format!( "Your credits have been exhausted: {details}\n\n\ - To add more credits, visit: {url}\n\n\ + Opening your browser to add more credits: {url}\n\n\ Once you've topped up, retry your last message to continue." - ) + ), + Err(browser_err) => { + tracing::warn!("Failed to open browser: {}", browser_err); + format!( + "Your credits have been exhausted: {details}\n\n\ + To add more credits, visit: {url}\n\n\ + Once you've topped up, retry your last message to continue." + ) + } } + } else { + format!( + "Your credits have been exhausted: {details}\n\n\ + Please check your account with your provider to add more \ + credits, then retry your last message to continue." + ) }; yield AgentEvent::Message( diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose/src/providers/openai_compatible.rs index 221388f4daeb..68a38c62433a 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -298,3 +298,98 @@ pub fn stream_openai_compat( } })) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn http_402_maps_to_credits_exhausted() { + let payload = json!({ + "error": { + "message": "Insufficient credits to complete this request" + } + }); + let err = map_http_error_to_provider_error( + StatusCode::PAYMENT_REQUIRED, + Some(payload), + ); + match err { + ProviderError::CreditsExhausted { + ref details, + ref top_up_url, + } => { + assert!( + details.contains("Insufficient credits"), + "Expected details to contain error message, got: {details}" + ); + // Generic handler doesn't know the provider, so no URL + assert_eq!(*top_up_url, None); + } + other => panic!("Expected CreditsExhausted, got: {:?}", other), + } + } + + #[test] + fn http_402_with_no_payload_maps_to_credits_exhausted() { + let err = map_http_error_to_provider_error(StatusCode::PAYMENT_REQUIRED, None); + assert!( + matches!(err, ProviderError::CreditsExhausted { .. }), + "Expected CreditsExhausted, got: {:?}", + err + ); + } + + #[test] + fn http_429_maps_to_rate_limit_not_credits() { + let payload = json!({ + "error": { + "message": "Rate limit exceeded" + } + }); + let err = + map_http_error_to_provider_error(StatusCode::TOO_MANY_REQUESTS, Some(payload)); + assert!( + matches!(err, ProviderError::RateLimitExceeded { .. }), + "Expected RateLimitExceeded, got: {:?}", + err + ); + } + + #[test] + fn http_401_maps_to_authentication() { + let err = map_http_error_to_provider_error(StatusCode::UNAUTHORIZED, None); + assert!( + matches!(err, ProviderError::Authentication(_)), + "Expected Authentication, got: {:?}", + err + ); + } + + #[test] + fn http_400_with_context_length_maps_correctly() { + let payload = json!({ + "error": { + "message": "This request exceeds the maximum context length" + } + }); + let err = map_http_error_to_provider_error(StatusCode::BAD_REQUEST, Some(payload)); + assert!( + matches!(err, ProviderError::ContextLengthExceeded(_)), + "Expected ContextLengthExceeded, got: {:?}", + err + ); + } + + #[test] + fn http_500_maps_to_server_error() { + let err = + map_http_error_to_provider_error(StatusCode::INTERNAL_SERVER_ERROR, None); + assert!( + matches!(err, ProviderError::ServerError(_)), + "Expected ServerError, got: {:?}", + err + ); + } +} diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 0aced647e354..999057d1337a 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -102,7 +102,18 @@ impl TetrateProvider { .response_post(session_id, "v1/chat/completions", payload) .await?; - // Check for HTTP 402 Payment Required before any other handling + // Check for HTTP 402 Payment Required before any other handling. + // + // NOTE: The exact HTTP status / error format Tetrate returns for credit + // exhaustion is not publicly documented. We defensively handle multiple + // possibilities: + // 1. HTTP 402 (Payment Required) — the standard status for this case + // 2. Error code 402 inside a 200 OK JSON body (Tetrate wraps some errors + // in successful responses) + // 3. Keyword-based detection on the error message (e.g. "insufficient + // credit", "payment required") as a safety net, regardless of the + // HTTP status or error code — this also catches 429 responses that + // are really about credits rather than rate limits. let status = response.status(); if status == reqwest::StatusCode::PAYMENT_REQUIRED { let body = response.text().await.unwrap_or_default(); @@ -419,3 +430,233 @@ impl Provider for TetrateProvider { self.supports_streaming } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── is_credits_exhausted: positive cases ────────────────────────── + + #[test] + fn credits_exhausted_detects_insufficient_credit() { + assert!(TetrateProvider::is_credits_exhausted( + "Insufficient credit on your account" + )); + } + + #[test] + fn credits_exhausted_detects_insufficient_balance() { + assert!(TetrateProvider::is_credits_exhausted( + "Insufficient balance to complete this request" + )); + } + + #[test] + fn credits_exhausted_detects_insufficient_funds() { + assert!(TetrateProvider::is_credits_exhausted( + "Insufficient funds in your account" + )); + } + + #[test] + fn credits_exhausted_detects_out_of_credit() { + assert!(TetrateProvider::is_credits_exhausted( + "You are out of credits" + )); + } + + #[test] + fn credits_exhausted_detects_credits_exhausted_phrase() { + assert!(TetrateProvider::is_credits_exhausted("Credits exhausted")); + } + + #[test] + fn credits_exhausted_detects_credits_have_been_exhausted() { + assert!(TetrateProvider::is_credits_exhausted( + "Your credits have been exhausted. Please top up." + )); + } + + #[test] + fn credits_exhausted_detects_no_credits_remaining() { + assert!(TetrateProvider::is_credits_exhausted( + "No credits remaining on this API key" + )); + } + + #[test] + fn credits_exhausted_detects_credit_balance() { + assert!(TetrateProvider::is_credits_exhausted( + "Your credit balance is $0.00" + )); + } + + #[test] + fn credits_exhausted_detects_balance_is_zero() { + assert!(TetrateProvider::is_credits_exhausted( + "Your balance is zero" + )); + } + + #[test] + fn credits_exhausted_detects_balance_too_low() { + assert!(TetrateProvider::is_credits_exhausted( + "Account balance too low for this model" + )); + } + + #[test] + fn credits_exhausted_detects_payment_required() { + assert!(TetrateProvider::is_credits_exhausted("Payment required")); + } + + #[test] + fn credits_exhausted_detects_billing() { + assert!(TetrateProvider::is_credits_exhausted( + "Please update your billing information" + )); + } + + #[test] + fn credits_exhausted_detects_top_up() { + assert!(TetrateProvider::is_credits_exhausted( + "Please top up your account" + )); + } + + #[test] + fn credits_exhausted_detects_add_credits() { + assert!(TetrateProvider::is_credits_exhausted( + "Please add credits to continue" + )); + } + + #[test] + fn credits_exhausted_detects_purchase_credits() { + assert!(TetrateProvider::is_credits_exhausted( + "Purchase credits at router.tetrate.ai" + )); + } + + #[test] + fn credits_exhausted_detects_exceeded_your_credit() { + assert!(TetrateProvider::is_credits_exhausted( + "You have exceeded your credit limit" + )); + } + + // ── is_credits_exhausted: case-insensitivity ────────────────────── + + #[test] + fn credits_exhausted_is_case_insensitive() { + assert!(TetrateProvider::is_credits_exhausted("INSUFFICIENT CREDIT")); + assert!(TetrateProvider::is_credits_exhausted("Out Of Credits")); + assert!(TetrateProvider::is_credits_exhausted("PAYMENT REQUIRED")); + } + + #[test] + fn credits_exhausted_matches_embedded_in_longer_message() { + assert!(TetrateProvider::is_credits_exhausted( + "Error 402: Payment required. Visit dashboard to add credits." + )); + } + + // ── is_credits_exhausted: negative cases ────────────────────────── + + #[test] + fn credits_exhausted_false_for_rate_limit() { + assert!(!TetrateProvider::is_credits_exhausted( + "Rate limit exceeded. Please retry after 30 seconds." + )); + } + + #[test] + fn credits_exhausted_false_for_server_error() { + assert!(!TetrateProvider::is_credits_exhausted( + "Internal server error" + )); + } + + #[test] + fn credits_exhausted_false_for_auth_error() { + assert!(!TetrateProvider::is_credits_exhausted( + "Invalid API key provided" + )); + } + + #[test] + fn credits_exhausted_false_for_context_length() { + assert!(!TetrateProvider::is_credits_exhausted( + "This model's maximum context length is 128000 tokens" + )); + } + + #[test] + fn credits_exhausted_false_for_empty_string() { + assert!(!TetrateProvider::is_credits_exhausted("")); + } + + #[test] + fn credits_exhausted_false_for_generic_error() { + assert!(!TetrateProvider::is_credits_exhausted( + "Something went wrong with the request" + )); + } + + #[test] + fn credits_exhausted_false_for_model_not_found() { + assert!(!TetrateProvider::is_credits_exhausted( + "Model 'gpt-99' not found" + )); + } + + // ── CreditsExhausted error variant ──────────────────────────────── + + #[test] + fn credits_exhausted_error_includes_dashboard_url() { + let err = ProviderError::CreditsExhausted { + details: "out of credits".to_string(), + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }; + match err { + ProviderError::CreditsExhausted { top_up_url, .. } => { + assert_eq!( + top_up_url.as_deref(), + Some("https://router.tetrate.ai/dashboard") + ); + } + _ => panic!("Expected CreditsExhausted variant"), + } + } + + #[test] + fn credits_exhausted_error_telemetry_type() { + let err = ProviderError::CreditsExhausted { + details: "test".to_string(), + top_up_url: None, + }; + assert_eq!(err.telemetry_type(), "credits_exhausted"); + } + + #[test] + fn credits_exhausted_error_display() { + let err = ProviderError::CreditsExhausted { + details: "No credits remaining".to_string(), + top_up_url: None, + }; + assert_eq!(err.to_string(), "Credits exhausted: No credits remaining"); + } + + #[test] + fn credits_exhausted_is_not_retried() { + use crate::providers::retry::should_retry; + let err = ProviderError::CreditsExhausted { + details: "out of credits".to_string(), + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }; + assert!( + !should_retry(&err), + "CreditsExhausted should not be retried — it is not a transient error" + ); + } +} From d7eb9d1335096f673e0f8c139656e8fb86f8c6ef Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 12 Feb 2026 08:29:17 +1100 Subject: [PATCH 03/16] refactor: move browser-open from agent to CLI presentation layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent is a core library and should not do UI actions like opening browsers. Different consumers (CLI, desktop app, API) need to handle credits-exhausted differently. Changes: - Add SystemNotificationType::CreditsExhausted variant to message.rs with documented data schema ({top_up_url: string | null}) - agent.rs now yields a structured CreditsExhausted notification with the user-facing message and top_up_url in the data field — no browser opening, no webbrowser dependency - goose-cli output.rs handles the new notification type: renders the message in yellow and calls webbrowser::open() if a top_up_url is present in the notification data - goose-server passes the notification through as a serialized SSE event (no changes needed) — desktop app can handle it however it wants (dialog, button, etc.) This follows the same pattern as ThinkingMessage and InlineMessage notifications: the agent produces structured events, the UI layer decides how to present them. --- crates/goose-cli/src/session/output.rs | 19 +++++++++++ crates/goose/src/agents/agent.rs | 42 +++++++++++------------- crates/goose/src/conversation/message.rs | 4 +++ 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index f1343e1bf390..52705c8b50ef 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -259,6 +259,25 @@ pub fn render_message(message: &Message, debug: bool) { hide_thinking(); println!("\n{}", style(¬ification.msg).yellow()); } + SystemNotificationType::CreditsExhausted => { + hide_thinking(); + println!("\n{}", style(¬ification.msg).yellow()); + + // If the provider supplied a top-up URL, try to open + // the user's browser so they can add credits. Uses the + // `webbrowser` crate (same cross-platform mechanism as + // the Tetrate OAuth sign-up flow). + if let Some(url) = notification + .data + .as_ref() + .and_then(|d| d.get("top_up_url")) + .and_then(|v| v.as_str()) + { + if let Err(e) = webbrowser::open(url) { + tracing::warn!("Failed to open browser for credits top-up: {}", e); + } + } + } } } _ => { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 742c85bc9b4f..0e24dc78e175 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1437,28 +1437,16 @@ impl Agent { crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); error!("Credits exhausted: {}", details); - // Build a user-facing message. If the provider supplied a - // top-up URL we try to open it in the default browser (uses - // the `webbrowser` crate — the same cross-platform mechanism - // used by the Tetrate OAuth sign-up flow in signup_tetrate). - // If no URL was provided (generic 402 from an unknown - // provider) we just tell the user what happened. - let browser_msg = if let Some(url) = top_up_url.as_deref() { - match webbrowser::open(url) { - Ok(_) => format!( - "Your credits have been exhausted: {details}\n\n\ - Opening your browser to add more credits: {url}\n\n\ - Once you've topped up, retry your last message to continue." - ), - Err(browser_err) => { - tracing::warn!("Failed to open browser: {}", browser_err); - format!( - "Your credits have been exhausted: {details}\n\n\ - To add more credits, visit: {url}\n\n\ - Once you've topped up, retry your last message to continue." - ) - } - } + // Surface the error as a structured CreditsExhausted + // notification so the UI layer (CLI, desktop app, API) + // can decide how to present it — e.g. opening a browser, + // showing a dialog, or returning it in a JSON response. + let user_msg = if let Some(url) = top_up_url.as_deref() { + format!( + "Your credits have been exhausted: {details}\n\n\ + To add more credits, visit: {url}\n\n\ + Once you've topped up, retry your last message to continue." + ) } else { format!( "Your credits have been exhausted: {details}\n\n\ @@ -1467,8 +1455,16 @@ impl Agent { ) }; + let notification_data = serde_json::json!({ + "top_up_url": top_up_url, + }); + yield AgentEvent::Message( - Message::assistant().with_text(browser_msg) + Message::assistant().with_system_notification_with_data( + SystemNotificationType::CreditsExhausted, + user_msg, + notification_data, + ) ); break; } diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index e116a7b9b886..e1a5120e3d2c 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -164,6 +164,10 @@ pub struct FrontendToolRequest { pub enum SystemNotificationType { ThinkingMessage, InlineMessage, + /// Provider credits have been exhausted. The `data` field of the + /// notification may contain `{"top_up_url": "..."}` so the UI layer + /// can open the user's browser or show a clickable link. + CreditsExhausted, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] From abba9cba4e762114629a25d8dc8b71eb60e6f1ee Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 12 Feb 2026 08:47:03 +1100 Subject: [PATCH 04/16] feat(desktop): handle CreditsExhausted notification in desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add desktop app support for the CreditsExhausted system notification that flows from the agent through goose-server SSE events. Changes: - openapi.json: Add 'creditsExhausted' to SystemNotificationType enum - types.gen.ts: Add 'creditsExhausted' to the union type - CreditsExhaustedNotification.tsx: New component that renders a yellow warning banner with the error message and a 'Top Up Credits' button that opens the provider's dashboard URL via window.electron.openExternal() - ProgressiveMessageList.tsx: Detect creditsExhausted notifications and render CreditsExhaustedNotification before other system notification checks The notification data schema is {top_up_url: string | null}. When top_up_url is present the button is shown; when null only the message is displayed. goose-server required no changes — it already serializes the full Message (including SystemNotificationContent with data) as SSE JSON events. --- ui/desktop/openapi.json | 3 +- ui/desktop/src/api/types.gen.ts | 2 +- .../src/components/ProgressiveMessageList.tsx | 20 +++++++ .../CreditsExhaustedNotification.tsx | 57 +++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d85209fd436a..857300cba19c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -6752,7 +6752,8 @@ "type": "string", "enum": [ "thinkingMessage", - "inlineMessage" + "inlineMessage", + "creditsExhausted" ] }, "TaskSupport": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index b3c40b587edb..40b422eb7068 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1146,7 +1146,7 @@ export type SystemNotificationContent = { notificationType: SystemNotificationType; }; -export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage'; +export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage' | 'creditsExhausted'; export type TaskSupport = string; diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 05b41dbc3d84..1f6c3503a855 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -19,6 +19,7 @@ import { Message } from '../api'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; import { SystemNotificationInline } from './context_management/SystemNotificationInline'; +import { CreditsExhaustedNotification } from './context_management/CreditsExhaustedNotification'; import { NotificationEvent } from '../types/message'; import LoadingGoose from './LoadingGoose'; import { ChatType } from '../types/chat'; @@ -78,6 +79,13 @@ export default function ProgressiveMessageList({ ); }; + const hasCreditsExhaustedNotification = (message: Message): boolean => { + return message.content.some( + (content) => + content.type === 'systemNotification' && content.notificationType === 'creditsExhausted' + ); + }; + // Simple progressive loading - start immediately when component mounts if needed useEffect(() => { if (messages.length <= showLoadingThreshold) { @@ -189,6 +197,18 @@ export default function ProgressiveMessageList({ } // System notifications are never user messages, handle them first + if (hasCreditsExhaustedNotification(message)) { + return ( +
+ +
+ ); + } + if (hasInlineSystemNotification(message)) { return (
= ({ + message, +}) => { + const notification = message.content.find( + (content): content is SystemNotificationContent & { type: 'systemNotification' } => + content.type === 'systemNotification' && content.notificationType === 'creditsExhausted' + ); + + if (!notification?.msg) { + return null; + } + + const topUpUrl = + notification.data && + typeof notification.data === 'object' && + 'top_up_url' in (notification.data as Record) + ? ((notification.data as Record).top_up_url as string | null) + : null; + + const handleTopUp = () => { + if (topUpUrl) { + window.electron.openExternal(topUpUrl); + } + }; + + return ( +
+
+
⚠️
+
+
{notification.msg}
+ {topUpUrl && ( + + )} +
+
+
+ ); +}; From 5e27201bf52ba7b55d71ec535e898d2a8bf9e705 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 13 Feb 2026 15:41:37 +1100 Subject: [PATCH 05/16] =?UTF-8?q?refactor:=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20simplify=20tetrate,=20remove=20comment=20slop,?= =?UTF-8?q?=20use=20test=5Fcase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove comment slop from output.rs, agent.rs, message.rs, tetrate.rs - Deduplicate error logging in agent.rs CreditsExhausted arm - Remove doc comment from CreditsExhausted variant (consistency with others) - Convert openai_compatible.rs tests to test_case pattern - Simplify tetrate.rs: rely on openai_compatible 402 handling, just enrich with dashboard URL. Remove is_credits_exhausted and all its tests. - Fix duplicate React keys in ProgressiveMessageList by using index-based keys - Refactor notification rendering: components take notification content directly, unified getSystemNotification + renderSystemNotification dispatches by type --- crates/goose-cli/src/session/output.rs | 4 - crates/goose/src/agents/agent.rs | 6 +- crates/goose/src/conversation/message.rs | 3 - .../goose/src/providers/openai_compatible.rs | 141 +++---- crates/goose/src/providers/tetrate.rs | 395 ++---------------- .../src/components/ProgressiveMessageList.tsx | 53 ++- .../CreditsExhaustedNotification.tsx | 27 +- .../SystemNotificationInline.tsx | 22 +- 8 files changed, 128 insertions(+), 523 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 52705c8b50ef..1f5778338e6a 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -263,10 +263,6 @@ pub fn render_message(message: &Message, debug: bool) { hide_thinking(); println!("\n{}", style(¬ification.msg).yellow()); - // If the provider supplied a top-up URL, try to open - // the user's browser so they can add credits. Uses the - // `webbrowser` crate (same cross-platform mechanism as - // the Tetrate OAuth sign-up flow). if let Some(url) = notification .data .as_ref() diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 0e24dc78e175..080ddfa4a21f 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1435,12 +1435,8 @@ impl Agent { } Err(ref provider_err @ ProviderError::CreditsExhausted { ref details, ref top_up_url }) => { crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); - error!("Credits exhausted: {}", details); + error!("Error: {}", provider_err); - // Surface the error as a structured CreditsExhausted - // notification so the UI layer (CLI, desktop app, API) - // can decide how to present it — e.g. opening a browser, - // showing a dialog, or returning it in a JSON response. let user_msg = if let Some(url) = top_up_url.as_deref() { format!( "Your credits have been exhausted: {details}\n\n\ diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index e1a5120e3d2c..35392c58e43a 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -164,9 +164,6 @@ pub struct FrontendToolRequest { pub enum SystemNotificationType { ThinkingMessage, InlineMessage, - /// Provider credits have been exhausted. The `data` field of the - /// notification may contain `{"top_up_url": "..."}` so the UI layer - /// can open the user's browser or show a clickable link. CreditsExhausted, } diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose/src/providers/openai_compatible.rs index 68a38c62433a..0105b6dfced2 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -303,93 +303,62 @@ pub fn stream_openai_compat( mod tests { use super::*; use serde_json::json; + use test_case::test_case; - #[test] - fn http_402_maps_to_credits_exhausted() { - let payload = json!({ - "error": { - "message": "Insufficient credits to complete this request" - } - }); - let err = map_http_error_to_provider_error( - StatusCode::PAYMENT_REQUIRED, - Some(payload), - ); - match err { - ProviderError::CreditsExhausted { - ref details, - ref top_up_url, - } => { - assert!( - details.contains("Insufficient credits"), - "Expected details to contain error message, got: {details}" - ); - // Generic handler doesn't know the provider, so no URL - assert_eq!(*top_up_url, None); - } - other => panic!("Expected CreditsExhausted, got: {:?}", other), - } - } - - #[test] - fn http_402_with_no_payload_maps_to_credits_exhausted() { - let err = map_http_error_to_provider_error(StatusCode::PAYMENT_REQUIRED, None); - assert!( - matches!(err, ProviderError::CreditsExhausted { .. }), - "Expected CreditsExhausted, got: {:?}", - err - ); - } - - #[test] - fn http_429_maps_to_rate_limit_not_credits() { - let payload = json!({ - "error": { - "message": "Rate limit exceeded" - } - }); - let err = - map_http_error_to_provider_error(StatusCode::TOO_MANY_REQUESTS, Some(payload)); - assert!( - matches!(err, ProviderError::RateLimitExceeded { .. }), - "Expected RateLimitExceeded, got: {:?}", - err - ); - } - - #[test] - fn http_401_maps_to_authentication() { - let err = map_http_error_to_provider_error(StatusCode::UNAUTHORIZED, None); - assert!( - matches!(err, ProviderError::Authentication(_)), - "Expected Authentication, got: {:?}", - err - ); - } - - #[test] - fn http_400_with_context_length_maps_correctly() { - let payload = json!({ - "error": { - "message": "This request exceeds the maximum context length" - } - }); - let err = map_http_error_to_provider_error(StatusCode::BAD_REQUEST, Some(payload)); - assert!( - matches!(err, ProviderError::ContextLengthExceeded(_)), - "Expected ContextLengthExceeded, got: {:?}", - err - ); - } - - #[test] - fn http_500_maps_to_server_error() { - let err = - map_http_error_to_provider_error(StatusCode::INTERNAL_SERVER_ERROR, None); - assert!( - matches!(err, ProviderError::ServerError(_)), - "Expected ServerError, got: {:?}", - err + #[test_case( + StatusCode::PAYMENT_REQUIRED, + Some(json!({"error": {"message": "Insufficient credits to complete this request"}})), + "CreditsExhausted" + ; "402 with payload" + )] + #[test_case( + StatusCode::PAYMENT_REQUIRED, + None, + "CreditsExhausted" + ; "402 without payload" + )] + #[test_case( + StatusCode::TOO_MANY_REQUESTS, + Some(json!({"error": {"message": "Rate limit exceeded"}})), + "RateLimitExceeded" + ; "429 rate limit" + )] + #[test_case( + StatusCode::UNAUTHORIZED, + None, + "Authentication" + ; "401 unauthorized" + )] + #[test_case( + StatusCode::BAD_REQUEST, + Some(json!({"error": {"message": "This request exceeds the maximum context length"}})), + "ContextLengthExceeded" + ; "400 context length" + )] + #[test_case( + StatusCode::INTERNAL_SERVER_ERROR, + None, + "ServerError" + ; "500 server error" + )] + fn http_status_maps_to_expected_error( + status: StatusCode, + payload: Option, + expected_variant: &str, + ) { + let err = map_http_error_to_provider_error(status, payload); + let actual = err.telemetry_type(); + let expected_telemetry = match expected_variant { + "CreditsExhausted" => "credits_exhausted", + "RateLimitExceeded" => "rate_limit", + "Authentication" => "auth", + "ContextLengthExceeded" => "context_length", + "ServerError" => "server", + other => panic!("Unknown variant: {other}"), + }; + assert_eq!( + actual, expected_telemetry, + "Expected {expected_variant}, got error: {err:?}" ); } } diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 999057d1337a..22ec85f73384 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -20,7 +20,6 @@ use crate::providers::formats::openai::{create_request, get_usage, response_to_m use rmcp::model::Tool; const TETRATE_PROVIDER_NAME: &str = "tetrate"; -// Tetrate Agent Router Service can run many models, we suggest the default pub const TETRATE_KNOWN_MODELS: &[&str] = &[ "claude-opus-4-1", "claude-3-7-sonnet-latest", @@ -50,7 +49,6 @@ impl TetrateProvider { pub async fn from_env(model: ModelConfig) -> Result { let config = crate::config::Config::global(); let api_key: String = config.get_secret("TETRATE_API_KEY")?; - // API host for LLM endpoints (/v1/chat/completions, /v1/models) let host: String = config .get_param("TETRATE_HOST") .unwrap_or_else(|_| "https://api.router.tetrate.ai".to_string()); @@ -68,28 +66,14 @@ impl TetrateProvider { }) } - /// Check if an error message indicates credit/balance exhaustion - fn is_credits_exhausted(message: &str) -> bool { - let lower = message.to_lowercase(); - let credit_phrases = [ - "insufficient credit", - "insufficient balance", - "insufficient fund", - "out of credit", - "credits exhausted", - "credits have been exhausted", - "no credits remaining", - "credit balance", - "balance is zero", - "balance too low", - "payment required", - "billing", - "top up", - "add credits", - "purchase credits", - "exceeded your credit", - ]; - credit_phrases.iter().any(|phrase| lower.contains(phrase)) + fn enrich_credits_error(err: ProviderError) -> ProviderError { + match err { + ProviderError::CreditsExhausted { details, .. } => ProviderError::CreditsExhausted { + details, + top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + }, + other => other, + } } async fn post( @@ -102,118 +86,15 @@ impl TetrateProvider { .response_post(session_id, "v1/chat/completions", payload) .await?; - // Check for HTTP 402 Payment Required before any other handling. - // - // NOTE: The exact HTTP status / error format Tetrate returns for credit - // exhaustion is not publicly documented. We defensively handle multiple - // possibilities: - // 1. HTTP 402 (Payment Required) — the standard status for this case - // 2. Error code 402 inside a 200 OK JSON body (Tetrate wraps some errors - // in successful responses) - // 3. Keyword-based detection on the error message (e.g. "insufficient - // credit", "payment required") as a safety net, regardless of the - // HTTP status or error code — this also catches 429 responses that - // are really about credits rather than rate limits. - let status = response.status(); - if status == reqwest::StatusCode::PAYMENT_REQUIRED { - let body = response.text().await.unwrap_or_default(); - let detail = if body.is_empty() { - "Your Tetrate Agent Router credits have been exhausted.".to_string() - } else { - // Try to extract message from JSON error body - serde_json::from_str::(&body) - .ok() - .and_then(|v| { - v.get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| { - "Your Tetrate Agent Router credits have been exhausted.".to_string() - }) - }; - return Err(ProviderError::CreditsExhausted { - details: detail, - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }); - } - - // Handle Google-compatible model responses differently if is_google_model(payload) { - return handle_response_google_compat(response).await; + return handle_response_google_compat(response) + .await + .map_err(Self::enrich_credits_error); } - // For OpenAI-compatible models, parse the response body to JSON - let response_body = handle_response_openai_compat(response) + handle_response_openai_compat(response) .await - .map_err(|e| { - // Check if the underlying error message indicates credit exhaustion - let err_str = e.to_string(); - if Self::is_credits_exhausted(&err_str) { - return ProviderError::CreditsExhausted { - details: err_str, - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }; - } - ProviderError::RequestFailed(format!("Failed to parse response: {e}")) - })?; - - let _debug = format!( - "Tetrate Agent Router Service request with payload: {} and response: {}", - serde_json::to_string_pretty(payload).unwrap_or_else(|_| "Invalid JSON".to_string()), - serde_json::to_string_pretty(&response_body) - .unwrap_or_else(|_| "Invalid JSON".to_string()) - ); - - // Tetrate Agent Router Service can return errors in 200 OK responses, so we have to check for errors explicitly - if let Some(error_obj) = response_body.get("error") { - // If there's an error object, extract the error message and code - let error_message = error_obj - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or("Unknown Tetrate Agent Router Service error"); - - let error_code = error_obj.get("code").and_then(|c| c.as_u64()).unwrap_or(0); - - // Check for credit exhaustion in error messages regardless of error code - if Self::is_credits_exhausted(error_message) || error_code == 402 { - return Err(ProviderError::CreditsExhausted { - details: error_message.to_string(), - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }); - } - - // Check for context length errors in the error message - if error_code == 400 && error_message.contains("maximum context length") { - return Err(ProviderError::ContextLengthExceeded( - error_message.to_string(), - )); - } - - // Return appropriate error based on the error code - match error_code { - 401 | 403 => return Err(ProviderError::Authentication(error_message.to_string())), - 429 => { - // A 429 could also be a credits issue disguised as rate limiting - if Self::is_credits_exhausted(error_message) { - return Err(ProviderError::CreditsExhausted { - details: error_message.to_string(), - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }); - } - return Err(ProviderError::RateLimitExceeded { - details: error_message.to_string(), - retry_delay: None, - }); - } - 500 | 503 => return Err(ProviderError::ServerError(error_message.to_string())), - _ => return Err(ProviderError::RequestFailed(error_message.to_string())), - } - } - - // No error detected, return the response body - Ok(response_body) + .map_err(Self::enrich_credits_error) } } @@ -321,36 +202,9 @@ impl Provider for TetrateProvider { .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - // Check for HTTP 402 before delegating to generic handler - if resp.status() == reqwest::StatusCode::PAYMENT_REQUIRED { - let body = resp.text().await.unwrap_or_default(); - let detail = serde_json::from_str::(&body) - .ok() - .and_then(|v| { - v.get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| { - "Your Tetrate Agent Router credits have been exhausted.".to_string() - }); - return Err(ProviderError::CreditsExhausted { - details: detail, - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }); - } - - handle_status_openai_compat(resp).await.map_err(|e| { - // Check if the error message from status handler indicates credit exhaustion - if Self::is_credits_exhausted(&e.to_string()) { - return ProviderError::CreditsExhausted { - details: e.to_string(), - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }; - } - e - }) + handle_status_openai_compat(resp) + .await + .map_err(Self::enrich_credits_error) }) .await .inspect_err(|e| { @@ -435,190 +289,13 @@ impl Provider for TetrateProvider { mod tests { use super::*; - // ── is_credits_exhausted: positive cases ────────────────────────── - - #[test] - fn credits_exhausted_detects_insufficient_credit() { - assert!(TetrateProvider::is_credits_exhausted( - "Insufficient credit on your account" - )); - } - - #[test] - fn credits_exhausted_detects_insufficient_balance() { - assert!(TetrateProvider::is_credits_exhausted( - "Insufficient balance to complete this request" - )); - } - - #[test] - fn credits_exhausted_detects_insufficient_funds() { - assert!(TetrateProvider::is_credits_exhausted( - "Insufficient funds in your account" - )); - } - - #[test] - fn credits_exhausted_detects_out_of_credit() { - assert!(TetrateProvider::is_credits_exhausted( - "You are out of credits" - )); - } - - #[test] - fn credits_exhausted_detects_credits_exhausted_phrase() { - assert!(TetrateProvider::is_credits_exhausted("Credits exhausted")); - } - - #[test] - fn credits_exhausted_detects_credits_have_been_exhausted() { - assert!(TetrateProvider::is_credits_exhausted( - "Your credits have been exhausted. Please top up." - )); - } - - #[test] - fn credits_exhausted_detects_no_credits_remaining() { - assert!(TetrateProvider::is_credits_exhausted( - "No credits remaining on this API key" - )); - } - - #[test] - fn credits_exhausted_detects_credit_balance() { - assert!(TetrateProvider::is_credits_exhausted( - "Your credit balance is $0.00" - )); - } - - #[test] - fn credits_exhausted_detects_balance_is_zero() { - assert!(TetrateProvider::is_credits_exhausted( - "Your balance is zero" - )); - } - - #[test] - fn credits_exhausted_detects_balance_too_low() { - assert!(TetrateProvider::is_credits_exhausted( - "Account balance too low for this model" - )); - } - - #[test] - fn credits_exhausted_detects_payment_required() { - assert!(TetrateProvider::is_credits_exhausted("Payment required")); - } - - #[test] - fn credits_exhausted_detects_billing() { - assert!(TetrateProvider::is_credits_exhausted( - "Please update your billing information" - )); - } - - #[test] - fn credits_exhausted_detects_top_up() { - assert!(TetrateProvider::is_credits_exhausted( - "Please top up your account" - )); - } - - #[test] - fn credits_exhausted_detects_add_credits() { - assert!(TetrateProvider::is_credits_exhausted( - "Please add credits to continue" - )); - } - - #[test] - fn credits_exhausted_detects_purchase_credits() { - assert!(TetrateProvider::is_credits_exhausted( - "Purchase credits at router.tetrate.ai" - )); - } - - #[test] - fn credits_exhausted_detects_exceeded_your_credit() { - assert!(TetrateProvider::is_credits_exhausted( - "You have exceeded your credit limit" - )); - } - - // ── is_credits_exhausted: case-insensitivity ────────────────────── - - #[test] - fn credits_exhausted_is_case_insensitive() { - assert!(TetrateProvider::is_credits_exhausted("INSUFFICIENT CREDIT")); - assert!(TetrateProvider::is_credits_exhausted("Out Of Credits")); - assert!(TetrateProvider::is_credits_exhausted("PAYMENT REQUIRED")); - } - - #[test] - fn credits_exhausted_matches_embedded_in_longer_message() { - assert!(TetrateProvider::is_credits_exhausted( - "Error 402: Payment required. Visit dashboard to add credits." - )); - } - - // ── is_credits_exhausted: negative cases ────────────────────────── - - #[test] - fn credits_exhausted_false_for_rate_limit() { - assert!(!TetrateProvider::is_credits_exhausted( - "Rate limit exceeded. Please retry after 30 seconds." - )); - } - - #[test] - fn credits_exhausted_false_for_server_error() { - assert!(!TetrateProvider::is_credits_exhausted( - "Internal server error" - )); - } - - #[test] - fn credits_exhausted_false_for_auth_error() { - assert!(!TetrateProvider::is_credits_exhausted( - "Invalid API key provided" - )); - } - - #[test] - fn credits_exhausted_false_for_context_length() { - assert!(!TetrateProvider::is_credits_exhausted( - "This model's maximum context length is 128000 tokens" - )); - } - - #[test] - fn credits_exhausted_false_for_empty_string() { - assert!(!TetrateProvider::is_credits_exhausted("")); - } - - #[test] - fn credits_exhausted_false_for_generic_error() { - assert!(!TetrateProvider::is_credits_exhausted( - "Something went wrong with the request" - )); - } - #[test] - fn credits_exhausted_false_for_model_not_found() { - assert!(!TetrateProvider::is_credits_exhausted( - "Model 'gpt-99' not found" - )); - } - - // ── CreditsExhausted error variant ──────────────────────────────── - - #[test] - fn credits_exhausted_error_includes_dashboard_url() { + fn enrich_adds_dashboard_url() { let err = ProviderError::CreditsExhausted { details: "out of credits".to_string(), - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + top_up_url: None, }; - match err { + match TetrateProvider::enrich_credits_error(err) { ProviderError::CreditsExhausted { top_up_url, .. } => { assert_eq!( top_up_url.as_deref(), @@ -630,33 +307,11 @@ mod tests { } #[test] - fn credits_exhausted_error_telemetry_type() { - let err = ProviderError::CreditsExhausted { - details: "test".to_string(), - top_up_url: None, - }; - assert_eq!(err.telemetry_type(), "credits_exhausted"); - } - - #[test] - fn credits_exhausted_error_display() { - let err = ProviderError::CreditsExhausted { - details: "No credits remaining".to_string(), - top_up_url: None, - }; - assert_eq!(err.to_string(), "Credits exhausted: No credits remaining"); - } - - #[test] - fn credits_exhausted_is_not_retried() { - use crate::providers::retry::should_retry; - let err = ProviderError::CreditsExhausted { - details: "out of credits".to_string(), - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), - }; - assert!( - !should_retry(&err), - "CreditsExhausted should not be retried — it is not a transient error" - ); + fn enrich_passes_through_other_errors() { + let err = ProviderError::ServerError("boom".to_string()); + assert!(matches!( + TetrateProvider::enrich_credits_error(err), + ProviderError::ServerError(_) + )); } } diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 1f6c3503a855..baff9837bb36 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -15,11 +15,17 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Message } from '../api'; +import { Message, SystemNotificationContent } from '../api'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; -import { SystemNotificationInline } from './context_management/SystemNotificationInline'; -import { CreditsExhaustedNotification } from './context_management/CreditsExhaustedNotification'; +import { + SystemNotificationInline, + getInlineSystemNotification, +} from './context_management/SystemNotificationInline'; +import { + CreditsExhaustedNotification, + getCreditsExhaustedNotification, +} from './context_management/CreditsExhaustedNotification'; import { NotificationEvent } from '../types/message'; import LoadingGoose from './LoadingGoose'; import { ChatType } from '../types/chat'; @@ -72,18 +78,19 @@ export default function ProgressiveMessageList({ const hasOnlyToolResponses = (message: Message) => message.content.every((c) => c.type === 'toolResponse'); - const hasInlineSystemNotification = (message: Message): boolean => { - return message.content.some( - (content) => - content.type === 'systemNotification' && content.notificationType === 'inlineMessage' - ); + const getSystemNotification = (message: Message): SystemNotificationContent | undefined => { + return getCreditsExhaustedNotification(message) ?? getInlineSystemNotification(message); }; - const hasCreditsExhaustedNotification = (message: Message): boolean => { - return message.content.some( - (content) => - content.type === 'systemNotification' && content.notificationType === 'creditsExhausted' - ); + const renderSystemNotification = (notification: SystemNotificationContent) => { + switch (notification.notificationType) { + case 'creditsExhausted': + return ; + case 'inlineMessage': + return ; + default: + return null; + } }; // Simple progressive loading - start immediately when component mounts if needed @@ -196,27 +203,15 @@ export default function ProgressiveMessageList({ return null; } - // System notifications are never user messages, handle them first - if (hasCreditsExhaustedNotification(message)) { - return ( -
- -
- ); - } - - if (hasInlineSystemNotification(message)) { + const notification = getSystemNotification(message); + if (notification) { return (
- + {renderSystemNotification(notification)}
); } diff --git a/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx index 9c4fcc778325..1ec2c81a8f20 100644 --- a/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx +++ b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx @@ -2,26 +2,12 @@ import React from 'react'; import { Message, SystemNotificationContent } from '../../api'; interface CreditsExhaustedNotificationProps { - message: Message; + notification: SystemNotificationContent; } -/** - * Renders a credits-exhausted notification with a prominent message and - * an optional "Top Up Credits" button that opens the provider's dashboard - * in the user's default browser. - */ export const CreditsExhaustedNotification: React.FC = ({ - message, + notification, }) => { - const notification = message.content.find( - (content): content is SystemNotificationContent & { type: 'systemNotification' } => - content.type === 'systemNotification' && content.notificationType === 'creditsExhausted' - ); - - if (!notification?.msg) { - return null; - } - const topUpUrl = notification.data && typeof notification.data === 'object' && @@ -55,3 +41,12 @@ export const CreditsExhaustedNotification: React.FC ); }; + +export function getCreditsExhaustedNotification( + message: Message +): SystemNotificationContent | undefined { + return message.content.find( + (content): content is SystemNotificationContent & { type: 'systemNotification' } => + content.type === 'systemNotification' && content.notificationType === 'creditsExhausted' + ); +} diff --git a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx index 71b0763a8a5b..ee8732bb20bb 100644 --- a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx +++ b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx @@ -2,18 +2,20 @@ import React from 'react'; import { Message, SystemNotificationContent } from '../../api'; interface SystemNotificationInlineProps { - message: Message; + notification: SystemNotificationContent; } -export const SystemNotificationInline: React.FC = ({ message }) => { - const systemNotification = message.content.find( +export const SystemNotificationInline: React.FC = ({ + notification, +}) => { + return
{notification.msg}
; +}; + +export function getInlineSystemNotification( + message: Message +): SystemNotificationContent | undefined { + return message.content.find( (content): content is SystemNotificationContent & { type: 'systemNotification' } => content.type === 'systemNotification' && content.notificationType === 'inlineMessage' ); - - if (!systemNotification?.msg) { - return null; - } - - return
{systemNotification.msg}
; -}; +} From 6851b0200de41ed946b16d03c8e6dc1c3974edb7 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 17:33:32 +0100 Subject: [PATCH 06/16] What Douwe said --- crates/goose/src/agents/agent.rs | 22 +++--- crates/goose/src/providers/tetrate.rs | 72 +++---------------- evals/open-model-gym/config.yaml | 18 +---- .../src/components/ProgressiveMessageList.tsx | 2 +- 4 files changed, 23 insertions(+), 91 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 080ddfa4a21f..75be1bedaae8 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1437,19 +1437,15 @@ impl Agent { crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); error!("Error: {}", provider_err); - let user_msg = if let Some(url) = top_up_url.as_deref() { - format!( - "Your credits have been exhausted: {details}\n\n\ - To add more credits, visit: {url}\n\n\ - Once you've topped up, retry your last message to continue." - ) - } else { - format!( - "Your credits have been exhausted: {details}\n\n\ - Please check your account with your provider to add more \ - credits, then retry your last message to continue." - ) - }; + let top_up_hint = top_up_url.as_deref().map_or_else( + || "Please check your account with your provider to add more credits.".to_string(), + |url| format!("To add more credits, visit: {url}"), + ); + let user_msg = format!( + "Your credits have been exhausted: {details}\n\n\ + {top_up_hint}\n\n\ + Once you've topped up, retry your last message to continue." + ); let notification_data = serde_json::json!({ "top_up_url": top_up_url, diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 22ec85f73384..ae82187af51c 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -20,18 +20,6 @@ use crate::providers::formats::openai::{create_request, get_usage, response_to_m use rmcp::model::Tool; const TETRATE_PROVIDER_NAME: &str = "tetrate"; -pub const TETRATE_KNOWN_MODELS: &[&str] = &[ - "claude-opus-4-1", - "claude-3-7-sonnet-latest", - "claude-sonnet-4-20250514", - "gemini-2.5-pro", - "gemini-2.0-flash", - "gemini-2.0-flash-lite", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "gpt-4.1", -]; pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; pub const TETRATE_DASHBOARD_URL: &str = "https://router.tetrate.ai/dashboard"; @@ -107,7 +95,7 @@ impl ProviderDef for TetrateProvider { "Tetrate Agent Router Service", "Enterprise router for AI models", TETRATE_DEFAULT_MODEL, - TETRATE_KNOWN_MODELS.to_vec(), + vec![], TETRATE_DOC_URL, vec![ ConfigKey::new("TETRATE_API_KEY", true, true, None), @@ -214,68 +202,30 @@ impl Provider for TetrateProvider { stream_openai_compat(response, log) } - /// Fetch supported models from Tetrate Agent Router Service API (only models with tool support) + /// Fetch supported models from Tetrate Agent Router Service API async fn fetch_supported_models(&self) -> Result, ProviderError> { - // Use the existing api_client which already has authentication configured - let response = match self + let response = self .api_client - .request(None, "v1/models") - .response_get() + .response_get(None, "v1/models") .await - { - Ok(response) => response, - Err(e) => { - return Err(ProviderError::ExecutionError(format!( - "Failed to fetch models from Tetrate API: {}. Please check your API key and account at {}", - e, TETRATE_DOC_URL - ))); - } - }; - - let json: serde_json::Value = response.json().await.map_err(|e| { - ProviderError::ExecutionError(format!( - "Failed to parse Tetrate API response: {}. Please check your API key and account at {}", - e, TETRATE_DOC_URL - )) - })?; + .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; + let json = handle_response_openai_compat(response).await?; - // Check for error in response if let Some(err_obj) = json.get("error") { let msg = err_obj .get("message") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); - return Err(ProviderError::ExecutionError(format!( - "Tetrate API error: {}. Please check your API key and account at {}", - msg, TETRATE_DOC_URL - ))); + return Err(ProviderError::Authentication(msg.to_string())); } - // The response format from /v1/models is expected to be OpenAI-compatible - // It should have a "data" field with an array of model objects - let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { - ProviderError::ExecutionError(format!( - "Tetrate API response missing 'data' field. Please check your API key and account at {}", - TETRATE_DOC_URL - )) + let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { + ProviderError::RequestFailed("Missing 'data' array in models response".to_string()) })?; - - let mut models: Vec = data + let mut models: Vec = arr .iter() - .filter_map(|model| { - let id = model.get("id").and_then(|v| v.as_str())?; - let supports_computer_use = model - .get("supports_computer_use") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if supports_computer_use { - Some(id.to_string()) - } else { - None - } - }) + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) .collect(); - models.sort(); Ok(models) } diff --git a/evals/open-model-gym/config.yaml b/evals/open-model-gym/config.yaml index 65b9f59cb4d5..64aede15d0b6 100644 --- a/evals/open-model-gym/config.yaml +++ b/evals/open-model-gym/config.yaml @@ -48,18 +48,13 @@ runners: # stdio: # - node mcp-harness/dist/index.js - - name: goose + - name: goose-full type: goose bin: goose extensions: [developer, todo, skills, code_execution, extensionmanager] stdio: - node mcp-harness/dist/index.js - - name: goose-diet - type: goose - bin: ~/Downloads/goose-diet - extensions: [developer] - - name: opencode type: opencode bin: opencode @@ -74,12 +69,6 @@ runners: stdio: - node mcp-harness/dist/index.js - - name: pi-lean - type: pi - bin: pi - # Pi takes provider/model from the test matrix, not config - # MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter` - # ============================================================================= # Test Matrix # ============================================================================= @@ -91,9 +80,6 @@ matrix: - scenario: everyday-app-automation - scenario: file-editing - # Feature removal: all runners - - scenario: remove-feature - # Multi-turn: goose and pi only (opencode doesn't support session continuation) - scenario: multi-turn-edit - runners: [pi, goose] + runners: [goose-full] diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index baff9837bb36..fd406dfb1b2a 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -207,7 +207,7 @@ export default function ProgressiveMessageList({ if (notification) { return (
From 1430bcbba01ae7ae8068dd2fce9f2a934c6c1ffb Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 18:37:37 +0100 Subject: [PATCH 07/16] Simplify --- crates/goose/src/providers/errors.rs | 1 - crates/goose/src/providers/tetrate.rs | 29 ++++++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index 60c3c837683f..71a99bbefad1 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -34,7 +34,6 @@ pub enum ProviderError { #[error("Credits exhausted: {details}")] CreditsExhausted { details: String, - /// URL where the user can add more credits / top up top_up_url: Option, }, } diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index ae82187af51c..7ab14ce1fcef 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -4,7 +4,8 @@ use super::base::{ }; use super::errors::ProviderError; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, stream_openai_compat, + handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error, + stream_openai_compat, }; use super::retry::ProviderRetry; use super::utils::{get_model, handle_response_google_compat, is_google_model, RequestLog}; @@ -23,6 +24,20 @@ const TETRATE_PROVIDER_NAME: &str = "tetrate"; pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; pub const TETRATE_DASHBOARD_URL: &str = "https://router.tetrate.ai/dashboard"; +/// Known models for Tetrate - used as fallback when dynamic fetch isn't available +const TETRATE_KNOWN_MODELS: &[&str] = &[ + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + "claude-sonnet-4-20250514", + "gemini-2.5-pro", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", +]; + #[derive(serde::Serialize)] pub struct TetrateProvider { #[serde(skip)] @@ -95,7 +110,7 @@ impl ProviderDef for TetrateProvider { "Tetrate Agent Router Service", "Enterprise router for AI models", TETRATE_DEFAULT_MODEL, - vec![], + TETRATE_KNOWN_MODELS.to_vec(), TETRATE_DOC_URL, vec![ ConfigKey::new("TETRATE_API_KEY", true, true, None), @@ -211,12 +226,12 @@ impl Provider for TetrateProvider { .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; let json = handle_response_openai_compat(response).await?; + // Tetrate can return errors in 200 OK responses, so check explicitly if let Some(err_obj) = json.get("error") { - let msg = err_obj - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("unknown error"); - return Err(ProviderError::Authentication(msg.to_string())); + let code = err_obj.get("code").and_then(|c| c.as_u64()).unwrap_or(500) as u16; + let status = reqwest::StatusCode::from_u16(code) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(map_http_error_to_provider_error(status, Some(json))); } let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { From 57f6a23bf186d980137c3a781bb258dbd72041cf Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 18:40:37 +0100 Subject: [PATCH 08/16] Restore --- crates/goose/src/providers/tetrate.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 7ab14ce1fcef..bb724d277135 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -24,10 +24,9 @@ const TETRATE_PROVIDER_NAME: &str = "tetrate"; pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; pub const TETRATE_DASHBOARD_URL: &str = "https://router.tetrate.ai/dashboard"; -/// Known models for Tetrate - used as fallback when dynamic fetch isn't available -const TETRATE_KNOWN_MODELS: &[&str] = &[ - "claude-3-5-sonnet-20241022", - "claude-3-7-sonnet-20250219", +pub const TETRATE_KNOWN_MODELS: &[&str] = &[ + "claude-opus-4-1", + "claude-3-7-sonnet-latest", "claude-sonnet-4-20250514", "gemini-2.5-pro", "gemini-2.0-flash", From 6fd91e39c646fd6136a0c43c356ac0aca659f1b4 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 19:27:17 +0100 Subject: [PATCH 09/16] Handle the error --- crates/goose/src/providers/tetrate.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index bb724d277135..732efe8abea1 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -94,9 +94,21 @@ impl TetrateProvider { .map_err(Self::enrich_credits_error); } - handle_response_openai_compat(response) + let response_body = handle_response_openai_compat(response) .await - .map_err(Self::enrich_credits_error) + .map_err(Self::enrich_credits_error)?; + + // Tetrate can return errors in 200 OK responses, so check explicitly + if let Some(err_obj) = response_body.get("error") { + let code = err_obj.get("code").and_then(|c| c.as_u64()).unwrap_or(500) as u16; + let status = reqwest::StatusCode::from_u16(code) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(Self::enrich_credits_error( + map_http_error_to_provider_error(status, Some(response_body)), + )); + } + + Ok(response_body) } } From a26dda30f959dc215a5aa850663bdd44f4cbe65e Mon Sep 17 00:00:00 2001 From: raj-subhankar Date: Tue, 17 Feb 2026 18:30:44 +0530 Subject: [PATCH 10/16] Add light/dark mode support Signed-off-by: raj-subhankar --- .../context_management/CreditsExhaustedNotification.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx index 1ec2c81a8f20..1959c00017f7 100644 --- a/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx +++ b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx @@ -22,11 +22,11 @@ export const CreditsExhaustedNotification: React.FC +
⚠️
-
{notification.msg}
+
{notification.msg}
{topUpUrl && ( )}
From bc182c6d768317bafc7fdf3713e92b0d9c842909 Mon Sep 17 00:00:00 2001 From: raj-subhankar Date: Wed, 18 Feb 2026 12:42:37 +0530 Subject: [PATCH 13/16] update cli message Signed-off-by: raj-subhankar --- crates/goose-cli/src/session/output.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 1f5778338e6a..479c8feeefd7 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -269,8 +269,16 @@ pub fn render_message(message: &Message, debug: bool) { .and_then(|d| d.get("top_up_url")) .and_then(|v| v.as_str()) { - if let Err(e) = webbrowser::open(url) { - tracing::warn!("Failed to open browser for credits top-up: {}", e); + println!( + "{}", + style(format!("Visit this URL to top up credits: {url}")).yellow() + ); + + if std::io::stdout().is_terminal() && webbrowser::open(url).is_err() { + println!( + "{}", + style("Could not open browser automatically. Visit the URL above.").yellow() + ); } } } From 9b41f7bae6b7cf40ad0d6349b93553267722cb2c Mon Sep 17 00:00:00 2001 From: raj-subhankar Date: Wed, 18 Feb 2026 14:16:56 +0530 Subject: [PATCH 14/16] validate url Signed-off-by: raj-subhankar --- crates/goose/src/providers/tetrate.rs | 4 +-- .../CreditsExhaustedNotification.tsx | 34 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index faf8304dab13..4d2dde7c5e3a 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -19,7 +19,7 @@ use rmcp::model::Tool; const TETRATE_PROVIDER_NAME: &str = "tetrate"; pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; -pub const TETRATE_DASHBOARD_URL: &str = "https://router.tetrate.ai/billing"; +pub const TETRATE_BILLING_URL: &str = "https://router.tetrate.ai/billing"; pub const TETRATE_KNOWN_MODELS: &[&str] = &[ "claude-opus-4-1", @@ -69,7 +69,7 @@ impl TetrateProvider { match err { ProviderError::CreditsExhausted { details, .. } => ProviderError::CreditsExhausted { details, - top_up_url: Some(TETRATE_DASHBOARD_URL.to_string()), + top_up_url: Some(TETRATE_BILLING_URL.to_string()), }, other => other, } diff --git a/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx index 11a55d1ddb62..9f6788307f0e 100644 --- a/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx +++ b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx @@ -1,20 +1,42 @@ import React from 'react'; import { AlertTriangle, ExternalLink } from 'lucide-react'; import { Message, SystemNotificationContent } from '../../api'; +import { WEB_PROTOCOLS } from '../../utils/urlSecurity'; interface CreditsExhaustedNotificationProps { notification: SystemNotificationContent; } +function getValidatedTopUpUrl(data: unknown): string | null { + if (!data || typeof data !== 'object') { + return null; + } + + const rawUrl = (data as Record).top_up_url; + if (typeof rawUrl !== 'string') { + return null; + } + + const url = rawUrl.trim(); + if (!url) { + return null; + } + + try { + const parsedUrl = new URL(url); + if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) { + return null; + } + return parsedUrl.toString(); + } catch { + return null; + } +} + export const CreditsExhaustedNotification: React.FC = ({ notification, }) => { - const topUpUrl = - notification.data && - typeof notification.data === 'object' && - 'top_up_url' in (notification.data as Record) - ? ((notification.data as Record).top_up_url as string | null) - : null; + const topUpUrl = getValidatedTopUpUrl(notification.data); const handleTopUp = () => { if (topUpUrl) { From 87f4c5abcd6850345c372ba3ef30b9b75b7cf9da Mon Sep 17 00:00:00 2001 From: raj-subhankar Date: Wed, 18 Feb 2026 15:49:52 +0530 Subject: [PATCH 15/16] address copilot feedback Signed-off-by: raj-subhankar --- crates/goose-cli/src/session/mod.rs | 41 ++++++++- crates/goose-cli/src/session/output.rs | 91 +++++++++++++------ crates/goose/src/providers/tetrate.rs | 115 ++++++++++++++++--------- 3 files changed, 181 insertions(+), 66 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index bfa5c8700cb0..a925c1a2dc0b 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -45,7 +45,8 @@ use goose::conversation::message::{ActionRequiredData, Message, MessageContent}; use rustyline::EditMode; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; @@ -967,6 +968,7 @@ impl CliSession { let mut progress_bars = output::McpSpinners::new(); let cancel_token_clone = cancel_token.clone(); let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new(); + let mut prompted_credits_urls: HashSet = HashSet::new(); use futures::StreamExt; loop { @@ -1040,6 +1042,11 @@ impl CliSession { emit_stream_event(&StreamEvent::Message { message: message.clone() }); } else if !is_json_mode { output::render_message_streaming(&message, &mut markdown_buffer, self.debug); + maybe_open_credits_top_up_url( + &message, + interactive, + &mut prompted_credits_urls, + ); } } } @@ -1451,6 +1458,38 @@ impl CliSession { } } +fn maybe_open_credits_top_up_url( + message: &Message, + interactive: bool, + prompted_credits_urls: &mut HashSet, +) { + if !interactive || !std::io::stdout().is_terminal() { + return; + } + + let Some(url) = output::get_credits_top_up_url(message) else { + return; + }; + + if !prompted_credits_urls.insert(url.clone()) { + return; + } + + output::hide_thinking(); + let should_open = cliclack::confirm("Open the top-up URL in your browser?") + .initial_value(false) + .interact() + .unwrap_or(false); + + if should_open && webbrowser::open(&url).is_err() { + output::render_text( + "Could not open browser automatically. Visit the URL above.", + Some(Color::Yellow), + true, + ); + } +} + fn emit_stream_event(event: &StreamEvent) { if let Ok(json) = serde_json::to_string(event) { println!("{}", json); diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 81e0827332a4..bf5d1f448ca7 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -3,7 +3,8 @@ use bat::WrappingMode; use console::{measure_text_width, style, Color, Term}; use goose::config::Config; use goose::conversation::message::{ - ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse, + ActionRequiredData, Message, MessageContent, SystemNotificationContent, SystemNotificationType, + ToolRequest, ToolResponse, }; use goose::providers::canonical::maybe_get_canonical_model; #[cfg(target_os = "windows")] @@ -252,8 +253,6 @@ pub fn render_message(message: &Message, debug: bool) { print_markdown("Thinking was redacted", theme); } MessageContent::SystemNotification(notification) => { - use goose::conversation::message::SystemNotificationType; - match notification.notification_type { SystemNotificationType::ThinkingMessage => { show_thinking(); @@ -264,27 +263,7 @@ pub fn render_message(message: &Message, debug: bool) { println!("\n{}", style(¬ification.msg).yellow()); } SystemNotificationType::CreditsExhausted => { - hide_thinking(); - println!("\n{}", style(¬ification.msg).yellow()); - - if let Some(url) = notification - .data - .as_ref() - .and_then(|d| d.get("top_up_url")) - .and_then(|v| v.as_str()) - { - println!( - "{}", - style(format!("Visit this URL to top up credits: {url}")).yellow() - ); - - if std::io::stdout().is_terminal() && webbrowser::open(url).is_err() { - println!( - "{}", - style("Could not open browser automatically. Visit the URL above.").yellow() - ); - } - } + render_credits_exhausted_notification(notification); } } } @@ -350,8 +329,6 @@ pub fn render_message_streaming(message: &Message, buffer: &mut MarkdownBuffer, print_markdown("Thinking was redacted", theme); } MessageContent::SystemNotification(notification) => { - use goose::conversation::message::SystemNotificationType; - match notification.notification_type { SystemNotificationType::ThinkingMessage => { show_thinking(); @@ -362,6 +339,10 @@ pub fn render_message_streaming(message: &Message, buffer: &mut MarkdownBuffer, hide_thinking(); println!("\n{}", style(¬ification.msg).yellow()); } + SystemNotificationType::CreditsExhausted => { + flush_markdown_buffer(buffer, theme); + render_credits_exhausted_notification(notification); + } } } _ => { @@ -374,6 +355,40 @@ pub fn render_message_streaming(message: &Message, buffer: &mut MarkdownBuffer, let _ = std::io::stdout().flush(); } +fn render_credits_exhausted_notification(notification: &SystemNotificationContent) { + hide_thinking(); + println!("\n{}", style(¬ification.msg).yellow()); + + if let Some(url) = notification + .data + .as_ref() + .and_then(|d| d.get("top_up_url")) + .and_then(|v| v.as_str()) + { + println!( + "{}", + style(format!("Visit this URL to top up credits: {url}")).yellow() + ); + } +} + +pub fn get_credits_top_up_url(message: &Message) -> Option { + message.content.iter().find_map(|content| { + let MessageContent::SystemNotification(notification) = content else { + return None; + }; + if notification.notification_type != SystemNotificationType::CreditsExhausted { + return None; + } + notification + .data + .as_ref() + .and_then(|d| d.get("top_up_url")) + .and_then(|v| v.as_str()) + .map(str::to_string) + }) +} + pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme) { let remaining = buffer.flush(); if !remaining.is_empty() { @@ -1416,6 +1431,7 @@ impl McpSpinners { #[cfg(test)] mod tests { use super::*; + use serde_json::json; use std::env; #[test] @@ -1483,4 +1499,27 @@ mod tests { "/v/l/p/w/m/components/file.txt" ); } + + #[test] + fn test_get_credits_top_up_url_from_credits_notification() { + let message = Message::assistant().with_system_notification_with_data( + SystemNotificationType::CreditsExhausted, + "Insufficient credits", + json!({"top_up_url": "https://router.tetrate.ai/billing"}), + ); + assert_eq!( + get_credits_top_up_url(&message).as_deref(), + Some("https://router.tetrate.ai/billing") + ); + } + + #[test] + fn test_get_credits_top_up_url_ignores_non_credits_notification() { + let message = Message::assistant().with_system_notification_with_data( + SystemNotificationType::InlineMessage, + "hello", + json!({"top_up_url": "https://router.tetrate.ai/billing"}), + ); + assert_eq!(get_credits_top_up_url(&message), None); + } } diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 4d2dde7c5e3a..37c3aced7932 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -16,6 +16,7 @@ use futures::future::BoxFuture; use crate::model::ModelConfig; use crate::providers::formats::openai::create_request; use rmcp::model::Tool; +use serde_json::Value; const TETRATE_PROVIDER_NAME: &str = "tetrate"; pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; @@ -75,37 +76,15 @@ impl TetrateProvider { } } - async fn post( - &self, - session_id: Option<&str>, - payload: &Value, - ) -> Result { - let response = self - .api_client - .response_post(session_id, "v1/chat/completions", payload) - .await?; - - if is_google_model(payload) { - return handle_response_google_compat(response) - .await - .map_err(Self::enrich_credits_error); - } - - let response_body = handle_response_openai_compat(response) - .await - .map_err(Self::enrich_credits_error)?; - - // Tetrate can return errors in 200 OK responses, so check explicitly - if let Some(err_obj) = response_body.get("error") { - let code = err_obj.get("code").and_then(|c| c.as_u64()).unwrap_or(500) as u16; - let status = reqwest::StatusCode::from_u16(code) - .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); - return Err(Self::enrich_credits_error( - map_http_error_to_provider_error(status, Some(response_body)), - )); - } - - Ok(response_body) + fn error_from_tetrate_error_payload(payload: Value) -> ProviderError { + let code = payload + .get("error") + .and_then(|e| e.get("code")) + .and_then(|c| c.as_u64()) + .unwrap_or(500) as u16; + let status = reqwest::StatusCode::from_u16(code) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + Self::enrich_credits_error(map_http_error_to_provider_error(status, Some(payload))) } } @@ -176,10 +155,34 @@ impl Provider for TetrateProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - - handle_status_openai_compat(resp) + let resp = handle_status_openai_compat(resp) .await - .map_err(Self::enrich_credits_error) + .map_err(Self::enrich_credits_error)?; + + let is_json = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_ascii_lowercase()) + .is_some_and(|v| v.contains("json")); + + if is_json { + // Streaming responses should be SSE; when we get JSON instead, parse it to map + // explicit error payloads and otherwise fail as a protocol mismatch. + let body = handle_response_openai_compat(resp) + .await + .map_err(Self::enrich_credits_error)?; + if body.get("error").is_some() { + return Err(Self::error_from_tetrate_error_payload(body)); + } + + return Err(ProviderError::ExecutionError( + "Expected streaming response but received non-streaming payload" + .to_string(), + )); + } + + Ok(resp) }) .await .inspect_err(|e| { @@ -199,11 +202,8 @@ impl Provider for TetrateProvider { let json = handle_response_openai_compat(response).await?; // Tetrate can return errors in 200 OK responses, so check explicitly - if let Some(err_obj) = json.get("error") { - let code = err_obj.get("code").and_then(|c| c.as_u64()).unwrap_or(500) as u16; - let status = reqwest::StatusCode::from_u16(code) - .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); - return Err(map_http_error_to_provider_error(status, Some(json))); + if json.get("error").is_some() { + return Err(Self::error_from_tetrate_error_payload(json)); } let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { @@ -221,6 +221,7 @@ impl Provider for TetrateProvider { #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn enrich_adds_dashboard_url() { @@ -247,4 +248,40 @@ mod tests { ProviderError::ServerError(_) )); } + + #[test] + fn error_payload_maps_credits_and_adds_billing_url() { + let payload = json!({ + "error": { + "code": 402, + "message": "Insufficient credits" + } + }); + match TetrateProvider::error_from_tetrate_error_payload(payload) { + ProviderError::CreditsExhausted { + details, + top_up_url, + } => { + assert!(details.contains("Insufficient credits")); + assert_eq!(top_up_url.as_deref(), Some(TETRATE_BILLING_URL)); + } + other => panic!("Expected CreditsExhausted, got {other:?}"), + } + } + + #[test] + fn error_payload_maps_authentication() { + let payload = json!({ + "error": { + "code": 401, + "message": "Invalid API key" + } + }); + match TetrateProvider::error_from_tetrate_error_payload(payload) { + ProviderError::Authentication(msg) => { + assert!(msg.contains("Invalid API key")); + } + other => panic!("Expected Authentication, got {other:?}"), + } + } } From b8a53c39909472320e81d486715ee52dc737d86d Mon Sep 17 00:00:00 2001 From: raj-subhankar Date: Wed, 18 Feb 2026 15:57:00 +0530 Subject: [PATCH 16/16] update Signed-off-by: raj-subhankar --- crates/goose-cli/src/session/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index a925c1a2dc0b..cdc2fd2b6fbe 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1475,7 +1475,6 @@ fn maybe_open_credits_top_up_url( return; } - output::hide_thinking(); let should_open = cliclack::confirm("Open the top-up URL in your browser?") .initial_value(false) .interact()