Skip to content

Commit f861479

Browse files
maxdeviantas-ciirtfeldmanmaxbrunsfeld
authored
collab: Update billing code for LLM usage billing (#18879)
This PR reworks our existing billing code in preparation for charging based on LLM usage. We aren't yet exercising the new billing-related code outside of development. There are some noteworthy changes for our existing LLM usage tracking: - A new `monthly_usages` table has been added for tracking usage per-user, per-model, per-month - The per-month usage measures have been removed, in favor of the `monthly_usages` table - All of the per-month metrics in the Clickhouse rows have been changed from a rolling 30-day window to a calendar month Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra <me@as-cii.com> Co-authored-by: Richard <richard@zed.dev> Co-authored-by: Max <max@zed.dev>
1 parent a95fb8f commit f861479

File tree

15 files changed

+390
-132
lines changed

15 files changed

+390
-132
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
create table monthly_usages (
2+
id serial primary key,
3+
user_id integer not null,
4+
model_id integer not null references models (id) on delete cascade,
5+
month integer not null,
6+
year integer not null,
7+
input_tokens bigint not null default 0,
8+
cache_creation_input_tokens bigint not null default 0,
9+
cache_read_input_tokens bigint not null default 0,
10+
output_tokens bigint not null default 0
11+
);
12+
13+
create unique index uix_monthly_usages_on_user_id_model_id_month_year on monthly_usages (user_id, model_id, month, year);

crates/collab/src/api/billing.rs

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ use stripe::{
2222
};
2323
use util::ResultExt;
2424

25-
use crate::db::billing_subscription::StripeSubscriptionStatus;
25+
use crate::db::billing_subscription::{self, StripeSubscriptionStatus};
2626
use crate::db::{
2727
billing_customer, BillingSubscriptionId, CreateBillingCustomerParams,
2828
CreateBillingSubscriptionParams, CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
2929
UpdateBillingSubscriptionParams,
3030
};
31+
use crate::llm::db::LlmDatabase;
32+
use crate::llm::MONTHLY_SPENDING_LIMIT_IN_CENTS;
33+
use crate::rpc::ResultExt as _;
3134
use crate::{AppState, Error, Result};
3235

3336
pub fn router() -> Router {
@@ -79,7 +82,7 @@ async fn list_billing_subscriptions(
7982
.into_iter()
8083
.map(|subscription| BillingSubscriptionJson {
8184
id: subscription.id,
82-
name: "Zed Pro".to_string(),
85+
name: "Zed LLM Usage".to_string(),
8386
status: subscription.stripe_subscription_status,
8487
cancel_at: subscription.stripe_cancel_at.map(|cancel_at| {
8588
cancel_at
@@ -117,7 +120,7 @@ async fn create_billing_subscription(
117120
let Some((stripe_client, stripe_price_id)) = app
118121
.stripe_client
119122
.clone()
120-
.zip(app.config.stripe_price_id.clone())
123+
.zip(app.config.stripe_llm_usage_price_id.clone())
121124
else {
122125
log::error!("failed to retrieve Stripe client or price ID");
123126
Err(Error::http(
@@ -150,7 +153,7 @@ async fn create_billing_subscription(
150153
params.client_reference_id = Some(user.github_login.as_str());
151154
params.line_items = Some(vec![CreateCheckoutSessionLineItems {
152155
price: Some(stripe_price_id.to_string()),
153-
quantity: Some(1),
156+
quantity: Some(0),
154157
..Default::default()
155158
}]);
156159
let success_url = format!("{}/account", app.config.zed_dot_dev_url());
@@ -631,3 +634,95 @@ async fn find_or_create_billing_customer(
631634

632635
Ok(Some(billing_customer))
633636
}
637+
638+
const SYNC_LLM_USAGE_WITH_STRIPE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
639+
640+
pub fn sync_llm_usage_with_stripe_periodically(app: Arc<AppState>, llm_db: LlmDatabase) {
641+
let Some(stripe_client) = app.stripe_client.clone() else {
642+
log::warn!("failed to retrieve Stripe client");
643+
return;
644+
};
645+
let Some(stripe_llm_usage_price_id) = app.config.stripe_llm_usage_price_id.clone() else {
646+
log::warn!("failed to retrieve Stripe LLM usage price ID");
647+
return;
648+
};
649+
650+
let executor = app.executor.clone();
651+
executor.spawn_detached({
652+
let executor = executor.clone();
653+
async move {
654+
loop {
655+
sync_with_stripe(
656+
&app,
657+
&llm_db,
658+
&stripe_client,
659+
stripe_llm_usage_price_id.clone(),
660+
)
661+
.await
662+
.trace_err();
663+
664+
executor.sleep(SYNC_LLM_USAGE_WITH_STRIPE_INTERVAL).await;
665+
}
666+
}
667+
});
668+
}
669+
670+
async fn sync_with_stripe(
671+
app: &Arc<AppState>,
672+
llm_db: &LlmDatabase,
673+
stripe_client: &stripe::Client,
674+
stripe_llm_usage_price_id: Arc<str>,
675+
) -> anyhow::Result<()> {
676+
let subscriptions = app.db.get_active_billing_subscriptions().await?;
677+
678+
for (customer, subscription) in subscriptions {
679+
update_stripe_subscription(
680+
llm_db,
681+
stripe_client,
682+
&stripe_llm_usage_price_id,
683+
customer,
684+
subscription,
685+
)
686+
.await
687+
.log_err();
688+
}
689+
690+
Ok(())
691+
}
692+
693+
async fn update_stripe_subscription(
694+
llm_db: &LlmDatabase,
695+
stripe_client: &stripe::Client,
696+
stripe_llm_usage_price_id: &Arc<str>,
697+
customer: billing_customer::Model,
698+
subscription: billing_subscription::Model,
699+
) -> Result<(), anyhow::Error> {
700+
let monthly_spending = llm_db
701+
.get_user_spending_for_month(customer.user_id, Utc::now())
702+
.await?;
703+
let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
704+
.context("failed to parse subscription ID")?;
705+
706+
let monthly_spending_over_free_tier =
707+
monthly_spending.saturating_sub(MONTHLY_SPENDING_LIMIT_IN_CENTS);
708+
709+
let new_quantity = (monthly_spending_over_free_tier as f32 / 100.).ceil();
710+
Subscription::update(
711+
stripe_client,
712+
&subscription_id,
713+
stripe::UpdateSubscription {
714+
items: Some(vec![stripe::UpdateSubscriptionItems {
715+
// TODO: Do we need to send up the `id` if a subscription item
716+
// with this price already exists, or will Stripe take care of
717+
// it?
718+
id: None,
719+
price: Some(stripe_llm_usage_price_id.to_string()),
720+
quantity: Some(new_quantity as u64),
721+
..Default::default()
722+
}]),
723+
..Default::default()
724+
},
725+
)
726+
.await?;
727+
Ok(())
728+
}

crates/collab/src/db/queries/billing_subscriptions.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ impl Database {
112112
.await
113113
}
114114

115+
pub async fn get_active_billing_subscriptions(
116+
&self,
117+
) -> Result<Vec<(billing_customer::Model, billing_subscription::Model)>> {
118+
self.transaction(|tx| async move {
119+
let mut result = Vec::new();
120+
let mut rows = billing_subscription::Entity::find()
121+
.inner_join(billing_customer::Entity)
122+
.select_also(billing_customer::Entity)
123+
.order_by_asc(billing_subscription::Column::Id)
124+
.stream(&*tx)
125+
.await?;
126+
127+
while let Some(row) = rows.next().await {
128+
if let (subscription, Some(customer)) = row? {
129+
result.push((customer, subscription));
130+
}
131+
}
132+
133+
Ok(result)
134+
})
135+
.await
136+
}
137+
115138
/// Returns whether the user has an active billing subscription.
116139
pub async fn has_active_billing_subscription(&self, user_id: UserId) -> Result<bool> {
117140
Ok(self.count_active_billing_subscriptions(user_id).await? > 0)

crates/collab/src/lib.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ pub struct Config {
174174
pub slack_panics_webhook: Option<String>,
175175
pub auto_join_channel_id: Option<ChannelId>,
176176
pub stripe_api_key: Option<String>,
177-
pub stripe_price_id: Option<Arc<str>>,
177+
pub stripe_llm_usage_price_id: Option<Arc<str>>,
178178
pub supermaven_admin_api_key: Option<Arc<str>>,
179179
pub user_backfiller_github_access_token: Option<Arc<str>>,
180180
}
@@ -193,6 +193,10 @@ impl Config {
193193
}
194194
}
195195

196+
pub fn is_llm_billing_enabled(&self) -> bool {
197+
self.stripe_llm_usage_price_id.is_some()
198+
}
199+
196200
#[cfg(test)]
197201
pub fn test() -> Self {
198202
Self {
@@ -231,7 +235,7 @@ impl Config {
231235
migrations_path: None,
232236
seed_path: None,
233237
stripe_api_key: None,
234-
stripe_price_id: None,
238+
stripe_llm_usage_price_id: None,
235239
supermaven_admin_api_key: None,
236240
user_backfiller_github_access_token: None,
237241
}

crates/collab/src/llm.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,9 @@ fn normalize_model_name(known_models: Vec<String>, name: String) -> String {
436436
}
437437
}
438438

439+
/// The maximum monthly spending an individual user can reach before they have to pay.
440+
pub const MONTHLY_SPENDING_LIMIT_IN_CENTS: usize = 5 * 100;
441+
439442
/// The maximum lifetime spending an individual user can reach before being cut off.
440443
///
441444
/// Represented in cents.
@@ -458,6 +461,18 @@ async fn check_usage_limit(
458461
)
459462
.await?;
460463

464+
if state.config.is_llm_billing_enabled() {
465+
if usage.spending_this_month >= MONTHLY_SPENDING_LIMIT_IN_CENTS {
466+
if !claims.has_llm_subscription.unwrap_or(false) {
467+
return Err(Error::http(
468+
StatusCode::PAYMENT_REQUIRED,
469+
"Maximum spending limit reached for this month.".to_string(),
470+
));
471+
}
472+
}
473+
}
474+
475+
// TODO: Remove this once we've rolled out monthly spending limits.
461476
if usage.lifetime_spending >= LIFETIME_SPENDING_LIMIT_IN_CENTS {
462477
return Err(Error::http(
463478
StatusCode::FORBIDDEN,
@@ -505,7 +520,6 @@ async fn check_usage_limit(
505520
UsageMeasure::RequestsPerMinute => "requests_per_minute",
506521
UsageMeasure::TokensPerMinute => "tokens_per_minute",
507522
UsageMeasure::TokensPerDay => "tokens_per_day",
508-
_ => "",
509523
};
510524

511525
if let Some(client) = state.clickhouse_client.as_ref() {

crates/collab/src/llm/db.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ impl LlmDatabase {
9797
.ok_or_else(|| anyhow!("unknown model {provider:?}:{name}"))?)
9898
}
9999

100+
pub fn model_by_id(&self, id: ModelId) -> Result<&model::Model> {
101+
Ok(self
102+
.models
103+
.values()
104+
.find(|model| model.id == id)
105+
.ok_or_else(|| anyhow!("no model for ID {id:?}"))?)
106+
}
107+
100108
pub fn options(&self) -> &ConnectOptions {
101109
&self.options
102110
}

0 commit comments

Comments
 (0)