diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 55aa2b1d03..2ab1183b06 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(); let mut thinking_header_shown = false; use futures::StreamExt; @@ -1041,6 +1043,11 @@ impl CliSession { emit_stream_event(&StreamEvent::Message { message: message.clone() }); } else if !is_json_mode { output::render_message_streaming(&message, &mut markdown_buffer, &mut thinking_header_shown, self.debug); + maybe_open_credits_top_up_url( + &message, + interactive, + &mut prompted_credits_urls, + ); } } } @@ -1452,6 +1459,37 @@ 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; + } + + 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 535fa0f028..28ccd1677e 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")] @@ -245,8 +246,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(); @@ -256,6 +255,9 @@ pub fn render_message(message: &Message, debug: bool) { hide_thinking(); println!("\n{}", style(¬ification.msg).yellow()); } + SystemNotificationType::CreditsExhausted => { + render_credits_exhausted_notification(notification); + } } } _ => { @@ -329,8 +331,6 @@ pub fn render_message_streaming( print_markdown("Thinking was redacted", theme); } MessageContent::SystemNotification(notification) => { - use goose::conversation::message::SystemNotificationType; - match notification.notification_type { SystemNotificationType::ThinkingMessage => { show_thinking(); @@ -341,6 +341,10 @@ pub fn render_message_streaming( hide_thinking(); println!("\n{}", style(¬ification.msg).yellow()); } + SystemNotificationType::CreditsExhausted => { + flush_markdown_buffer(buffer, theme); + render_credits_exhausted_notification(notification); + } } } _ => { @@ -353,6 +357,40 @@ pub fn render_message_streaming( 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() { @@ -1434,6 +1472,7 @@ impl McpSpinners { #[cfg(test)] mod tests { use super::*; + use serde_json::json; use std::env; #[test] @@ -1501,4 +1540,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/agents/agent.rs b/crates/goose/src/agents/agent.rs index 4aa8fc4c40..a1439079ea 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1500,6 +1500,29 @@ impl Agent { } } } + Err(ref provider_err @ ProviderError::CreditsExhausted { details: _, ref top_up_url }) => { + crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); + error!("Error: {}", provider_err); + + let user_msg = if top_up_url.is_some() { + "Please add credits to your account, then resend your message to continue.".to_string() + } else { + "Please check your account with your provider to add more credits, then resend your message to continue.".to_string() + }; + + let notification_data = serde_json::json!({ + "top_up_url": top_up_url, + }); + + yield AgentEvent::Message( + Message::assistant().with_system_notification_with_data( + SystemNotificationType::CreditsExhausted, + user_msg, + notification_data, + ) + ); + 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/conversation/message.rs b/crates/goose/src/conversation/message.rs index b4922d6722..efa1d19228 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -164,6 +164,7 @@ pub struct FrontendToolRequest { pub enum SystemNotificationType { ThinkingMessage, InlineMessage, + CreditsExhausted, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index 90f6d94b9d..71a99bbefa 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -30,6 +30,12 @@ pub enum ProviderError { #[error("Unsupported operation: {0}")] NotImplemented(String), + + #[error("Credits exhausted: {details}")] + CreditsExhausted { + details: String, + top_up_url: Option, + }, } impl ProviderError { @@ -43,6 +49,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 0eb1c811ef..e996c82d69 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -176,6 +176,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(); @@ -251,3 +255,67 @@ pub fn stream_openai_compat( } })) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use test_case::test_case; + + #[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 f906aa597c..37c3aced79 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -1,7 +1,10 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; -use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use super::openai_compatible::{ + handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error, + stream_openai_compat, +}; use super::retry::ProviderRetry; use super::utils::RequestLog; use crate::config::signup_tetrate::TETRATE_DEFAULT_MODEL; @@ -13,9 +16,12 @@ 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"; -// Tetrate Agent Router Service can run many models, we suggest the default +pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; +pub const TETRATE_BILLING_URL: &str = "https://router.tetrate.ai/billing"; + pub const TETRATE_KNOWN_MODELS: &[&str] = &[ "claude-opus-4-1", "claude-3-7-sonnet-latest", @@ -28,7 +34,6 @@ pub const TETRATE_KNOWN_MODELS: &[&str] = &[ "gpt-5-nano", "gpt-4.1", ]; -pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai"; #[derive(serde::Serialize)] pub struct TetrateProvider { @@ -44,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()); @@ -61,6 +65,27 @@ impl TetrateProvider { name: TETRATE_PROVIDER_NAME.to_string(), }) } + + fn enrich_credits_error(err: ProviderError) -> ProviderError { + match err { + ProviderError::CreditsExhausted { details, .. } => ProviderError::CreditsExhausted { + details, + top_up_url: Some(TETRATE_BILLING_URL.to_string()), + }, + other => other, + } + } + + 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))) + } } impl ProviderDef for TetrateProvider { @@ -130,7 +155,34 @@ impl Provider for TetrateProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + let resp = handle_status_openai_compat(resp) + .await + .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| { @@ -140,69 +192,96 @@ 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 - ))); - } - }; + .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; + let json = handle_response_openai_compat(response).await?; - 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 - )) - })?; - - // 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 - ))); + // Tetrate can return errors in 200 OK responses, so check explicitly + if json.get("error").is_some() { + return Err(Self::error_from_tetrate_error_payload(json)); } - // 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) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn enrich_adds_dashboard_url() { + let err = ProviderError::CreditsExhausted { + details: "out of credits".to_string(), + top_up_url: None, + }; + match TetrateProvider::enrich_credits_error(err) { + ProviderError::CreditsExhausted { top_up_url, .. } => { + assert_eq!( + top_up_url.as_deref(), + Some("https://router.tetrate.ai/billing") + ); + } + _ => panic!("Expected CreditsExhausted variant"), + } + } + + #[test] + fn enrich_passes_through_other_errors() { + let err = ProviderError::ServerError("boom".to_string()); + assert!(matches!( + TetrateProvider::enrich_credits_error(err), + 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:?}"), + } + } +} diff --git a/evals/open-model-gym/config.yaml b/evals/open-model-gym/config.yaml index 65b9f59cb4..64aede15d0 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/openapi.json b/ui/desktop/openapi.json index 33106d8fb2..fb9712d723 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -6815,7 +6815,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 1dfb9b6d4d..22524f9403 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1167,7 +1167,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 b1fa8ae029..7cd3686dc8 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -15,10 +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 { + 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'; @@ -71,11 +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 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 @@ -188,15 +203,15 @@ export default function ProgressiveMessageList({ return null; } - // System notifications are never user messages, handle them first - 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 new file mode 100644 index 0000000000..9f6788307f --- /dev/null +++ b/ui/desktop/src/components/context_management/CreditsExhaustedNotification.tsx @@ -0,0 +1,76 @@ +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 = getValidatedTopUpUrl(notification.data); + + const handleTopUp = () => { + if (topUpUrl) { + window.electron.openExternal(topUpUrl); + } + }; + + return ( +
+
+ +
+
Insufficient Credits
+
{notification.msg}
+ {topUpUrl && ( + + )} +
+
+
+ ); +}; + +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 71b0763a8a..ee8732bb20 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}
; -}; +}