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
1 change: 1 addition & 0 deletions app/src/ai/blocklist/agent_view/agent_message_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ impl AgentMessageBar {
if matches!(
event,
AIRequestUsageModelEvent::RequestUsageUpdated
| AIRequestUsageModelEvent::CreditAvailabilityUpdated
| AIRequestUsageModelEvent::AmbientCreditsBannerDismissed
) {
ctx.notify();
Expand Down
5 changes: 3 additions & 2 deletions app/src/ai/blocklist/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3217,11 +3217,12 @@ impl BlocklistAIController {

// If a user is below their personal limits, then we know that they won't eat into overages,
// so we don't need to refresh.
let has_no_requests_remaining = !AIRequestUsageModel::as_ref(ctx).has_requests_remaining();
let has_no_base_plan_requests_remaining =
!AIRequestUsageModel::as_ref(ctx).has_base_plan_requests_remaining();
// If overages aren't enabled, we're not going to reap the benefit of refreshing at all anyway.
let are_overages_enabled = workspace.are_overages_enabled();

if are_overages_enabled && has_no_requests_remaining {
if are_overages_enabled && has_no_base_plan_requests_remaining {
// Give a one second delay to ensure that Stripe has been charged and the database is completely updated,
// before syncing new AI overages data.
ctx.spawn(
Expand Down
62 changes: 59 additions & 3 deletions app/src/ai/blocklist/prompt/prompt_alert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use warpui::{

use crate::ai::AIRequestUsageModel;
use crate::ai::blocklist::error_color;
use crate::ai::credit_availability::{AICreditAvailability, AICreditDenialReason};
use crate::auth::AuthStateProvider;
use crate::network::NetworkStatus;
use crate::server::ids::ServerId;
Expand Down Expand Up @@ -123,7 +124,10 @@ impl PromptAlertView {
}

let request_usage_model = AIRequestUsageModel::as_ref(app);
let has_requests_remaining = request_usage_model.has_requests_remaining();
// Anonymous soft/hard gates are based on the base-plan request quota,
// not overall AI availability (bonus grants / BYO / etc.).
let has_base_plan_requests_remaining =
request_usage_model.has_base_plan_requests_remaining();
let auth_state = AuthStateProvider::as_ref(app).get();

// Next, if the user is anonymous, we check if they have reached a certain percentage of requests used.
Expand All @@ -134,14 +138,24 @@ impl PromptAlertView {
let percentage_used = request_usage_model.request_percentage_used();

if percentage_used >= ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PERCENTAGE {
if has_requests_remaining {
if has_base_plan_requests_remaining {
return PromptAlertState::AnonymousUserRequestLimitSoftGate;
} else {
return PromptAlertState::AnonymousUserRequestLimitHardGate;
}
}
}

// The server-authoritative availability decision drives the alert once
// it has been fetched; local data below is only a pre-fetch fallback.
if let Some(availability) = request_usage_model.server_availability() {
return Self::state_from_server_availability(availability, app);
}

// Legacy locally derived fallback, used only before the first
// successful availability fetch (e.g. right after startup or against
// servers that don't support the availability field yet).

// Next, make sure the user isn't delinquent in their plan.
let workspace = UserWorkspaces::as_ref(app).current_workspace();
if workspace.is_some_and(|w| w.billing_metadata.is_delinquent_due_to_payment_issue()) {
Expand All @@ -153,8 +167,46 @@ impl PromptAlertView {
return PromptAlertState::NoAlert;
}

Self::out_of_credits_presentation(app)
}

/// Maps the server-authoritative availability decision to presentation
/// state. The server decides *whether* AI is available; workspace policy
/// only shapes the call-to-action copy.
fn state_from_server_availability(
availability: AICreditAvailability,
app: &AppContext,
) -> PromptAlertState {
if availability.available {
return PromptAlertState::NoAlert;
}

match availability.denial_reason {
AICreditDenialReason::Delinquent => PromptAlertState::DelinquentDueToPaymentIssue,
AICreditDenialReason::EnterpriseTeamSpendLimitHit
| AICreditDenialReason::EnterprisePerUserSpendLimitHit
| AICreditDenialReason::EnterpriseWorkspaceSpendLimitHit => {
PromptAlertState::MonthlyOveragesSpendLimitReached
}
AICreditDenialReason::None
| AICreditDenialReason::OutOfCredits
| AICreditDenialReason::Unknown => {
// An out-of-credits denial only means the server found no path
// it can see; a locally stored API key still permits requests,
// which `has_any_ai_remaining` accounts for.
if AIRequestUsageModel::as_ref(app).has_any_ai_remaining(app) {
return PromptAlertState::NoAlert;
}
Self::out_of_credits_presentation(app)
}
}
}

/// Picks the most actionable presentation for an out-of-credits denial
/// based on the current workspace's overage policy.
fn out_of_credits_presentation(app: &AppContext) -> PromptAlertState {
// Check if overages are available.
if let Some(workspace) = workspace {
if let Some(workspace) = UserWorkspaces::as_ref(app).current_workspace() {
let are_overages_toggleable = workspace.are_overages_toggleable();
let are_overages_enabled = workspace.are_overages_enabled();

Expand Down Expand Up @@ -481,3 +533,7 @@ impl TypedActionView for PromptAlertView {
}
}
}

#[cfg(test)]
#[path = "prompt_alert_tests.rs"]
mod tests;
165 changes: 165 additions & 0 deletions app/src/ai/blocklist/prompt/prompt_alert_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use std::sync::Arc;

use ai::LLMProvider;
use warpui::App;

use super::*;
use crate::ai::credit_availability::AICreditSource;
use crate::server::server_api::ServerApiProvider;
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::workspaces::workspace::{ByoApiKeyPolicy, Workspace, WorkspaceUid};

fn initialize_app(app: &mut App) {
initialize_app_with_workspaces(app, vec![]);
}

fn initialize_app_with_workspaces(app: &mut App, workspaces: Vec<Workspace>) {
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(
Arc::new(MockTeamClient::new()),
Arc::new(MockWorkspaceClient::new()),
workspaces,
ctx,
)
});
if app
.models_of_type::<settings::PrivatePreferences>()
.is_empty()
{
app.update(crate::settings::init_and_register_user_preferences);
}
app.update(|ctx| {
warpui_extras::secure_storage::register_noop("test", ctx);
ctx.add_singleton_model(ApiKeyManager::new);
});
app.add_singleton_model(|_| crate::pricing::PricingInfoModel::new());
app.add_singleton_model(|ctx| {
AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx)
});
}

fn apply_server_availability(app: &mut App, availability: AICreditAvailability) {
AIRequestUsageModel::handle(app).update(app, |model, ctx| {
model.apply_server_availability(Ok(availability), ctx);
});
}

fn determine_state(app: &mut App) -> PromptAlertState {
app.read(PromptAlertView::determine_state)
}

#[test]
fn test_server_available_maps_to_no_alert() {
App::test((), |mut app| async move {
initialize_app(&mut app);
apply_server_availability(
&mut app,
AICreditAvailability::available_with_source(Some(AICreditSource::BaseLimit)),
);
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
});
}

#[test]
fn test_server_delinquent_maps_to_delinquency_alert() {
App::test((), |mut app| async move {
initialize_app(&mut app);
apply_server_availability(
&mut app,
AICreditAvailability::unavailable(AICreditDenialReason::Delinquent),
);
assert_eq!(
determine_state(&mut app),
PromptAlertState::DelinquentDueToPaymentIssue
);
});
}

#[test]
fn test_server_spend_limit_reasons_map_to_spend_limit_alert() {
App::test((), |mut app| async move {
initialize_app(&mut app);
for reason in [
AICreditDenialReason::EnterpriseTeamSpendLimitHit,
AICreditDenialReason::EnterprisePerUserSpendLimitHit,
AICreditDenialReason::EnterpriseWorkspaceSpendLimitHit,
] {
apply_server_availability(&mut app, AICreditAvailability::unavailable(reason));
assert_eq!(
determine_state(&mut app),
PromptAlertState::MonthlyOveragesSpendLimitReached,
"unexpected alert state for {reason:?}",
);
}
});
}

#[test]
fn test_server_out_of_credits_maps_to_request_limit_reached() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// With no workspace overage policy in play, an out-of-credits denial
// falls through to the generic request limit alert.
for reason in [
AICreditDenialReason::OutOfCredits,
AICreditDenialReason::Unknown,
] {
apply_server_availability(&mut app, AICreditAvailability::unavailable(reason));
assert_eq!(
determine_state(&mut app),
PromptAlertState::RequestLimitReached,
"unexpected alert state for {reason:?}",
);
}
});
}

#[test]
fn test_legacy_fallback_used_before_first_server_response() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// No server availability applied: the default request limit info has
// requests remaining, so the legacy derivation reports no alert.
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
});
}

#[test]
fn test_server_managed_availability_maps_to_no_alert() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// `available` with no credit source means a server-managed BYO path
// is configured — definite availability, no local key required.
apply_server_availability(&mut app, AICreditAvailability::available_with_source(None));
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
});
}

#[test]
fn test_out_of_credits_with_local_key_maps_to_no_alert() {
App::test((), |mut app| async move {
let uid = WorkspaceUid::from(crate::server::ids::ServerId::from(1_i64));
let mut workspace = Workspace::from_local_cache(uid, "Test Workspace".to_string(), None);
workspace.billing_metadata.tier.byo_api_key_policy =
Some(ByoApiKeyPolicy { enabled: true });
initialize_app_with_workspaces(&mut app, vec![workspace]);

ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| {
manager.set_provider_key(LLMProvider::OpenAI, Some("test-key".to_string()), ctx);
});

// The server cannot see the locally stored key; the client refines
// its OUT_OF_CREDITS answer.
apply_server_availability(
&mut app,
AICreditAvailability::unavailable(AICreditDenialReason::OutOfCredits),
);
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
});
}
Loading
Loading