Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
19 changes: 19 additions & 0 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ pub fn render_message(message: &Message, debug: bool) {
hide_thinking();
println!("\n{}", style(&notification.msg).yellow());
}
SystemNotificationType::CreditsExhausted => {
hide_thinking();
println!("\n{}", style(&notification.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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could do without that comment (but also realizing I'm losing that battle0

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you know - I didn't look at a single bit of this PR, not even once (at least other than having AI.staged describe it too me - as wasn't sure if people cared!) but now they do, will tidy up

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);
Comment thread
raj-subhankar marked this conversation as resolved.
Outdated
}
}
}
}
}
_ => {
Expand Down
35 changes: 35 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,41 @@ 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);

// 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\
Please check your account with your provider to add more \
credits, then retry your last message to continue."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could remove the one line duplication here

};

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
4 changes: 4 additions & 0 deletions crates/goose/src/conversation/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment should go - or we need to document the others too

CreditsExhausted,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
Expand Down
8 changes: 8 additions & 0 deletions crates/goose/src/providers/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
}

impl ProviderError {
Expand All @@ -43,6 +50,7 @@ impl ProviderError {
ProviderError::ExecutionError(_) => "execution",
ProviderError::UsageError(_) => "usage",
ProviderError::NotImplemented(_) => "not_implemented",
ProviderError::CreditsExhausted { .. } => "credits_exhausted",
}
}
}
Expand Down
99 changes: 99 additions & 0 deletions crates/goose/src/providers/openai_compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@
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 @@ -294,3 +298,98 @@
}
}))
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn http_402_maps_to_credits_exhausted() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see these tests fail unless we change functionality, but if you want to keep them, can we change them into the test_case pattern?

let payload = json!({
"error": {
"message": "Insufficient credits to complete this request"

Check warning on line 311 in crates/goose/src/providers/openai_compatible.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose/src/providers/openai_compatible.rs
}
});
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"

Check warning on line 348 in crates/goose/src/providers/openai_compatible.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose/src/providers/openai_compatible.rs
}
});
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
);
}

Check warning on line 384 in crates/goose/src/providers/openai_compatible.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose/src/providers/openai_compatible.rs
#[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
);
}
}
Loading
Loading