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

1"""Tests for the docs generator (prices.build_docs). 

2 

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

7 

8from __future__ import annotations 

9 

10import json 

11import re 

12import shutil 

13from pathlib import Path 

14from typing import Any, cast 

15 

16import pytest 

17 

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) 

49 

50 

51def _real_data() -> list[dict[str, Any]]: 

52 return json.loads(DATA_JSON.read_text()) 

53 

54 

55# ---- price shape normalizer -------------------------------------------------- 

56 

57 

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 

63 

64 

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 

70 

71 

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 

84 

85 

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} 

89 

90 

91def test_base_prices_empty(): 

92 flat, tiered, daily = base_prices({}) 

93 assert flat == {} 

94 assert tiered is False 

95 assert daily is False 

96 

97 

98# ---- modality detection ------------------------------------------------------ 

99 

100 

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 

106 

107 

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' 

115 

116 

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' 

127 

128 

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 } 

142 

143 

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 

153 

154 

155# ---- catalog ----------------------------------------------------------------- 

156 

157 

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

173 

174 

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

187 

188 

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

204 

205 

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 

212 

213 

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 

220 

221 

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) 

242 

243 

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 

251 

252 

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) 

260 

261 

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

279 

280 

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 

293 

294 

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

313 

314 

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

329 

330 

331# ---- real catalog ------------------------------------------------------------ 

332 

333 

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 == [] 

348 

349 

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

357 

358 

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 } 

374 

375 

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 ] 

383 

384 

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 

392 

393 

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 

410 

411 

412# ---- MDX rendering ----------------------------------------------------------- 

413 

414 

415def test_cell_escapes_mdx_and_table_syntax(): 

416 assert cell('a|b') == r'a\|b' 

417 assert cell('<script>') == '&lt;script>' 

418 assert cell('{value}') == '&#123;value}' 

419 

420 

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

427 

428 

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' 

434 

435 

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

441 

442 

443def test_mdx_table_empty_says_so_rather_than_rendering_a_headless_table(): 

444 assert mdx_table(['Model'], []) == '_No models priced yet._' 

445 

446 

447def _row(**values: float | int | None) -> ModelRow: 

448 return {'id': 'm', 'name': 'M', 'values': dict(values), 'status': None, 'verified': None, 'markers': []} 

449 

450 

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

457 

458 

459def test_slug_makes_provider_ids_url_safe(): 

460 assert slug('huggingface_novita') == 'huggingface-novita' 

461 assert slug('x-ai') == 'x-ai' 

462 

463 

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 

473 

474 

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 

486 

487 

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 

498 

499 

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 

508 

509 

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 

516 

517 

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 

527 

528 

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 

537 

538 

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 

547 

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 

554 

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 

558 

559 

560def test_estimated_rates_are_marked_and_footnoted(): 

561 """A vendor's own estimate must not print like a billed rate. 

562 

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) 

570 

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

577 

578 assert '`gpt-4o-transcribe` <sup>estimated</sup>' in page 

579 assert 'not the meter the vendor bills on' in page 

580 

581 

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 

588 

589 

590# ---- navigation -------------------------------------------------------------- 

591 

592 

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

601 

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

608 

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

612 

613 

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 ] 

627 

628 

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} 

645 

646 

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' 

650 

651 

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 

657 

658 

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

666 

667 

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 

676 

677 

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' 

684 

685 

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 == [] 

696 

697 

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 

704 

705 

706# ---- generated-block injection ---------------------------------------------- 

707 

708 

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

717 

718 

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 

724 

725 

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

732 

733 

734# ---- full build -------------------------------------------------------------- 

735 

736 

737def test_build_docs_is_deterministic_and_committed_output_is_in_sync(tmp_path: Path): 

738 """The drift gate. 

739 

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) 

747 

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

756 

757 

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

765 

766 

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 

773 

774 

775# ---- LiveKit vs direct comparison ------------------------------------------- 

776 

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] 

808 

809 

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) 

817 

818 

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) 

825 

826 

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 

833 

834 

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 

842 

843 

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()) == [] 

848 

849 

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) 

853 

854 

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()) == [] 

859 

860 

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

867 

868 

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 

878 

879 

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 

900 

901 

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 

919 

920 

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

927 

928 

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) 

939 

940 

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