forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllm_usage.py
More file actions
763 lines (658 loc) · 28.9 KB
/
Copy pathllm_usage.py
File metadata and controls
763 lines (658 loc) · 28.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
"""
LLM Usage Database Operations.
Stores and queries LLM token usage by feature in Firestore.
Schema: users/{uid}/llm_usage/{date} -> {feature -> {model -> {input_tokens, output_tokens}}}
"""
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Dict, List, Optional, cast
from google.cloud import firestore
from config.plan_catalog import PlanType, WIRE_PLAN_ALIASES
from ._client import db
transactional = getattr(firestore, 'transactional', lambda fn: fn) # pyright: ignore[reportUnknownMemberType]
def _usage_client(firestore_client: Any | None) -> Any:
return firestore_client if firestore_client is not None else db
def _typed_doc(doc: Any) -> Dict[str, Any]:
raw: object = doc.to_dict()
return cast(Dict[str, Any], raw) if isinstance(raw, dict) else {}
_UNATTRIBUTED_PLAN = '_unattributed'
_COST_STATUSES = {'complete', 'partial', 'missing', 'excluded'}
def _canonical_plan_id(value: object) -> str | None:
"""Return a catalog plan id, or ``None`` for an unknown wire value."""
raw = value.value if isinstance(value, PlanType) else value
if not isinstance(raw, str):
return None
raw = raw.strip()
if raw == 'free':
raw = PlanType.basic.value
try:
return PlanType(raw).value
except ValueError:
alias = WIRE_PLAN_ALIASES.get(raw)
return alias.value if alias is not None else None
def resolve_usage_plan_id(uid: str, *, firestore_client: Any | None = None) -> str | None:
"""Resolve the server-owned catalog plan used for a usage event.
A missing or unreadable subscription is deliberately returned as ``None``.
It must become an explicitly unattributed report row, never an assumed
``basic`` row: otherwise a read failure would move paid cost into the free
plan and make the resulting COGS report look complete.
"""
if not uid:
return None
client = _usage_client(firestore_client)
try:
snapshot = client.collection('users').document(uid).get(['subscription'])
except TypeError:
# Small test doubles and older Firestore adapters do not accept a field
# projection. The full read remains bounded to this one user document.
try:
snapshot = client.collection('users').document(uid).get()
except Exception:
return None
except Exception:
return None
if not getattr(snapshot, 'exists', False):
return None
raw = snapshot.to_dict()
if not isinstance(raw, dict):
return None
subscription = raw.get('subscription')
if not isinstance(subscription, dict):
return None
return _canonical_plan_id(subscription.get('plan'))
def _plan_key(uid: str, firestore_client: Any | None) -> str:
return resolve_usage_plan_id(uid, firestore_client=firestore_client) or _UNATTRIBUTED_PLAN
def _nested(update: Dict[str, Any]) -> Dict[str, Any]:
"""Expand dotted field paths into nested maps, for writes that use ``set(..., merge=True)``.
Firestore treats a dot as a field PATH in ``update()`` but as a literal character in ``set()``. These
usage documents are written with ``set(merge=True)`` because they must be created on first use, so a
key like ``chat.gpt-4o.input_tokens`` lands as ONE top-level field whose name contains dots — the
counter still increments correctly, but nothing is nested, and every reader that walks
``feature -> model -> counter`` (``get_usage_summary``, ``_aggregate_summary``,
``get_plan_usage_report``) skips it and reports empty usage.
Every dot in these keys is a separator by construction: the model name, the cost-exclusion label and
the plan id are each sanitised of ``.`` before being interpolated, and the remaining segments are
literals in this module.
"""
nested: Dict[str, Any] = {}
for path, value in update.items():
if '.' not in path:
nested[path] = value
continue
cursor = nested
*parents, leaf = path.split('.')
for segment in parents:
branch = cursor.get(segment)
if not isinstance(branch, dict):
branch = {}
cursor[segment] = branch
cursor = branch
cursor[leaf] = value
return nested
def _record_plan_metadata(
update: Dict[str, Any],
plan_key: str,
*,
cost_status: str,
cost_exclusion: str | None = None,
) -> None:
"""Add auditable status counters without fabricating a cost value."""
status = cost_status if cost_status in _COST_STATUSES else 'missing'
if cost_exclusion is None and status in {'missing', 'partial'}:
cost_exclusion = 'provider_cost_not_recorded'
root = f'plan_usage.{plan_key}._metadata'
update[f'{root}.cost_status_counts.{status}'] = firestore.Increment(1)
update[f'{root}.last_cost_status'] = status
if cost_exclusion:
safe_exclusion = (
cost_exclusion.replace('.', '_')
.replace('/', '_')
.replace('~', '_')
.replace('*', '_')
.replace('[', '_')
.replace(']', '_')
.replace('`', '_')
)
update[f'{root}.cost_exclusions.{safe_exclusion}'] = firestore.Increment(1)
def _record_plan_bucket(
update: Dict[str, Any],
plan_key: str,
bucket: str,
*,
input_tokens: int = 0,
output_tokens: int = 0,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
total_tokens: int = 0,
quota_questions: int = 0,
cost_usd: float | None = None,
count_call: bool = True,
) -> None:
prefix = f'plan_usage.{plan_key}.{bucket}'
for field, value in (
('input_tokens', input_tokens),
('output_tokens', output_tokens),
('cache_read_tokens', cache_read_tokens),
('cache_write_tokens', cache_write_tokens),
('total_tokens', total_tokens),
('quota_questions', quota_questions),
):
if value:
update[f'{prefix}.{field}'] = firestore.Increment(value)
if cost_usd is not None:
update[f'{prefix}.cost_usd'] = firestore.Increment(cost_usd)
if count_call:
update[f'{prefix}.call_count'] = firestore.Increment(1)
def record_llm_usage(
uid: str,
feature: str,
model: str,
input_tokens: int,
output_tokens: int,
*,
cost_usd: float | None = None,
cost_status: str = 'missing',
cost_exclusion: str | None = None,
firestore_client: Any | None = None,
) -> None:
"""
Record LLM token usage for a user and feature.
Uses Firestore atomic increments for safe concurrent updates.
Args:
uid: User ID
feature: Feature name (e.g., "chat", "rag", "conversation_processing")
model: Model name (e.g., "gpt-5.6-luna", "gpt-5-nano")
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
"""
if input_tokens == 0 and output_tokens == 0:
return
if cost_status == 'complete' and cost_usd is None:
raise ValueError('complete cost attribution requires cost_usd')
now = datetime.now(timezone.utc)
doc_id = f"{now.year}-{now.month:02d}-{now.day:02d}"
client = _usage_client(firestore_client)
user_ref = client.collection("users").document(uid)
usage_ref = user_ref.collection("llm_usage").document(doc_id)
# Use nested field paths for atomic increments
# Structure: {feature}.{model}.{input_tokens|output_tokens}
# Firestore doesn't allow '.', '/', '[', ']', '*', '`', '~' in field names
if not model:
model = "unknown"
safe_model = (
model.replace(".", "_")
.replace("/", "_")
.replace("~", "_")
.replace("*", "_")
.replace("[", "_")
.replace("]", "_")
.replace("`", "_")
)
update_data: Dict[str, Any] = {
f"{feature}.{safe_model}.input_tokens": firestore.Increment(input_tokens),
f"{feature}.{safe_model}.output_tokens": firestore.Increment(output_tokens),
f"{feature}.{safe_model}.call_count": firestore.Increment(1),
"date": doc_id, # Store date as a field for collection-group queries
"last_updated": datetime.now(timezone.utc),
}
plan_key = _plan_key(uid, firestore_client)
plan_prefix = f'plan_usage.{plan_key}.{feature}.{safe_model}'
update_data[f'{plan_prefix}.input_tokens'] = firestore.Increment(input_tokens)
update_data[f'{plan_prefix}.output_tokens'] = firestore.Increment(output_tokens)
update_data[f'{plan_prefix}.call_count'] = firestore.Increment(1)
if cost_usd is not None:
update_data[f'{plan_prefix}.cost_usd'] = firestore.Increment(cost_usd)
_record_plan_metadata(
update_data,
plan_key,
cost_status=cost_status,
cost_exclusion=cost_exclusion,
)
usage_ref.set(_nested(update_data), merge=True)
@transactional # pyright: ignore[reportUntypedFunctionDecorator]
def _record_chat_quota_question_transaction(
transaction: Any,
usage_ref: Any,
event_ref: Any,
event_data: Dict[str, Any],
doc_id: str,
plan_key: str,
usage_update_for: Callable[[str, str], Dict[str, Any]] | None = None,
) -> bool:
event_snapshot = event_ref.get(transaction=transaction)
if getattr(event_snapshot, "exists", False):
return False
now = datetime.now(timezone.utc)
transaction.set(event_ref, event_data)
update: Dict[str, Any] = {
'backend_chat.quota_questions': firestore.Increment(1),
'date': doc_id,
'last_updated': now,
}
_record_plan_bucket(update, plan_key, 'backend_chat', quota_questions=1)
if usage_update_for is not None:
# The caller's token/cost telemetry is built here, on this write's own
# day and plan key, and lands in the same transaction as the question:
# a retry that finds the event recorded also finds the telemetry
# recorded, never one without the other, and never on a different day
# or plan than the question.
update.update(usage_update_for(plan_key, doc_id))
else:
_record_plan_metadata(update, plan_key, cost_status='missing', cost_exclusion='chat_token_cost_not_recorded')
transaction.set(usage_ref, _nested(update), merge=True)
return True
def record_chat_quota_question(
uid: str,
idempotency_key: str,
source: str,
message_id: Optional[str] = None,
chat_session_id: Optional[str] = None,
platform: Optional[str] = None,
*,
firestore_client: Any | None = None,
usage_update_for: Callable[[str, str], Dict[str, Any]] | None = None,
) -> bool:
"""Record one accepted visible backend chat question exactly once.
This is the product-boundary quota counter for mobile/backend chat. It is
intentionally separate from ``chat.*.call_count``, which is LLM telemetry
and can vary with implementation details.
``usage_update_for(plan_key, day)`` returns token/cost telemetry (see
:func:`usage_bucket_update`) that must be recorded exactly when the question
is, in the same transaction and on the same day and plan — the realtime hub
uses it so a retried usage report records neither a second question nor a
second cost, and never one without the other.
"""
if not idempotency_key:
raise ValueError('idempotency_key is required')
now = datetime.now(timezone.utc)
doc_id = now.strftime('%Y-%m-%d')
event_id = hashlib.sha256(f'{uid}:{idempotency_key}'.encode('utf-8')).hexdigest()
client = _usage_client(firestore_client)
plan_key = _plan_key(uid, firestore_client)
plan_id = None if plan_key == _UNATTRIBUTED_PLAN else plan_key
user_ref = client.collection('users').document(uid)
usage_ref = user_ref.collection('llm_usage').document(doc_id)
event_ref = user_ref.collection('chat_quota_events').document(event_id)
event_data: Dict[str, Any] = {
'idempotency_key': idempotency_key,
'source': source,
'message_id': message_id,
'chat_session_id': chat_session_id,
'platform': platform,
'created_at': now,
'date': doc_id,
'plan_id': plan_id,
'plan_attribution_status': 'complete' if plan_id is not None else 'missing',
}
transaction = client.transaction()
return _record_chat_quota_question_transaction(
transaction, usage_ref, event_ref, event_data, doc_id, plan_key, usage_update_for
)
def get_daily_usage(uid: str, date: Optional[datetime] = None) -> Dict[str, Any]:
"""
Get LLM usage for a specific day.
Args:
uid: User ID
date: Date to query (defaults to today)
Returns:
Dict with usage data by feature and model
"""
if date is None:
date = datetime.now(timezone.utc)
doc_id = f"{date.year}-{date.month:02d}-{date.day:02d}"
user_ref = db.collection("users").document(uid)
usage_ref = user_ref.collection("llm_usage").document(doc_id)
doc = usage_ref.get()
if getattr(doc, "exists", False):
return _typed_doc(doc)
return {}
def _aggregate_summary(data: Dict[str, Any]) -> Dict[str, Dict[str, int]]:
summary: Dict[str, Dict[str, int]] = {}
for feature, models in data.items():
if feature in ("last_updated",):
continue
if not isinstance(models, dict):
continue
if feature not in summary:
summary[feature] = {"input_tokens": 0, "output_tokens": 0, "call_count": 0}
models_dict: Dict[str, Any] = cast(Dict[str, Any], models)
for _, tokens in models_dict.items():
if isinstance(tokens, dict):
token_dict: Dict[str, Any] = cast(Dict[str, Any], tokens)
summary[feature]["input_tokens"] += int(token_dict.get("input_tokens", 0) or 0)
summary[feature]["output_tokens"] += int(token_dict.get("output_tokens", 0) or 0)
summary[feature]["call_count"] += int(token_dict.get("call_count", 0) or 0)
return summary
def get_usage_summary(uid: str, days: int = 30) -> Dict[str, Dict[str, int]]:
"""
Get aggregated LLM usage summary for the last N days.
Args:
uid: User ID
days: Number of days to aggregate
Returns:
Dict with total usage by feature
"""
user_ref = db.collection("users").document(uid)
usage_collection = user_ref.collection("llm_usage")
# Query last N days
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
cutoff_id = f"{cutoff.year}-{cutoff.month:02d}-{cutoff.day:02d}"
# The value of a `__name__` filter must be a Key, not a string: Firestore answers
# `400 __key__ filter value must be a Key` otherwise, so this raised on every call.
docs = usage_collection.where("__name__", ">=", usage_collection.document(cutoff_id)).stream()
# Aggregate by feature
summary: Dict[str, Dict[str, int]] = {}
for doc in docs:
data = _typed_doc(doc)
partial = _aggregate_summary(data)
for feature, tokens in partial.items():
if feature not in summary:
summary[feature] = {"input_tokens": 0, "output_tokens": 0, "call_count": 0}
summary[feature]["input_tokens"] += tokens["input_tokens"]
summary[feature]["output_tokens"] += tokens["output_tokens"]
summary[feature]["call_count"] += tokens["call_count"]
return summary
def _features_from_summary(summary: Dict[str, Dict[str, int]], limit: int) -> List[Dict[str, Any]]:
features: List[Dict[str, Any]] = []
for feature, tokens in summary.items():
total = tokens.get("input_tokens", 0) + tokens.get("output_tokens", 0)
features.append(
{
"feature": feature,
"input_tokens": tokens.get("input_tokens", 0),
"output_tokens": tokens.get("output_tokens", 0),
"total_tokens": total,
"call_count": tokens.get("call_count", 0),
}
)
features.sort(key=lambda x: x["total_tokens"], reverse=True)
return features[:limit]
def get_top_features(uid: str, days: int = 30, limit: int = 3) -> List[Dict[str, Any]]:
"""
Get top features by total token usage.
Args:
uid: User ID
days: Number of days to aggregate
limit: Number of top features to return
Returns:
List of dicts with feature name and total tokens, sorted by usage
"""
summary = get_usage_summary(uid, days)
return _features_from_summary(summary, limit)
def get_global_top_features(days: int = 30, limit: int = 3) -> List[Dict[str, Any]]:
"""
Get top features across all users by total token usage.
Args:
days: Number of days to aggregate
limit: Number of top features to return
Returns:
List of dicts with feature name and total tokens
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
cutoff_id = f"{cutoff.year}-{cutoff.month:02d}-{cutoff.day:02d}"
# Query all users' llm_usage subcollections
# Note: This is a collection group query; use 'date' field instead of __name__
# since __name__ comparisons don't work reliably for collection-group queries
usage_query = db.collection_group("llm_usage").where("date", ">=", cutoff_id)
global_summary: Dict[str, Dict[str, int]] = {}
for doc in usage_query.stream():
data = _typed_doc(doc)
partial = _aggregate_summary(data)
for feature, tokens in partial.items():
if feature not in global_summary:
global_summary[feature] = {"input_tokens": 0, "output_tokens": 0, "call_count": 0}
global_summary[feature]["input_tokens"] += tokens["input_tokens"]
global_summary[feature]["output_tokens"] += tokens["output_tokens"]
global_summary[feature]["call_count"] += tokens["call_count"]
return _features_from_summary(global_summary, limit)
# ============================================================================
# BUCKET-BASED LLM USAGE
#
# Flat key scheme ("desktop_chat" / "desktop_chat_{account}") with fields:
# input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
# total_tokens, cost_usd, call_count.
#
# This differs from the {feature}.{model} nesting above. Both schemas
# coexist in the same date-keyed documents using Firestore's schemaless design.
# ============================================================================
def record_llm_usage_bucket(
uid: str,
input_tokens: int,
output_tokens: int,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
total_tokens: int = 0,
cost_usd: float | None = None,
bucket: str = 'desktop_chat',
account: str = 'omi',
*,
cost_status: str = 'missing',
cost_exclusion: str | None = None,
quota_questions: int = 0,
count_call: bool = True,
firestore_client: Any | None = None,
) -> None:
"""Record LLM token usage into a flat bucket with atomic increments.
Dual-writes to both the primary bucket and a per-account alias
(``{bucket}_{account}``) for per-account breakdown.
"""
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
ref = _usage_client(firestore_client).collection("users").document(uid).collection("llm_usage").document(today)
update = usage_bucket_update(
uid,
today=today,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
total_tokens=total_tokens,
cost_usd=cost_usd,
bucket=bucket,
account=account,
cost_status=cost_status,
cost_exclusion=cost_exclusion,
quota_questions=quota_questions,
count_call=count_call,
firestore_client=firestore_client,
)
ref.set(_nested(update), merge=True)
def usage_bucket_update(
uid: str,
*,
input_tokens: int,
output_tokens: int,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
total_tokens: int = 0,
cost_usd: float | None = None,
bucket: str = 'desktop_chat',
account: str = 'omi',
cost_status: str = 'missing',
cost_exclusion: str | None = None,
quota_questions: int = 0,
count_call: bool = True,
firestore_client: Any | None = None,
plan_key: str | None = None,
today: str | None = None,
) -> Dict[str, Any]:
"""The dotted-path increments :func:`record_llm_usage_bucket` writes, without writing them.
Pure apart from the plan lookup, so a caller can fold the same telemetry
into its own transaction (see :func:`record_chat_quota_question`); that
caller passes the transaction's own ``plan_key`` and ``today`` so the
telemetry cannot land on a different day or plan than the question.
"""
if cost_status == 'complete' and cost_usd is None:
raise ValueError('complete cost attribution requires cost_usd')
today = today or datetime.now(timezone.utc).strftime('%Y-%m-%d')
acct_key = f'{bucket}_{account}'
update: Dict[str, Any] = {
f'{bucket}.input_tokens': firestore.Increment(input_tokens),
f'{bucket}.output_tokens': firestore.Increment(output_tokens),
f'{bucket}.cache_read_tokens': firestore.Increment(cache_read_tokens),
f'{bucket}.cache_write_tokens': firestore.Increment(cache_write_tokens),
f'{bucket}.total_tokens': firestore.Increment(total_tokens),
f'{acct_key}.input_tokens': firestore.Increment(input_tokens),
f'{acct_key}.output_tokens': firestore.Increment(output_tokens),
f'{acct_key}.cache_read_tokens': firestore.Increment(cache_read_tokens),
f'{acct_key}.cache_write_tokens': firestore.Increment(cache_write_tokens),
f'{acct_key}.total_tokens': firestore.Increment(total_tokens),
'date': today,
'last_updated': datetime.now(timezone.utc),
}
if cost_usd is not None:
update[f'{bucket}.cost_usd'] = firestore.Increment(cost_usd)
update[f'{acct_key}.cost_usd'] = firestore.Increment(cost_usd)
if count_call:
update[f'{bucket}.call_count'] = firestore.Increment(1)
update[f'{acct_key}.call_count'] = firestore.Increment(1)
if quota_questions:
update[f'{bucket}.quota_questions'] = firestore.Increment(quota_questions)
update[f'{acct_key}.quota_questions'] = firestore.Increment(quota_questions)
plan_key = plan_key or _plan_key(uid, firestore_client)
_record_plan_bucket(
update,
plan_key,
bucket,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
total_tokens=total_tokens,
quota_questions=quota_questions,
cost_usd=cost_usd,
count_call=count_call,
)
_record_plan_metadata(update, plan_key, cost_status=cost_status, cost_exclusion=cost_exclusion)
return update
def record_llm_cost_exclusion(
uid: str,
*,
bucket: str = 'desktop_chat',
account: str = 'omi',
cost_exclusion: str,
firestore_client: Any | None = None,
) -> None:
"""Record a non-OMI or otherwise excluded cost without fake usage/cost."""
if not cost_exclusion:
raise ValueError('cost_exclusion is required')
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
ref = _usage_client(firestore_client).collection('users').document(uid).collection('llm_usage').document(today)
plan_key = _plan_key(uid, firestore_client)
update: Dict[str, Any] = {'date': today, 'last_updated': datetime.now(timezone.utc)}
_record_plan_metadata(update, plan_key, cost_status='excluded', cost_exclusion=cost_exclusion)
ref.set(_nested(update), merge=True)
def _merge_cost_status(existing: str | None, observed: str) -> str:
statuses = {existing, observed} - {None}
if statuses == {'complete'} or (statuses and statuses <= {'complete', 'excluded'} and 'complete' in statuses):
return 'complete'
if statuses == {'excluded'}:
return 'excluded'
if 'partial' in statuses:
return 'partial'
if 'missing' in statuses:
return 'missing'
return observed
def _accumulate_plan_data(row: Dict[str, Any], value: Dict[str, Any]) -> None:
"""Collect metrics from both bucket and feature/model plan layouts."""
for key, child in value.items():
if key == '_metadata':
continue
if isinstance(child, dict):
_accumulate_plan_data(row, child)
continue
if key == 'input_tokens':
row['input_tokens'] += int(child or 0)
elif key == 'output_tokens':
row['output_tokens'] += int(child or 0)
elif key == 'total_tokens':
row['total_tokens'] += int(child or 0)
elif key == 'quota_questions':
row['questions'] += int(child or 0)
elif key == 'cost_usd':
row['cost_usd'] = (row['cost_usd'] or 0.0) + float(child or 0.0)
def get_plan_usage_report(uid: str, days: int = 30) -> Dict[str, Dict[str, Any]]:
"""Return usage/cost rows keyed by catalog plan without zero-filling cost.
Legacy rows that predate plan attribution are retained under
``_unattributed``. A missing cost field is represented by ``None`` and a
status, so a real zero and an unmeasured cost cannot be confused.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
cutoff_id = f'{cutoff.year}-{cutoff.month:02d}-{cutoff.day:02d}'
usage_collection = db.collection('users').document(uid).collection('llm_usage')
report: Dict[str, Dict[str, Any]] = {}
# A `__name__` filter takes a Key, not a string (see get_usage_summary above).
for doc in usage_collection.where('__name__', '>=', usage_collection.document(cutoff_id)).stream():
data = _typed_doc(doc)
plan_usage = data.get('plan_usage')
if isinstance(plan_usage, dict):
for plan_key, plan_data in plan_usage.items():
if not isinstance(plan_data, dict):
continue
row = report.setdefault(
str(plan_key),
{
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'questions': 0,
'cost_usd': None,
'cost_status': None,
'cost_exclusions': {},
},
)
metadata = plan_data.get('_metadata')
if isinstance(metadata, dict):
counts = metadata.get('cost_status_counts')
if isinstance(counts, dict):
for status, count in counts.items():
if int(count or 0) > 0:
row['cost_status'] = _merge_cost_status(row['cost_status'], str(status))
exclusions = metadata.get('cost_exclusions')
if isinstance(exclusions, dict):
for exclusion, count in exclusions.items():
row['cost_exclusions'][str(exclusion)] = row['cost_exclusions'].get(
str(exclusion), 0
) + int(count or 0)
_accumulate_plan_data(row, plan_data)
# A document with only legacy fields cannot be safely joined to a
# catalog plan. Mark that fact explicitly rather than assigning basic.
if not isinstance(plan_usage, dict) and any(
key == 'desktop_chat' or key.startswith('desktop_chat.') or key.startswith('chat.') for key in data
):
row = report.setdefault(
_UNATTRIBUTED_PLAN,
{
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'questions': 0,
'cost_usd': None,
'cost_status': 'missing',
'cost_exclusions': {},
},
)
for key, value in data.items():
if isinstance(value, dict) and (key == 'desktop_chat' or key.startswith('chat')):
_accumulate_plan_data(row, value)
elif isinstance(value, (int, float)):
if key.endswith('.quota_questions') or (key.startswith('chat.') and key.endswith('.call_count')):
row['questions'] += int(value)
row['cost_exclusions']['plan_snapshot_missing'] = row['cost_exclusions'].get('plan_snapshot_missing', 0) + 1
row['cost_status'] = 'missing'
for row in report.values():
row['cost_status'] = row['cost_status'] or 'missing'
return report
def get_total_llm_cost(uid: str, bucket: str = 'desktop_chat') -> float:
"""Sum cost_usd from the given bucket.
When the bucket dual-writes to both ``{bucket}`` and ``{bucket}_{account}``,
this reads only the primary bucket to avoid double-counting.
"""
col = db.collection("users").document(uid).collection("llm_usage")
total = 0.0
for doc in col.stream():
data = _typed_doc(doc)
dc = data.get(bucket)
if isinstance(dc, dict):
dc_dict: Dict[str, Any] = cast(Dict[str, Any], dc)
total += float(dc_dict.get('cost_usd', 0.0) or 0.0)
return round(total, 6)