agentcap-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentcap/pricing.py ADDED
@@ -0,0 +1,620 @@
1
+ """Model pricing lookup and cost math.
2
+
3
+ Prices are stored as decimal strings so that no money value ever travels
4
+ through ``float``. Lookup is exact first and then longest-prefix, which makes
5
+ dated model ids (``openai/gpt-4o-2024-08-06``) resolve to their family entry
6
+ without letting ``gpt-4o`` shadow ``gpt-4o-mini`` or ``gpt-5`` shadow
7
+ ``gpt-5.4-mini``.
8
+
9
+ Optional features:
10
+
11
+ * ``schedule`` — dated price changes (Google announced future increases).
12
+ * ``long_context`` — a higher rate above a prompt-size threshold.
13
+ * ``cache_write_per_mtok`` — conservative rate for cache-write capable
14
+ requests (``prompt_cache*`` / ``cache_control``).
15
+ * ``service_tiers`` — OpenAI service-tier multipliers.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import json
22
+ import os
23
+ from dataclasses import dataclass
24
+ from datetime import date, datetime, timezone
25
+ from decimal import Decimal, InvalidOperation
26
+ from pathlib import Path
27
+ from typing import Any, Mapping
28
+
29
+ ONE_MILLION = Decimal("1000000")
30
+ MICRO_USD = Decimal("0.000001")
31
+
32
+ BUNDLED_PRICING_PATH = Path(__file__).resolve().parent / "data" / "pricing.json"
33
+
34
+
35
+ class PricingError(Exception):
36
+ """Base class for pricing problems."""
37
+
38
+
39
+ class InvalidPricingError(PricingError):
40
+ """A pricing document is malformed or incomplete."""
41
+
42
+
43
+ class UnknownModelError(PricingError):
44
+ """No pricing entry (exact or prefix) exists for a model id."""
45
+
46
+ def __init__(self, model: str) -> None:
47
+ self.model = model
48
+ super().__init__(
49
+ f"Unknown model {model!r}: no pricing entry found. "
50
+ "Add one to the pricing override file (AGENTCAP_PRICING_FILE) "
51
+ "before sending requests."
52
+ )
53
+
54
+
55
+ class UnknownServiceTierError(PricingError):
56
+ """A service tier has no configured multiplier (fail closed)."""
57
+
58
+ def __init__(self, provider: str, tier: str) -> None:
59
+ self.provider = provider
60
+ self.tier = tier
61
+ super().__init__(
62
+ f"Unknown service tier {tier!r} for provider {provider!r}; "
63
+ "refusing to guess a multiplier."
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class LongContextPrice:
69
+ threshold_tokens: int
70
+ input_per_mtok: Decimal
71
+ output_per_mtok: Decimal
72
+ cached_input_per_mtok: Decimal | None = None
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ScheduleEntry:
77
+ """A full price triple that applies from ``effective_from`` (UTC date)."""
78
+
79
+ effective_from: date
80
+ input_per_mtok: Decimal
81
+ cached_input_per_mtok: Decimal
82
+ output_per_mtok: Decimal
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ModelPrice:
87
+ key: str
88
+ provider: str
89
+ model: str
90
+ input_per_mtok: Decimal
91
+ cached_input_per_mtok: Decimal
92
+ output_per_mtok: Decimal
93
+ max_output_tokens: int
94
+ source: str = ""
95
+ verified: bool = False
96
+ verified_at: str = ""
97
+ long_context: LongContextPrice | None = None
98
+ schedule: tuple[ScheduleEntry, ...] = ()
99
+ cache_write_per_mtok: Decimal | None = None
100
+
101
+ @property
102
+ def is_verified(self) -> bool:
103
+ return bool(self.verified)
104
+
105
+ @property
106
+ def verified_date(self) -> str:
107
+ """The verified-on date, or an empty string for legacy bool entries."""
108
+
109
+ return self.verified_at
110
+
111
+ def rates_at(
112
+ self, now: datetime | date | None = None
113
+ ) -> tuple[Decimal, Decimal, Decimal]:
114
+ """Base (input, cached, output) rates in effect at ``now``."""
115
+
116
+ rates = (
117
+ self.input_per_mtok,
118
+ self.cached_input_per_mtok,
119
+ self.output_per_mtok,
120
+ )
121
+ if not self.schedule:
122
+ return rates
123
+ current = _utc_date(now)
124
+ applicable: ScheduleEntry | None = None
125
+ for entry in self.schedule:
126
+ if entry.effective_from <= current:
127
+ applicable = entry
128
+ if applicable is None:
129
+ return rates
130
+ return (
131
+ applicable.input_per_mtok,
132
+ applicable.cached_input_per_mtok,
133
+ applicable.output_per_mtok,
134
+ )
135
+
136
+
137
+ def _utc_date(value: datetime | date | None) -> date:
138
+ if value is None:
139
+ return datetime.now(timezone.utc).date()
140
+ if isinstance(value, datetime):
141
+ if value.tzinfo is None:
142
+ value = value.replace(tzinfo=timezone.utc)
143
+ return value.astimezone(timezone.utc).date()
144
+ if isinstance(value, date):
145
+ return value
146
+ raise TypeError("now must be a datetime, date or None")
147
+
148
+
149
+ def _to_decimal(value: Any, *, field: str, key: str) -> Decimal:
150
+ if isinstance(value, Decimal):
151
+ result = value
152
+ elif isinstance(value, bool):
153
+ raise InvalidPricingError(f"{key}: {field} must be a decimal string, got bool")
154
+ else:
155
+ try:
156
+ result = Decimal(str(value))
157
+ except (InvalidOperation, ValueError, TypeError) as exc:
158
+ raise InvalidPricingError(
159
+ f"{key}: {field} is not a valid decimal: {value!r}"
160
+ ) from exc
161
+ if not result.is_finite() or result < 0:
162
+ raise InvalidPricingError(f"{key}: {field} must be a finite non-negative number")
163
+ return result
164
+
165
+
166
+ def _to_positive_int(value: Any, *, field: str, key: str) -> int:
167
+ if isinstance(value, bool):
168
+ raise InvalidPricingError(f"{key}: {field} must be a positive integer")
169
+ try:
170
+ result = int(value)
171
+ except (TypeError, ValueError) as exc:
172
+ raise InvalidPricingError(
173
+ f"{key}: {field} must be a positive integer, got {value!r}"
174
+ ) from exc
175
+ if result <= 0:
176
+ raise InvalidPricingError(f"{key}: {field} must be a positive integer")
177
+ return result
178
+
179
+
180
+ def _parse_verified(raw: Any, *, key: str) -> tuple[bool, str]:
181
+ """Accept a bool legacy flag or a date string (``YYYY-MM-DD``)."""
182
+
183
+ if raw is None:
184
+ return False, ""
185
+ if isinstance(raw, bool):
186
+ return raw, ""
187
+ if isinstance(raw, str):
188
+ text = raw.strip()
189
+ if not text or text.lower() == "false":
190
+ return False, ""
191
+ if text.lower() == "true":
192
+ return True, ""
193
+ try:
194
+ date.fromisoformat(text)
195
+ except ValueError as exc:
196
+ raise InvalidPricingError(
197
+ f"{key}: verified must be a bool or a YYYY-MM-DD date, got {raw!r}"
198
+ ) from exc
199
+ return True, text
200
+ raise InvalidPricingError(
201
+ f"{key}: verified must be a bool or a YYYY-MM-DD date, got {type(raw).__name__}"
202
+ )
203
+
204
+
205
+ def _parse_long_context(raw: Any, *, key: str) -> LongContextPrice | None:
206
+ if raw is None:
207
+ return None
208
+ if not isinstance(raw, Mapping):
209
+ raise InvalidPricingError(f"{key}: long_context must be an object")
210
+ cached = raw.get("cached_input_per_mtok")
211
+ return LongContextPrice(
212
+ threshold_tokens=_to_positive_int(
213
+ raw.get("threshold_tokens"), field="long_context.threshold_tokens", key=key
214
+ ),
215
+ input_per_mtok=_to_decimal(
216
+ raw.get("input_per_mtok"), field="long_context.input_per_mtok", key=key
217
+ ),
218
+ output_per_mtok=_to_decimal(
219
+ raw.get("output_per_mtok"), field="long_context.output_per_mtok", key=key
220
+ ),
221
+ cached_input_per_mtok=(
222
+ None
223
+ if cached is None
224
+ else _to_decimal(
225
+ cached, field="long_context.cached_input_per_mtok", key=key
226
+ )
227
+ ),
228
+ )
229
+
230
+
231
+ def _parse_schedule(raw: Any, *, key: str) -> tuple[ScheduleEntry, ...]:
232
+ if raw is None:
233
+ return ()
234
+ if not isinstance(raw, list):
235
+ raise InvalidPricingError(f"{key}: schedule must be a list")
236
+ entries: list[ScheduleEntry] = []
237
+ for index, item in enumerate(raw):
238
+ field = f"schedule[{index}]"
239
+ if not isinstance(item, Mapping):
240
+ raise InvalidPricingError(f"{key}: {field} must be an object")
241
+ raw_date = item.get("effective_from")
242
+ if not isinstance(raw_date, str):
243
+ raise InvalidPricingError(
244
+ f"{key}: {field}.effective_from must be a YYYY-MM-DD string"
245
+ )
246
+ try:
247
+ effective = date.fromisoformat(raw_date)
248
+ except ValueError as exc:
249
+ raise InvalidPricingError(
250
+ f"{key}: {field}.effective_from is not a valid date: {raw_date!r}"
251
+ ) from exc
252
+ entries.append(
253
+ ScheduleEntry(
254
+ effective_from=effective,
255
+ input_per_mtok=_to_decimal(
256
+ item.get("input_per_mtok"),
257
+ field=f"{field}.input_per_mtok",
258
+ key=key,
259
+ ),
260
+ cached_input_per_mtok=_to_decimal(
261
+ item.get("cached_input_per_mtok"),
262
+ field=f"{field}.cached_input_per_mtok",
263
+ key=key,
264
+ ),
265
+ output_per_mtok=_to_decimal(
266
+ item.get("output_per_mtok"),
267
+ field=f"{field}.output_per_mtok",
268
+ key=key,
269
+ ),
270
+ )
271
+ )
272
+ return tuple(sorted(entries, key=lambda entry: entry.effective_from))
273
+
274
+
275
+ def _parse_model_price(key: str, raw: Any) -> ModelPrice:
276
+ if not isinstance(raw, Mapping):
277
+ raise InvalidPricingError(f"{key}: entry must be an object")
278
+ if "/" not in key or not key.split("/", 1)[1]:
279
+ raise InvalidPricingError(
280
+ f"{key!r}: model keys must use the '<provider>/<model>' form"
281
+ )
282
+ required = (
283
+ "input_per_mtok",
284
+ "cached_input_per_mtok",
285
+ "output_per_mtok",
286
+ "max_output_tokens",
287
+ )
288
+ missing = [field for field in required if field not in raw]
289
+ if missing:
290
+ raise InvalidPricingError(
291
+ f"{key}: missing required field(s): {', '.join(missing)}"
292
+ )
293
+ verified, verified_at = _parse_verified(raw.get("verified", False), key=key)
294
+ cache_write = raw.get("cache_write_per_mtok")
295
+ provider, model = key.split("/", 1)
296
+ return ModelPrice(
297
+ key=key,
298
+ provider=provider,
299
+ model=model,
300
+ input_per_mtok=_to_decimal(raw["input_per_mtok"], field="input_per_mtok", key=key),
301
+ cached_input_per_mtok=_to_decimal(
302
+ raw["cached_input_per_mtok"], field="cached_input_per_mtok", key=key
303
+ ),
304
+ output_per_mtok=_to_decimal(
305
+ raw["output_per_mtok"], field="output_per_mtok", key=key
306
+ ),
307
+ max_output_tokens=_to_positive_int(
308
+ raw["max_output_tokens"], field="max_output_tokens", key=key
309
+ ),
310
+ source=str(raw.get("source", "")),
311
+ verified=verified,
312
+ verified_at=verified_at,
313
+ long_context=_parse_long_context(raw.get("long_context"), key=key),
314
+ schedule=_parse_schedule(raw.get("schedule"), key=key),
315
+ cache_write_per_mtok=(
316
+ _to_decimal(cache_write, field="cache_write_per_mtok", key=key)
317
+ if cache_write is not None
318
+ else None
319
+ ),
320
+ )
321
+
322
+
323
+ def _parse_service_tiers(raw: Any) -> dict[str, dict[str, Decimal]]:
324
+ if raw is None:
325
+ return {}
326
+ if not isinstance(raw, Mapping):
327
+ raise InvalidPricingError("service_tiers must be an object")
328
+ result: dict[str, dict[str, Decimal]] = {}
329
+ for provider, tiers in raw.items():
330
+ if not isinstance(tiers, Mapping):
331
+ raise InvalidPricingError(
332
+ f"service_tiers.{provider}: must be an object of tier -> multiplier"
333
+ )
334
+ parsed: dict[str, Decimal] = {}
335
+ for tier, multiplier in tiers.items():
336
+ if isinstance(multiplier, bool) or not isinstance(multiplier, (str, int, Decimal)):
337
+ raise InvalidPricingError(
338
+ f"service_tiers.{provider}.{tier}: multiplier must be a decimal"
339
+ )
340
+ parsed[str(tier)] = _to_decimal(
341
+ multiplier, field=f"service_tiers.{provider}.{tier}", key=provider
342
+ )
343
+ result[str(provider)] = parsed
344
+ return result
345
+
346
+
347
+ def _merge_documents(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
348
+ merged: dict[str, Any] = copy.deepcopy(dict(base))
349
+ for field in (
350
+ "version",
351
+ "updated",
352
+ "verified",
353
+ "note",
354
+ "sources",
355
+ "service_tiers",
356
+ ):
357
+ if field in override:
358
+ merged[field] = override[field]
359
+
360
+ # Merge service-tier maps provider by provider so an override can add or
361
+ # change one tier without dropping the rest.
362
+ if "service_tiers" in base and "service_tiers" in override:
363
+ merged_tiers: dict[str, Any] = copy.deepcopy(dict(base["service_tiers"]))
364
+ for provider, tiers in (override["service_tiers"] or {}).items():
365
+ if isinstance(merged_tiers.get(provider), Mapping) and isinstance(
366
+ tiers, Mapping
367
+ ):
368
+ merged_tiers[provider] = {**merged_tiers[provider], **tiers}
369
+ else:
370
+ merged_tiers[provider] = copy.deepcopy(tiers)
371
+ merged["service_tiers"] = merged_tiers
372
+
373
+ models: dict[str, Any] = dict(merged.get("models") or {})
374
+ for key, raw in (override.get("models") or {}).items():
375
+ if not isinstance(raw, Mapping):
376
+ raise InvalidPricingError(f"{key}: override entry must be an object")
377
+ if key in models and isinstance(models[key], Mapping):
378
+ entry = {**models[key], **raw}
379
+ base_long = models[key].get("long_context")
380
+ if (
381
+ "long_context" in raw
382
+ and isinstance(raw["long_context"], Mapping)
383
+ and isinstance(base_long, Mapping)
384
+ ):
385
+ entry["long_context"] = {**base_long, **raw["long_context"]}
386
+ models[key] = entry
387
+ else:
388
+ models[key] = dict(raw)
389
+ merged["models"] = models
390
+ return merged
391
+
392
+
393
+ def _read_json(path: Path) -> Any:
394
+ try:
395
+ with path.open("r", encoding="utf-8") as handle:
396
+ return json.load(handle)
397
+ except FileNotFoundError:
398
+ raise
399
+ except json.JSONDecodeError as exc:
400
+ raise InvalidPricingError(f"{path}: invalid JSON: {exc}") from exc
401
+ except OSError as exc:
402
+ raise InvalidPricingError(f"{path}: cannot read pricing file: {exc}") from exc
403
+
404
+
405
+ def _is_prefix_boundary(rest: str) -> bool:
406
+ """True when ``rest`` is a legal suffix after a model family prefix.
407
+
408
+ Allowed: nothing at all (exact family), ``@...`` / ``:...`` (provider
409
+ qualifiers) and ``-YYYY...`` date snapshots. A plain ``-mini`` or a bare
410
+ digit is NOT a boundary, so ``gpt-5`` never swallows ``gpt-5.4-mini`` or
411
+ ``gpt-50``.
412
+ """
413
+
414
+ if rest == "":
415
+ return True
416
+ if rest[0] in "@:":
417
+ return True
418
+ return rest[0] == "-" and len(rest) > 1 and rest[1].isdigit()
419
+
420
+
421
+ class PricingStore:
422
+ """Immutable price table with exact + longest-prefix lookup."""
423
+
424
+ def __init__(
425
+ self,
426
+ models: Mapping[str, ModelPrice],
427
+ *,
428
+ version: str = "",
429
+ updated: str = "",
430
+ verified: bool = False,
431
+ note: str = "",
432
+ sources: Mapping[str, str] | None = None,
433
+ service_tiers: Mapping[str, Mapping[str, Decimal]] | None = None,
434
+ ) -> None:
435
+ self._models: dict[str, ModelPrice] = dict(models)
436
+ self.version = version
437
+ self.updated = updated
438
+ self.verified = verified
439
+ self.note = note
440
+ self.sources: dict[str, str] = dict(sources or {})
441
+ self.service_tiers: dict[str, dict[str, Decimal]] = {
442
+ provider: dict(tiers)
443
+ for provider, tiers in (service_tiers or {}).items()
444
+ }
445
+ self._prefix_order = sorted(self._models, key=len, reverse=True)
446
+
447
+ @classmethod
448
+ def from_document(cls, document: Mapping[str, Any]) -> "PricingStore":
449
+ if not isinstance(document, Mapping):
450
+ raise InvalidPricingError("pricing document must be a JSON object")
451
+ raw_models = document.get("models")
452
+ if not isinstance(raw_models, Mapping) or not raw_models:
453
+ raise InvalidPricingError("pricing document must contain a non-empty 'models' object")
454
+ models = {
455
+ str(key): _parse_model_price(str(key), raw)
456
+ for key, raw in raw_models.items()
457
+ }
458
+ sources = document.get("sources") or {}
459
+ if not isinstance(sources, Mapping):
460
+ raise InvalidPricingError("'sources' must be an object")
461
+ return cls(
462
+ models,
463
+ version=str(document.get("version", "")),
464
+ updated=str(document.get("updated", "")),
465
+ verified=bool(document.get("verified", False)),
466
+ note=str(document.get("note", "")),
467
+ sources={str(key): str(value) for key, value in sources.items()},
468
+ service_tiers=_parse_service_tiers(document.get("service_tiers")),
469
+ )
470
+
471
+ def __len__(self) -> int:
472
+ return len(self._models)
473
+
474
+ def keys(self) -> list[str]:
475
+ return list(self._models)
476
+
477
+ def service_tier_multiplier(self, provider: str, tier: str) -> Decimal:
478
+ tiers = self.service_tiers.get(provider)
479
+ if not tiers or tier not in tiers:
480
+ raise UnknownServiceTierError(provider, tier)
481
+ return tiers[tier]
482
+
483
+ def get(self, model: str) -> ModelPrice:
484
+ if not isinstance(model, str) or not model.strip():
485
+ raise UnknownModelError(str(model))
486
+ query = model.strip()
487
+ variants = [query]
488
+ if query.startswith("models/"):
489
+ variants.append(query[len("models/"):])
490
+ if "/models/" in query:
491
+ variants.append(query.replace("/models/", "/", 1))
492
+
493
+ for candidate in variants:
494
+ exact = self._models.get(candidate)
495
+ if exact is not None:
496
+ return exact
497
+
498
+ # Longest matching prefix wins. A prefix is only accepted when the
499
+ # remaining suffix is a legal boundary (see ``_is_prefix_boundary``).
500
+ best: ModelPrice | None = None
501
+ best_len = -1
502
+ for key in self._prefix_order:
503
+ price = self._models[key]
504
+ for candidate in variants:
505
+ for prefix in (key, price.model):
506
+ if not candidate.startswith(prefix):
507
+ continue
508
+ if not _is_prefix_boundary(candidate[len(prefix):]):
509
+ continue
510
+ if len(prefix) > best_len:
511
+ best = price
512
+ best_len = len(prefix)
513
+
514
+ if best is not None:
515
+ return best
516
+
517
+ raise UnknownModelError(model)
518
+
519
+ # Friendly aliases for the lookup operation.
520
+ def lookup(self, model: str) -> ModelPrice:
521
+ return self.get(model)
522
+
523
+ def price_for(self, model: str) -> ModelPrice:
524
+ return self.get(model)
525
+
526
+ @classmethod
527
+ def load(cls, path: str | os.PathLike[str] | None = None) -> "PricingStore":
528
+ return load_pricing(path)
529
+
530
+
531
+ def default_override_path() -> Path:
532
+ return Path.home() / ".config" / "agentcap" / "pricing.json"
533
+
534
+
535
+ def load_pricing(path: str | os.PathLike[str] | None = None) -> PricingStore:
536
+ """Load bundled prices, merged with an optional user override file.
537
+
538
+ Resolution order: explicit ``path`` argument, ``AGENTCAP_PRICING_FILE``,
539
+ then ``~/.config/agentcap/pricing.json``. A missing override file is not an
540
+ error; a malformed one is.
541
+ """
542
+
543
+ base_document = _read_json(BUNDLED_PRICING_PATH)
544
+
545
+ if path is not None:
546
+ override_path = Path(path)
547
+ else:
548
+ env_path = os.environ.get("AGENTCAP_PRICING_FILE")
549
+ override_path = Path(env_path) if env_path else default_override_path()
550
+
551
+ if override_path.exists():
552
+ override_document = _read_json(override_path)
553
+ if not isinstance(override_document, Mapping):
554
+ raise InvalidPricingError(f"{override_path}: override must be a JSON object")
555
+ base_document = _merge_documents(base_document, override_document)
556
+
557
+ return PricingStore.from_document(base_document)
558
+
559
+
560
+ def effective_rates(
561
+ model_price: ModelPrice,
562
+ input_tokens: int,
563
+ now: datetime | date | None = None,
564
+ ) -> tuple[Decimal, Decimal, Decimal]:
565
+ """Return (input, cached_input, output) rates for the applicable tier."""
566
+
567
+ input_rate, cached_rate, output_rate = model_price.rates_at(now)
568
+ long_context = model_price.long_context
569
+ if long_context is not None and input_tokens > long_context.threshold_tokens:
570
+ return (
571
+ long_context.input_per_mtok,
572
+ (
573
+ long_context.cached_input_per_mtok
574
+ if long_context.cached_input_per_mtok is not None
575
+ else cached_rate
576
+ ),
577
+ long_context.output_per_mtok,
578
+ )
579
+ return input_rate, cached_rate, output_rate
580
+
581
+
582
+ def cost(
583
+ model_price: ModelPrice,
584
+ input_tokens: int,
585
+ cached_input_tokens: int = 0,
586
+ output_tokens: int = 0,
587
+ now: datetime | date | None = None,
588
+ cache_write: bool = False,
589
+ ) -> Decimal:
590
+ """Cost in USD for a completed request.
591
+
592
+ ``input_tokens`` includes cached tokens; ``cached_input_tokens`` is billed
593
+ at the discounted cached rate. When the prompt crosses a long-context
594
+ threshold the whole request is billed at that tier's rates. When
595
+ ``cache_write`` is true, non-cached input is billed at
596
+ ``max(input, cache_write)``.
597
+ """
598
+
599
+ for name, value in (
600
+ ("input_tokens", input_tokens),
601
+ ("cached_input_tokens", cached_input_tokens),
602
+ ("output_tokens", output_tokens),
603
+ ):
604
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
605
+ raise ValueError(f"{name} must be a non-negative integer")
606
+
607
+ cached = min(cached_input_tokens, input_tokens)
608
+ uncached = input_tokens - cached
609
+ input_rate, cached_rate, output_rate = effective_rates(
610
+ model_price, input_tokens, now=now
611
+ )
612
+ if cache_write and model_price.cache_write_per_mtok is not None:
613
+ input_rate = max(input_rate, model_price.cache_write_per_mtok)
614
+
615
+ total = (
616
+ Decimal(uncached) * input_rate
617
+ + Decimal(cached) * cached_rate
618
+ + Decimal(output_tokens) * output_rate
619
+ ) / ONE_MILLION
620
+ return total