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
« 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
4import pytest
5from inline_snapshot import snapshot
7from voice_prices import Usage, calc_price
8from voice_prices.types import ModelPrice, TieredPrices
10pytestmark = pytest.mark.anyio
13def test_sync_success_with_provider():
14 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o', provider_id='openai')
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
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
38def test_sync_success_with_model():
39 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o')
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
49def test_sync_success_with_model_regex():
50 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='o3')
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')
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 )
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')
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
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')
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)'
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')
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')
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')
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')
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')
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')
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')
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')
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]
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)
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)
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'))
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'))
240 assert calc_price(
241 Usage(output_tokens=mil),
242 'gemini-2.5-flash',
243 ).total_price == snapshot(Decimal('2.5'))
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'))
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'))
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
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 )
284def test_output_audio_usage():
285 mil = 1_000_000
287 assert calc_price(
288 Usage(output_tokens=mil),
289 'gpt-4o-realtime-preview',
290 ).total_price == snapshot(Decimal('20.0'))
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'))
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 )
311# ----------------------------------------------------------------------------
312# Section 3 engine semantics: voice_class edge cases (9 deterministic cases)
313# ----------------------------------------------------------------------------
316import random as _random # noqa: E402 (kept near use-site to make the seed obvious)
317import warnings as _warnings # noqa: E402
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
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')
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')
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')
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')
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')
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')
392def test_voice_class_edge_case_7_conditional_price_picks_correct_multipliers():
393 """Edge case 7: ConditionalPrice with per-conditional voice_multipliers.
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
400 from voice_prices.types import (
401 ClauseEquals as _ClauseEquals,
402 ConditionalPrice,
403 ModelInfo,
404 Provider,
405 StartDateConstraint,
406 )
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'.*')
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')
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')
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.
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
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.
457 Engine never sees this; see test_schema_validation.py::test_voice_multiplier_values_positive.
458 """
459 pass
462# ----------------------------------------------------------------------------
463# Math invariant: total_price == breakdown.sum() over a deterministic matrix
464# ----------------------------------------------------------------------------
467def test_invariant_breakdown_sums_to_total():
468 """Section 5: seeded matrix over (chars, kchars_rate, multiplier, audio_secs, kseconds_rate).
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
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)))
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)
484 audio_seconds = rng.randint(0, 100_000)
485 kseconds_rate = _rand_decimal(0.001, 1.0)
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
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
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)
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 )
522 assert not failures, '\n'.join(failures[:5])
525# ----------------------------------------------------------------------------
526# v0.0.7 STT additions: int-coercion forward-compat for audio duration fields
527# ----------------------------------------------------------------------------
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')
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')