Coverage for tests/test_build_docs.py: 99.21%
455 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
1"""Tests for the docs generator (prices.build_docs).
3Covers the price-shape normalizer, modality detection (including the curated overrides), MDX
4rendering, the generated navigation, and a full build against the real prices/data.json so the
5committed pages can never silently drift from the catalog they claim to describe.
6"""
8from __future__ import annotations
10import json
11import re
12import shutil
13from pathlib import Path
14from typing import Any, cast
16import pytest
18from prices.build_docs import (
19 CATEGORIES,
20 DATA_JSON,
21 DOCS_DIR,
22 INDEX_PAGE,
23 MODALITY_OVERRIDES,
24 Column,
25 Comparison,
26 ModelRow,
27 ProviderEntry,
28 _resolve_direct,
29 active_columns,
30 base_prices,
31 build_catalog,
32 build_comparison,
33 build_docs,
34 build_navigation,
35 category_tab,
36 cell,
37 detect_modality,
38 fmt_pct,
39 fmt_usd,
40 inject_blocks,
41 mdx_table,
42 missing_alias_targets,
43 provenance_source,
44 render_index_page,
45 render_provider_page,
46 slug,
47 unknown_prefix_providers,
48)
51def _real_data() -> list[dict[str, Any]]:
52 return json.loads(DATA_JSON.read_text())
55# ---- price shape normalizer --------------------------------------------------
58def test_base_prices_flat():
59 flat, tiered, daily = base_prices({'input_mtok': 2.0, 'output_mtok': 8.0})
60 assert flat == {'input_mtok': 2.0, 'output_mtok': 8.0}
61 assert tiered is False
62 assert daily is False
65def test_base_prices_tiered_reads_base():
66 flat, tiered, daily = base_prices({'input_mtok': {'base': 3, 'tiers': [{'start': 200000, 'price': 6}]}})
67 assert flat == {'input_mtok': 3.0}
68 assert tiered is True
69 assert daily is False
72def test_base_prices_conditional_uses_base_block_and_flags_daily():
73 prices = [
74 {'prices': {'input_mtok': 0.135, 'output_mtok': 0.55}},
75 {
76 'constraint': {'start_time': '00:30:00Z', 'end_time': '16:30:00Z'},
77 'prices': {'input_mtok': 0.27, 'output_mtok': 1.1},
78 },
79 ]
80 flat, tiered, daily = base_prices(prices)
81 assert flat == {'input_mtok': 0.135, 'output_mtok': 0.55}
82 assert tiered is False
83 assert daily is True
86def test_base_prices_skips_voice_multipliers():
87 flat, _, _ = base_prices({'input_kchars': 0.04, 'voice_multipliers': {'default': 1.0, 'premium': 1.5}})
88 assert flat == {'input_kchars': 0.04}
91def test_base_prices_empty():
92 flat, tiered, daily = base_prices({})
93 assert flat == {}
94 assert tiered is False
95 assert daily is False
98# ---- modality detection ------------------------------------------------------
101def test_detect_modality_by_priced_field():
102 assert detect_modality({'input_kchars': 0.04}) == 'tts'
103 assert detect_modality({'input_audio_kseconds': 0.08}) == 'stt'
104 assert detect_modality({'input_mtok': 2.0, 'output_mtok': 8.0}) == 'llm'
105 assert detect_modality({}) is None
108def test_detect_modality_s2s_needs_both_audio_directions():
109 # Bidirectional audio pricing is what makes a model speech-to-speech.
110 assert detect_modality({'input_audio_mtok': 32.0, 'output_audio_mtok': 64.0}) == 's2s'
111 # Audio in only is a multimodal chat model, not a realtime voice model.
112 assert detect_modality({'input_mtok': 5.0, 'input_audio_mtok': 40.0}) == 'llm'
113 # Audio out only, with no override, is likewise not s2s.
114 assert detect_modality({'input_mtok': 0.6, 'output_audio_mtok': 12.0}) == 'llm'
117def test_detect_modality_overrides_win_over_the_field_rule():
118 # gpt-4o-transcribe bills audio per token, so the field rule alone would file it under llm and
119 # it would disappear from the STT tab.
120 audio_tokens = {'input_mtok': 2.5, 'output_mtok': 10.0, 'input_audio_mtok': 6.0}
121 assert detect_modality(audio_tokens) == 'llm'
122 assert detect_modality(audio_tokens, ('openai', 'gpt-4o-transcribe')) == 'stt'
123 assert detect_modality({'output_audio_mtok': 12.0}, ('openai', 'gpt-4o-mini-tts')) == 'tts'
124 # A VAD model is priced exactly like STT and is only distinguishable by the override.
125 assert detect_modality({'input_audio_kseconds': 0.025}) == 'stt'
126 assert detect_modality({'input_audio_kseconds': 0.025}, ('ai_coustics', 'quail-vad-2.0-xxs-16khz')) == 'vad'
129def test_modality_overrides_are_exactly_these():
130 # The override map is the one place a model's category is decided by hand rather than by its
131 # priced fields. Pinning it means a new entry has to be a deliberate, reviewed change.
132 assert MODALITY_OVERRIDES == {
133 ('openai', 'gpt-4o-transcribe'): 'stt',
134 ('openai', 'gpt-4o-mini-transcribe'): 'stt',
135 ('openai', 'gpt-4o-mini-tts'): 'tts',
136 ('soniox', 'stt-async'): 'stt',
137 ('soniox', 'stt-rt'): 'stt',
138 ('soniox', 'tts-rt'): 'tts',
139 ('ai_coustics', 'quail-vad-2.0-xxs-16khz'): 'vad',
140 ('ai_coustics', 'quail-vf-vad-2.0-s-16khz'): 'vad',
141 }
144def test_every_override_target_exists_in_the_real_catalog():
145 # An override naming a renamed or removed model would silently stop applying, dropping the model
146 # back onto the wrong tab with no error anywhere.
147 present: set[tuple[str, str]] = set()
148 for provider in _real_data():
149 models: list[dict[str, Any]] = provider.get('models') or []
150 for model in models:
151 present.add((str(provider.get('id')), str(model.get('id'))))
152 assert set(MODALITY_OVERRIDES) <= present
155# ---- catalog -----------------------------------------------------------------
158def test_build_catalog_excludes_unpriced_and_deprecated():
159 data: list[dict[str, Any]] = [
160 {
161 'id': 'acme',
162 'name': 'Acme',
163 'models': [
164 {'id': 'acme-1', 'prices': {'input_mtok': 2.0, 'output_mtok': 8.0}, 'context_window': 1000},
165 {'id': 'acme-free', 'prices': {}}, # unpriced: excluded
166 {'id': 'acme-old', 'prices': {'input_mtok': 1.0}, 'deprecated': True}, # deprecated: excluded
167 ],
168 }
169 ]
170 catalog = build_catalog(data)
171 assert [p['id'] for p in catalog['llm']] == ['acme']
172 assert [m['id'] for m in catalog['llm'][0]['models']] == ['acme-1']
175def test_build_catalog_sorts_models_by_id():
176 data: list[dict[str, Any]] = [
177 {
178 'id': 'acme',
179 'name': 'Acme',
180 'models': [
181 {'id': 'z-model', 'prices': {'input_mtok': 1.0}},
182 {'id': 'a-model', 'prices': {'input_mtok': 2.0}},
183 ],
184 }
185 ]
186 assert [m['id'] for m in build_catalog(data)['llm'][0]['models']] == ['a-model', 'z-model']
189def test_build_catalog_provider_can_span_categories():
190 data: list[dict[str, Any]] = [
191 {
192 'id': 'multi',
193 'name': 'Multi',
194 'models': [
195 {'id': 'multi-llm', 'prices': {'input_mtok': 1.0, 'output_mtok': 2.0}},
196 {'id': 'multi-tts', 'prices': {'input_kchars': 0.02}},
197 ],
198 }
199 ]
200 catalog = build_catalog(data)
201 assert [p['id'] for p in catalog['llm']] == ['multi']
202 assert [p['id'] for p in catalog['tts']] == ['multi']
203 assert catalog['stt'] == []
206def test_stt_derives_per_minute():
207 data: list[dict[str, Any]] = [
208 {'id': 'd', 'name': 'D', 'models': [{'id': 'nova', 'prices': {'input_audio_kseconds': 0.08}}]}
209 ]
210 row = build_catalog(data)['stt'][0]['models'][0]
211 assert row['values']['per_min'] == 0.08 * 60 / 1000 # 0.0048
214def test_tts_derives_per_million_chars():
215 data: list[dict[str, Any]] = [
216 {'id': 'e', 'name': 'E', 'models': [{'id': 'flash', 'prices': {'input_kchars': 0.05}}]}
217 ]
218 row = build_catalog(data)['tts'][0]['models'][0]
219 assert row['values']['per_mchars'] == 50.0
222def test_voice_rows_carry_source_and_verified_date():
223 data: list[dict[str, Any]] = [
224 {
225 'id': 'd',
226 'name': 'D',
227 'models': [
228 {
229 'id': 'a-verified',
230 'prices': {'input_audio_kseconds': 0.08},
231 'provenance': {'last_verified': '2026-07-01'},
232 },
233 {'id': 'b-imported', 'prices': {'input_audio_kseconds': 0.09}, 'provenance': {'source': 'imported'}},
234 {'id': 'c-seed', 'prices': {'input_audio_kseconds': 0.10}},
235 ],
236 }
237 ]
238 rows = {r['id']: r for r in build_catalog(data)['stt'][0]['models']}
239 assert (rows['a-verified']['status'], rows['a-verified']['verified']) == ('verified', '2026-07-01')
240 assert (rows['b-imported']['status'], rows['b-imported']['verified']) == ('imported', None)
241 assert (rows['c-seed']['status'], rows['c-seed']['verified']) == ('seed', None)
244def test_llm_rows_carry_no_source():
245 data: list[dict[str, Any]] = [
246 {'id': 'o', 'name': 'O', 'models': [{'id': 'gpt', 'prices': {'input_mtok': 2.5, 'output_mtok': 10.0}}]}
247 ]
248 row = build_catalog(data)['llm'][0]['models'][0]
249 assert row['status'] is None
250 assert row['verified'] is None
253def test_provenance_source_never_computes_staleness():
254 # The committed pages must be a pure function of data.json. If this ever returned a
255 # wall-clock-dependent value, every page would rewrite itself the day a model went stale and
256 # turn an unrelated pull request red.
257 assert provenance_source({'last_verified': '2020-01-01'}) == ('verified', '2020-01-01')
258 assert provenance_source(None) == ('seed', None)
259 assert provenance_source({'source': 'imported'}) == ('imported', None)
262def test_api_backed_models_carry_an_api_marker():
263 # The split the docs expose: a rate a machine can re-read versus one a person read off a page.
264 data: list[dict[str, Any]] = [
265 {
266 'id': 'acme',
267 'name': 'Acme',
268 'models': [
269 {'id': 'from-api', 'prices': {'input_mtok': 1.0}, 'provenance': {'api_backed': True}},
270 {'id': 'from-page', 'prices': {'input_mtok': 2.0}, 'provenance': {'last_verified': '2026-07-01'}},
271 {'id': 'no-provenance', 'prices': {'input_mtok': 3.0}},
272 ],
273 }
274 ]
275 rows = {row['id']: row for row in build_catalog(data)['llm'][0]['models']}
276 assert rows['from-api']['markers'] == ['api']
277 assert rows['from-page']['markers'] == []
278 assert rows['no-provenance']['markers'] == []
281def test_the_api_marker_is_explained_only_when_it_appears():
282 data: list[dict[str, Any]] = [
283 {
284 'id': 'a',
285 'name': 'A',
286 'models': [{'id': 'm', 'prices': {'input_mtok': 1.0}, 'provenance': {'api_backed': True}}],
287 }
288 ]
289 catalog = build_catalog(data)
290 page = render_provider_page('llm', catalog['llm'][0])
291 assert '<sup>api</sup>' in page
292 assert 'machine-readable endpoint' in page
295def test_real_catalog_has_at_least_one_api_backed_model():
296 # Telnyx and Cerebras both publish a machine-readable pricing endpoint, so the coverage line on
297 # the overview is not vacuous. Pinned as a set so adding a provider here is a deliberate change:
298 # the marker asserts a rate is re-read automatically, which is only true if an importer reads it.
299 catalog = build_catalog(_real_data())
300 backed = [
301 (entry['id'], row['id'])
302 for category in CATEGORIES
303 for entry in catalog[category]
304 for row in entry['models']
305 if 'api' in row['markers']
306 ]
307 assert backed
308 assert {provider for provider, _ in backed} == {'telnyx', 'cerebras', 'livekit', 'livekit-scale'}
309 # The count is pinned too, not just the provider set: without it this passes even if every
310 # marker but one per provider disappeared, and the coverage line on the overview would quietly
311 # collapse from 15 to 2 with nothing failing.
312 assert len(backed) == 158, f'expected 158 api-backed models, found {len(backed)}: {sorted(backed)}'
315def test_markers_flag_tiered_daily_and_voices():
316 data: list[dict[str, Any]] = [
317 {
318 'id': 'm',
319 'name': 'M',
320 'models': [
321 {'id': 'tiered-one', 'prices': {'input_mtok': {'base': 1.0, 'tiers': [{'start': 1, 'price': 2}]}}},
322 {'id': 'voiced', 'prices': {'input_kchars': 0.04, 'voice_multipliers': {'default': 1.0, 'p': 1.5}}},
323 ],
324 }
325 ]
326 catalog = build_catalog(data)
327 assert catalog['llm'][0]['models'][0]['markers'] == ['tiered']
328 assert catalog['tts'][0]['models'][0]['markers'] == ['voices']
331# ---- real catalog ------------------------------------------------------------
334def test_real_catalog_classifies_every_priced_model():
335 # Nothing priced may fall through the classifier: a model with no category is a model that
336 # silently vanishes from the docs site.
337 data = _real_data()
338 unclassified: list[str] = []
339 for provider in data:
340 models: list[dict[str, Any]] = provider.get('models') or []
341 for model in models:
342 if model.get('deprecated') is True:
343 continue
344 flat, _, _ = base_prices(model.get('prices'))
345 if flat and detect_modality(flat, (str(provider['id']), str(model['id']))) is None: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
346 unclassified.append(f'{provider["id"]}:{model["id"]}')
347 assert unclassified == []
350def test_real_catalog_has_every_category_populated():
351 catalog = build_catalog(_real_data())
352 for category in CATEGORIES:
353 assert catalog[category], f'{category} has no providers'
354 assert 'anthropic' in {p['id'] for p in catalog['llm']}
355 assert 'deepgram' in {p['id'] for p in catalog['stt']}
356 assert 'cartesia' in {p['id'] for p in catalog['tts']}
359def test_s2s_is_exactly_the_bidirectional_realtime_models():
360 # Pinned on purpose: a new realtime model appearing here should be a visible, reviewed change,
361 # and a model dropping out means the S2S tab quietly lost a row.
362 catalog = build_catalog(_real_data())
363 found = {(entry['id'], model['id']) for entry in catalog['s2s'] for model in entry['models']}
364 assert found == {
365 ('aws', 'amazon.nova-sonic-v1:0'),
366 ('boson', 'higgs-realtime'),
367 ('openrouter', 'openai/gpt-audio'),
368 ('google', 'gemini-live-2.5-flash-preview'),
369 ('openai', 'gpt-4o-mini-realtime-preview'),
370 ('openai', 'gpt-4o-realtime-preview'),
371 ('openai', 'gpt-realtime'),
372 ('openai', 'gpt-realtime-mini'),
373 }
376def test_vad_is_the_ai_coustics_models():
377 catalog = build_catalog(_real_data())
378 assert [entry['id'] for entry in catalog['vad']] == ['ai_coustics']
379 assert [m['id'] for m in catalog['vad'][0]['models']] == [
380 'quail-vad-2.0-xxs-16khz',
381 'quail-vf-vad-2.0-s-16khz',
382 ]
385def test_ai_coustics_vad_rate_matches_the_published_per_minute_rate():
386 # $0.0015/minute at the Startup tier, stored as $ per 1,000 seconds.
387 catalog = build_catalog(_real_data())
388 for model in catalog['vad'][0]['models']:
389 per_min = model['values']['per_min']
390 assert per_min is not None
391 assert round(per_min, 8) == 0.0015
394def test_no_deprecated_model_leaks_into_the_catalog():
395 # Model ids are not globally unique (OpenRouter mirrors other providers' ids), so this is
396 # checked per (provider id, model id), not globally.
397 data = _real_data()
398 deprecated: set[tuple[str, str]] = set()
399 for provider in data:
400 models: list[dict[str, Any]] = provider.get('models') or []
401 for model_data in models:
402 if model_data.get('deprecated') is True and provider['id'] not in ('livekit', 'livekit-scale'):
403 deprecated.add((str(provider['id']), str(model_data['id'])))
404 assert deprecated # data.json has at least one deprecated model today
405 catalog = build_catalog(data)
406 for entries in catalog.values():
407 for entry in entries:
408 for model in entry['models']:
409 assert (entry['id'], model['id']) not in deprecated
412# ---- MDX rendering -----------------------------------------------------------
415def test_cell_escapes_mdx_and_table_syntax():
416 assert cell('a|b') == r'a\|b'
417 assert cell('<script>') == '<script>'
418 assert cell('{value}') == '{value}'
421def test_fmt_usd_strips_trailing_zeros_and_keeps_small_rates():
422 assert fmt_usd(50.0) == '$50'
423 assert fmt_usd(1.25) == '$1.25'
424 assert fmt_usd(0.0015) == '$0.0015'
425 assert fmt_usd(0) == '$0'
426 assert fmt_usd(None) == '-'
429def test_fmt_pct_never_shows_a_misleading_plus_zero():
430 assert fmt_pct(200.0) == '+200.0%'
431 assert fmt_pct(-2.0) == '-2.0%'
432 assert fmt_pct(0.0) == 'same'
433 assert fmt_pct(None) == 'LiveKit only'
436def test_mdx_table_aligns_text_then_numbers():
437 out = mdx_table(['Model', 'Name', 'Rate'], [['`a`', 'A', '$1']])
438 assert out.splitlines()[1] == '| --- | --- | ---: |'
439 wide = mdx_table(['Provider', 'Model', 'Name', 'Rate'], [['P', '`a`', 'A', '$1']], text_cols=3)
440 assert wide.splitlines()[1] == '| --- | --- | --- | ---: |'
443def test_mdx_table_empty_says_so_rather_than_rendering_a_headless_table():
444 assert mdx_table(['Model'], []) == '_No models priced yet._'
447def _row(**values: float | int | None) -> ModelRow:
448 return {'id': 'm', 'name': 'M', 'values': dict(values), 'status': None, 'verified': None, 'markers': []}
451def test_active_columns_drops_columns_that_are_none_for_every_row():
452 columns = (Column('A', 'a', 'usd'), Column('B', 'b', 'usd'))
453 rows = [_row(a=1.0, b=None), _row(a=2.0, b=None)]
454 assert [c.header for c in active_columns(rows, columns)] == ['A']
455 rows = [_row(a=1.0, b=None), _row(a=2.0, b=3.0)]
456 assert [c.header for c in active_columns(rows, columns)] == ['A', 'B']
459def test_slug_makes_provider_ids_url_safe():
460 assert slug('huggingface_novita') == 'huggingface-novita'
461 assert slug('x-ai') == 'x-ai'
464def test_render_provider_page_has_frontmatter_and_the_do_not_edit_notice():
465 catalog = build_catalog(_real_data())
466 entry = next(e for e in catalog['stt'] if e['id'] == 'deepgram')
467 page = render_provider_page('stt', entry)
468 assert page.startswith('---\n')
469 assert 'sidebarTitle: "Deepgram"' in page
470 assert 'Do not edit by hand' in page
471 assert 'prices/providers/deepgram.yml' in page
472 assert '$ / min' in page
475def test_frontmatter_escapes_quotes_in_contributor_supplied_values():
476 # A provider `name` comes straight from YAML someone else wrote. One unescaped quote would make
477 # the frontmatter invalid and fail the whole docs build, not just that page.
478 entry: ProviderEntry = {
479 'id': 'q',
480 'name': 'A "quoted" vendor',
481 'models': [_row(per_min=0.01)],
482 'pricing_tier': None,
483 }
484 page = render_provider_page('stt', entry)
485 assert 'sidebarTitle: "A \\"quoted\\" vendor"' in page
488def test_page_descriptions_only_claim_what_the_page_renders():
489 # An LLM page shows no per-model source, so its description must not promise one.
490 catalog = build_catalog(_real_data())
491 llm = render_provider_page('llm', next(e for e in catalog['llm'] if e['id'] == 'anthropic'))
492 assert 'where it came from' not in llm
493 stt = render_provider_page('stt', next(e for e in catalog['stt'] if e['id'] == 'deepgram'))
494 assert 'where it came from' in stt
495 # The LLM index summarises by vendor, so it is not "cheapest first" either.
496 index = render_index_page('llm', catalog['llm'], build_comparison(_real_data())['llm'])
497 assert 'cheapest first' not in index
500def test_llm_pages_carry_no_source_column():
501 # LLM rates are cross-checked against aggregators rather than per-model vendor pages, so a
502 # per-model source column there would read as low confidence when it means nothing of the sort.
503 catalog = build_catalog(_real_data())
504 entry = next(e for e in catalog['llm'] if e['id'] == 'anthropic')
505 page = render_provider_page('llm', entry)
506 assert 'Last verified' not in page
507 assert '<Badge' not in page
510def test_voice_pages_carry_a_source_badge():
511 catalog = build_catalog(_real_data())
512 entry = next(e for e in catalog['tts'] if e['id'] == 'elevenlabs')
513 page = render_provider_page('tts', entry)
514 assert 'Last verified' in page
515 assert '<Badge color="green" size="sm">verified</Badge>' in page
518def test_token_priced_voice_models_render_in_their_own_table():
519 # TTS rather than STT: every STT model in the catalog now carries a per-second rate, so
520 # gpt-4o-mini-tts is the last remaining voice model priced only in audio tokens. OpenAI
521 # publishes no per-character figure for it, not even an estimate, so it cannot be given one.
522 catalog = build_catalog(_real_data())
523 entry = next(e for e in catalog['tts'] if e['id'] == 'openai')
524 page = render_provider_page('tts', entry)
525 assert '## Billed per audio token' in page
526 assert 'gpt-4o-mini-tts' in page
529def test_llm_index_summarises_by_vendor_rather_than_listing_every_model():
530 # Over a thousand rows on one page is not readable; the vendor pages carry the detail.
531 data = _real_data()
532 catalog = build_catalog(data)
533 page = render_index_page('llm', catalog['llm'], build_comparison(data)['llm'])
534 assert 'Cheapest input $ / Mtok' in page
535 assert page.count('\n|') < 100 # one row per provider, not per model
536 assert '/llm/openrouter' in page
539def test_index_page_lists_every_model_for_small_categories():
540 data = _real_data()
541 catalog = build_catalog(data)
542 page = render_index_page('stt', catalog['stt'], build_comparison(data)['stt'])
543 total = sum(len(entry['models']) for entry in catalog['stt'])
544 # every model gets a row, minus the token-priced ones which are called out separately
545 token_priced = sum(1 for e in catalog['stt'] for m in e['models'] if m['values']['per_min'] is None)
546 assert page.count('\n| [') == total - token_priced
548 # STT has token-priced rows again: Soniox bills transcription by token rather than by
549 # audio-second, so they cannot share a column with the per-minute rates and are called out
550 # separately. (Between #107 and Soniox landing this was briefly zero, and this assertion
551 # said so; the callout is data-driven, not a permanent fixture either way.)
552 assert token_priced > 0
553 assert 'bill audio as tokens' in page
555 # TTS likewise: gpt-4o-mini-tts and Soniox's tts-rt.
556 tts_page = render_index_page('tts', catalog['tts'], build_comparison(data)['tts'])
557 assert 'bill audio as tokens' in tts_page
560def test_estimated_rates_are_marked_and_footnoted():
561 """A vendor's own estimate must not print like a billed rate.
563 gpt-4o-transcribe bills per token; its $0.006/minute comes from a column OpenAI heads
564 "Estimated cost". whisper-1 shows the same $0.006/minute as its actual billed meter. The
565 two sit adjacent in the same column, so the marker is the only thing separating them.
566 """
567 catalog = build_catalog(_real_data())
568 entry = next(e for e in catalog['stt'] if e['id'] == 'openai')
569 page = render_provider_page('stt', entry)
571 rows = {row['id']: row for row in entry['models']}
572 assert 'estimated' in rows['gpt-4o-transcribe']['markers']
573 assert 'estimated' in rows['gpt-4o-mini-transcribe']['markers']
574 # Billed meters, including one at the identical rate, stay unmarked.
575 assert 'estimated' not in rows['whisper-1']['markers']
576 assert 'estimated' not in rows['gpt-transcribe']['markers']
578 assert '`gpt-4o-transcribe` <sup>estimated</sup>' in page
579 assert 'not the meter the vendor bills on' in page
582def test_comparison_section_renders_on_voice_index_pages():
583 data = _real_data()
584 catalog = build_catalog(data)
585 page = render_index_page('tts', catalog['tts'], build_comparison(data)['tts'])
586 assert '## Via LiveKit Inference' in page
587 assert 'vs direct' in page
590# ---- navigation --------------------------------------------------------------
593def test_category_tab_groups_a_large_sidebar_and_leaves_a_small_one_flat():
594 catalog = build_catalog(_real_data())
595 llm = category_tab('llm', catalog['llm'])
596 assert llm['tab'] == 'LLM'
597 pages = cast('list[Any]', llm['pages'])
598 assert pages[0] == f'llm/{INDEX_PAGE}'
599 groups = [str(cast('dict[str, Any]', page)['group']) for page in pages if isinstance(page, dict)]
600 assert groups == ['Direct vendors', 'Gateways', 'HuggingFace Inference']
602 # S2S crossed MIN_PROVIDERS_FOR_GROUPS once Boson and OpenRouter landed, so it groups now too.
603 s2s = category_tab('s2s', catalog['s2s'])
604 s2s_groups = [
605 str(cast('dict[str, Any]', page)['group']) for page in cast('list[Any]', s2s['pages']) if isinstance(page, dict)
606 ]
607 assert s2s_groups == ['Direct vendors', 'Gateways']
609 # VAD is the small one now: one provider, so no group headings.
610 vad = category_tab('vad', catalog['vad'])
611 assert all(isinstance(page, str) for page in cast('list[Any]', vad['pages']))
614def test_build_navigation_has_the_eight_tabs_in_order():
615 nav = build_navigation(build_catalog(_real_data()))
616 assert [tab['tab'] for tab in nav['tabs']] == [
617 'Overview',
618 'STT',
619 'LLM',
620 'TTS',
621 'S2S',
622 'VAD',
623 'Agents',
624 'Telephony',
625 'Add Yours',
626 ]
629# Every icon name used anywhere in docs/, verified to exist in the Lucide set. Mintlify defaults to
630# Font Awesome, where most of these names do not exist, so the library declaration below and this
631# list have to stay in step: a mismatch silently renders a tab with no icon at all.
632LUCIDE_ICONS = {
633 'activity',
634 'audio-waveform',
635 'book-open',
636 'bot',
637 'brain',
638 'clock',
639 'git-pull-request',
640 'mic',
641 'phone',
642 'rss',
643 'volume-2',
644}
647def test_docs_json_declares_the_lucide_icon_library():
648 config = json.loads((DOCS_DIR / 'docs.json').read_text())
649 assert config.get('icons', {}).get('library') == 'lucide'
652def test_every_navigation_icon_is_a_known_lucide_name():
653 nav = build_navigation(build_catalog(_real_data()))
654 icons = {str(tab['icon']) for tab in nav['tabs']}
655 assert icons <= LUCIDE_ICONS, f'unverified icon name(s): {sorted(icons - LUCIDE_ICONS)}'
656 assert len(icons) == len(nav['tabs']) # every tab has its own icon, none dropped
659def test_every_page_frontmatter_icon_is_a_known_lucide_name():
660 used: set[str] = set()
661 for page in DOCS_DIR.rglob('*.mdx'):
662 match = re.search(r'^icon: "([^"]+)"$', page.read_text(), re.MULTILINE)
663 if match:
664 used.add(match.group(1))
665 assert used <= LUCIDE_ICONS, f'unverified icon name(s): {sorted(used - LUCIDE_ICONS)}'
668def _nav_pages(pages: list[Any]) -> list[str]:
669 flat: list[str] = []
670 for page in pages:
671 if isinstance(page, str):
672 flat.append(page)
673 else:
674 flat += _nav_pages(page['pages'])
675 return flat
678def test_every_navigation_entry_resolves_to_a_committed_page():
679 # Mintlify fails the build on a nav entry with no file, so this catches it before a deploy does.
680 config = json.loads((DOCS_DIR / 'docs.json').read_text())
681 for tab in config['navigation']['tabs']:
682 for page in _nav_pages(tab['pages']):
683 assert (DOCS_DIR / f'{page}.mdx').is_file(), f'{page}.mdx missing'
686def test_no_internal_link_points_at_a_missing_page():
687 # A broken internal link fails the Mintlify build. This also covers the hand-written pages,
688 # which the generator never touches and so nothing else would catch.
689 pages = {str(path.relative_to(DOCS_DIR).with_suffix('')) for path in DOCS_DIR.rglob('*.mdx')}
690 broken: list[str] = []
691 for page in sorted(DOCS_DIR.rglob('*.mdx')):
692 for target in re.findall(r'\]\((/[^)#\s]*)', page.read_text()):
693 if target.lstrip('/') not in pages: 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true
694 broken.append(f'{page.relative_to(DOCS_DIR)} -> {target}')
695 assert broken == []
698def test_every_committed_page_is_reachable_from_the_navigation():
699 # The other direction: an orphaned page is dead weight Mintlify warns about.
700 config = json.loads((DOCS_DIR / 'docs.json').read_text())
701 listed = {page for tab in config['navigation']['tabs'] for page in _nav_pages(tab['pages'])}
702 on_disk = {str(path.relative_to(DOCS_DIR).with_suffix('')) for path in DOCS_DIR.rglob('*.mdx')}
703 assert on_disk == listed
706# ---- generated-block injection ----------------------------------------------
709def test_inject_blocks_replaces_between_markers(tmp_path: Path):
710 page = tmp_path / 'page.mdx'
711 page.write_text('before\n{/* generated:counts start */}\nold\n{/* generated:counts end */}\nafter\n')
712 assert inject_blocks(page, {'counts': 'new'}) is True
713 assert 'new' in page.read_text()
714 assert 'old' not in page.read_text()
715 assert page.read_text().startswith('before')
716 assert page.read_text().endswith('after\n')
719def test_inject_blocks_is_idempotent(tmp_path: Path):
720 page = tmp_path / 'page.mdx'
721 page.write_text('{/* generated:counts start */}\nold\n{/* generated:counts end */}\n')
722 inject_blocks(page, {'counts': 'new'})
723 assert inject_blocks(page, {'counts': 'new'}) is False # second run changes nothing
726def test_inject_blocks_raises_when_the_marker_is_missing(tmp_path: Path):
727 # Silently dropping a generated number is how a docs site starts lying.
728 page = tmp_path / 'page.mdx'
729 page.write_text('no markers here\n')
730 with pytest.raises(ValueError, match='no `generated:counts` marker pair'):
731 inject_blocks(page, {'counts': 'new'})
734# ---- full build --------------------------------------------------------------
737def test_build_docs_is_deterministic_and_committed_output_is_in_sync(tmp_path: Path):
738 """The drift gate.
740 Regenerate the whole site into a scratch copy and byte-compare against what is committed. A
741 failure here means someone edited a price without running `make build`, so the published docs
742 would show a number the library no longer agrees with.
743 """
744 scratch = tmp_path / 'docs'
745 shutil.copytree(DOCS_DIR, scratch)
746 build_docs(scratch)
748 committed = sorted(path.relative_to(DOCS_DIR) for path in DOCS_DIR.rglob('*.mdx'))
749 regenerated = sorted(path.relative_to(scratch) for path in scratch.rglob('*.mdx'))
750 assert committed == regenerated, 'a page was added or removed by regeneration'
751 for relative in committed:
752 assert (DOCS_DIR / relative).read_text() == (scratch / relative).read_text(), (
753 f'{relative} is stale: run `make build`'
754 )
755 assert json.loads((DOCS_DIR / 'docs.json').read_text()) == json.loads((scratch / 'docs.json').read_text())
758def test_build_docs_prunes_a_page_whose_provider_no_longer_prices_anything(tmp_path: Path):
759 scratch = tmp_path / 'docs'
760 shutil.copytree(DOCS_DIR, scratch)
761 orphan = scratch / 'stt' / 'gone-vendor.mdx'
762 orphan.write_text('stale page\n')
763 build_docs(scratch)
764 assert not orphan.exists()
767def test_build_docs_leaves_hand_written_pages_alone(tmp_path: Path):
768 scratch = tmp_path / 'docs'
769 shutil.copytree(DOCS_DIR, scratch)
770 before = (scratch / 'how-fresh.mdx').read_text()
771 build_docs(scratch)
772 assert (scratch / 'how-fresh.mdx').read_text() == before
775# ---- LiveKit vs direct comparison -------------------------------------------
777COMP_DATA: list[dict[str, Any]] = [
778 {'id': 'deepgram', 'name': 'Deepgram', 'models': [{'id': 'nova-2', 'prices': {'input_audio_kseconds': 0.098333}}]},
779 {'id': 'cartesia', 'name': 'Cartesia', 'models': [{'id': 'sonic-3', 'prices': {'input_kchars': 0.04}}]},
780 {
781 'id': 'openai',
782 'name': 'OpenAI',
783 'models': [{'id': 'gpt-4o', 'prices': {'input_mtok': 2.5, 'output_mtok': 10.0}}],
784 },
785 {
786 'id': 'livekit',
787 'name': 'LiveKit Inference',
788 'models': [
789 {'id': 'deepgram/nova-2', 'name': 'Nova-2', 'prices': {'input_audio_kseconds': 0.096667}},
790 {'id': 'cartesia/sonic-2', 'name': 'Sonic 2', 'prices': {'input_kchars': 0.05}},
791 {'id': 'openai/gpt-4o', 'name': 'GPT-4o', 'prices': {'input_mtok': 2.5, 'output_mtok': 10.0}},
792 {
793 'id': 'speechmatics/standard',
794 'name': 'Speechmatics Standard',
795 'prices': {'input_audio_kseconds': 0.0833},
796 },
797 ],
798 },
799 {
800 'id': 'livekit-scale',
801 'name': 'LiveKit Inference (Scale)',
802 'models': [
803 {'id': 'deepgram/nova-2', 'name': 'Nova-2', 'prices': {'input_audio_kseconds': 0.078333}},
804 {'id': 'cartesia/sonic-2', 'name': 'Sonic 2', 'prices': {'input_kchars': 0.0375}},
805 ],
806 },
807]
810def test_build_comparison_maps_direct_and_computes_delta():
811 comp = build_comparison(COMP_DATA)
812 nova = next(r for r in comp['stt'] if r['id'] == 'deepgram/nova-2')
813 assert nova['direct'] == round(0.098333 * 60 / 1000, 6) # $/min, auto-matched to deepgram:nova-2
814 assert nova['livekit'] == round(0.096667 * 60 / 1000, 6)
815 assert nova['scale'] == round(0.078333 * 60 / 1000, 6)
816 assert nova['delta'] == round((0.096667 - 0.098333) / 0.098333 * 100, 1)
819def test_build_comparison_alias_resolves_cartesia_to_sonic3():
820 comp = build_comparison(COMP_DATA)
821 sonic = next(r for r in comp['tts'] if r['id'] == 'cartesia/sonic-2')
822 assert sonic['direct'] == round(0.04 * 1000, 6) # $/1M chars from the single direct cartesia model
823 assert sonic['livekit'] == round(0.05 * 1000, 6)
824 assert sonic['scale'] == round(0.0375 * 1000, 6)
827def test_build_comparison_livekit_only_has_no_direct_baseline():
828 comp = build_comparison(COMP_DATA)
829 spx = next(r for r in comp['stt'] if r['id'] == 'speechmatics/standard')
830 assert spx['direct'] is None # speechmatics is not a direct provider
831 assert spx['delta'] is None
832 assert spx['scale'] is None # absent from livekit-scale -> falls back to livekit
835def test_build_comparison_llm_is_pass_through():
836 comp = build_comparison(COMP_DATA)
837 gpt = next(r for r in comp['llm'] if r['id'] == 'openai/gpt-4o')
838 assert gpt['direct'] == 2.5 # input $/Mtok
839 assert gpt['livekit'] == 2.5
840 assert gpt['delta'] == 0.0
841 assert gpt['scale'] is None # LLM never in livekit-scale
844def test_alias_targets_all_present_in_real_catalog():
845 # Every curated LiveKit->direct alias must point at a model that exists in the catalog, so a
846 # future direct-model rename surfaces here instead of silently dropping a comparison baseline.
847 assert missing_alias_targets(_real_data()) == []
850def test_missing_alias_targets_detects_renamed_target():
851 broken = [p for p in _real_data() if p.get('id') != 'cartesia'] # drop the provider holding sonic-3
852 assert 'cartesia/sonic-2' in missing_alias_targets(broken)
855def test_livekit_prefix_providers_all_exist_in_catalog():
856 # Guards the x_ai/x-ai class of typo: a prefix mapped to a missing provider would silently make
857 # every model with that prefix show as LiveKit-only even when a direct baseline exists.
858 assert unknown_prefix_providers(_real_data()) == []
861def test_elevenlabs_flash_and_turbo_v2_resolve_to_their_own_direct_entry():
862 # Flash/Turbo v2 must compare against their OWN direct price, not the v2.5 entry. A cross-version
863 # alias made the site report a "+233% Flash v2" markup computed against Flash v2.5's price, which
864 # is indefensible (two different models). They now have explicit direct entries.
865 assert _resolve_direct('elevenlabs/eleven_flash_v2') == ('elevenlabs', 'eleven_flash_v2')
866 assert _resolve_direct('elevenlabs/eleven_turbo_v2') == ('elevenlabs', 'eleven_turbo_v2')
869def test_elevenlabs_flash_and_turbo_v2_are_distinct_priced_direct_models():
870 # The v2 and v2.5 entries share a price ($0.045/kchar) and an id prefix, so the collapse hook
871 # would merge v2.5 into v2 and drop it. Both must survive in the catalog so each LiveKit model
872 # compares against its matching version.
873 elevenlabs = next(p for p in _real_data() if p['id'] == 'elevenlabs')
874 priced = {m['id']: m for m in elevenlabs['models'] if m.get('prices')}
875 for model_id in ('eleven_flash_v2', 'eleven_flash_v2_5', 'eleven_turbo_v2', 'eleven_turbo_v2_5'):
876 assert model_id in priced, f'{model_id} missing from the direct ElevenLabs catalog'
877 assert priced[model_id]['prices'].get('input_kchars') is not None
880def test_elevenlabs_flash_v2_markup_is_a_true_same_model_comparison():
881 # Flash v2's baseline must come from Flash v2's OWN direct entry (guaranteed by the resolve test
882 # above), not a cross-version proxy. Flash v2 and v2.5 bill the same direct rate, so both rows show
883 # the same real markup. Value-independent on purpose: the exact percentage is not pinned, because it
884 # tracks whatever direct-rate basis the catalog uses and would churn on any legitimate reprice.
885 data = _real_data()
886 livekit = next(p for p in data if p['id'] == 'livekit')
887 livekit['models'].extend(
888 [
889 {'id': 'elevenlabs/' + model, 'prices': {'input_kchars': 0.08}}
890 for model in ('eleven_flash_v2', 'eleven_flash_v2_5')
891 ]
892 )
893 comp = build_comparison(data)
894 v2 = next(r for r in comp['tts'] if r['id'] == 'elevenlabs/eleven_flash_v2')
895 v2_5 = next(r for r in comp['tts'] if r['id'] == 'elevenlabs/eleven_flash_v2_5')
896 assert v2['direct'] is not None and v2_5['direct'] is not None
897 assert v2['direct'] == v2_5['direct'] # both Flash models bill the same direct rate
898 assert v2['delta'] == v2_5['delta']
899 assert v2['delta'] is not None and v2['delta'] > 0 # a real markup against its own baseline
902def test_livekit_xai_grok_resolves_to_a_direct_baseline():
903 # Regression: xai/grok-4-1-fast was wrongly LiveKit-only because the prefix mapped to 'x_ai'
904 # rather than the real provider id 'x-ai'. It has an exact direct match (pass-through).
905 data = _real_data()
906 livekit = next(p for p in data if p['id'] == 'livekit')
907 livekit['models'].extend(
908 [
909 {'id': 'xai/grok-4-1-fast-non-reasoning', 'prices': {'input_mtok': 0.2}},
910 {'id': 'openai/gpt-5.3-chat-latest', 'prices': {'input_mtok': 1.75}},
911 ]
912 )
913 comp = build_comparison(data)
914 grok = next(r for r in comp['llm'] if r['id'] == 'xai/grok-4-1-fast-non-reasoning')
915 assert grok['direct'] is not None # the regression: was wrongly None
916 assert grok['direct'] == grok['livekit'] # resolved to the same-priced direct model (pass-through)
917 latest = next(r for r in comp['llm'] if r['id'] == 'openai/gpt-5.3-chat-latest')
918 assert latest['direct'] is not None
921def test_comparison_has_no_s2s_or_vad_rows():
922 # Neither has a single comparable unit, and LiveKit prices neither today. A row appearing here
923 # would render with an empty rate column.
924 comp: Comparison = build_comparison(_real_data())
925 assert comp['s2s'] == []
926 assert comp['vad'] == []
929@pytest.mark.parametrize(
930 'start,end', [('2000-01-01', '2100-01-01'), ('2000-01-01', '2001-01-01'), ('2100-01-01', '2101-01-01')]
931)
932def test_scheduled_docs_use_regular_rate_regardless_of_promotion_dates(start: str, end: str):
933 prices = [
934 {'prices': {'input_kchars': 0.048}},
935 {'constraint': {'start_timestamp': start + 'T07:00:00Z'}, 'prices': {'input_kchars': 0}},
936 {'constraint': {'start_timestamp': end + 'T07:00:00Z'}, 'prices': {'input_kchars': 0.048}},
937 ]
938 assert base_prices(prices) == ({'input_kchars': 0.048}, False, True)
941def test_promotion_docs_explain_regular_rates_and_link_to_dates():
942 data = _real_data()
943 catalog = build_catalog(data)
944 entry = next(p for p in catalog['tts'] if p['id'] == 'livekit')
945 page = render_provider_page('tts', entry)
946 assert 'The regular rate is shown' in page
947 assert 'see the promotion dates below' not in page
948 assert '[provider YAML files](https://github.com/mahimailabs/voice-prices/blob/main/prices/providers)' in page
949 comparison = build_comparison(data)
950 coda = next(row for row in comparison['tts'] if row['id'] == 'rime/coda')
951 assert coda['livekit'] == 50
952 assert coda['delta'] != -100