mirror of
https://github.com/block/goose.git
synced 2026-04-26 10:40:45 +00:00
fix: detect low balance and prompt for top up (#7166)
Signed-off-by: raj-subhankar <subhankar.rj@gmail.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: raj-subhankar <subhankar.rj@gmail.com>
This commit is contained in:
parent
3600c84e4b
commit
629108d0fc
13 changed files with 461 additions and 103 deletions
|
|
@ -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<String> = 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<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);
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ pub struct FrontendToolRequest {
|
|||
pub enum SystemNotificationType {
|
||||
ThinkingMessage,
|
||||
InlineMessage,
|
||||
CreditsExhausted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -43,6 +49,7 @@ impl ProviderError {
|
|||
ProviderError::ExecutionError(_) => "execution",
|
||||
ProviderError::UsageError(_) => "usage",
|
||||
ProviderError::NotImplemented(_) => "not_implemented",
|
||||
ProviderError::CreditsExhausted { .. } => "credits_exhausted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<Vec<String>, 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<String> = data
|
||||
let mut models: Vec<String> = 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -6815,7 +6815,8 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"thinkingMessage",
|
||||
"inlineMessage"
|
||||
"inlineMessage",
|
||||
"creditsExhausted"
|
||||
]
|
||||
},
|
||||
"TaskSupport": {
|
||||
|
|
|
|||
|
|
@ -1167,7 +1167,7 @@ export type SystemNotificationContent = {
|
|||
notificationType: SystemNotificationType;
|
||||
};
|
||||
|
||||
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage';
|
||||
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage' | 'creditsExhausted';
|
||||
|
||||
export type TaskSupport = string;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <CreditsExhaustedNotification notification={notification} />;
|
||||
case 'inlineMessage':
|
||||
return <SystemNotificationInline notification={notification} />;
|
||||
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 (
|
||||
<div
|
||||
key={message.id ?? `msg-${index}-${message.created}`}
|
||||
key={`notification-${message.id ?? `msg-${index}-${message.created}`}`}
|
||||
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} assistant`}
|
||||
data-testid="message-container"
|
||||
>
|
||||
<SystemNotificationInline message={message} />
|
||||
{renderSystemNotification(notification)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).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<CreditsExhaustedNotificationProps> = ({
|
||||
notification,
|
||||
}) => {
|
||||
const topUpUrl = getValidatedTopUpUrl(notification.data);
|
||||
|
||||
const handleTopUp = () => {
|
||||
if (topUpUrl) {
|
||||
window.electron.openExternal(topUpUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-600/30 dark:border-yellow-500/30 bg-yellow-500/10 dark:bg-yellow-500/10 p-4 my-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Insufficient Credits</div>
|
||||
<div className="text-sm text-yellow-800/80 dark:text-yellow-200/80 mt-1">{notification.msg}</div>
|
||||
{topUpUrl && (
|
||||
<button
|
||||
onClick={handleTopUp}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-md bg-yellow-600 hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 text-white text-sm font-medium px-4 py-2 transition-colors"
|
||||
>
|
||||
Add credits
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function getCreditsExhaustedNotification(
|
||||
message: Message
|
||||
): SystemNotificationContent | undefined {
|
||||
return message.content.find(
|
||||
(content): content is SystemNotificationContent & { type: 'systemNotification' } =>
|
||||
content.type === 'systemNotification' && content.notificationType === 'creditsExhausted'
|
||||
);
|
||||
}
|
||||
|
|
@ -2,18 +2,20 @@ import React from 'react';
|
|||
import { Message, SystemNotificationContent } from '../../api';
|
||||
|
||||
interface SystemNotificationInlineProps {
|
||||
message: Message;
|
||||
notification: SystemNotificationContent;
|
||||
}
|
||||
|
||||
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({ message }) => {
|
||||
const systemNotification = message.content.find(
|
||||
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({
|
||||
notification,
|
||||
}) => {
|
||||
return <div className="text-xs text-gray-400 py-2 text-left">{notification.msg}</div>;
|
||||
};
|
||||
|
||||
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 <div className="text-xs text-gray-400 py-2 text-left">{systemNotification.msg}</div>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue