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

1from __future__ import annotations as _annotations 

2 

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 

11 

12import pydantic 

13from typing_extensions import TypedDict 

14 

15if TYPE_CHECKING: 

16 from .confidence import Freshness 

17 

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) 

49 

50 

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] 

55 

56 

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] 

67 

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] 

86 

87 

88@dataclass 

89class ArrayMatch: 

90 type: Literal['array-match'] 

91 field: str 

92 match: MatchLogic 

93 

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 

99 

100 

101ExtractPath = str | Sequence[str | ArrayMatch] 

102 

103 

104@dataclass 

105class PriceBreakdown: 

106 """Per-component USD contributions that sum to total_price. 

107 

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 """ 

117 

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) 

125 

126 input_kchars: Decimal = Decimal(0) 

127 output_audio_kseconds: Decimal = Decimal(0) 

128 input_audio_kseconds: Decimal = Decimal(0) 

129 

130 voice_class_input_adjustment: Decimal = Decimal(0) 

131 """USD adjustment caused by voice_multiplier on input_kchars. 

132 

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 """ 

137 

138 voice_class_output_adjustment: Decimal = Decimal(0) 

139 """USD adjustment caused by voice_multiplier on output_audio_kseconds. 

140 

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 """ 

144 

145 agent_kminutes: Decimal = Decimal(0) 

146 """USD for bundled voice-agent session time. 

147 

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 """ 

153 

154 telephony_kminutes: Decimal = Decimal(0) 

155 """USD for connected call minutes on the phone network. 

156 

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 """ 

161 

162 requests: Decimal = Decimal(0) 

163 

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 ) 

183 

184 

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')). 

193 

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. 

203 

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". 

207 

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. 

214 

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 """ 

218 

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 ) 

233 

234 def freshness(self, *, today: date | None = None) -> Freshness: 

235 """How trustworthy this rate is: verification_status, a coarse confidence label, and staleness. 

236 

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 

241 

242 return model_freshness(self.model, self.provider, today=today) 

243 

244 

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 

251 

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. 

256 

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') 

265 

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 ) 

272 

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 ) 

281 

282 def __add__(self, other: ExtractedUsage | Any) -> ExtractedUsage: 

283 """Accumulate inner Usage, handling nullable usage fields. 

284 

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 

287 

288 Args: 

289 other: The usage to accumulate with this usage extraction instance. 

290 """ 

291 

292 if not isinstance(other, ExtractedUsage): 

293 return NotImplemented # will raise a TypeError 

294 

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}') 

298 

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 ) 

304 

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 ) 

311 

312 def __radd__(self, other: ExtractedUsage | Any) -> ExtractedUsage: 

313 return self + other 

314 

315 

316class AbstractUsage(Protocol): 

317 """Abstract definition of data about token usage for a single LLM or voice call.""" 

318 

319 @property 

320 def input_tokens(self) -> int | None: 

321 """Total number of input/prompt tokens. 

322 

323 Note this should INCLUDE both uncached and cached tokens. 

324 """ 

325 

326 @property 

327 def cache_write_tokens(self) -> int | None: 

328 """Number of tokens written to the cache.""" 

329 

330 @property 

331 def cache_read_tokens(self) -> int | None: 

332 """Number of tokens read from the cache. 

333 

334 For many models this is described as just "cached tokens". 

335 """ 

336 

337 @property 

338 def output_tokens(self) -> int | None: 

339 """Number of output/completion tokens.""" 

340 

341 @property 

342 def input_audio_tokens(self) -> int | None: 

343 """Number of audio input tokens.""" 

344 

345 @property 

346 def cache_audio_read_tokens(self) -> int | None: 

347 """Number of audio tokens read from the cache.""" 

348 

349 @property 

350 def output_audio_tokens(self) -> int | None: 

351 """Number of output audio tokens.""" 

352 

353 @property 

354 def characters(self) -> int | None: 

355 """Number of input characters submitted to a TTS model.""" 

356 

357 @property 

358 def audio_output_seconds(self) -> Decimal | None: 

359 """Number of seconds of generated audio (TTS audio output). 

360 

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 """ 

366 

367 @property 

368 def audio_input_seconds(self) -> Decimal | None: 

369 """Duration (in seconds) of audio submitted to the model (STT input). 

370 

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. 

374 

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 """ 

379 

380 @property 

381 def voice_class(self) -> str | None: 

382 """Voice class identifier used for this call. 

383 

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 """ 

390 

391 

392@dataclass 

393class Usage: 

394 """Simple implementation of `AbstractUsage` as a dataclass.""" 

395 

396 input_tokens: int | None = None 

397 """Number of input/prompt tokens.""" 

398 

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.""" 

403 

404 output_tokens: int | None = None 

405 """Number of output/completion tokens.""" 

406 

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.""" 

413 

414 characters: int | None = None 

415 """Number of input characters submitted to a TTS model.""" 

416 

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. 

420 

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 """ 

426 

427 audio_input_seconds: Decimal | None = None 

428 """Duration (in seconds) of audio submitted to the model (STT input). 

429 

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. 

433 

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 """ 

438 

439 agent_minutes: Decimal | None = None 

440 """Duration of a bundled voice-agent session, in minutes. 

441 

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 """ 

447 

448 telephony_minutes: Decimal | None = None 

449 """Connected call minutes carried over the phone network. 

450 

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 """ 

455 

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 """ 

460 

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 

464 

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) 

467 

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 

474 

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 

483 

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 ) 

499 

500 def __radd__(self, other: Usage) -> Usage: 

501 return self + other 

502 

503 

504@dataclass 

505class Provider: 

506 """Information about an LLM inference provider""" 

507 

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. 

528 

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. 

539 

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. 

544 

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""" 

552 

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 

566 

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. 

569 

570 Args: 

571 response_data: The response data from the provider's API. 

572 api_flavor: The flavor of API used for this request. 

573 

574 Raises: 

575 ValueError: If the response data is invalid or the API flavor is not found. 

576 

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') 

582 

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 

588 

589 return extractor.extract(response_data) 

590 

591 def summary(self) -> str: 

592 return f'Provider(id={self.id!r}, name={self.name!r}, ...)' 

593 

594 

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] 

609 

610 

611@dataclass 

612class UsageExtractorMapping: 

613 """Mappings from used to build usage.""" 

614 

615 path: ExtractPath 

616 """Path to the value to extract""" 

617 dest: UsageField 

618 """Destination field to store the extracted value. 

619 

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""" 

624 

625 

626@dataclass 

627class UsageExtractor: 

628 """Logic for extracting usage information from a response.""" 

629 

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.""" 

638 

639 def extract(self, response_data: Any) -> tuple[str | None, Usage]: 

640 """Extract model name and usage information from a response. 

641 

642 Args: 

643 response_data: The response data to extract usage information from, generally the decoded JSON response. 

644 

645 Raises: 

646 ValueError: If no usage information is found at the root. 

647 

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, []) 

652 

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] 

656 

657 usage_obj = cast(dict[str, Any], _extract_path(root, response_data, Mapping, True, [])) 

658 

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 

670 

671 

672E = TypeVar('E') 

673 

674 

675@overload 

676def _extract_path( 

677 path: ExtractPath, data: Any, extract_type: type[E], required: Literal[True], data_path: Sequence[str | ArrayMatch] 

678) -> E: ... 

679 

680 

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: ... 

689 

690 

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] 

696 

697 *steps, last = path 

698 last = cast(str, last) 

699 

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 

727 

728 if data is None and not required: 

729 return None 

730 

731 if not _expect_mapping(data, required, data_path, error_path): 

732 return None 

733 

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 ) 

750 

751 

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 

760 

761 

762def _is_mapping(item: Any) -> TypeGuard[Mapping[str, Any]]: 

763 return isinstance(item, Mapping) 

764 

765 

766def _is_sequence(item: Any) -> TypeGuard[Sequence[Any]]: 

767 return isinstance(item, Sequence) 

768 

769 

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]) 

772 

773 

774def _type_name(v: Any) -> str: 

775 return 'None' if v is None else type(v).__name__ 

776 

777 

778@dataclass 

779class AgentVotes: 

780 """Verifier-agent vote tally recorded by the freshness bot (never a human date).""" 

781 

782 approve: int 

783 total: int 

784 

785 

786@dataclass 

787class SourceRate: 

788 """The rate as observed on the vendor page, in the vendor's own unit, for audit.""" 

789 

790 value: Decimal 

791 unit: Literal['per_kchar', 'per_ksecond'] 

792 

793 

794@dataclass 

795class Provenance: 

796 """Freshness provenance for a model's price. 

797 

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 """ 

802 

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. 

808 

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. 

824 

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. 

829 

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. 

833 

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 """ 

838 

839 

840@dataclass 

841class ModelInfo: 

842 """Information about an LLM model""" 

843 

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. 

862 

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`.""" 

873 

874 prices: ModelPrice | list[ConditionalPrice] = dataclasses.field(default_factory=list) 

875 """Set of prices for using this model. 

876 

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. 

879 

880 If no conditional models match the conditions, the first one is used. 

881 """ 

882 

883 def is_match(self, model_ref: str) -> bool: 

884 return self.match.is_match(model_ref.lower()) 

885 

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 

895 

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) 

906 

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 ) 

924 

925 def summary(self) -> str: 

926 return f'Model(id={self.id!r}, name={self.name!r}, ...)' 

927 

928 

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, ...] 

936 

937 

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} 

966 

967 

968@dataclass 

969class ModelPrice: 

970 """Set of prices for using a model""" 

971 

972 input_mtok: Decimal | TieredPrices | None = None 

973 """price in USD per million uncached text input/prompt token""" 

974 

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""" 

979 

980 output_mtok: Decimal | TieredPrices | None = None 

981 """price in USD per million output/completion tokens""" 

982 

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""" 

989 

990 requests_kcount: Decimal | None = None 

991 """price in USD per thousand requests""" 

992 

993 input_kchars: Decimal | None = None 

994 """price in USD per 1,000 input characters (TTS text input)""" 

995 

996 output_audio_kseconds: Decimal | None = None 

997 """price in USD per 1,000 seconds of generated audio output. 

998 

999 Reserved for v0.2 (PlayHT, Murf, similar). No v0.1 catalog entry sets this. 

1000 """ 

1001 

1002 input_audio_kseconds: Decimal | None = None 

1003 """price in USD per 1,000 seconds of input audio (STT audio input). 

1004 

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 """ 

1009 

1010 telephony_kminutes: Decimal | None = None 

1011 """USD per 1,000 connected call minutes on the phone network. 

1012 

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 """ 

1017 

1018 agent_kminutes: Decimal | None = None 

1019 """price in USD per 1,000 minutes of bundled voice-agent session time. 

1020 

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 """ 

1026 

1027 voice_multipliers: dict[str, Decimal] | None = None 

1028 """Multiplicative adjustments keyed by voice class. 

1029 

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 """ 

1033 

1034 def calc_price(self, usage: AbstractUsage) -> CalcPrice: 

1035 """Calculate the price of usage in USD with this model price.""" 

1036 breakdown = PriceBreakdown() 

1037 

1038 total_input_tokens = usage.input_tokens or 0 

1039 

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 

1045 

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 ) 

1052 

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 ) 

1058 

1059 if priced_audio_input_tokens < 0: # pragma: no cover 

1060 raise ValueError('cache_audio_read_tokens cannot be greater than input_audio_tokens') 

1061 

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 

1065 

1066 if priced_cache_read_tokens < 0: # pragma: no cover 

1067 raise ValueError('cache_audio_read_tokens cannot be greater than cache_read_tokens') 

1068 

1069 priced_cache_write_tokens = cache_write_tokens if self.cache_write_mtok is not None else 0 

1070 

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 ) 

1080 

1081 if priced_text_input_tokens < 0: # pragma: no cover 

1082 raise ValueError('Uncached text input tokens cannot be negative') 

1083 

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 ) 

1098 

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 ) 

1104 

1105 if priced_text_output_tokens < 0: # pragma: no cover 

1106 raise ValueError('output_audio_tokens cannot be greater than output_tokens') 

1107 

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 ) 

1112 

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') 

1121 

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) 

1127 

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'] 

1143 

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 

1148 

1149 # Requests 

1150 if self.requests_kcount is not None: 

1151 breakdown.requests = self.requests_kcount / Decimal(1000) 

1152 

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 ) 

1174 

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 ) 

1195 

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 } 

1204 

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') 

1218 

1219 return ', '.join(parts) 

1220 

1221 

1222def _read_quantity(usage: AbstractUsage, field: str) -> Decimal: 

1223 """A usage quantity as a Decimal, or zero when absent. 

1224 

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) 

1232 

1233 

1234def _per_thousand(rate: Decimal | None, quantity: Decimal) -> Decimal: 

1235 """USD for `quantity` units at `rate` per 1,000 of them. 

1236 

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) 

1243 

1244 

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). 

1249 

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. 

1252 

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) 

1260 

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 

1274 

1275 

1276@dataclass 

1277class TieredPrices: 

1278 """Pricing model when the amount paid varies by number of tokens. 

1279 

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.). 

1282 

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 """ 

1287 

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.""" 

1292 

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) 

1296 

1297 

1298@dataclass 

1299class Tier: 

1300 """Price tier""" 

1301 

1302 start: int 

1303 """Start of the tier""" 

1304 price: Decimal 

1305 """Price for this tier""" 

1306 

1307 

1308@dataclass 

1309class ConditionalPrice: 

1310 """Pricing together with constraints that define when those prices should be used. 

1311 

1312 The last price active price (price where the constraints are met) is used. 

1313 """ 

1314 

1315 constraint: StartDateConstraint | StartTimestampConstraint | TimeOfDateConstraint | None = None 

1316 """Timestamp when this price starts, None means this price is always valid.""" 

1317 

1318 _: dataclasses.KW_ONLY 

1319 

1320 prices: ModelPrice 

1321 """Prices for this condition.""" 

1322 

1323 

1324@dataclass 

1325class StartTimestampConstraint: 

1326 """An exact, timezone-aware instant when a price takes effect.""" 

1327 

1328 start_timestamp: pydantic.AwareDatetime 

1329 

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 

1335 

1336 

1337@dataclass 

1338class StartDateConstraint: 

1339 """Constraint that defines when this price starts, e.g. when a new price is introduced.""" 

1340 

1341 start_date: date 

1342 """Date when this price starts""" 

1343 

1344 def active(self, request_timestamp: datetime) -> bool: 

1345 return request_timestamp.date() >= self.start_date 

1346 

1347 

1348@dataclass 

1349class TimeOfDateConstraint: 

1350 """Constraint that defines a daily interval when a price applies, useful for off-peak pricing like deepseek.""" 

1351 

1352 start_time: time 

1353 """Start time of the interval.""" 

1354 end_time: time 

1355 """End time of the interval.""" 

1356 

1357 def active(self, request_timestamp: datetime) -> bool: 

1358 return self.start_time <= request_timestamp.timetz() < self.end_time 

1359 

1360 

1361@dataclass 

1362class ClauseStartsWith: 

1363 starts_with: str 

1364 

1365 def is_match(self, text: str) -> bool: 

1366 return text.lower().startswith(self.starts_with.lower()) 

1367 

1368 

1369@dataclass 

1370class ClauseEndsWith: 

1371 ends_with: str 

1372 

1373 def is_match(self, text: str) -> bool: 

1374 return text.lower().endswith(self.ends_with.lower()) 

1375 

1376 

1377@dataclass 

1378class ClauseContains: 

1379 contains: str 

1380 

1381 def is_match(self, text: str) -> bool: 

1382 return self.contains.lower() in text.lower() 

1383 

1384 

1385@dataclass 

1386class ClauseRegex: 

1387 regex: str 

1388 

1389 def is_match(self, text: str) -> bool: 

1390 return bool(re.search(self.regex, text)) 

1391 

1392 

1393@dataclass 

1394class ClauseEquals: 

1395 equals: str 

1396 

1397 def is_match(self, text: str) -> bool: 

1398 return text.lower() == self.equals.lower() 

1399 

1400 

1401@dataclass 

1402class ClauseOr: 

1403 or_: Annotated[list[MatchLogic], pydantic.Field(validation_alias='or')] 

1404 

1405 def is_match(self, text: str) -> bool: 

1406 return any(clause.is_match(text) for clause in self.or_) 

1407 

1408 

1409@dataclass 

1410class ClauseAnd: 

1411 and_: Annotated[list[MatchLogic], pydantic.Field(validation_alias='and')] 

1412 

1413 def is_match(self, text: str) -> bool: 

1414 return all(clause.is_match(text) for clause in self.and_) 

1415 

1416 

1417providers_schema = pydantic.TypeAdapter(list[Provider], config=pydantic.ConfigDict(defer_build=True))