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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> = HashSet::new();
let mut thinking_header_shown = false;

use futures::StreamExt;
Expand Down Expand Up @@ -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,
);
}
}
}
Expand Down Expand Up @@ -1452,6 +1459,37 @@ impl CliSession {
}
}

fn maybe_open_credits_top_up_url(
message: &Message,
interactive: bool,
prompted_credits_urls: &mut HashSet<String>,
) {
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);
Expand Down
72 changes: 67 additions & 5 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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();
Expand All @@ -256,6 +255,9 @@ pub fn render_message(message: &Message, debug: bool) {
hide_thinking();
println!("\n{}", style(&notification.msg).yellow());
}
SystemNotificationType::CreditsExhausted => {
render_credits_exhausted_notification(notification);
}
}
}
_ => {
Expand Down Expand Up @@ -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();
Expand All @@ -341,6 +341,10 @@ pub fn render_message_streaming(
hide_thinking();
println!("\n{}", style(&notification.msg).yellow());
}
SystemNotificationType::CreditsExhausted => {
flush_markdown_buffer(buffer, theme);
render_credits_exhausted_notification(notification);
}
}
}
_ => {
Expand All @@ -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(&notification.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<String> {
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() {
Expand Down Expand Up @@ -1434,6 +1472,7 @@ impl McpSpinners {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::env;

#[test]
Expand Down Expand Up @@ -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);
}
}
23 changes: 23 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/goose/src/conversation/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ pub struct FrontendToolRequest {
pub enum SystemNotificationType {
ThinkingMessage,
InlineMessage,
CreditsExhausted,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
Expand Down
7 changes: 7 additions & 0 deletions crates/goose/src/providers/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ pub enum ProviderError {

#[error("Unsupported operation: {0}")]
NotImplemented(String),

#[error("Credits exhausted: {details}")]
CreditsExhausted {
details: String,
top_up_url: Option<String>,
},
}

impl ProviderError {
Expand All @@ -43,6 +49,7 @@ impl ProviderError {
ProviderError::ExecutionError(_) => "execution",
ProviderError::UsageError(_) => "usage",
ProviderError::NotImplemented(_) => "not_implemented",
ProviderError::CreditsExhausted { .. } => "credits_exhausted",
}
}
}
Expand Down
68 changes: 68 additions & 0 deletions crates/goose/src/providers/openai_compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<Value>,
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:?}"
);
}
}
Loading
Loading