Coverage for packages/python/voice_prices/types.py: 96.00%
561 statements
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
1from __future__ import annotations as _annotations
3import dataclasses
4import re
5import warnings
6from collections.abc import Mapping, Sequence
7from dataclasses import dataclass
8from datetime import date, datetime, time, timezone
9from decimal import Decimal
10from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeGuard, TypeVar, cast, overload
12import pydantic
13from typing_extensions import TypedDict
15if TYPE_CHECKING:
16 from .confidence import Freshness
18__all__ = (
19 'ProviderID',
20 'PriceBreakdown',
21 'PriceCalculation',
22 'AbstractUsage',
23 'Usage',
24 'Provider',
25 'UsageExtractorMapping',
26 'UsageExtractor',
27 'ModelInfo',
28 'Provenance',
29 'AgentVotes',
30 'SourceRate',
31 'ModelPrice',
32 'TieredPrices',
33 'Tier',
34 'ConditionalPrice',
35 'StartDateConstraint',
36 'StartTimestampConstraint',
37 'TimeOfDateConstraint',
38 'ClauseStartsWith',
39 'ClauseEndsWith',
40 'ClauseContains',
41 'ClauseRegex',
42 'ClauseEquals',
43 'ClauseOr',
44 'ClauseAnd',
45 'MatchLogic',
46 'ArrayMatch',
47 'providers_schema',
48)
51# Define MatchLogic after __all__ to avoid forward reference issues
52def clause_discriminator(v: Any) -> str | None:
53 assert isinstance(v, dict), f'Expected dict, got {type(v)}'
54 return next(iter(v)) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType]
57MatchLogic = Annotated[
58 Annotated['ClauseStartsWith', pydantic.Tag('starts_with')]
59 | Annotated['ClauseEndsWith', pydantic.Tag('ends_with')]
60 | Annotated['ClauseContains', pydantic.Tag('contains')]
61 | Annotated['ClauseRegex', pydantic.Tag('regex')]
62 | Annotated['ClauseEquals', pydantic.Tag('equals')]
63 | Annotated['ClauseOr', pydantic.Tag('or')]
64 | Annotated['ClauseAnd', pydantic.Tag('and')],
65 pydantic.Discriminator(clause_discriminator),
66]
68ProviderID = Literal[
69 'avian',
70 'groq',
71 'openai',
72 'novita',
73 'fireworks',
74 'deepseek',
75 'mistral',
76 'x-ai',
77 'google',
78 'perplexity',
79 'aws',
80 'together',
81 'anthropic',
82 'azure',
83 'cohere',
84 'openrouter',
85]
88@dataclass
89class ArrayMatch:
90 type: Literal['array-match']
91 field: str
92 match: MatchLogic
94 def extract(self, items: Sequence[Any]) -> Mapping[str, Any] | None:
95 for item in items:
96 if _is_mapping(item) and (item_field := item.get(self.field)): 96 ↛ 95line 96 didn't jump to line 95 because the condition on line 96 was always true
97 if self.match.is_match(item_field):
98 return item
101ExtractPath = str | Sequence[str | ArrayMatch]
104@dataclass
105class PriceBreakdown:
106 """Per-component USD contributions that sum to total_price.
108 Maintenance contract:
109 Every Decimal-typed priced field on ModelPrice MUST have a matching
110 Decimal field on PriceBreakdown. When ModelPrice gains a new priced
111 field (e.g. upstream voice-prices adds `input_image_mtok`), add the
112 corresponding `input_image_tokens: Decimal = Decimal(0)` here in the
113 same PR. The test `test_breakdown_covers_priced_fields` walks
114 ModelPrice fields at runtime and fails if any priced field is missing
115 its PriceBreakdown counterpart, so drift cannot survive CI.
116 """
118 input_tokens: Decimal = Decimal(0)
119 output_tokens: Decimal = Decimal(0)
120 cache_read_tokens: Decimal = Decimal(0)
121 cache_write_tokens: Decimal = Decimal(0)
122 input_audio_tokens: Decimal = Decimal(0)
123 output_audio_tokens: Decimal = Decimal(0)
124 cache_audio_read_tokens: Decimal = Decimal(0)
126 input_kchars: Decimal = Decimal(0)
127 output_audio_kseconds: Decimal = Decimal(0)
128 input_audio_kseconds: Decimal = Decimal(0)
130 voice_class_input_adjustment: Decimal = Decimal(0)
131 """USD adjustment caused by voice_multiplier on input_kchars.
133 Equals 0 when no multiplier fired or multiplier == 1.0; positive when
134 multiplier > 1.0; negative when multiplier < 1.0. Sums with input_kchars
135 to give the post-multiplier input character cost.
136 """
138 voice_class_output_adjustment: Decimal = Decimal(0)
139 """USD adjustment caused by voice_multiplier on output_audio_kseconds.
141 Always Decimal(0) in v0.1 (no catalog entries set output_audio_kseconds);
142 activates with v0.2 catalog expansion for PlayHT/Murf-style models.
143 """
145 agent_kminutes: Decimal = Decimal(0)
146 """USD for bundled voice-agent session time.
148 Neither input nor output: a bundled agent minute prices orchestration plus
149 whatever STT/LLM/TTS the platform chose, in proportions it does not disclose.
150 Attributing it to a direction would invent a split the vendor never published,
151 so it sums into total_price alongside `requests`.
152 """
154 telephony_kminutes: Decimal = Decimal(0)
155 """USD for connected call minutes on the phone network.
157 Charged by the carrier for the call leg itself, on top of whatever the agent spends on
158 speech and the model. Sums into total_price alongside `requests` and `agent_kminutes`:
159 a carrier minute is not attributable to input or output.
160 """
162 requests: Decimal = Decimal(0)
164 def sum(self) -> Decimal:
165 """Sum of every contributing component. Equals total_price by invariant."""
166 return (
167 self.input_tokens
168 + self.output_tokens
169 + self.cache_read_tokens
170 + self.cache_write_tokens
171 + self.input_audio_tokens
172 + self.output_audio_tokens
173 + self.cache_audio_read_tokens
174 + self.input_kchars
175 + self.output_audio_kseconds
176 + self.input_audio_kseconds
177 + self.voice_class_input_adjustment
178 + self.voice_class_output_adjustment
179 + self.agent_kminutes
180 + self.telephony_kminutes
181 + self.requests
182 )
185@dataclass(repr=False)
186class PriceCalculation:
187 input_price: Decimal
188 output_price: Decimal
189 total_price: Decimal
190 breakdown: PriceBreakdown
191 applied_voice_multiplier: Decimal | None
192 """The raw multiplier value looked up for this call (e.g. Decimal('1.5')).
194 None when the resolved ModelPrice has no voice_multipliers OR when the
195 caller did not pass voice_class and there is no default to apply.
196 """
197 model: ModelInfo = dataclasses.field(repr=False)
198 provider: Provider = dataclasses.field(repr=False)
199 model_price: ModelPrice
200 auto_update_timestamp: datetime | None
201 unpriced_usage: tuple[str, ...] = ()
202 """`Usage` field names the caller supplied that no rate on this model could price.
204 Empty for the overwhelming majority of calls. When it is not empty, `total_price` is an
205 undercount, and a zero `total_price` means "this model has no meter for what you
206 measured", not "this call was free".
208 That distinction has no other signal. A model can resolve, carry prices, and still
209 return `Decimal('0')` when the caller measures in a unit the vendor does not bill in:
210 OpenAI prices its audio models per token, a voice runtime measures seconds and
211 characters, and nothing bridges the two. The result is a successful calculation with
212 every breakdown field at zero, indistinguishable downstream from a genuinely zero-cost
213 call. Read this field before trusting a zero.
215 Names are `Usage` field names (`audio_input_seconds`), not `ModelPrice` rate names
216 (`input_audio_kseconds`), because the caller supplied the former. Order is stable.
217 """
219 def __repr__(self) -> str:
220 return (
221 'PriceCalculation('
222 f'input_price={self.input_price!r}, '
223 f'output_price={self.output_price!r}, '
224 f'total_price={self.total_price!r}, '
225 f'breakdown={self.breakdown!r}, '
226 f'applied_voice_multiplier={self.applied_voice_multiplier!r}, '
227 f'model={self.model.summary()}, '
228 f'provider={self.provider.summary()}, '
229 f'model_price=ModelPrice({self.model_price}), '
230 f'auto_update_timestamp={self.auto_update_timestamp!r}, '
231 f'unpriced_usage={self.unpriced_usage!r})'
232 )
234 def freshness(self, *, today: date | None = None) -> Freshness:
235 """How trustworthy this rate is: verification_status, a coarse confidence label, and staleness.
237 Derived at call time from the model's provenance and the provider's staleness threshold.
238 See ``voice_prices.confidence`` for the rules.
239 """
240 from .confidence import model_freshness
242 return model_freshness(self.model, self.provider, today=today)
245@dataclass(repr=False)
246class ExtractedUsage:
247 usage: Usage
248 model: ModelInfo | None = dataclasses.field(repr=False)
249 provider: Provider = dataclasses.field(repr=False)
250 auto_update_timestamp: datetime | None
252 def calc_price(
253 self, *, genai_request_timestamp: datetime | None = None, model: ModelInfo | None = None
254 ) -> PriceCalculation:
255 """Calculate the price for the given usage.
257 Args:
258 genai_request_timestamp: The timestamp of the request to the GenAI service, use `None` to use the current
259 time.
260 model: The model to calculate the price for, if `None` the model from the response data is used.
261 """
262 model = model or self.model
263 if model is None:
264 raise ValueError('No model reference found in response data and model not provided')
266 return model.calc_price(
267 self.usage,
268 self.provider,
269 genai_request_timestamp=genai_request_timestamp,
270 auto_update_timestamp=self.auto_update_timestamp,
271 )
273 def __repr__(self) -> str:
274 return (
275 'ExtractedUsage('
276 f'usage={self.usage!r}, '
277 f'model={self.model.summary() if self.model else None}, '
278 f'provider={self.provider.summary()}, '
279 f'auto_update_timestamp={self.auto_update_timestamp!r})'
280 )
282 def __add__(self, other: ExtractedUsage | Any) -> ExtractedUsage:
283 """Accumulate inner Usage, handling nullable usage fields.
285 Accumulating usage is useful for common streaming situations where user wants to save and compute costs for
286 all the response chunks in a stream
288 Args:
289 other: The usage to accumulate with this usage extraction instance.
290 """
292 if not isinstance(other, ExtractedUsage):
293 return NotImplemented # will raise a TypeError
295 models_match = self.model and other.model and other.model.id == self.model.id
296 if not models_match:
297 raise ValueError(f'Cannot add {other} to {self}, models do not match {other.model} != {self.model}')
299 providers_match = self.provider and other.provider and other.provider.id == self.provider.id
300 if not providers_match: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 raise ValueError(
302 f'Cannot add {other} to {self}, providers do not match {other.provider} != {self.provider}'
303 )
305 return ExtractedUsage(
306 model=self.model,
307 provider=self.provider,
308 auto_update_timestamp=self.auto_update_timestamp,
309 usage=self.usage + other.usage,
310 )
312 def __radd__(self, other: ExtractedUsage | Any) -> ExtractedUsage:
313 return self + other
316class AbstractUsage(Protocol):
317 """Abstract definition of data about token usage for a single LLM or voice call."""
319 @property
320 def input_tokens(self) -> int | None:
321 """Total number of input/prompt tokens.
323 Note this should INCLUDE both uncached and cached tokens.
324 """
326 @property
327 def cache_write_tokens(self) -> int | None:
328 """Number of tokens written to the cache."""
330 @property
331 def cache_read_tokens(self) -> int | None:
332 """Number of tokens read from the cache.
334 For many models this is described as just "cached tokens".
335 """
337 @property
338 def output_tokens(self) -> int | None:
339 """Number of output/completion tokens."""
341 @property
342 def input_audio_tokens(self) -> int | None:
343 """Number of audio input tokens."""
345 @property
346 def cache_audio_read_tokens(self) -> int | None:
347 """Number of audio tokens read from the cache."""
349 @property
350 def output_audio_tokens(self) -> int | None:
351 """Number of output audio tokens."""
353 @property
354 def characters(self) -> int | None:
355 """Number of input characters submitted to a TTS model."""
357 @property
358 def audio_output_seconds(self) -> Decimal | None:
359 """Number of seconds of generated audio (TTS audio output).
361 Migrated from int to Decimal in v0.0.7 for sub-second precision and
362 symmetry with audio_input_seconds. Backwards-compatible at runtime:
363 callers passing int continue to work via Decimal(int) coercion in the
364 engine.
365 """
367 @property
368 def audio_input_seconds(self) -> Decimal | None:
369 """Duration (in seconds) of audio submitted to the model (STT input).
371 ModelPrice puts direction before modality (input_audio_kseconds); Usage
372 puts modality before direction (audio_input_seconds). Intentional;
373 mirrors the existing output_audio_kseconds / audio_output_seconds pair.
375 Decimal-typed so callers can express sub-second precision; Deepgram and
376 AssemblyAI bill in 0.01s increments. Pass via Decimal('12.34') or
377 convert from float at the call site.
378 """
380 @property
381 def voice_class(self) -> str | None:
382 """Voice class identifier used for this call.
384 Matches a key in ModelPrice.voice_multipliers for the resolved
385 provider+model. Strings are scoped to the provider+model; the same
386 string may mean different things across providers. Callers construct
387 the string from the voice they used and the model they called against,
388 usually via a per-model lookup in their own config.
389 """
392@dataclass
393class Usage:
394 """Simple implementation of `AbstractUsage` as a dataclass."""
396 input_tokens: int | None = None
397 """Number of input/prompt tokens."""
399 cache_write_tokens: int | None = None
400 """Number of tokens written to the cache."""
401 cache_read_tokens: int | None = None
402 """Number of tokens read from the cache."""
404 output_tokens: int | None = None
405 """Number of output/completion tokens."""
407 input_audio_tokens: int | None = None
408 """Number of audio input tokens."""
409 cache_audio_read_tokens: int | None = None
410 """Number of audio tokens read from the cache."""
411 output_audio_tokens: int | None = None
412 """Number of output audio tokens."""
414 characters: int | None = None
415 """Number of input characters submitted to a TTS model."""
417 audio_output_seconds: Decimal | None = None
418 """Duration of generated audio (TTS audio output, schema-only until
419 PlayHT/Murf land in catalog). Decimal-typed for sub-second precision.
421 Backwards compatibility: the runtime engine wraps the value with
422 Decimal(...) before computing cost, so v0.x consumers passing an int
423 continue to work at runtime. Type checkers will flag the int under strict
424 mode; pass Decimal(60) or Decimal('60') to silence them.
425 """
427 audio_input_seconds: Decimal | None = None
428 """Duration (in seconds) of audio submitted to the model (STT input).
430 ModelPrice puts direction before modality (input_audio_kseconds); Usage
431 puts modality before direction (audio_input_seconds). Intentional;
432 mirrors the existing output_audio_kseconds / audio_output_seconds pair.
434 Decimal-typed so callers can express sub-second precision; Deepgram and
435 AssemblyAI bill in 0.01s increments. Pass via Decimal('12.34') or convert
436 from float at the call site.
437 """
439 agent_minutes: Decimal | None = None
440 """Duration of a bundled voice-agent session, in minutes.
442 For platforms that bill one blended per-minute rate covering STT, LLM, TTS and
443 orchestration together (Vapi, Retell, Bland, ElevenLabs Agents and similar).
444 Decimal-typed because calls are not whole minutes; most platforms bill per second
445 and round at the end.
446 """
448 telephony_minutes: Decimal | None = None
449 """Connected call minutes carried over the phone network.
451 The carrier leg, billed whether or not the agent speaks. Independent of
452 `agent_minutes`: a call on a bundled platform accrues both, one for the platform and
453 one for the carrier. Decimal-typed because carriers bill per second and round at the end.
454 """
456 voice_class: str | None = None
457 """Voice class identifier used for this call (matches a key in
458 `ModelPrice.voice_multipliers` for the resolved provider+model).
459 """
461 def __add__(self, other: Usage | Any) -> Usage:
462 if not isinstance(other, Usage): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 return NotImplemented
465 def _add_int(a: int | None, b: int | None) -> int | None:
466 return None if a is b is None else (a or 0) + (b or 0)
468 def _add_decimal(a: Decimal | None, b: Decimal | None) -> Decimal | None:
469 if a is None and b is None:
470 return None
471 a_val = Decimal(0) if a is None else a
472 b_val = Decimal(0) if b is None else b
473 return a_val + b_val
475 def _coalesce_str(a: str | None, b: str | None) -> str | None:
476 if a is None: 476 ↛ 478line 476 didn't jump to line 478 because the condition on line 476 was always true
477 return b
478 if b is None:
479 return a
480 if a != b:
481 raise ValueError(f'Cannot add Usages with conflicting voice_class values: {a!r} != {b!r}')
482 return a
484 return Usage(
485 input_tokens=_add_int(self.input_tokens, other.input_tokens),
486 cache_write_tokens=_add_int(self.cache_write_tokens, other.cache_write_tokens),
487 cache_read_tokens=_add_int(self.cache_read_tokens, other.cache_read_tokens),
488 output_tokens=_add_int(self.output_tokens, other.output_tokens),
489 input_audio_tokens=_add_int(self.input_audio_tokens, other.input_audio_tokens),
490 cache_audio_read_tokens=_add_int(self.cache_audio_read_tokens, other.cache_audio_read_tokens),
491 output_audio_tokens=_add_int(self.output_audio_tokens, other.output_audio_tokens),
492 characters=_add_int(self.characters, other.characters),
493 audio_output_seconds=_add_decimal(self.audio_output_seconds, other.audio_output_seconds),
494 audio_input_seconds=_add_decimal(self.audio_input_seconds, other.audio_input_seconds),
495 agent_minutes=_add_decimal(self.agent_minutes, other.agent_minutes),
496 telephony_minutes=_add_decimal(self.telephony_minutes, other.telephony_minutes),
497 voice_class=_coalesce_str(self.voice_class, other.voice_class),
498 )
500 def __radd__(self, other: Usage) -> Usage:
501 return self + other
504@dataclass
505class Provider:
506 """Information about an LLM inference provider"""
508 id: str
509 """Unique identifier for the provider"""
510 name: str
511 """Link to pricing page for the provider"""
512 api_pattern: str
513 """Common name of the organization"""
514 pricing_urls: list[str] | None = None
515 """Pattern to identify provider via HTTP API URL."""
516 description: str | None = None
517 """Description of the provider"""
518 price_comments: str | None = None
519 """Comments about the pricing of this provider's models, especially challenges in representing the provider's pricing model."""
520 model_match: MatchLogic | None = None
521 """Logic to find a provider based on the model reference."""
522 provider_match: MatchLogic | None = None
523 """Logic to find a provider based on the provider identifier."""
524 extractors: list[UsageExtractor] | None = None
525 """Logic to extract usage information from the provider's API responses."""
526 fallback_model_providers: list[str] | None = None
527 """List of provider identifiers to fallback to to get prices if this provider doesn't have a price.
529 This is used when one provider offers another provider's models, e.g. Google and AWS offer Anthropic models,
530 Azure offers OpenAI models, etc.
531 """
532 staleness_threshold_days: int = 60
533 """Recommended maximum age (in days) for `prices_checked` on this provider's models before consumers
534 should re-verify against `pricing_source_url`. Metadata only; voice-prices itself does not warn or
535 fail on stale entries.
536 """
537 pricing_tier: str | None = None
538 """The vendor plan every rate from this provider is taken from, named as the vendor names it.
540 Most vendors publish several prices for the same model: pay-as-you-go, one or more committed
541 plans, and an enterprise rate. This catalog takes the cheapest tier a new account can reach
542 with no spend commitment, and names it here so a consumer can tell whether the rate matches
543 the plan they are actually on.
545 Verbatim from the vendor (`Pay As You Go`, `On-Demand`, `Standard`), not a normalised enum,
546 so a reader can find the same words on the pricing page. The literal `Single published rate`
547 means the vendor publishes one price with no plan to choose from; `None` means no one has
548 recorded it yet, which is not the same claim.
549 """
550 models: list[ModelInfo] = dataclasses.field(default_factory=list)
551 """List of models supported by this provider"""
553 def find_model(self, model_ref: str, *, all_providers: list[Provider] | None = None) -> ModelInfo | None:
554 model_ref = model_ref.lower()
555 for model in self.models:
556 if model.is_match(model_ref):
557 return model
558 if self.fallback_model_providers and all_providers:
559 for provider_id in self.fallback_model_providers:
560 provider = next((p for p in all_providers if p.id == provider_id), None)
561 if provider:
562 # don't pass all_providers when falling back, so we can only have one step of fallback
563 if model := provider.find_model(model_ref):
564 return model
565 return None
567 def extract_usage(self, response_data: Any, *, api_flavor: str = 'default') -> tuple[str | None, Usage]:
568 """Extract model name and usage information from a response.
570 Args:
571 response_data: The response data from the provider's API.
572 api_flavor: The flavor of API used for this request.
574 Raises:
575 ValueError: If the response data is invalid or the API flavor is not found.
577 Returns:
578 tuple[str, Usage]: The extracted model name and usage information.
579 """
580 if self.extractors is None:
581 raise ValueError('No extraction logic defined for this provider')
583 try:
584 extractor = next(e for e in self.extractors if e.api_flavor == api_flavor)
585 except StopIteration as e:
586 fs = ', '.join(e.api_flavor for e in self.extractors)
587 raise ValueError(f'Unknown api_flavor {api_flavor!r}, allowed values: {fs}') from e
589 return extractor.extract(response_data)
591 def summary(self) -> str:
592 return f'Provider(id={self.id!r}, name={self.name!r}, ...)'
595UsageField = Literal[
596 'input_tokens',
597 'cache_write_tokens',
598 'cache_read_tokens',
599 'output_tokens',
600 'input_audio_tokens',
601 'cache_audio_read_tokens',
602 'output_audio_tokens',
603 'characters',
604 'audio_output_seconds',
605 'audio_input_seconds',
606 'agent_minutes',
607 'telephony_minutes',
608]
611@dataclass
612class UsageExtractorMapping:
613 """Mappings from used to build usage."""
615 path: ExtractPath
616 """Path to the value to extract"""
617 dest: UsageField
618 """Destination field to store the extracted value.
620 If multiple mappings point to the same destination, the values are summed.
621 """
622 required: bool = True
623 """Whether the value is required to be present in the response"""
626@dataclass
627class UsageExtractor:
628 """Logic for extracting usage information from a response."""
630 root: ExtractPath
631 """Path to the root of the usage information in the response, generally `usage`."""
632 mappings: list[UsageExtractorMapping]
633 """Mappings from used to build usage."""
634 api_flavor: str = 'default'
635 """Name of the API flavor, only needed when a provider has multiple flavors, e.g. OpenAI has `chat` and `responses`."""
636 model_path: ExtractPath = 'model'
637 """Path to the model name in the response."""
639 def extract(self, response_data: Any) -> tuple[str | None, Usage]:
640 """Extract model name and usage information from a response.
642 Args:
643 response_data: The response data to extract usage information from, generally the decoded JSON response.
645 Raises:
646 ValueError: If no usage information is found at the root.
648 Returns:
649 tuple[str, Usage]: The extracted model name and usage information.
650 """
651 model_name = _extract_path(self.model_path, response_data, str, False, [])
653 root = self.root
654 if isinstance(root, str): 654 ↛ 657line 654 didn't jump to line 657 because the condition on line 654 was always true
655 root = [root]
657 usage_obj = cast(dict[str, Any], _extract_path(root, response_data, Mapping, True, []))
659 usage = Usage()
660 values_set = False
661 for mapping in self.mappings:
662 value = _extract_path(mapping.path, usage_obj, int, mapping.required, root)
663 if value is not None:
664 current_value = getattr(usage, mapping.dest) or 0
665 setattr(usage, mapping.dest, current_value + value)
666 values_set = True
667 if not values_set:
668 raise ValueError(f'No usage information found at {self.root}')
669 return model_name, usage
672E = TypeVar('E')
675@overload
676def _extract_path(
677 path: ExtractPath, data: Any, extract_type: type[E], required: Literal[True], data_path: Sequence[str | ArrayMatch]
678) -> E: ...
681@overload
682def _extract_path(
683 path: ExtractPath,
684 data: Any,
685 extract_type: type[E],
686 required: Literal[False],
687 data_path: Sequence[str | ArrayMatch],
688) -> E | None: ...
691def _extract_path(
692 path: ExtractPath, data: Any, extract_type: type[E], required: bool, data_path: Sequence[str | ArrayMatch]
693) -> E | None:
694 if isinstance(path, str):
695 path = [path]
697 *steps, last = path
698 last = cast(str, last)
700 error_path: list[str | ArrayMatch] = []
701 for step in steps:
702 error_path.append(step)
703 if isinstance(step, ArrayMatch):
704 if not _is_sequence(data):
705 if required: 705 ↛ 706line 705 didn't jump to line 706 because the condition on line 705 was never true
706 raise ValueError(
707 f'Expected `{_dot_path(data_path, error_path)}` value to be a sequence, got {_type_name(data)}'
708 )
709 else:
710 return None
711 if extracted_data := step.extract(data):
712 data = extracted_data
713 elif required: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true
714 raise ValueError(f'Unable to find item at `{_dot_path(data_path, error_path)}`')
715 else:
716 return None
717 else:
718 if not _expect_mapping(data, required, data_path, error_path): 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true
719 return None
720 try:
721 data = data[step]
722 except KeyError as e:
723 if required: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true
724 raise ValueError(f'Missing value at `{_dot_path(data_path, error_path)}`') from e
725 else:
726 return None
728 if data is None and not required:
729 return None
731 if not _expect_mapping(data, required, data_path, error_path):
732 return None
734 try:
735 value = data[last]
736 except KeyError as e:
737 if required:
738 error_path.append(last)
739 raise ValueError(f'Missing value at `{_dot_path(data_path, error_path)}`') from e
740 else:
741 return None
742 else:
743 if isinstance(value, extract_type):
744 return value
745 elif required:
746 error_path.append(last)
747 raise ValueError(
748 f'Expected `{_dot_path(data_path, error_path)}` value to be a {extract_type.__name__}, got {_type_name(value)}'
749 )
752def _expect_mapping(
753 data: Any, required: bool, data_path: Sequence[str | ArrayMatch], error_path: Sequence[str | ArrayMatch]
754) -> TypeGuard[Mapping[str, Any]]:
755 if _is_mapping(data):
756 return True
757 if required:
758 raise ValueError(f'Expected `{_dot_path(data_path, error_path)}` value to be a dict, got {_type_name(data)}')
759 return False
762def _is_mapping(item: Any) -> TypeGuard[Mapping[str, Any]]:
763 return isinstance(item, Mapping)
766def _is_sequence(item: Any) -> TypeGuard[Sequence[Any]]:
767 return isinstance(item, Sequence)
770def _dot_path(data_path: Sequence[str | ArrayMatch], error_path: Sequence[str | ArrayMatch]) -> str:
771 return '.'.join([str(p) for p in data_path] + [str(p) for p in error_path])
774def _type_name(v: Any) -> str:
775 return 'None' if v is None else type(v).__name__
778@dataclass
779class AgentVotes:
780 """Verifier-agent vote tally recorded by the freshness bot (never a human date)."""
782 approve: int
783 total: int
786@dataclass
787class SourceRate:
788 """The rate as observed on the vendor page, in the vendor's own unit, for audit."""
790 value: Decimal
791 unit: Literal['per_kchar', 'per_ksecond']
794@dataclass
795class Provenance:
796 """Freshness provenance for a model's price.
798 `verification_status` / `stale` / a coarse confidence label are derived at load time (see
799 `voice_prices.confidence`) from `last_verified` plus the provider's `staleness_threshold_days`;
800 they are not stored here. This block carries only the raw inputs.
801 """
803 source: Literal['imported', 'seed'] | None = None
804 """Origin of an unverified rate: `imported` (from another catalog) or `seed` (bootstrap).
805 A `verified` status is not stored: it is derived from `last_verified` being present."""
806 api_backed: bool | None = None
807 """Whether this rate is published by the vendor at a machine-readable endpoint.
809 True means the rate can be re-read and compared automatically, so a reprice surfaces without a
810 human re-reading a pricing page. Absent means it was read off a pricing page, and only a person
811 (or the LLM-assisted freshness job) can confirm it still holds. Tri-state so the shipped data
812 carries the flag only where it is true.
813 """
814 last_verified: date | None = None
815 """Date the rate was last human-verified. Emitted at build from the model's `prices_checked`."""
816 agent_votes: AgentVotes | None = None
817 """Verifier-agent tally written by the freshness bot."""
818 evidence: str | None = None
819 """The exact price string an agent quoted from the vendor page, recorded on a consensus run."""
820 source_rate: SourceRate | None = None
821 """The observed rate in the vendor's own unit, for audit."""
822 estimated_fields: list[str] | None = None
823 """Priced fields whose value is the vendor's own published estimate, not the meter they bill on.
825 Absent (the normal case) means every rate on the model is a billing rate. When present, a rate
826 named here will not reconcile against an invoice: either it is a per-token bill restated per
827 minute or per character, or it is a floor the vendor published instead of a rate ("starting
828 at $0.005 per minute"), which bounds the invoice from below rather than stating it.
830 OpenAI's `gpt-4o-transcribe` is the case this was added for. It bills per token and also
831 prints "$0.006 / minute" under a column headed *Estimated cost*, derived from an assumed
832 speech density. Both numbers are published; only one is charged.
834 Per-field rather than per-model, because the split is per-field: the token rates on that model
835 are billed and only `input_audio_kseconds` is estimated. Callers that must match an invoice
836 should prefer a field not named here; callers doing capacity planning can use either.
837 """
840@dataclass
841class ModelInfo:
842 """Information about an LLM model"""
844 id: str
845 """Primary unique identifier for the model"""
846 match: MatchLogic
847 """Boolean logic for matching this model to any identifier which could be used to reference the model in API requests"""
848 name: str | None = None
849 """Name of the model"""
850 description: str | None = None
851 """Description of the model"""
852 context_window: int | None = None
853 """Maximum number of input tokens allowed for this model"""
854 price_comments: str | None = None
855 """Comments about the pricing of the model, especially challenges in representing the provider's pricing model."""
856 pricing_source_url: str | None = None
857 """Deep-link (as a string) to the row or anchor on the provider's pricing page that establishes
858 this model's price. Per-model; distinct from `Provider.pricing_urls` which is provider-wide.
859 """
860 free: bool = False
861 """The provider charges nothing for this model, so an empty `prices` block is the true rate.
863 Distinguishes "free" from "nobody has entered a rate", which are otherwise the same bytes:
864 both are a ModelPrice with every field None. When True, a zero `total_price` is correct and
865 `unpriced_usage` is empty. When False and the model carries no rate, `unpriced_usage` names
866 the fields that found no meter, and the zero should not be billed on.
867 """
868 deprecated: bool | None = None
869 """Flag indicating this model is deprecated by the provider but still functional."""
870 provenance: Provenance | None = None
871 """Freshness provenance emitted to consumers (source, last_verified, agent_votes, evidence,
872 source_rate). Derived status/confidence live in `voice_prices.confidence`."""
874 prices: ModelPrice | list[ConditionalPrice] = dataclasses.field(default_factory=list)
875 """Set of prices for using this model.
877 When multiple `ConditionalPrice`s are used, they are tried last to first to find a pricing model to use.
878 E.g. later conditional prices take precedence over earlier ones.
880 If no conditional models match the conditions, the first one is used.
881 """
883 def is_match(self, model_ref: str) -> bool:
884 return self.match.is_match(model_ref.lower())
886 def get_prices(self, request_timestamp: datetime) -> ModelPrice:
887 if isinstance(self.prices, ModelPrice):
888 return self.prices
889 else:
890 # reversed because the last price takes precedence
891 for conditional_price in reversed(self.prices): 891 ↛ 894line 891 didn't jump to line 894 because the loop on line 891 didn't complete
892 if conditional_price.constraint is None or conditional_price.constraint.active(request_timestamp):
893 return conditional_price.prices
894 return self.prices[0].prices
896 def calc_price(
897 self,
898 usage: AbstractUsage,
899 provider: Provider,
900 *,
901 genai_request_timestamp: datetime | None = None,
902 auto_update_timestamp: datetime | None = None,
903 ) -> PriceCalculation:
904 """Calculate the price for the given usage."""
905 genai_request_timestamp = genai_request_timestamp or datetime.now(tz=timezone.utc)
907 model_price = self.get_prices(genai_request_timestamp)
908 price = model_price.calc_price(usage)
909 return PriceCalculation(
910 input_price=price['input_price'],
911 output_price=price['output_price'],
912 total_price=price['total_price'],
913 breakdown=price['breakdown'],
914 applied_voice_multiplier=price['applied_voice_multiplier'],
915 model=self,
916 provider=provider,
917 model_price=model_price,
918 auto_update_timestamp=auto_update_timestamp,
919 # A free model has no gap to report: the zero IS the rate. Without this, every free
920 # model would name its usage fields as unpriced and be indistinguishable from a row
921 # nobody has entered a rate for, which is the ambiguity `free` exists to remove.
922 unpriced_usage=() if self.free else price['unpriced_usage'],
923 )
925 def summary(self) -> str:
926 return f'Model(id={self.id!r}, name={self.name!r}, ...)'
929class CalcPrice(TypedDict):
930 input_price: Decimal
931 output_price: Decimal
932 total_price: Decimal
933 breakdown: PriceBreakdown
934 applied_voice_multiplier: Decimal | None
935 unpriced_usage: tuple[str, ...]
938# For each `Usage` field, the `ModelPrice` rates that can pay for it, in the order the
939# bucket logic in `ModelPrice.calc_price` actually falls back through. A field counts as
940# unpriced only when EVERY rate in its chain is absent.
941#
942# Walking the chain is the whole point. "The matching rate is None" would be wrong: a model
943# with no `cache_read_mtok` still prices `cache_read_tokens`, at `input_mtok`, because the
944# bucket logic leaves those tokens inside the text-input remainder rather than dropping
945# them. 250 models in the catalog have no cache rate; flagging all of them would make this
946# field noise, and a noisy warning is a warning nobody reads.
947#
948# The token chains assume the caller follows the API convention these fields come from,
949# where `input_tokens` is the total (audio and cached tokens included) and `output_tokens`
950# likewise. A caller that reports audio tokens *outside* the totals gets a conservative
951# answer: the audio rate covers the field, so nothing is reported.
952_UNPRICED_RATE_CHAINS: dict[str, tuple[str, ...]] = {
953 'input_tokens': ('input_mtok',),
954 'cache_read_tokens': ('cache_read_mtok', 'input_mtok'),
955 'cache_write_tokens': ('cache_write_mtok', 'input_mtok'),
956 'input_audio_tokens': ('input_audio_mtok', 'input_mtok'),
957 'cache_audio_read_tokens': ('cache_audio_read_mtok', 'cache_read_mtok', 'input_mtok'),
958 'output_tokens': ('output_mtok',),
959 'output_audio_tokens': ('output_audio_mtok', 'output_mtok'),
960 'characters': ('input_kchars',),
961 'audio_input_seconds': ('input_audio_kseconds',),
962 'audio_output_seconds': ('output_audio_kseconds',),
963 'agent_minutes': ('agent_kminutes',),
964 'telephony_minutes': ('telephony_kminutes',),
965}
968@dataclass
969class ModelPrice:
970 """Set of prices for using a model"""
972 input_mtok: Decimal | TieredPrices | None = None
973 """price in USD per million uncached text input/prompt token"""
975 cache_write_mtok: Decimal | TieredPrices | None = None
976 """price in USD per million tokens written to the cache"""
977 cache_read_mtok: Decimal | TieredPrices | None = None
978 """price in USD per million tokens read from the cache"""
980 output_mtok: Decimal | TieredPrices | None = None
981 """price in USD per million output/completion tokens"""
983 input_audio_mtok: Decimal | TieredPrices | None = None
984 """price in USD per million audio input tokens"""
985 cache_audio_read_mtok: Decimal | TieredPrices | None = None
986 """price in USD per million audio tokens read from the cache"""
987 output_audio_mtok: Decimal | TieredPrices | None = None
988 """price in USD per million output audio tokens"""
990 requests_kcount: Decimal | None = None
991 """price in USD per thousand requests"""
993 input_kchars: Decimal | None = None
994 """price in USD per 1,000 input characters (TTS text input)"""
996 output_audio_kseconds: Decimal | None = None
997 """price in USD per 1,000 seconds of generated audio output.
999 Reserved for v0.2 (PlayHT, Murf, similar). No v0.1 catalog entry sets this.
1000 """
1002 input_audio_kseconds: Decimal | None = None
1003 """price in USD per 1,000 seconds of input audio (STT audio input).
1005 ModelPrice puts direction before modality (input_audio_kseconds); Usage puts
1006 modality before direction (audio_input_seconds). Intentional; mirrors the
1007 existing output_audio_kseconds / audio_output_seconds pair.
1008 """
1010 telephony_kminutes: Decimal | None = None
1011 """USD per 1,000 connected call minutes on the phone network.
1013 Separate from `agent_kminutes` on purpose: a bundled agent minute already contains
1014 speech-to-text, the model and text-to-speech, while a carrier minute is the call leg
1015 itself. A phone call on a bundled platform pays both.
1016 """
1018 agent_kminutes: Decimal | None = None
1019 """price in USD per 1,000 minutes of bundled voice-agent session time.
1021 Set only by platforms that sell one blended per-minute rate covering STT, LLM,
1022 TTS and orchestration together. A model priced this way is filed under the
1023 `agent` modality and is deliberately not comparable with the component rates:
1024 what the bundle contains is the platform's choice and is rarely disclosed.
1025 """
1027 voice_multipliers: dict[str, Decimal] | None = None
1028 """Multiplicative adjustments keyed by voice class.
1030 Scales `input_kchars` + `output_audio_kseconds` only (not token-based fields, requests, or
1031 cached buckets). Must include a `default` key. See engine semantics for fallback behavior.
1032 """
1034 def calc_price(self, usage: AbstractUsage) -> CalcPrice:
1035 """Calculate the price of usage in USD with this model price."""
1036 breakdown = PriceBreakdown()
1038 total_input_tokens = usage.input_tokens or 0
1040 cache_read_tokens = usage.cache_read_tokens or 0
1041 cache_write_tokens = usage.cache_write_tokens or 0
1042 cache_audio_read_tokens = usage.cache_audio_read_tokens or 0
1043 input_audio_tokens = usage.input_audio_tokens or 0
1044 output_audio_tokens = usage.output_audio_tokens or 0
1046 # Parent/child bucket logic for token fields. See pre-refactor comment in git
1047 # history for the full worked example; the algorithm is unchanged here.
1048 priced_cache_audio_read_tokens = cache_audio_read_tokens if self.cache_audio_read_mtok is not None else 0
1049 cache_audio_read_tokens_priced_as_cache_read = (
1050 cache_audio_read_tokens if self.cache_audio_read_mtok is None and self.cache_read_mtok is not None else 0
1051 )
1053 priced_audio_input_tokens = 0
1054 if self.input_audio_mtok is not None:
1055 priced_audio_input_tokens = (
1056 input_audio_tokens - priced_cache_audio_read_tokens - cache_audio_read_tokens_priced_as_cache_read
1057 )
1059 if priced_audio_input_tokens < 0: # pragma: no cover
1060 raise ValueError('cache_audio_read_tokens cannot be greater than input_audio_tokens')
1062 priced_cache_read_tokens = 0
1063 if self.cache_read_mtok is not None:
1064 priced_cache_read_tokens = cache_read_tokens - priced_cache_audio_read_tokens
1066 if priced_cache_read_tokens < 0: # pragma: no cover
1067 raise ValueError('cache_audio_read_tokens cannot be greater than cache_read_tokens')
1069 priced_cache_write_tokens = cache_write_tokens if self.cache_write_mtok is not None else 0
1071 priced_text_input_tokens = 0
1072 if self.input_mtok is not None:
1073 priced_text_input_tokens = (
1074 total_input_tokens
1075 - priced_cache_read_tokens
1076 - priced_cache_write_tokens
1077 - priced_audio_input_tokens
1078 - priced_cache_audio_read_tokens
1079 )
1081 if priced_text_input_tokens < 0: # pragma: no cover
1082 raise ValueError('Uncached text input tokens cannot be negative')
1084 # Token contributions into the breakdown
1085 breakdown.input_tokens = calc_mtok_price(self.input_mtok, priced_text_input_tokens, total_input_tokens)
1086 breakdown.cache_write_tokens = calc_mtok_price(
1087 self.cache_write_mtok, priced_cache_write_tokens, total_input_tokens
1088 )
1089 breakdown.cache_read_tokens = calc_mtok_price(
1090 self.cache_read_mtok, priced_cache_read_tokens, total_input_tokens
1091 )
1092 breakdown.input_audio_tokens = calc_mtok_price(
1093 self.input_audio_mtok, priced_audio_input_tokens, total_input_tokens
1094 )
1095 breakdown.cache_audio_read_tokens = calc_mtok_price(
1096 self.cache_audio_read_mtok, priced_cache_audio_read_tokens, total_input_tokens
1097 )
1099 priced_text_output_tokens = 0
1100 if self.output_mtok is not None:
1101 priced_text_output_tokens = (usage.output_tokens or 0) - (
1102 output_audio_tokens if self.output_audio_mtok is not None else 0
1103 )
1105 if priced_text_output_tokens < 0: # pragma: no cover
1106 raise ValueError('output_audio_tokens cannot be greater than output_tokens')
1108 breakdown.output_tokens = calc_mtok_price(self.output_mtok, priced_text_output_tokens, total_input_tokens)
1109 breakdown.output_audio_tokens = calc_mtok_price(
1110 self.output_audio_mtok, usage.output_audio_tokens, total_input_tokens
1111 )
1113 # Per-1000-unit contributions: characters (TTS), audio seconds in both directions (STT),
1114 # bundled agent minutes, and carrier minutes. Five meters, one shape, so they go through
1115 # `_read_quantity` / `_per_thousand` rather than five near-identical branches.
1116 characters = _read_quantity(usage, 'characters')
1117 audio_output_seconds = _read_quantity(usage, 'audio_output_seconds')
1118 audio_input_seconds = _read_quantity(usage, 'audio_input_seconds')
1119 agent_minutes = _read_quantity(usage, 'agent_minutes')
1120 telephony_minutes = _read_quantity(usage, 'telephony_minutes')
1122 breakdown.input_kchars = _per_thousand(self.input_kchars, characters)
1123 breakdown.output_audio_kseconds = _per_thousand(self.output_audio_kseconds, audio_output_seconds)
1124 breakdown.input_audio_kseconds = _per_thousand(self.input_audio_kseconds, audio_input_seconds)
1125 breakdown.agent_kminutes = _per_thousand(self.agent_kminutes, agent_minutes)
1126 breakdown.telephony_kminutes = _per_thousand(self.telephony_kminutes, telephony_minutes)
1128 # Voice multiplier resolution and per-direction adjustment
1129 applied_voice_multiplier: Decimal | None = None
1130 if self.voice_multipliers is not None:
1131 voice_class = getattr(usage, 'voice_class', None)
1132 if voice_class is None:
1133 applied_voice_multiplier = self.voice_multipliers['default']
1134 elif voice_class in self.voice_multipliers:
1135 applied_voice_multiplier = self.voice_multipliers[voice_class]
1136 else:
1137 warnings.warn(
1138 f'Unknown voice_class {voice_class!r}; falling back to default multiplier. '
1139 f'Known classes: {sorted(self.voice_multipliers)}.',
1140 stacklevel=2,
1141 )
1142 applied_voice_multiplier = self.voice_multipliers['default']
1144 if applied_voice_multiplier is not None:
1145 delta = applied_voice_multiplier - Decimal(1)
1146 breakdown.voice_class_input_adjustment = breakdown.input_kchars * delta
1147 breakdown.voice_class_output_adjustment = breakdown.output_audio_kseconds * delta
1149 # Requests
1150 if self.requests_kcount is not None:
1151 breakdown.requests = self.requests_kcount / Decimal(1000)
1153 # Usage the caller supplied that no rate here could pay for. Computed from the same
1154 # values the branches above consumed, so it cannot drift from what was actually priced.
1155 supplied: dict[str, Decimal | int] = {
1156 'input_tokens': total_input_tokens,
1157 'cache_read_tokens': cache_read_tokens,
1158 'cache_write_tokens': cache_write_tokens,
1159 'input_audio_tokens': input_audio_tokens,
1160 'cache_audio_read_tokens': cache_audio_read_tokens,
1161 'output_tokens': usage.output_tokens or 0,
1162 'output_audio_tokens': output_audio_tokens,
1163 'characters': characters,
1164 'audio_input_seconds': audio_input_seconds,
1165 'audio_output_seconds': audio_output_seconds,
1166 'agent_minutes': agent_minutes,
1167 'telephony_minutes': telephony_minutes,
1168 }
1169 unpriced_usage = tuple(
1170 field
1171 for field, chain in _UNPRICED_RATE_CHAINS.items()
1172 if supplied.get(field) and all(getattr(self, rate, None) is None for rate in chain)
1173 )
1175 # Assemble input/output/total from the breakdown so the invariant holds by construction
1176 input_price = (
1177 breakdown.input_tokens
1178 + breakdown.cache_read_tokens
1179 + breakdown.cache_write_tokens
1180 + breakdown.input_audio_tokens
1181 + breakdown.cache_audio_read_tokens
1182 + breakdown.input_kchars
1183 + breakdown.voice_class_input_adjustment
1184 + breakdown.input_audio_kseconds
1185 )
1186 output_price = (
1187 breakdown.output_tokens
1188 + breakdown.output_audio_tokens
1189 + breakdown.output_audio_kseconds
1190 + breakdown.voice_class_output_adjustment
1191 )
1192 total_price = (
1193 input_price + output_price + breakdown.requests + breakdown.agent_kminutes + breakdown.telephony_kminutes
1194 )
1196 return {
1197 'input_price': input_price,
1198 'output_price': output_price,
1199 'total_price': total_price,
1200 'breakdown': breakdown,
1201 'applied_voice_multiplier': applied_voice_multiplier,
1202 'unpriced_usage': unpriced_usage,
1203 }
1205 def __str__(self) -> str:
1206 parts: list[str] = []
1207 for field in dataclasses.fields(self):
1208 value = getattr(self, field.name)
1209 if value is not None:
1210 if field.name == 'requests_kcount':
1211 parts.append(f'${value} / K requests')
1212 else:
1213 name = field.name.replace('_mtok', '').replace('_', ' ')
1214 if isinstance(value, TieredPrices):
1215 parts.append(f'${value.base}/{name} MTok (+tiers)')
1216 else:
1217 parts.append(f'${value}/{name} MTok')
1219 return ', '.join(parts)
1222def _read_quantity(usage: AbstractUsage, field: str) -> Decimal:
1223 """A usage quantity as a Decimal, or zero when absent.
1225 Read with getattr so an `AbstractUsage` implementation predating a field (a user-defined
1226 dataclass, say) keeps working instead of raising. The explicit Decimal() coercion is the
1227 contract that lets callers pass a plain int for a Decimal-typed field, which several do
1228 (see test_int_audio_*_seconds_prices_correctly).
1229 """
1230 raw = getattr(usage, field, None)
1231 return Decimal(0) if raw is None else Decimal(raw)
1234def _per_thousand(rate: Decimal | None, quantity: Decimal) -> Decimal:
1235 """USD for `quantity` units at `rate` per 1,000 of them.
1237 Zero when the model has no such rate, which is what makes a missing meter a silent zero;
1238 `PriceCalculation.unpriced_usage` is what tells the caller that happened.
1239 """
1240 if rate is None or quantity <= 0:
1241 return Decimal(0)
1242 return rate * quantity / Decimal(1000)
1245def calc_mtok_price(
1246 field_mtok: Decimal | TieredPrices | None, token_count: int | None, total_input_tokens: int
1247) -> Decimal:
1248 """Calculate the price for a given number of tokens based on the price in USD per million tokens (mtok).
1250 For tiered pricing, uses threshold-based pricing where crossing a tier applies that rate to ALL tokens.
1251 This is the industry standard used by Anthropic, Google, OpenAI, and most other providers.
1253 Args:
1254 field_mtok: Price per million tokens, either flat rate or tiered
1255 token_count: Number of tokens of this specific type to price
1256 total_input_tokens: Total input tokens for tier determination (used only for tiered pricing)
1257 """
1258 if field_mtok is None or token_count is None:
1259 return Decimal(0)
1261 if isinstance(field_mtok, TieredPrices):
1262 # Threshold-based pricing: tier is determined by total_input_tokens
1263 # Find the highest tier that applies based on total input tokens
1264 # When total_input_tokens is 0, no tier condition is met, so base rate is used
1265 applicable_price = field_mtok.base
1266 for tier in reversed(field_mtok.tiers):
1267 if total_input_tokens > tier.start:
1268 applicable_price = tier.price
1269 break
1270 price = applicable_price * token_count
1271 else:
1272 price = field_mtok * token_count
1273 return price / 1_000_000
1276@dataclass
1277class TieredPrices:
1278 """Pricing model when the amount paid varies by number of tokens.
1280 Uses threshold-based pricing where crossing a tier applies that rate to ALL tokens.
1281 This is the industry standard "cliff" model used by most providers (Anthropic, Google, OpenAI, etc.).
1283 Example: For a tier starting at 200K tokens:
1284 - Using 199,999 tokens: all tokens pay base rate
1285 - Using 200,001 tokens: all tokens pay tier rate (not just the tokens above 200K)
1286 """
1288 base: Decimal
1289 """Base price in USD per million tokens, e.g. price until the first tier."""
1290 tiers: list[Tier]
1291 """Extra price tiers."""
1293 def __post_init__(self) -> None:
1294 """Ensure tiers are sorted in ascending order by start threshold."""
1295 self.tiers.sort(key=lambda tier: tier.start)
1298@dataclass
1299class Tier:
1300 """Price tier"""
1302 start: int
1303 """Start of the tier"""
1304 price: Decimal
1305 """Price for this tier"""
1308@dataclass
1309class ConditionalPrice:
1310 """Pricing together with constraints that define when those prices should be used.
1312 The last price active price (price where the constraints are met) is used.
1313 """
1315 constraint: StartDateConstraint | StartTimestampConstraint | TimeOfDateConstraint | None = None
1316 """Timestamp when this price starts, None means this price is always valid."""
1318 _: dataclasses.KW_ONLY
1320 prices: ModelPrice
1321 """Prices for this condition."""
1324@dataclass
1325class StartTimestampConstraint:
1326 """An exact, timezone-aware instant when a price takes effect."""
1328 start_timestamp: pydantic.AwareDatetime
1330 def active(self, request_timestamp: datetime) -> bool:
1331 # Caller-supplied naive timestamps follow the calculator's UTC default.
1332 if request_timestamp.utcoffset() is None:
1333 request_timestamp = request_timestamp.replace(tzinfo=timezone.utc)
1334 return request_timestamp >= self.start_timestamp
1337@dataclass
1338class StartDateConstraint:
1339 """Constraint that defines when this price starts, e.g. when a new price is introduced."""
1341 start_date: date
1342 """Date when this price starts"""
1344 def active(self, request_timestamp: datetime) -> bool:
1345 return request_timestamp.date() >= self.start_date
1348@dataclass
1349class TimeOfDateConstraint:
1350 """Constraint that defines a daily interval when a price applies, useful for off-peak pricing like deepseek."""
1352 start_time: time
1353 """Start time of the interval."""
1354 end_time: time
1355 """End time of the interval."""
1357 def active(self, request_timestamp: datetime) -> bool:
1358 return self.start_time <= request_timestamp.timetz() < self.end_time
1361@dataclass
1362class ClauseStartsWith:
1363 starts_with: str
1365 def is_match(self, text: str) -> bool:
1366 return text.lower().startswith(self.starts_with.lower())
1369@dataclass
1370class ClauseEndsWith:
1371 ends_with: str
1373 def is_match(self, text: str) -> bool:
1374 return text.lower().endswith(self.ends_with.lower())
1377@dataclass
1378class ClauseContains:
1379 contains: str
1381 def is_match(self, text: str) -> bool:
1382 return self.contains.lower() in text.lower()
1385@dataclass
1386class ClauseRegex:
1387 regex: str
1389 def is_match(self, text: str) -> bool:
1390 return bool(re.search(self.regex, text))
1393@dataclass
1394class ClauseEquals:
1395 equals: str
1397 def is_match(self, text: str) -> bool:
1398 return text.lower() == self.equals.lower()
1401@dataclass
1402class ClauseOr:
1403 or_: Annotated[list[MatchLogic], pydantic.Field(validation_alias='or')]
1405 def is_match(self, text: str) -> bool:
1406 return any(clause.is_match(text) for clause in self.or_)
1409@dataclass
1410class ClauseAnd:
1411 and_: Annotated[list[MatchLogic], pydantic.Field(validation_alias='and')]
1413 def is_match(self, text: str) -> bool:
1414 return all(clause.is_match(text) for clause in self.and_)
1417providers_schema = pydantic.TypeAdapter(list[Provider], config=pydantic.ConfigDict(defer_build=True))