Skip to content

Commit f87d0fa

Browse files
authored
feat(billing): handle limit aggregation for llm requests DEV-1031 (#6291)
### 📣 Summary Enables aggregation of automated QA billing limits and the inclusion of these limits in service usage data returned by the API.
1 parent c67f74b commit f87d0fa

File tree

10 files changed

+94
-78
lines changed

10 files changed

+94
-78
lines changed

kobo/apps/openrosa/apps/api/tests/viewsets/test_xform_submission_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_query_counts(self):
7979
# so should find a way to keep that out of this count
8080
if settings.STRIPE_ENABLED:
8181
# But because of cache, sometimes goes down to 62
82-
expected_queries = FuzzyInt(62, 84)
82+
expected_queries = FuzzyInt(62, 90)
8383
with self.assertNumQueries(expected_queries):
8484
self.view(request)
8585

kobo/apps/organizations/constants.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
11
from django.db import models
22

33

4-
# Temporary const for code that iterates over usage types
5-
# TODO: Remove when Stripe code has been updated to
6-
# handle LLM usage type
7-
class SupportedUsageType(models.TextChoices):
8-
SUBMISSION = 'submission'
9-
STORAGE_BYTES = 'storage_bytes'
10-
MT_CHARACTERS = 'mt_characters'
11-
ASR_SECONDS = 'asr_seconds'
12-
13-
144
class UsageType(models.TextChoices):
155
SUBMISSION = 'submission'
166
STORAGE_BYTES = 'storage_bytes'

kobo/apps/organizations/tests/test_organizations_service_usage_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ def test_check_api_response_without_stripe(self):
8484
assert response.data['total_nlp_usage']['asr_seconds_all_time'] == 4728
8585
assert response.data['total_nlp_usage']['mt_characters_current_period'] == 5473
8686
assert response.data['total_nlp_usage']['mt_characters_all_time'] == 6726
87-
assert response.data['total_nlp_usage']['llm_requests_current_period'] == 20
88-
assert response.data['total_nlp_usage']['llm_requests_all_time'] == 70
87+
assert response.data['total_nlp_usage']['llm_requests_current_period'] == 30
88+
assert response.data['total_nlp_usage']['llm_requests_all_time'] == 80
8989
assert response.data['total_storage_bytes'] == self.expected_file_size()
9090

9191
# Without stripe, there are no usage limits and

kobo/apps/stripe/models.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from djstripe.models import Charge, Price, Subscription
1010

1111
from kobo.apps.kobo_auth.shortcuts import User
12-
from kobo.apps.organizations.constants import SupportedUsageType, UsageType
12+
from kobo.apps.organizations.constants import UsageType
1313
from kobo.apps.organizations.models import Organization
1414
from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES
1515
from kobo.apps.stripe.utils.subscription_limits import get_default_add_on_limits
@@ -176,6 +176,26 @@ def get_organizations_totals(organizations: list['Organization'] = None):
176176
),
177177
0,
178178
),
179+
llm_requests_remaining=Coalesce(
180+
Sum(
181+
Cast(
182+
PlanAddOn.get_limits_remaining_field(
183+
UsageType.LLM_REQUESTS
184+
),
185+
output_field=IntegerField(),
186+
)
187+
),
188+
0,
189+
),
190+
total_llm_requests_limit=Coalesce(
191+
Sum(
192+
Cast(
193+
PlanAddOn.get_usage_limits_field(UsageType.LLM_REQUESTS),
194+
output_field=IntegerField(),
195+
)
196+
),
197+
0,
198+
),
179199
submission_remaining=Coalesce(
180200
Sum(
181201
Cast(
@@ -311,6 +331,6 @@ class ExceededLimitCounter(models.Model):
311331
on_delete=models.CASCADE,
312332
)
313333
days = models.PositiveSmallIntegerField(default=0)
314-
limit_type = models.CharField(choices=SupportedUsageType.choices, max_length=20)
334+
limit_type = models.CharField(choices=UsageType.choices, max_length=20)
315335
date_created = models.DateTimeField(auto_now_add=True)
316336
date_modified = models.DateTimeField(auto_now=True)

kobo/apps/stripe/tests/test_stripe_utils.py

Lines changed: 48 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from model_bakery import baker
1515

1616
from kobo.apps.kobo_auth.shortcuts import User
17-
from kobo.apps.organizations.constants import SupportedUsageType, UsageType
17+
from kobo.apps.organizations.constants import UsageType
1818
from kobo.apps.organizations.models import Organization
1919
from kobo.apps.organizations.utils import get_billing_dates
2020
from kobo.apps.stripe.models import ExceededLimitCounter
@@ -67,75 +67,57 @@ def setUpTestData(cls):
6767

6868
def test_get_organization_subscription_limits(self):
6969
free_plan = generate_free_plan()
70+
first_paid_plan_limits = {
71+
f'{UsageType.MT_CHARACTERS}_limit': '100',
72+
f'{UsageType.ASR_SECONDS}_limit': '200',
73+
f'{UsageType.LLM_REQUESTS}_limit': '300',
74+
f'{UsageType.SUBMISSION}_limit': '400',
75+
f'{UsageType.STORAGE_BYTES}_limit': '500',
76+
}
77+
# second plan for org2 where we append (not add) 1 to all the limits
78+
second_paid_plan_limits = {
79+
key: f'{value}1' for key, value in first_paid_plan_limits.items()
80+
}
7081
product_metadata = {
71-
f'{UsageType.MT_CHARACTERS}_limit': '1234',
72-
f'{UsageType.ASR_SECONDS}_limit': '5678',
73-
f'{UsageType.SUBMISSION}_limit': '91011',
74-
f'{UsageType.STORAGE_BYTES}_limit': '121314',
7582
'product_type': 'plan',
7683
'plan_type': 'enterprise',
7784
}
78-
# create a second plan for org2 where we append (not add) 1 to all the limits
85+
first_product_metadata = {
86+
**first_paid_plan_limits,
87+
**product_metadata,
88+
}
7989
second_product_metadata = {
80-
key: f'{value}1' if key.endswith('limit') else value
81-
for key, value in product_metadata.items()
90+
**second_paid_plan_limits,
91+
**product_metadata,
8292
}
83-
generate_plan_subscription(self.organization, metadata=product_metadata)
93+
94+
generate_plan_subscription(self.organization, metadata=first_product_metadata)
8495
generate_plan_subscription(
8596
self.second_organization, metadata=second_product_metadata
8697
)
8798
all_limits = get_organizations_subscription_limits()
88-
assert (
89-
all_limits[self.organization.id][f'{UsageType.MT_CHARACTERS}_limit'] == 1234
90-
)
91-
assert (
92-
all_limits[self.second_organization.id][f'{UsageType.MT_CHARACTERS}_limit']
93-
== 12341
94-
)
95-
assert (
96-
all_limits[self.organization.id][f'{UsageType.ASR_SECONDS}_limit'] == 5678
97-
)
98-
assert (
99-
all_limits[self.second_organization.id][f'{UsageType.ASR_SECONDS}_limit']
100-
== 56781
101-
)
102-
assert (
103-
all_limits[self.organization.id][f'{UsageType.SUBMISSION}_limit'] == 91011
104-
)
105-
assert (
106-
all_limits[self.second_organization.id][f'{UsageType.SUBMISSION}_limit']
107-
== 910111
108-
)
109-
assert (
110-
all_limits[self.organization.id][f'{UsageType.STORAGE_BYTES}_limit']
111-
== 121314
112-
)
113-
assert (
114-
all_limits[self.second_organization.id][f'{UsageType.STORAGE_BYTES}_limit']
115-
== 1213141
116-
)
11799

118100
other_orgs = Organization.objects.exclude(
119101
id__in=[self.organization.id, self.second_organization.id]
120102
)
121-
for org in other_orgs:
122-
assert all_limits[org.id][f'{UsageType.MT_CHARACTERS}_limit'] == int(
123-
free_plan.metadata[f'{UsageType.MT_CHARACTERS}_limit']
124-
)
125-
assert all_limits[org.id][f'{UsageType.ASR_SECONDS}_limit'] == int(
126-
free_plan.metadata[f'{UsageType.ASR_SECONDS}_limit']
127-
)
128-
assert all_limits[org.id][f'{UsageType.SUBMISSION}_limit'] == int(
129-
free_plan.metadata[f'{UsageType.SUBMISSION}_limit']
130-
)
131-
assert all_limits[org.id][f'{UsageType.STORAGE_BYTES}_limit'] == int(
132-
free_plan.metadata[f'{UsageType.STORAGE_BYTES}_limit']
103+
for usage_type, _ in UsageType.choices:
104+
assert all_limits[self.organization.id][f'{usage_type}_limit'] == float(
105+
first_paid_plan_limits[f'{usage_type}_limit']
133106
)
107+
assert all_limits[self.second_organization.id][
108+
f'{usage_type}_limit'
109+
] == float(second_paid_plan_limits[f'{usage_type}_limit'])
110+
111+
for org in other_orgs:
112+
assert all_limits[org.id][f'{usage_type}_limit'] == float(
113+
free_plan.metadata[f'{usage_type}_limit']
114+
)
134115

135116
def test__prioritizes_price_metadata(self):
136117
product_metadata = {
137118
f'{UsageType.MT_CHARACTERS}_limit': '1',
138119
f'{UsageType.ASR_SECONDS}_limit': '1',
120+
f'{UsageType.LLM_REQUESTS}_limit': '1',
139121
f'{UsageType.SUBMISSION}_limit': '1',
140122
f'{UsageType.STORAGE_BYTES}_limit': '1',
141123
'product_type': 'plan',
@@ -144,14 +126,15 @@ def test__prioritizes_price_metadata(self):
144126
price_metadata = {
145127
f'{UsageType.MT_CHARACTERS}_limit': '2',
146128
f'{UsageType.ASR_SECONDS}_limit': '2',
129+
f'{UsageType.LLM_REQUESTS}_limit': '2',
147130
f'{UsageType.SUBMISSION}_limit': '2',
148131
f'{UsageType.STORAGE_BYTES}_limit': '2',
149132
}
150133
generate_plan_subscription(
151134
self.organization, metadata=product_metadata, price_metadata=price_metadata
152135
)
153136
limits = get_paid_subscription_limits([self.organization.id]).first()
154-
for usage_type, _ in SupportedUsageType.choices:
137+
for usage_type, _ in UsageType.choices:
155138
assert limits[f'{usage_type}_limit'] == '2'
156139

157140
def test_get_subscription_limits_takes_most_recent_active_subscriptions(self):
@@ -477,6 +460,7 @@ def test_get_org_effective_limits(self, include_onetime_addons):
477460
plan_product_metadata = {
478461
f'{UsageType.MT_CHARACTERS}_limit': '1',
479462
f'{UsageType.ASR_SECONDS}_limit': '1',
463+
f'{UsageType.LLM_REQUESTS}_limit': '1',
480464
f'{UsageType.SUBMISSION}_limit': '1',
481465
f'{UsageType.STORAGE_BYTES}_limit': '1',
482466
'product_type': 'plan',
@@ -485,6 +469,7 @@ def test_get_org_effective_limits(self, include_onetime_addons):
485469
product_metadata = {
486470
f'{UsageType.MT_CHARACTERS}_limit': '2',
487471
f'{UsageType.ASR_SECONDS}_limit': '2',
472+
f'{UsageType.LLM_REQUESTS}_limit': '2',
488473
f'{UsageType.SUBMISSION}_limit': '2',
489474
f'{UsageType.STORAGE_BYTES}_limit': '2',
490475
'product_type': 'plan',
@@ -497,6 +482,7 @@ def test_get_org_effective_limits(self, include_onetime_addons):
497482
one_time_nlp_addon_metadata = {
498483
f'{UsageType.MT_CHARACTERS}_limit': '15',
499484
f'{UsageType.ASR_SECONDS}_limit': '20',
485+
f'{UsageType.LLM_REQUESTS}_limit': '20',
500486
}
501487
nlp_addon = _create_one_time_addon_product(one_time_nlp_addon_metadata)
502488
customer = baker.make(Customer, subscriber=self.organization)
@@ -526,11 +512,17 @@ def test_get_org_effective_limits(self, include_onetime_addons):
526512
assert (
527513
results[self.second_organization.id][f'{UsageType.ASR_SECONDS}_limit'] == 2
528514
)
515+
assert (
516+
results[self.second_organization.id][f'{UsageType.LLM_REQUESTS}_limit'] == 2
517+
)
529518
if include_onetime_addons:
530519
assert (
531520
results[self.organization.id][f'{UsageType.MT_CHARACTERS}_limit'] == 16
532521
)
533522
assert results[self.organization.id][f'{UsageType.ASR_SECONDS}_limit'] == 21
523+
assert (
524+
results[self.organization.id][f'{UsageType.LLM_REQUESTS}_limit'] == 21
525+
)
534526
assert (
535527
results[self.second_organization.id][f'{UsageType.SUBMISSION}_limit']
536528
== 12
@@ -540,6 +532,7 @@ def test_get_org_effective_limits(self, include_onetime_addons):
540532
results[self.organization.id][f'{UsageType.MT_CHARACTERS}_limit'] == 1
541533
)
542534
assert results[self.organization.id][f'{UsageType.ASR_SECONDS}_limit'] == 1
535+
assert results[self.organization.id][f'{UsageType.LLM_REQUESTS}_limit'] == 1
543536
assert (
544537
results[self.second_organization.id][f'{UsageType.SUBMISSION}_limit']
545538
== 2
@@ -603,6 +596,7 @@ def setUp(self):
603596
product_metadata = {
604597
f'{UsageType.MT_CHARACTERS}_limit': '1',
605598
f'{UsageType.ASR_SECONDS}_limit': '1',
599+
f'{UsageType.LLM_REQUESTS}_limit': '1',
606600
f'{UsageType.SUBMISSION}_limit': '1',
607601
f'{UsageType.STORAGE_BYTES}_limit': '1',
608602
'product_type': 'plan',
@@ -627,7 +621,7 @@ def test_check_exceeded_limit_adds_counters(self):
627621
):
628622
self.add_submissions(count=2, asset=self.asset, username='someuser')
629623
self.add_nlp_trackers()
630-
for usage_type, _ in SupportedUsageType.choices:
624+
for usage_type, _ in UsageType.choices:
631625
check_exceeded_limit(self.someuser, usage_type)
632626
assert (
633627
ExceededLimitCounter.objects.filter(
@@ -638,7 +632,7 @@ def test_check_exceeded_limit_adds_counters(self):
638632

639633
def test_check_exceeded_limit_updates_counters(self):
640634
today = timezone.now()
641-
for usage_type, _ in SupportedUsageType.choices:
635+
for usage_type, _ in UsageType.choices:
642636
baker.make(
643637
ExceededLimitCounter,
644638
user=self.anotheruser,
@@ -655,7 +649,7 @@ def test_check_exceeded_limit_updates_counters(self):
655649
):
656650
self.add_submissions(count=2, asset=self.asset, username='someuser')
657651
self.add_nlp_trackers()
658-
for usage_type, _ in SupportedUsageType.choices:
652+
for usage_type, _ in UsageType.choices:
659653
check_exceeded_limit(self.someuser, usage_type)
660654
counter = ExceededLimitCounter.objects.get(
661655
user_id=self.anotheruser.id, limit_type=usage_type

kobo/apps/stripe/tests/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def generate_free_plan():
2323
'product_type': 'plan',
2424
f'{UsageType.SUBMISSION}_limit': '5000',
2525
f'{UsageType.ASR_SECONDS}_limit': '600',
26+
f'{UsageType.LLM_REQUESTS}_limit': '20',
2627
f'{UsageType.MT_CHARACTERS}_limit': '6000',
2728
f'{UsageType.STORAGE_BYTES}_limit': '1000',
2829
'default_free_plan': 'true',

kobo/apps/stripe/utils/subscription_limits.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.db.models import F, Max, Q, QuerySet, Window
77
from django.db.models.functions import Coalesce
88

9-
from kobo.apps.organizations.constants import SupportedUsageType, UsageType
9+
from kobo.apps.organizations.constants import UsageType
1010
from kobo.apps.organizations.models import Organization, OrganizationUser
1111
from kobo.apps.organizations.types import UsageLimits
1212
from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES
@@ -34,6 +34,7 @@ def get_default_add_on_limits():
3434
f'{UsageType.SUBMISSION}_limit': 0,
3535
f'{UsageType.ASR_SECONDS}_limit': 0,
3636
f'{UsageType.MT_CHARACTERS}_limit': 0,
37+
f'{UsageType.LLM_REQUESTS}_limit': 0,
3738
}
3839

3940

@@ -114,7 +115,7 @@ def get_organizations_subscription_limits(
114115
if row['product_type'] == 'plan':
115116
row_limits = {
116117
f'{usage_type}_limit': row[f'{usage_type}_limit']
117-
for usage_type, _ in SupportedUsageType.choices
118+
for usage_type, _ in UsageType.choices
118119
}
119120
elif row['product_type'] == 'addon':
120121
row_limits['addon_storage_limit'] = row[f'{UsageType.STORAGE_BYTES}_limit']
@@ -124,6 +125,7 @@ def get_organizations_subscription_limits(
124125
submission_limit = _get_limit_key(UsageType.SUBMISSION)
125126
characters_limit = _get_limit_key(UsageType.MT_CHARACTERS)
126127
seconds_limit = _get_limit_key(UsageType.ASR_SECONDS)
128+
requests_limit = _get_limit_key(UsageType.LLM_REQUESTS)
127129
# Anyone who does not have a subscription is on the free tier plan by default
128130
default_plan = (
129131
Product.objects.filter(metadata__default_free_plan='true')
@@ -132,11 +134,12 @@ def get_organizations_subscription_limits(
132134
submission_limit=F(f'metadata__{submission_limit}'),
133135
mt_characters_limit=F(f'metadata__{characters_limit}'),
134136
asr_seconds_limit=F(f'metadata__{seconds_limit}'),
137+
llm_requests_limit=F(f'metadata__{requests_limit}'),
135138
)
136139
.first()
137140
) or {}
138141
default_plan_limits = {}
139-
for usage_type, _ in SupportedUsageType.choices:
142+
for usage_type, _ in UsageType.choices:
140143
limit_key = f'{usage_type}_limit'
141144
default_limit = default_plan.get(limit_key)
142145
if default_limit is None:
@@ -147,7 +150,7 @@ def get_organizations_subscription_limits(
147150
results = {}
148151
for org_id in all_org_ids:
149152
all_org_limits = {}
150-
for usage_type, _ in SupportedUsageType.choices:
153+
for usage_type, _ in UsageType.choices:
151154
plan_limit = subscription_limits_by_org_id.get(org_id, {}).get(
152155
f'{usage_type}_limit'
153156
)
@@ -205,7 +208,7 @@ def get_organizations_effective_limits(
205208
PlanAddOn = apps.get_model('stripe', 'PlanAddOn') # noqa
206209
addon_limits = PlanAddOn.get_organizations_totals(organizations=organizations)
207210
for org_id, limits in effective_limits.items():
208-
for usage_type, _ in SupportedUsageType.choices:
211+
for usage_type, _ in UsageType.choices:
209212
addon = addon_limits.get(org_id, {}).get(f'total_{usage_type}_limit', 0)
210213
limits[f'{usage_type}_limit'] += addon
211214
return effective_limits
@@ -235,6 +238,9 @@ def get_paid_subscription_limits(organization_ids: list[str], **kwargs) -> Query
235238
price_seconds_key, product_seconds_key = (
236239
_get_subscription_metadata_fields_for_usage_type(UsageType.ASR_SECONDS)
237240
)
241+
price_requests_key, product_requests_key = (
242+
_get_subscription_metadata_fields_for_usage_type(UsageType.LLM_REQUESTS)
243+
)
238244

239245
# Get organizations we care about (either those in the 'organizations' param or all)
240246
org_filter = Q(customer__subscriber_id__in=[org_id for org_id in organization_ids])
@@ -256,6 +262,7 @@ def get_paid_subscription_limits(organization_ids: list[str], **kwargs) -> Query
256262
mt_characters_limit=Coalesce(
257263
F(price_characters_key), F(product_characters_key)
258264
),
265+
llm_requests_limit=Coalesce(F(price_requests_key), F(product_requests_key)),
259266
sub_start_date=F('start_date'),
260267
product_type=F('items__price__product__metadata__product_type'),
261268
)

kpi/serializers/v2/service_usage.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def _get_nlp_tracking_data(self, asset, start_date=None):
6666
if not asset.has_deployment:
6767
return {
6868
'total_nlp_asr_seconds': 0,
69+
'total_nlp_llm_requests': 0,
6970
'total_nlp_mt_characters': 0,
7071
}
7172
return OpenRosaDeploymentBackend.nlp_tracking_data(

0 commit comments

Comments
 (0)