Files
galaxy/app/src/ai/request_usage_model.rs
T

646 lines
23 KiB
Rust

#![allow(dead_code, unused_imports, unused_variables)]
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
use crate::auth::AuthStateProvider;
use crate::pricing::PricingInfoModel;
use crate::server::server_api::ai::AIClient;
use crate::settings::AISettings;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::WorkspaceUid;
use crate::BlocklistAIHistoryModel;
use ai::api_keys::ApiKeyManager;
use chrono::{DateTime, Utc};
use galaxy_core::user_preferences::GetUserPreferences as _;
use galaxy_graphql::scalars::time::ServerTimestamp;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use instant::Instant;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub use galaxy_graphql::billing::BonusGrantType;
/// Threshold of ambient-only credits at which we surface upgrade/CTA UI.
pub const AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD: i32 = 20;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BonusGrantScope {
User,
Workspace(WorkspaceUid),
}
#[derive(Clone, Debug, PartialEq, Default)]
pub enum BuyCreditsBannerDisplayState {
#[default]
Hidden,
OutOfCredits,
MonthlyLimitReached,
}
#[derive(Clone, Debug)]
pub struct BonusGrant {
pub created_at: chrono::DateTime<chrono::Utc>,
pub cost_cents: i32,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub grant_type: BonusGrantType,
pub reason: String,
pub user_facing_message: Option<String>,
pub request_credits_granted: i32,
pub request_credits_remaining: i32,
pub scope: BonusGrantScope,
}
/// The key for the corresponding entry in UserDefaults.
const REQUEST_LIMIT_INFO_CACHE_KEY: &str = "AIRequestLimitInfo";
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub enum RequestLimitRefreshDuration {
Weekly,
Monthly,
EveryTwoWeeks,
}
/// The current rate limit info for the user.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub struct RequestLimitInfo {
pub limit: usize,
pub num_requests_used_since_refresh: usize,
pub next_refresh_time: ServerTimestamp,
pub is_unlimited: bool,
pub request_limit_refresh_duration: RequestLimitRefreshDuration,
pub is_unlimited_voice: bool,
#[serde(default)]
pub voice_request_limit: usize,
#[serde(default)]
pub voice_requests_used_since_last_refresh: usize,
#[serde(default)]
pub is_unlimited_codebase_indices: bool,
#[serde(default)]
pub max_codebase_indices: usize,
#[serde(default)]
pub max_files_per_repo: usize,
#[serde(default)]
pub embedding_generation_batch_size: usize,
}
fn default_voice_requests_limit() -> usize {
10000
}
impl Default for RequestLimitInfo {
/// This is the default rate limit for the free tier imposed by the server as of 02/10/25.
fn default() -> Self {
Self {
limit: 150,
num_requests_used_since_refresh: 0,
next_refresh_time: ServerTimestamp::new(Utc::now() + chrono::Duration::days(30)),
is_unlimited: false,
request_limit_refresh_duration: RequestLimitRefreshDuration::Monthly,
is_unlimited_voice: false,
voice_request_limit: default_voice_requests_limit(),
voice_requests_used_since_last_refresh: 0,
is_unlimited_codebase_indices: false,
max_codebase_indices: 3,
max_files_per_repo: 5000,
embedding_generation_batch_size: 100,
}
}
}
#[cfg(test)]
impl RequestLimitInfo {
pub fn new_for_test(limit: usize, num_requests_used_since_refresh: usize) -> Self {
Self {
limit,
num_requests_used_since_refresh,
..Self::default()
}
}
}
pub struct CodebaseContextUsageLimit {
pub max_files_per_repo: usize,
pub max_indices_allowed: Option<usize>,
pub embedding_generation_batch_size: usize,
}
/// Contains all usage-related information fetched from the server.
pub struct RequestUsageInfo {
pub request_limit_info: RequestLimitInfo,
pub bonus_grants: Vec<BonusGrant>,
}
#[cfg(feature = "agent_mode_evals")]
impl RequestLimitInfo {
pub fn new_for_evals() -> Self {
Self {
limit: 999999,
num_requests_used_since_refresh: 0,
next_refresh_time: ServerTimestamp::new(Utc::now() + chrono::Duration::days(30)),
is_unlimited: true,
request_limit_refresh_duration: RequestLimitRefreshDuration::Monthly,
is_unlimited_voice: true,
voice_request_limit: 999999,
voice_requests_used_since_last_refresh: 0,
is_unlimited_codebase_indices: false,
max_codebase_indices: 40,
max_files_per_repo: 10000,
embedding_generation_batch_size: 100,
}
}
}
fn cache_request_limit_info(request_limit_info: RequestLimitInfo, app_mut: &mut AppContext) {
if let Ok(serialized) = serde_json::to_string(&request_limit_info) {
let _ = app_mut
.private_user_preferences()
.write_value(REQUEST_LIMIT_INFO_CACHE_KEY, serialized);
}
}
fn get_cached_request_limit_info(app_mut: &mut AppContext) -> Option<RequestLimitInfo> {
app_mut
.private_user_preferences()
.read_value(REQUEST_LIMIT_INFO_CACHE_KEY)
.unwrap_or_default()
.and_then(|serialized| serde_json::from_str(serialized.as_str()).ok())
}
pub struct AIRequestUsageModel {
ai_client: Arc<dyn AIClient>,
/// The last time at which `request_limit_info` was updated.
last_update_time: Option<Instant>,
request_limit_info: RequestLimitInfo,
bonus_grants: Vec<BonusGrant>,
/// Whether the buy credits banner has been dismissed by the user.
buy_addon_credits_banner_dismissed: bool,
}
impl Entity for AIRequestUsageModel {
type Event = AIRequestUsageModelEvent;
}
pub enum AIRequestUsageModelEvent {
RequestUsageUpdated,
RequestBonusRefunded {
requests_refunded: i32,
server_conversation_id: String,
request_id: String,
},
}
impl AIRequestUsageModel {
pub fn new(ai_client: Arc<dyn AIClient>, ctx: &mut ModelContext<Self>) -> Self {
// Check if the user has cached request limit info from before.
// This is only used to show the latest known value before we finish refreshing from the server below.
let cached_request_limit_info = get_cached_request_limit_info(ctx);
let request_limit_info = cached_request_limit_info.unwrap_or_default();
Self {
ai_client,
request_limit_info,
last_update_time: None,
bonus_grants: vec![],
buy_addon_credits_banner_dismissed: false,
}
}
#[cfg(test)]
pub fn new_for_test(ai_client: Arc<dyn AIClient>, _ctx: &mut ModelContext<Self>) -> Self {
Self {
ai_client,
last_update_time: None,
request_limit_info: RequestLimitInfo::default(),
bonus_grants: vec![],
buy_addon_credits_banner_dismissed: false,
}
}
pub fn last_update_time(&self) -> Option<Instant> {
self.last_update_time
}
/// Spawns a task to refresh the latest AI request usage and bonus grants, fetching from the server.
pub fn refresh_request_usage_async(&mut self, ctx: &mut ModelContext<Self>) {
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
return;
}
let ai_client = self.ai_client.clone();
ctx.spawn(
async move { ai_client.get_request_limit_info().await },
|model, result, ctx| match result {
Ok(usage_info) => {
model.bonus_grants = usage_info.bonus_grants;
model.update_request_limit_info(usage_info.request_limit_info, ctx);
}
Err(e) => {
log::warn!("Failed to retrieve initial request limit info: {e:#}");
}
},
);
}
pub fn update_request_limit_info(
&mut self,
request_limit_info: RequestLimitInfo,
ctx: &mut ModelContext<Self>,
) {
self.last_update_time = Some(Instant::now());
self.request_limit_info = request_limit_info;
cache_request_limit_info(request_limit_info, ctx);
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
ai_settings.update_quota_info(&request_limit_info, ctx);
});
ctx.emit(AIRequestUsageModelEvent::RequestUsageUpdated);
}
pub fn provide_negative_feedback_response_for_ai_conversation(
&mut self,
client_conversation_id: AIConversationId,
request_id: String,
client_exchange_id: AIAgentExchangeId,
ctx: &mut ModelContext<Self>,
) {
let server_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&client_conversation_id)
.and_then(|conversation| conversation.server_conversation_token());
let Some(server_conversation_id) = server_conversation_id else {
return;
};
let server_conversation_id_string = server_conversation_id.as_str().to_string();
let server_conversation_id_string_clone = server_conversation_id_string.clone();
let request_ids = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&client_conversation_id)
.map(|conversation| {
let mut request_ids = vec![];
let target_exchange = conversation
.root_task_exchanges()
.find(|exchange| exchange.id == client_exchange_id);
let mut found_target = false;
for exchange in conversation.exchanges_reversed() {
if let Some(target_exchange) = target_exchange {
if exchange.id == target_exchange.id {
found_target = true;
}
} else {
break;
}
if found_target {
if let Some(server_output_id) = exchange.output_status.server_output_id() {
request_ids.push(server_output_id.to_string());
}
if exchange
.input
.iter()
.any(|input| input.user_query().is_some())
{
break;
}
}
}
request_ids
})
.unwrap_or_default();
// No reason to refund if there are no request ids.
if request_ids.is_empty() {
return;
}
let ai_client = self.ai_client.clone();
ctx.spawn(
async move {
ai_client
.provide_negative_feedback_response_for_ai_conversation(
server_conversation_id_string_clone,
request_ids,
)
.await
},
|_, result, ctx| match result {
Ok(requests_refunded) => {
if requests_refunded > 0 {
ctx.emit(AIRequestUsageModelEvent::RequestBonusRefunded {
requests_refunded,
server_conversation_id: server_conversation_id_string,
request_id,
});
}
}
Err(e) => {
log::error!(
"Failed to provide negative feedback response for ai conversation: {e:?}"
);
}
},
);
}
/// Returns the number of remaining requests the user has based on their latest rate limit info.
/// If the current time is past the next refresh time, then the number of remaining reqs is the limit.
fn requests_remaining(&self) -> usize {
if self.next_refresh_time() <= Utc::now() || self.is_unlimited() {
self.request_limit_info.limit
} else {
self.request_limit_info
.limit
.saturating_sub(self.request_limit_info.num_requests_used_since_refresh)
}
}
/// Returns `true` if the user has at least one request remaining before hitting the AI request
/// limit.
///
/// WARNING: This method doesn't account for add-on credits. Consider if you want
/// [`Self::has_any_ai_remaining`] instead.
pub fn has_requests_remaining(&self) -> bool {
self.requests_remaining() > 0
}
/// Returns `true` if the user meets one of the following conditions:
/// 1. user has ai credits from the plan base limit
/// 2. user has overage enabled
/// 3. user has bonus grants (either team grants or user grants)
/// 4. user's team plan has pay-as-you-go enabled (enterprise only)
/// 5. user's team is on enterprise with bonus grants auto-reload enable (enterprise only)
/// 6. user has BYOK enabled and has provided at least one API key
/// Use this method as the starting point for AI availability checking.
pub fn has_any_ai_remaining(&self, ctx: &AppContext) -> bool {
let current_workspace = UserWorkspaces::as_ref(ctx).current_workspace();
let has_base_plan_ai_requests = self.has_requests_remaining();
let user_bonus_credits = self.total_user_interactive_bonus_credits_remaining() > 0;
let workspace_bonus_credits = current_workspace
.map(|workspace| self.total_workspace_bonus_credits_remaining(workspace.uid) > 0)
.unwrap_or_default();
let workspace_has_overages =
current_workspace.is_some_and(|workspace| workspace.are_overages_remaining());
let is_payg_enabled = current_workspace
.is_some_and(|w| w.billing_metadata.is_enterprise_pay_as_you_go_enabled());
let is_enterprise_auto_reload_enabled = current_workspace
.is_some_and(|w| w.billing_metadata.is_enterprise_auto_reload_enabled());
// If you have provided your own API key,
// it doesn't matter if you are out of warp-provided requests.
let has_byo_api_key = UserWorkspaces::as_ref(ctx).is_byo_api_key_enabled()
&& ApiKeyManager::as_ref(ctx).keys().has_any_key();
has_base_plan_ai_requests
|| (user_bonus_credits || workspace_bonus_credits)
|| workspace_has_overages
|| is_payg_enabled
|| is_enterprise_auto_reload_enabled
|| has_byo_api_key
}
pub fn requests_used(&self) -> usize {
if self.next_refresh_time() <= Utc::now() {
return 0;
}
self.request_limit_info.num_requests_used_since_refresh
}
pub fn request_percentage_used(&self) -> f32 {
self.requests_used() as f32 / self.request_limit() as f32
}
pub fn request_limit(&self) -> usize {
self.request_limit_info.limit
}
/// Returns the number of indices the user's tier allows them to create and the number of files
/// the user's tier allows them to index. If the user is allowed unlimited indices, then the
/// max_indices_allowed is None.
pub fn codebase_context_limits(&self) -> CodebaseContextUsageLimit {
CodebaseContextUsageLimit {
max_files_per_repo: self.request_limit_info.max_files_per_repo,
max_indices_allowed: if self.request_limit_info.is_unlimited_codebase_indices {
None
} else {
Some(self.request_limit_info.max_codebase_indices)
},
embedding_generation_batch_size: self
.request_limit_info
.embedding_generation_batch_size,
}
}
/// Returns whether the user has hit their maximum codebase allowance.
/// (If the user is allowed unlimited indices, this is vacuously false.)
pub fn hit_codebase_index_limit(&self, current_indices: usize) -> bool {
self.codebase_context_limits()
.max_indices_allowed
.map(|lim| current_indices >= lim)
.unwrap_or(false)
}
pub fn next_refresh_time(&self) -> DateTime<Utc> {
self.request_limit_info.next_refresh_time.utc()
}
pub fn is_unlimited(&self) -> bool {
self.request_limit_info.is_unlimited
}
pub fn refresh_duration_to_string(&self) -> String {
match self.request_limit_info.request_limit_refresh_duration {
RequestLimitRefreshDuration::Weekly => "weekly".to_string(),
RequestLimitRefreshDuration::Monthly => "monthly".to_string(),
RequestLimitRefreshDuration::EveryTwoWeeks => "biweekly".to_string(),
}
}
pub fn bonus_grants(&self) -> &[BonusGrant] {
&self.bonus_grants
}
/// Returns the total remaining ambient-only credits for the user.
/// Returns None if the user has never received any ambient-only grants.
pub fn ambient_only_credits_remaining(&self) -> Option<i32> {
let ambient_grants: Vec<_> = self
.bonus_grants
.iter()
.filter(|g| g.grant_type == BonusGrantType::AmbientOnly)
.collect();
if ambient_grants.is_empty() {
None
} else {
Some(
ambient_grants
.iter()
.map(|g| g.request_credits_remaining)
.sum(),
)
}
}
pub fn total_workspace_bonus_credits_remaining(&self, uid: WorkspaceUid) -> i32 {
let now = Utc::now();
self.bonus_grants
.iter()
.filter(|grant| grant.scope == BonusGrantScope::Workspace(uid))
.filter(|grant| grant.expiration.is_none_or(|exp| now < exp))
.map(|grant| grant.request_credits_remaining)
.sum()
}
pub fn total_current_workspace_bonus_credits_remaining(&self, ctx: &AppContext) -> i32 {
UserWorkspaces::as_ref(ctx)
.current_workspace()
.map(|workspace| self.total_workspace_bonus_credits_remaining(workspace.uid))
.unwrap_or(0)
}
fn total_user_interactive_bonus_credits_remaining(&self) -> i32 {
let now = Utc::now();
self.bonus_grants
.iter()
.filter(|grant| grant.scope == BonusGrantScope::User)
.filter(|grant| grant.grant_type != BonusGrantType::AmbientOnly)
.filter(|grant| grant.expiration.is_none_or(|exp| now < exp))
.map(|grant| grant.request_credits_remaining)
.sum()
}
/// Computes the current banner state based on live conditions.
/// This is called on-demand and always returns fresh state.
pub fn compute_buy_addon_credits_banner_display_state(
&self,
ctx: &AppContext,
) -> BuyCreditsBannerDisplayState {
// Early return if user dismissed
if self.buy_addon_credits_banner_dismissed {
return BuyCreditsBannerDisplayState::Hidden;
}
let current_workspace = UserWorkspaces::as_ref(ctx).current_workspace();
let policy_allows_purchasing = current_workspace
.map(|w| {
w.billing_metadata
.tier
.purchase_add_on_credits_policy
.is_some_and(|p| p.enabled)
})
.unwrap_or(false);
// TODO: we might want to suggest credits purchase if request_remain/bonus credits is below certain threshold
// something to consider after launch
// Ambient-only credits are usable for cloud agents and should not suppress this banner.
let now = Utc::now();
let has_non_ambient_bonus_credits = self
.bonus_grants
.iter()
.filter(|grant| grant.grant_type != BonusGrantType::AmbientOnly)
.filter(|grant| grant.expiration.is_none_or(|exp| now < exp))
.filter(|grant| grant.request_credits_remaining > 0)
.any(|grant| match grant.scope {
BonusGrantScope::User => true,
BonusGrantScope::Workspace(uid) => {
current_workspace.is_some_and(|workspace| workspace.uid == uid)
}
});
if !policy_allows_purchasing
|| self.has_requests_remaining()
|| has_non_ambient_bonus_credits
{
return BuyCreditsBannerDisplayState::Hidden;
}
let auto_reload_enabled = current_workspace
.is_some_and(|w| w.settings.addon_credits_settings.auto_reload_enabled);
if !auto_reload_enabled {
return BuyCreditsBannerDisplayState::OutOfCredits;
}
let at_monthly_limit =
current_workspace.is_some_and(|w| w.is_at_addon_credits_monthly_limit());
let auto_reload_would_exceed = current_workspace
.and_then(|workspace| {
let options = PricingInfoModel::as_ref(ctx).addon_credits_options()?;
let price = workspace.get_auto_reload_price_cents(options)?;
Some(workspace.would_addon_purchase_reach_limit(price))
})
.unwrap_or(false);
if at_monthly_limit || auto_reload_would_exceed {
BuyCreditsBannerDisplayState::MonthlyLimitReached
} else {
BuyCreditsBannerDisplayState::Hidden
}
}
pub fn dismiss_buy_credits_banner(&mut self, ctx: &mut ModelContext<Self>) {
self.buy_addon_credits_banner_dismissed = true;
ctx.notify();
}
pub fn enable_buy_credits_banner(&mut self, ctx: &mut ModelContext<Self>) {
self.buy_addon_credits_banner_dismissed = false;
ctx.notify();
}
}
/// Voice request usage, only available if built with voice input support.
#[cfg(feature = "voice_input")]
impl AIRequestUsageModel {
fn voice_requests(&self) -> usize {
self.request_limit_info
.voice_requests_used_since_last_refresh
}
fn voice_requests_limit(&self) -> usize {
self.request_limit_info.voice_request_limit
}
fn is_unlimited_voice_requests(&self) -> bool {
self.request_limit_info.is_unlimited_voice
}
/// Returns the number of remaining requests the user has based on their latest rate limit info.
/// If the current time is past the next refresh time, then the number of remaining reqs is the limit.
fn voice_requests_remaining(&self) -> usize {
if self.next_refresh_time() <= Utc::now() || self.is_unlimited_voice_requests() {
self.voice_requests_limit()
} else {
self.voice_requests_limit()
.saturating_sub(self.voice_requests())
}
}
/// Returns `true` if the user has at least one voice request before hitting the
/// limit. Returns `false` otherwise.
fn has_voice_requests_remaining(&self) -> bool {
self.voice_requests_remaining() > 0
}
/// Checks request limits to see if the user can make a voice request.
/// Returns true if the user can make a voice request, false otherwise.
pub fn can_request_voice(&self) -> bool {
self.has_voice_requests_remaining()
}
}
impl SingletonEntity for AIRequestUsageModel {}
#[cfg(test)]
#[path = "request_usage_model_test.rs"]
mod tests;