Coverage for tests/test_price_calc.py: 99.13%

225 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2026-09-27 04:08 +0000

1from datetime import datetime, timezone 

2from decimal import Decimal 

3 

4import pytest 

5from inline_snapshot import snapshot 

6 

7from voice_prices import Usage, calc_price 

8from voice_prices.types import ModelPrice, TieredPrices 

9 

10pytestmark = pytest.mark.anyio 

11 

12 

13def test_sync_success_with_provider(): 

14 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o', provider_id='openai') 

15 

16 assert price.input_price == snapshot(Decimal('0.0025')) 

17 assert price.output_price == snapshot(Decimal('0.001')) 

18 assert price.total_price == snapshot(Decimal('0.0035')) 

19 assert price.model.name == snapshot('gpt 4o') 

20 assert price.provider.id == snapshot('openai') 

21 assert price.auto_update_timestamp is None 

22 

23 

24def test_sync_success_with_url(): 

25 price = calc_price( 

26 Usage(input_tokens=1000, output_tokens=100, cache_write_tokens=20, cache_read_tokens=30), 

27 model_ref='claude-3.5-sonnet@abc', 

28 provider_api_url='https://api.anthropic.com/foo/bar', 

29 ) 

30 assert price.input_price == snapshot(Decimal('0.002934')) 

31 assert price.output_price == snapshot(Decimal('0.0015')) 

32 assert price.total_price == snapshot(Decimal('0.004434')) 

33 assert price.model.name == snapshot('Claude Sonnet 3.5') 

34 assert price.provider.name == snapshot('Anthropic') 

35 assert price.auto_update_timestamp is None 

36 

37 

38def test_sync_success_with_model(): 

39 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o') 

40 

41 assert price.input_price == snapshot(Decimal('0.0025')) 

42 assert price.output_price == snapshot(Decimal('0.001')) 

43 assert price.total_price == snapshot(Decimal('0.0035')) 

44 assert price.model.name == snapshot('gpt 4o') 

45 assert price.provider.id == snapshot('openai') 

46 assert price.auto_update_timestamp is None 

47 

48 

49def test_sync_success_with_model_regex(): 

50 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='o3') 

51 

52 assert price.input_price == snapshot(Decimal('0.002')) 

53 assert price.output_price == snapshot(Decimal('0.0008')) 

54 assert price.total_price == snapshot(Decimal('0.0028')) 

55 assert price.model.name == snapshot('o3') 

56 assert price.provider.id == snapshot('openai') 

57 

58 

59def test_openrouter_deepseek_v32_price(): 

60 price = calc_price( 

61 Usage(input_tokens=2_000_000, output_tokens=1_000_000, cache_read_tokens=1_000_000), 

62 model_ref='deepseek/deepseek-v3.2', 

63 provider_id='openrouter', 

64 ) 

65 

66 assert price.input_price == snapshot(Decimal('0.2772')) 

67 assert price.output_price == snapshot(Decimal('0.3780')) 

68 assert price.total_price == snapshot(Decimal('0.6552')) 

69 assert price.model.name == snapshot('DeepSeek V3.2') 

70 assert price.provider.id == snapshot('openrouter') 

71 

72 

73def test_tiered_prices(): 

74 price = calc_price(Usage(input_tokens=500_000), model_ref='gemini-1.5-flash', provider_id='google') 

75 # Google uses threshold-based pricing: if context > 128K, ALL tokens charged at tier price 

76 # (0.15 * 500000) / 1_000_000 = 0.075 

77 

78 assert price.input_price == snapshot(Decimal('0.075')) 

79 assert price.output_price == snapshot(Decimal('0')) 

80 assert price.total_price == snapshot(Decimal('0.075')) 

81 assert price.model.name == snapshot('gemini 1.5 flash') 

82 assert price.provider.id == snapshot('google') 

83 

84 

85def test_model_price_str_tiered_prices_include_dollar_prefix(): 

86 model_price = ModelPrice(input_mtok=TieredPrices(base=Decimal('2.5'), tiers=[])) 

87 assert str(model_price) == '$2.5/input MTok (+tiers)' 

88 

89 

90def test_requests_kcount_prices(): 

91 # request count defaults to 1 

92 price = calc_price(Usage(), model_ref='sonar', provider_id='perplexity') 

93 assert price.input_price == snapshot(Decimal('0')) 

94 assert price.output_price == snapshot(Decimal('0')) 

95 assert price.total_price == snapshot(Decimal('0.012')) 

96 assert price.model.name == snapshot('Sonar') 

97 assert price.provider.name == snapshot('Perplexity') 

98 

99 

100def test_price_constraint_before(): 

101 price = calc_price(Usage(input_tokens=1000), model_ref='o3', genai_request_timestamp=datetime(2025, 6, 1)) 

102 assert price.input_price == snapshot(Decimal('0.01')) 

103 assert price.output_price == snapshot(Decimal('0')) 

104 assert price.total_price == snapshot(Decimal('0.01')) 

105 assert price.model.name == snapshot('o3') 

106 assert price.provider.name == snapshot('OpenAI') 

107 

108 

109def test_price_constraint_after(): 

110 price = calc_price(Usage(input_tokens=1000), model_ref='o3') 

111 assert price.input_price == snapshot(Decimal('0.002')) 

112 assert price.output_price == snapshot(Decimal('0')) 

113 assert price.total_price == snapshot(Decimal('0.002')) 

114 assert price.model.name == snapshot('o3') 

115 assert price.provider.name == snapshot('OpenAI') 

116 

117 

118def test_price_constraint_time_of_date(): 

119 price = calc_price( 

120 Usage(input_tokens=100_000_000), 

121 model_ref='deepseek-chat', 

122 genai_request_timestamp=datetime(2025, 6, 1, 16, tzinfo=timezone.utc), 

123 ) 

124 assert price.input_price == snapshot(Decimal('27.00')) 

125 assert price.output_price == snapshot(Decimal('0')) 

126 assert price.total_price == snapshot(Decimal('27')) 

127 assert price.model.name == snapshot('DeepSeek Chat') 

128 assert price.provider.name == snapshot('Deepseek') 

129 price = calc_price( 

130 Usage(input_tokens=100_000_000), 

131 model_ref='deepseek-chat', 

132 genai_request_timestamp=datetime(2025, 6, 1, 17, tzinfo=timezone.utc), 

133 ) 

134 assert price.input_price == snapshot(Decimal('13.500')) 

135 assert price.output_price == snapshot(Decimal('0')) 

136 assert price.total_price == snapshot(Decimal('13.5')) 

137 assert price.model.name == snapshot('DeepSeek Chat') 

138 assert price.provider.name == snapshot('Deepseek') 

139 

140 

141def test_provider_not_found_id(): 

142 with pytest.raises(LookupError, match="Unable to find provider provider_id='foobar'"): 

143 calc_price(Usage(input_tokens=500_000), model_ref='gemini-1.5-flash', provider_id='foobar') 

144 

145 

146def test_provider_not_found_url(): 

147 with pytest.raises(LookupError, match="Unable to find provider provider_api_url='foobar'"): 

148 calc_price(Usage(input_tokens=500_000), model_ref='gemini-1.5-flash', provider_api_url='foobar') 

149 

150 

151def test_provider_not_found_model_ref(): 

152 with pytest.raises(LookupError, match="Unable to find provider with model matching 'llama2-70b-4096'"): 

153 calc_price(Usage(input_tokens=500_000), model_ref='llama2-70b-4096') 

154 

155 

156def test_model_not_found(): 

157 with pytest.raises(LookupError, match="Unable to find model with model_ref='wrong' in google"): 

158 calc_price(Usage(input_tokens=500_000), model_ref='wrong', provider_id='google') 

159 

160 

161EXAMPLES: list[tuple[str, str]] = [ 

162 # ('openrouter', 'amazon/us.amazon.nova-micro-v1:0'), 

163 # ('openrouter', 'amazon/us.amazon.nova-pro-v1:0'), 

164 ('anthropic', 'anthropic.claude-v2'), 

165 ('anthropic', 'claude-3-5-haiku-123'), 

166 ('anthropic', 'claude-3-5-haiku-20241022'), 

167 ('anthropic', 'claude-3-5-haiku-latest'), 

168 ('anthropic', 'claude-3-5-sonnet-20241022'), 

169 ('anthropic', 'claude-3-5-sonnet-latest'), 

170 ('anthropic', 'claude-3-7-sonnet-20250219'), 

171 ('anthropic', 'claude-3-7-sonnet-latest'), 

172 ('anthropic', 'claude-3-opus-20240229'), 

173 ('anthropic', 'claude-opus-4-20250514'), 

174 ('anthropic', 'claude-opus-4-20250514'), 

175 ('anthropic', 'claude-opus-4-0'), 

176 ('cohere', 'command-r7b-12-2024'), 

177 ('deepseek', 'deepseek-r1-distill-llama-70b'), 

178 ('google', 'gemini-1.5-flash-002'), 

179 ('google', 'gemini-1.5-flash-123'), 

180 ('google', 'gemini-1.5-flash'), 

181 ('google', 'gemini-1.5-pro-002'), 

182 ('google', 'gemini-2.0-flash-exp'), 

183 ('google', 'gemini-2.0-flash-thinking-exp-01-21'), 

184 ('google', 'gemini-2.0-flash'), 

185 ('google', 'gemini-2.5-pro-preview-03-25'), 

186 # ('openrouter', 'meta-llama/llama-3.3-70b-versatile'), 

187 # ('openrouter', 'meta-llama/llama-4-scout-17b-16e-instruct'), 

188 ('mistral', 'mistral-small-latest'), 

189 ('mistral', 'pixtral-12b-latest'), 

190 ('openai', 'gpt-3.5-turbo-0125'), 

191 ('openai', 'gpt-3.5-turbo-instruct:20230824-v2'), 

192 ('openai', 'gpt-4-0613'), 

193 ('openai', 'gpt-4.1-2025-04-14'), 

194 ('openai', 'gpt-4.1-mini-2025-04-14'), 

195 ('openai', 'gpt-4.1-mini'), 

196 ('openai', 'gpt-4.1-nano-2025-04-14'), 

197 ('openai', 'gpt-4.5-preview-2025-02-27'), 

198 ('openai', 'gpt-4o-2024-08-06'), 

199 ('openai', 'gpt-4o-2024-11-20'), 

200 ('openai', 'gpt-4o-audio-preview-2024-10-01'), 

201 ('openai', 'gpt-4o-audio-preview-2024-12-17'), 

202 ('openai', 'gpt-4o-mini-2024-07-18'), 

203 ('openai', 'gpt-4o-mini'), 

204 ('openai', 'gpt-4o'), 

205 ('openai', 'o3-mini-2025-01-31'), 

206 ('openai', 'gpt-5.4'), 

207 ('openai', 'gpt-5.4-pro'), 

208 ('openai', 'text-embedding-3-small'), 

209] 

210 

211 

212@pytest.mark.parametrize('provider,model', EXAMPLES) 

213def test_models_found(provider: str, model: str): 

214 calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref=model, provider_id=provider) 

215 

216 

217def test_complex_usage(): 

218 # Based on https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash 

219 # Input price 

220 # $0.30 (text / image / video) 

221 # $1.00 (audio) 

222 # Output price (including thinking tokens) 

223 # $2.50 

224 # Context caching price 

225 # $0.03 (text / image / video) 

226 # $0.10 (audio) 

227 

228 mil = 1_000_000 

229 assert calc_price( 

230 Usage(input_tokens=mil), 

231 'gemini-2.5-flash', 

232 ).total_price == snapshot(Decimal('0.3')) 

233 

234 # input_audio_tokens == input_tokens means all tokens are audio tokens 

235 assert calc_price( 

236 Usage(input_tokens=mil, input_audio_tokens=mil), 

237 'gemini-2.5-flash', 

238 ).total_price == snapshot(Decimal('1.0')) 

239 

240 assert calc_price( 

241 Usage(output_tokens=mil), 

242 'gemini-2.5-flash', 

243 ).total_price == snapshot(Decimal('2.5')) 

244 

245 # All cached text tokens 

246 assert calc_price( 

247 Usage(input_tokens=mil, cache_read_tokens=mil), 

248 'gemini-2.5-flash', 

249 ).total_price == snapshot(Decimal('0.03')) 

250 

251 # All cached audio tokens 

252 assert calc_price( 

253 Usage(input_tokens=mil, input_audio_tokens=mil, cache_read_tokens=mil, cache_audio_read_tokens=mil), 

254 'gemini-2.5-flash', 

255 ).total_price == snapshot(Decimal('0.10')) 

256 

257 cached_text_tokens = 1 

258 uncached_text_tokens = 1_000 

259 cached_audio_tokens = 1_000_000 

260 uncached_audio_tokens = 1_000_000_000 

261 cached_tokens = cached_text_tokens + cached_audio_tokens 

262 audio_tokens = uncached_audio_tokens + cached_audio_tokens 

263 total_input_tokens = cached_text_tokens + uncached_text_tokens + cached_audio_tokens + uncached_audio_tokens 

264 assert total_input_tokens == 1_001_001_001 

265 

266 assert ( 

267 calc_price( 

268 Usage( 

269 input_tokens=total_input_tokens, 

270 input_audio_tokens=audio_tokens, 

271 cache_read_tokens=cached_tokens, 

272 cache_audio_read_tokens=cached_audio_tokens, 

273 ), 

274 'gemini-2.5-flash', 

275 ).total_price 

276 == snapshot(Decimal('1000.100_300_03')) 

277 == Decimal('0.03') * cached_text_tokens / mil 

278 + Decimal('0.3') * uncached_text_tokens / mil 

279 + Decimal('0.1') * cached_audio_tokens / mil 

280 + Decimal('1.0') * uncached_audio_tokens / mil 

281 ) 

282 

283 

284def test_output_audio_usage(): 

285 mil = 1_000_000 

286 

287 assert calc_price( 

288 Usage(output_tokens=mil), 

289 'gpt-4o-realtime-preview', 

290 ).total_price == snapshot(Decimal('20.0')) 

291 

292 # All audio tokens 

293 assert calc_price( 

294 Usage(output_tokens=mil, output_audio_tokens=mil), 

295 'gpt-4o-realtime-preview', 

296 ).total_price == snapshot(Decimal('80.0')) 

297 

298 output_text_tokens = mil 

299 output_audio_tokens = mil * 1000 

300 total_output_tokens = output_text_tokens + output_audio_tokens 

301 assert ( 

302 calc_price( 

303 Usage(output_tokens=total_output_tokens, output_audio_tokens=output_audio_tokens), 

304 'gpt-4o-realtime-preview', 

305 ).total_price 

306 == snapshot(Decimal('80020.0')) 

307 == Decimal('20') * output_text_tokens / mil + Decimal('80') * output_audio_tokens / mil 

308 ) 

309 

310 

311# ---------------------------------------------------------------------------- 

312# Section 3 engine semantics: voice_class edge cases (9 deterministic cases) 

313# ---------------------------------------------------------------------------- 

314 

315 

316import random as _random # noqa: E402 (kept near use-site to make the seed obvious) 

317import warnings as _warnings # noqa: E402 

318 

319 

320def _tts_model(**price_kwargs: object) -> ModelPrice: 

321 """Build a ModelPrice for TTS-side engine tests. Defaults to $0.18/kchar.""" 

322 defaults: dict[str, object] = {'input_kchars': Decimal('0.18')} 

323 defaults.update(price_kwargs) 

324 return ModelPrice(**defaults) # type: ignore[arg-type] # kwargs forwarded to dataclass 

325 

326 

327def test_voice_class_edge_case_1_missing_falls_back_to_default_silently(): 

328 """Edge case 1: missing voice_class with multipliers present -> silent default.""" 

329 mp = _tts_model(voice_multipliers={'default': Decimal('1.0'), 'premium': Decimal('1.5')}) 

330 with _warnings.catch_warnings(): 

331 _warnings.simplefilter('error') # any warning becomes an exception 

332 result = mp.calc_price(Usage(characters=200)) 

333 assert result['applied_voice_multiplier'] == Decimal('1.0') 

334 assert result['total_price'] == Decimal('0.036') 

335 

336 

337def test_voice_class_edge_case_2_unknown_emits_warning_and_falls_back(): 

338 """Edge case 2: unknown voice_class emits warning, falls back to default.""" 

339 mp = _tts_model(voice_multipliers={'default': Decimal('1.0'), 'premium': Decimal('1.5')}) 

340 with _warnings.catch_warnings(record=True) as caught: 

341 _warnings.simplefilter('always') 

342 result = mp.calc_price(Usage(characters=200, voice_class='typoed')) 

343 assert len(caught) == 1 

344 assert 'typoed' in str(caught[0].message) 

345 assert result['applied_voice_multiplier'] == Decimal('1.0') 

346 

347 

348def test_voice_class_edge_case_3_class_provided_but_no_multipliers(): 

349 """Edge case 3: voice_class on Usage, multipliers unset on model -> silently ignored.""" 

350 mp = _tts_model() # no voice_multipliers 

351 with _warnings.catch_warnings(): 

352 _warnings.simplefilter('error') 

353 result = mp.calc_price(Usage(characters=200, voice_class='premium')) 

354 assert result['applied_voice_multiplier'] is None 

355 assert result['breakdown'].voice_class_input_adjustment == Decimal('0') 

356 

357 

358def test_voice_class_edge_case_4_multipliers_set_but_no_char_or_sec_usage(): 

359 """Edge case 4: multipliers set, no char/sec in Usage -> adjustments are 0, 

360 applied_voice_multiplier still reports the looked-up value. 

361 """ 

362 mp = ModelPrice( 

363 input_audio_mtok=Decimal('40'), 

364 voice_multipliers={'default': Decimal('1.0'), 'premium': Decimal('1.5')}, 

365 ) 

366 result = mp.calc_price(Usage(input_audio_tokens=1000, voice_class='premium')) 

367 assert result['applied_voice_multiplier'] == Decimal('1.5') # reported even though nothing scaled 

368 assert result['breakdown'].voice_class_input_adjustment == Decimal('0') 

369 assert result['breakdown'].voice_class_output_adjustment == Decimal('0') 

370 

371 

372def test_voice_class_edge_case_5_input_mtok_and_input_kchars_coexist(): 

373 """Edge case 5: both input_mtok and input_kchars set; they read disjoint Usage fields.""" 

374 mp = ModelPrice(input_mtok=Decimal('1.0'), input_kchars=Decimal('0.18')) 

375 result = mp.calc_price(Usage(input_tokens=1_000_000, characters=1000)) 

376 # input_mtok: 1.0 / mil * 1_000_000 = 1.0 

377 # input_kchars: 0.18 / 1000 * 1000 = 0.18 

378 assert result['breakdown'].input_tokens == Decimal('1.0') 

379 assert result['breakdown'].input_kchars == Decimal('0.18') 

380 assert result['input_price'] == Decimal('1.18') 

381 

382 

383def test_voice_class_edge_case_6_zero_characters_with_multipliers(): 

384 """Edge case 6: zero characters, multipliers set -> input_kchars = 0, adjustment = 0.""" 

385 mp = _tts_model(voice_multipliers={'default': Decimal('1.0'), 'premium': Decimal('1.5')}) 

386 result = mp.calc_price(Usage(characters=0, voice_class='premium')) 

387 assert result['breakdown'].input_kchars == Decimal('0') 

388 assert result['breakdown'].voice_class_input_adjustment == Decimal('0') 

389 assert result['applied_voice_multiplier'] == Decimal('1.5') 

390 

391 

392def test_voice_class_edge_case_7_conditional_price_picks_correct_multipliers(): 

393 """Edge case 7: ConditionalPrice with per-conditional voice_multipliers. 

394 

395 Two date-effective ModelPrice blocks, each with its own multipliers; calc_price 

396 runs the multiplier resolution against the active block. 

397 """ 

398 from datetime import date 

399 

400 from voice_prices.types import ( 

401 ClauseEquals as _ClauseEquals, 

402 ConditionalPrice, 

403 ModelInfo, 

404 Provider, 

405 StartDateConstraint, 

406 ) 

407 

408 old_block = ModelPrice( 

409 input_kchars=Decimal('0.18'), 

410 voice_multipliers={'default': Decimal('1.0'), 'premium': Decimal('2.0')}, 

411 ) 

412 new_block = ModelPrice( 

413 input_kchars=Decimal('0.18'), 

414 voice_multipliers={'default': Decimal('1.0'), 'premium': Decimal('1.5')}, 

415 ) 

416 model = ModelInfo( 

417 id='x', 

418 match=_ClauseEquals('x'), 

419 prices=[ 

420 ConditionalPrice(constraint=None, prices=old_block), 

421 ConditionalPrice(constraint=StartDateConstraint(start_date=date(2026, 1, 1)), prices=new_block), 

422 ], 

423 ) 

424 provider = Provider(id='p', name='P', api_pattern=r'.*') 

425 

426 # Before 2026-01-01 -> old block, 2.0x multiplier 

427 pre = model.calc_price( 

428 Usage(characters=200, voice_class='premium'), 

429 provider, 

430 genai_request_timestamp=datetime(2025, 12, 31, tzinfo=timezone.utc), 

431 ) 

432 assert pre.applied_voice_multiplier == Decimal('2.0') 

433 

434 # After 2026-01-01 -> new block, 1.5x multiplier 

435 post = model.calc_price( 

436 Usage(characters=200, voice_class='premium'), 

437 provider, 

438 genai_request_timestamp=datetime(2026, 6, 1, tzinfo=timezone.utc), 

439 ) 

440 assert post.applied_voice_multiplier == Decimal('1.5') 

441 

442 

443def test_voice_class_edge_case_8_tiered_prices_on_kchars_rejected_at_schema_layer(): 

444 """Edge case 8: TieredPrices on input_kchars is rejected at schema validation. 

445 

446 Engine never sees this; the Pydantic schema in prices_types.py rejects it. See 

447 test_schema_validation.py::test_no_tiers_on_char_or_sec_fields. 

448 """ 

449 # Sanity placeholder. The validator lives on the Pydantic schema, exercised in 

450 # the schema-validation suite. Engine has no special handling needed. 

451 pass 

452 

453 

454def test_voice_class_edge_case_9_negative_multiplier_rejected_at_schema_layer(): 

455 """Edge case 9: zero or negative multiplier rejected by Gt(0) constraint. 

456 

457 Engine never sees this; see test_schema_validation.py::test_voice_multiplier_values_positive. 

458 """ 

459 pass 

460 

461 

462# ---------------------------------------------------------------------------- 

463# Math invariant: total_price == breakdown.sum() over a deterministic matrix 

464# ---------------------------------------------------------------------------- 

465 

466 

467def test_invariant_breakdown_sums_to_total(): 

468 """Section 5: seeded matrix over (chars, kchars_rate, multiplier, audio_secs, kseconds_rate). 

469 

470 For each random (rate, usage, multiplier) tuple, the math invariant must hold: 

471 `total_price == sum(every Decimal field on PriceBreakdown)`. 

472 """ 

473 rng = _random.Random(20260527) # seed pinned in design Section 5.2 

474 

475 def _rand_decimal(low: float, high: float, places: int = 6) -> Decimal: 

476 # quantize to a Decimal with `places` fractional digits to avoid float noise 

477 return Decimal(str(round(rng.uniform(low, high), places))) 

478 

479 failures: list[str] = [] 

480 for _ in range(500): 

481 characters = rng.randint(0, 1_000_000) 

482 kchars_rate = _rand_decimal(0.001, 1.0) 

483 

484 audio_seconds = rng.randint(0, 100_000) 

485 kseconds_rate = _rand_decimal(0.001, 1.0) 

486 

487 # Multiplier sometimes None (no voice_multipliers), sometimes in [0.1, 5.0]. 

488 use_multipliers = rng.random() < 0.7 

489 if use_multipliers: 

490 multiplier_value = _rand_decimal(0.1, 5.0, places=3) 

491 voice_multipliers = {'default': multiplier_value} 

492 voice_class = None 

493 else: 

494 voice_multipliers = None 

495 voice_class = None 

496 

497 # Optional token rate to mix with TTS. 

498 input_mtok = _rand_decimal(0.1, 50.0) if rng.random() < 0.5 else None 

499 input_tokens = rng.randint(0, 1_000_000) if input_mtok is not None else 0 

500 

501 mp = ModelPrice( 

502 input_mtok=input_mtok, 

503 input_kchars=kchars_rate, 

504 output_audio_kseconds=kseconds_rate, 

505 voice_multipliers=voice_multipliers, 

506 ) 

507 usage = Usage( 

508 input_tokens=input_tokens if input_tokens > 0 else None, 

509 characters=characters, 

510 audio_output_seconds=Decimal(audio_seconds), 

511 voice_class=voice_class, 

512 ) 

513 result = mp.calc_price(usage) 

514 

515 total = result['total_price'] 

516 breakdown_sum = result['breakdown'].sum() 

517 if total != breakdown_sum: 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true

518 failures.append( 

519 f'mismatch: total={total} sum={breakdown_sum} usage={usage} mp.voice_multipliers={voice_multipliers}' 

520 ) 

521 

522 assert not failures, '\n'.join(failures[:5]) 

523 

524 

525# ---------------------------------------------------------------------------- 

526# v0.0.7 STT additions: int-coercion forward-compat for audio duration fields 

527# ---------------------------------------------------------------------------- 

528 

529 

530def test_int_audio_output_seconds_prices_correctly(): 

531 """Migration safety net: v0.x callers passing `audio_output_seconds=60` (int) 

532 continue to price correctly under v0.0.7's Decimal annotation, via the 

533 engine's runtime Decimal(...) coercion. Lock the contract so a future 

534 cleanup of the redundant-looking Decimal() wrap doesn't silently break int 

535 callers. 

536 """ 

537 mp = ModelPrice(output_audio_kseconds=Decimal('0.50')) # $0.50/k secs 

538 r_int = mp.calc_price(Usage(audio_output_seconds=60)) # type: ignore[arg-type] 

539 r_dec = mp.calc_price(Usage(audio_output_seconds=Decimal(60))) 

540 assert r_int['total_price'] == r_dec['total_price'] == Decimal('0.03') 

541 

542 

543def test_int_audio_input_seconds_prices_correctly(): 

544 """Forward-compat: the engine wraps audio_input_seconds with Decimal(...) before 

545 computing cost, which accepts int too. Lock the contract so a future cleanup 

546 of the redundant-looking Decimal() wrap doesn't silently break int callers 

547 (e.g. someone passing a raw int(duration_seconds) from ffmpeg). 

548 """ 

549 mp = ModelPrice(input_audio_kseconds=Decimal('0.10')) # $0.10/k secs 

550 r_int = mp.calc_price(Usage(audio_input_seconds=60)) # type: ignore[arg-type] 

551 r_dec = mp.calc_price(Usage(audio_input_seconds=Decimal(60))) 

552 assert r_int['total_price'] == r_dec['total_price'] == Decimal('0.006')