Coverage for packages/python/voice_prices/_cli.py: 95.80%

366 statements  

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

1from __future__ import annotations 

2 

3import argparse 

4import dataclasses 

5import difflib 

6import hashlib 

7import sys 

8from collections.abc import Sequence 

9from datetime import datetime 

10from typing import Any, cast 

11 

12from pydantic import AliasChoices, Field 

13from pydantic.fields import FieldInfo 

14 

15from . import Usage, __version__, calc_price, update_prices 

16from .types import ModelPrice, PriceCalculation, Provider, TieredPrices 

17 

18try: 

19 from pydantic_settings import ( 

20 BaseSettings, 

21 CliApp, 

22 CliPositionalArg, 

23 CliSettingsSource, 

24 CliSubCommand, 

25 PydanticBaseSettingsSource, 

26 SettingsConfigDict, 

27 get_subcommand, 

28 ) 

29 from rich import box 

30 from rich.columns import Columns 

31 from rich.console import Console 

32 from rich.markup import escape 

33 from rich.table import Table 

34 from rich.text import Text 

35 from rich_argparse import RichHelpFormatter 

36except ModuleNotFoundError as exc: 

37 package = (exc.name or '').split('.')[0] 

38 if package in {'pydantic_settings', 'rich', 'rich_argparse'}: 

39 print( 

40 f'Optional CLI dependency {package!r} is not installed. ' 

41 'Install CLI extras with: pip install "voice-prices[cli]"', 

42 file=sys.stderr, 

43 ) 

44 raise SystemExit(1) from None 

45 raise 

46 

47PROGRAM_NAME = 'voice-prices' 

48 

49_PROVIDER_COLORS = ( 

50 'steel_blue1', 

51 'sea_green2', 

52 'gold3', 

53 'orchid2', 

54 'turquoise2', 

55 'light_sky_blue3', 

56 'medium_purple4', 

57 'chartreuse3', 

58 'deep_pink3', 

59 'sandy_brown', 

60 'deep_sky_blue1', 

61 'dodger_blue2', 

62 'cyan3', 

63 'spring_green2', 

64 'green_yellow', 

65 'yellow2', 

66 'orange3', 

67 'dark_orange3', 

68 'red3', 

69 'magenta3', 

70 'purple3', 

71 'violet', 

72 'hot_pink3', 

73 'salmon1', 

74 'light_salmon3', 

75 'khaki1', 

76 'olive_drab3', 

77 'aquamarine3', 

78 'medium_turquoise', 

79 'light_coral', 

80) 

81_PRICE_STYLES: dict[str, str] = { 

82 'input_mtok': 'deep_sky_blue2', 

83 'cache_write_mtok': 'dark_goldenrod', 

84 'cache_read_mtok': 'khaki3', 

85 'output_mtok': 'orange_red1', 

86 'input_audio_mtok': 'medium_purple3', 

87 'cache_audio_read_mtok': 'plum3', 

88 'output_audio_mtok': 'hot_pink2', 

89 'requests_kcount': 'dark_turquoise', 

90} 

91 

92 

93def _build_root_parser() -> argparse.ArgumentParser: 

94 return argparse.ArgumentParser( 

95 prog=PROGRAM_NAME, 

96 description=f'{PROGRAM_NAME} CLI v{__version__}\n\nCalculate prices for calling LLM inference APIs.\n', 

97 formatter_class=RichHelpFormatter, 

98 ) 

99 

100 

101class _CLIBase( 

102 BaseSettings, 

103 cli_enforce_required=True, 

104 cli_hide_none_type=True, 

105 cli_exit_on_error=True, 

106 case_sensitive=True, 

107): 

108 model_config = SettingsConfigDict( 

109 extra='forbid', 

110 cli_parse_args=False, 

111 cli_implicit_flags=True, 

112 ) 

113 

114 

115class _ToggleCliSettingsSource(CliSettingsSource[Any]): 

116 # Workaround for toggle-only boolean flags; upstream support merged in 

117 # https://github.com/pydantic/pydantic-settings/pull/717 but not in a tagged release yet. 

118 def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None: 

119 if kwargs.get('metavar') == 'bool' and self.cli_implicit_flags: 

120 del kwargs['metavar'] 

121 if kwargs.get('required'): 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 kwargs['action'] = argparse.BooleanOptionalAction 

123 else: 

124 kwargs['action'] = 'store_false' if model_default is True else 'store_true' 

125 

126 

127class CalcCLI(_CLIBase): 

128 """calculate prices""" 

129 

130 model: CliPositionalArg[list[str]] = Field( 

131 ..., 

132 description='Model and optionally provider used: either just the model ID, e.g. "gpt-4o" or in format "<provider>:<model>" e.g. "openai:gpt-4o".', 

133 ) 

134 update_prices: bool = Field( 

135 False, 

136 validation_alias=AliasChoices('u', 'update-prices'), 

137 description='Whether to update the model prices from GitHub.', 

138 ) 

139 timestamp: datetime | None = Field( 

140 None, 

141 validation_alias=AliasChoices('t', 'timestamp'), 

142 description='Timestamp of the request, in RFC 3339 format, if not provided, the current time will be used.', 

143 ) 

144 table: bool = Field( 

145 False, 

146 validation_alias=AliasChoices('T', 'table'), 

147 description='Whether to use wide table output with one row per model.', 

148 ) 

149 no_color: bool = Field( 

150 False, 

151 validation_alias=AliasChoices('n', 'no-color'), 

152 description='Whether to disable colors in calc output.', 

153 ) 

154 keep_going: bool = Field( 

155 False, 

156 validation_alias=AliasChoices('k', 'keep-going'), 

157 description='Whether to continue if a model is not found.', 

158 ) 

159 input_tokens: int | None = Field( 

160 None, 

161 validation_alias=AliasChoices('i', 'input-tokens'), 

162 description='Usage: Number of text input/prompt tokens.', 

163 ) 

164 cache_write_tokens: int | None = Field( 

165 None, 

166 validation_alias=AliasChoices('w', 'cache-write-tokens'), 

167 description='Usage: Number of tokens written to the cache.', 

168 ) 

169 cache_read_tokens: int | None = Field( 

170 None, 

171 validation_alias=AliasChoices('r', 'cache-read-tokens'), 

172 description='Usage: Number of tokens read from the cache.', 

173 ) 

174 output_tokens: int | None = Field( 

175 None, 

176 validation_alias=AliasChoices('o', 'output-tokens'), 

177 description='Usage: Number of text output/completion tokens.', 

178 ) 

179 input_audio_tokens: int | None = Field( 

180 None, 

181 validation_alias=AliasChoices('a', 'input-audio-tokens'), 

182 description='Usage: Number of audio input tokens.', 

183 ) 

184 cache_audio_read_tokens: int | None = Field( 

185 None, 

186 validation_alias=AliasChoices('A', 'cache-audio-read-tokens'), 

187 description='Usage: Number of audio tokens read from the cache.', 

188 ) 

189 output_audio_tokens: int | None = Field( 

190 None, 

191 validation_alias=AliasChoices('O', 'output-audio-tokens'), 

192 description='Usage: Number of output audio tokens.', 

193 ) 

194 

195 

196class ListCLI(_CLIBase): 

197 """list providers and models""" 

198 

199 provider: CliPositionalArg[str | None] = Field( 

200 None, 

201 description='Only list models for the provider.', 

202 ) 

203 

204 

205class CLIRoot(_CLIBase): 

206 calc: CliSubCommand[CalcCLI] = Field( 

207 description='Calculate prices.', 

208 ) 

209 list: CliSubCommand[ListCLI] = Field( 

210 description='List providers and models.', 

211 ) 

212 version: bool = Field( 

213 False, 

214 validation_alias=AliasChoices('v', 'version'), 

215 description='Show version and exit', 

216 ) 

217 plain: bool = Field( 

218 False, 

219 validation_alias=AliasChoices('p', 'plain'), 

220 description='Use plain output without rich formatting.', 

221 ) 

222 

223 @classmethod 

224 def settings_customise_sources( 

225 cls, 

226 settings_cls: type[BaseSettings], 

227 init_settings: PydanticBaseSettingsSource, 

228 env_settings: PydanticBaseSettingsSource, 

229 dotenv_settings: PydanticBaseSettingsSource, 

230 file_secret_settings: PydanticBaseSettingsSource, 

231 ) -> tuple[PydanticBaseSettingsSource, ...]: 

232 return ( 

233 cast( 

234 PydanticBaseSettingsSource, 

235 _ToggleCliSettingsSource( 

236 CLIRoot, 

237 root_parser=_build_root_parser(), 

238 formatter_class=RichHelpFormatter, 

239 cli_parse_args=True, 

240 ), 

241 ), 

242 ) 

243 

244 

245def cli() -> int: # pragma: no cover 

246 """Run the CLI.""" 

247 sys.exit(cli_logic()) 

248 

249 

250def cli_logic(args_list: Sequence[str] | None = None) -> int: 

251 try: 

252 cli = _parse_cli(args_list) 

253 except SystemExit as exc: 

254 return int(exc.code) if exc.code is not None else 1 

255 

256 if cli.version: 

257 if cli.plain: 

258 print(f'{PROGRAM_NAME} {__version__}') 

259 else: 

260 Console(soft_wrap=True).print(f'{PROGRAM_NAME} {__version__}', highlight=False) 

261 return 0 

262 

263 sub = get_subcommand(cli, is_required=False) 

264 if sub is None: 

265 try: 

266 _parse_cli(['--help']) 

267 except SystemExit: 

268 pass 

269 return 1 

270 

271 if isinstance(sub, CalcCLI): 

272 return calc_prices(sub, plain=cli.plain) 

273 if isinstance(sub, ListCLI): 273 ↛ 276line 273 didn't jump to line 276 because the condition on line 273 was always true

274 return list_models(sub, plain=cli.plain) 

275 

276 _build_root_parser().print_help() 

277 return 1 

278 

279 

280def _parse_cli(args_list: Sequence[str] | None) -> CLIRoot: 

281 if args_list is None: 

282 return CliApp.run(CLIRoot) 

283 

284 original_argv = sys.argv 

285 try: 

286 sys.argv = [PROGRAM_NAME, *args_list] 

287 return CliApp.run(CLIRoot) 

288 finally: 

289 sys.argv = original_argv 

290 

291 

292def calc_prices(args: CalcCLI, *, plain: bool) -> int: 

293 from .data import providers 

294 

295 usage = Usage( 

296 input_tokens=args.input_tokens, 

297 cache_write_tokens=args.cache_write_tokens, 

298 cache_read_tokens=args.cache_read_tokens, 

299 output_tokens=args.output_tokens, 

300 input_audio_tokens=args.input_audio_tokens, 

301 cache_audio_read_tokens=args.cache_audio_read_tokens, 

302 output_audio_tokens=args.output_audio_tokens, 

303 ) 

304 console = Console(soft_wrap=True) 

305 err_console = Console(stderr=True, soft_wrap=True) 

306 use_color = not args.no_color 

307 tables: list[Table] = [] 

308 summary_results: list[PriceCalculation] = [] 

309 seen_models: set[tuple[str, str]] = set() 

310 had_error = False 

311 if args.update_prices: 

312 price_update = update_prices.UpdatePrices() 

313 price_update.start(wait=True) 

314 for model in args.model: 

315 provider_id = None 

316 if ':' in model: 

317 provider_id, model = model.split(':', 1) 

318 

319 try: 

320 price_calc = calc_price( 

321 usage, 

322 model_ref=model, 

323 provider_id=provider_id, 

324 genai_request_timestamp=args.timestamp, 

325 ) 

326 except LookupError as exc: 

327 had_error = True 

328 _render_calc_error( 

329 err_console, 

330 message=str(exc), 

331 model_ref=model, 

332 provider_id=provider_id, 

333 providers=providers, 

334 plain=plain, 

335 use_color=use_color, 

336 ) 

337 if not args.keep_going: 

338 if not plain: 

339 if args.table: 

340 _render_calc_summary_results(console, summary_results, use_color=use_color) 

341 else: 

342 _render_calc_tables(console, tables) 

343 return 1 

344 continue 

345 

346 resolved_key = (price_calc.provider.id, price_calc.model.id) 

347 if resolved_key in seen_models: 

348 continue 

349 seen_models.add(resolved_key) 

350 w = price_calc.model.context_window 

351 output: list[tuple[str, str | None]] = [ 

352 ('Provider', price_calc.provider.name), 

353 ('Model', price_calc.model.name or price_calc.model.id), 

354 ('Model Prices', str(price_calc.model_price)), 

355 ('Context Window', f'{w:,d}' if w is not None else None), 

356 ('Input Price', f'${price_calc.input_price}'), 

357 ('Output Price', f'${price_calc.output_price}'), 

358 ('Total Price', f'${price_calc.total_price}'), 

359 ] 

360 if plain: 

361 for key, value in output: 

362 if value is not None: 

363 print(f'{key:>14}: {value}') 

364 print('') 

365 elif args.table: 

366 summary_results.append(price_calc) 

367 else: 

368 tables.append(_build_calc_table(price_calc, output, split_prices=True, use_color=use_color)) 

369 

370 if not plain: 

371 if args.table: 

372 _render_calc_summary_results(console, summary_results, use_color=use_color) 

373 else: 

374 _render_calc_tables(console, tables) 

375 return 1 if had_error else 0 

376 

377 

378def list_models(args: ListCLI, *, plain: bool) -> int: 

379 from .data import providers 

380 

381 console = Console(soft_wrap=True) 

382 err_console = Console(stderr=True, soft_wrap=True) 

383 

384 if args.provider: 

385 provider_ids = {p.id for p in providers} 

386 if args.provider not in provider_ids: 

387 message = f'Error: provider {args.provider!r} not found in {sorted(provider_ids)}' 

388 if plain: 

389 print(message, file=sys.stderr) 

390 else: 

391 err_console.print(message, highlight=False) 

392 return 1 

393 

394 for provider in providers: 

395 if args.provider and provider.id != args.provider: 

396 continue 

397 if plain: 

398 print(f'{provider.name}: ({len(provider.models)} models)') 

399 for model in provider.models: 

400 if model.name: 

401 print(f' {provider.id}:{model.id}: {model.name}') 

402 else: 

403 print(f' {provider.id}:{model.id}') 

404 else: 

405 _render_list_provider(console, provider) 

406 return 0 

407 

408 

409def _build_calc_table( 

410 price_calc: PriceCalculation, 

411 output: list[tuple[str, str | None]], 

412 *, 

413 split_prices: bool, 

414 use_color: bool, 

415) -> Table: 

416 table = Table(show_header=False, box=box.SIMPLE, pad_edge=False) 

417 table.add_column(justify='right') 

418 table.add_column() 

419 for key, value in output: 

420 if value is None: 

421 continue 

422 renderable = _format_calc_value(key, value, price_calc, split_prices=split_prices, use_color=use_color) 

423 table.add_row(_format_calc_label(key, use_color=use_color), renderable) 

424 return table 

425 

426 

427def _render_calc_tables(console: Console, tables: list[Table]) -> None: 

428 if not tables: 

429 return 

430 if len(tables) == 1: 

431 console.print(tables[0]) 

432 else: 

433 console.print(Columns(tables, expand=True, equal=False)) 

434 console.print('') 

435 

436 

437def _build_calc_summary_table(price_fields: Sequence[str] | None, *, use_color: bool) -> Table: 

438 table = Table(show_header=True, box=box.SIMPLE, pad_edge=False) 

439 table.add_column('Provider', header_style='bold cyan' if use_color else None) 

440 table.add_column('Model', header_style='bold cyan' if use_color else None) 

441 if price_fields: 

442 for field_name in price_fields: 

443 table.add_column( 

444 _price_field_label(field_name), 

445 header_style=_price_field_header_style(field_name) if use_color else None, 

446 justify='right', 

447 ) 

448 else: 

449 table.add_column('Model Prices', header_style='bold cyan' if use_color else None) 

450 table.add_column('Context Window', header_style='bold cyan' if use_color else None, justify='right') 

451 table.add_column('Input Price', header_style='bold sea_green3' if use_color else None, justify='right') 

452 table.add_column('Output Price', header_style='bold dark_orange3' if use_color else None, justify='right') 

453 table.add_column('Total Price', header_style='bold bright_white' if use_color else None, justify='right') 

454 return table 

455 

456 

457def _add_calc_summary_row( 

458 table: Table, 

459 price_calc: PriceCalculation, 

460 price_fields: Sequence[str] | None, 

461 *, 

462 use_color: bool, 

463) -> None: 

464 context_window = price_calc.model.context_window 

465 price_cells: list[Text] = [] 

466 if price_fields: 

467 price_cells = [ 

468 _format_model_price_value(price_calc.model_price, field_name, use_color=use_color) 

469 for field_name in price_fields 

470 ] 

471 else: 

472 price_cells = [_format_model_prices(price_calc.model_price, split_lines=True, use_color=use_color)] 

473 table.add_row( 

474 Text(price_calc.provider.name, style=_provider_style(price_calc.provider.id)) 

475 if use_color 

476 else Text(price_calc.provider.name), 

477 Text(price_calc.model.name or price_calc.model.id), 

478 *price_cells, 

479 Text(f'{context_window:,d}' if context_window is not None else ''), 

480 _format_calc_value( 

481 'Input Price', f'${price_calc.input_price}', price_calc, split_prices=True, use_color=use_color 

482 ), 

483 _format_calc_value( 

484 'Output Price', f'${price_calc.output_price}', price_calc, split_prices=True, use_color=use_color 

485 ), 

486 _format_calc_value( 

487 'Total Price', f'${price_calc.total_price}', price_calc, split_prices=True, use_color=use_color 

488 ), 

489 ) 

490 

491 

492def _render_calc_summary_results(console: Console, results: Sequence[PriceCalculation], *, use_color: bool) -> None: 

493 if not results: 

494 return 

495 price_fields = _collect_model_price_fields(results) 

496 split_prices = _should_split_model_price_columns(console, price_fields) 

497 fields = price_fields if split_prices else None 

498 table = _build_calc_summary_table(fields, use_color=use_color) 

499 for price_calc in results: 

500 _add_calc_summary_row(table, price_calc, fields, use_color=use_color) 

501 console.print(table) 

502 console.print('') 

503 

504 

505def _render_list_provider(console: Console, provider: Provider) -> None: 

506 style = _provider_style(provider.id) 

507 console.print(f'[{style}]{provider.name}[/]: ({len(provider.models)} models)', highlight=False) 

508 for model in provider.models: 

509 prefix = f' [{style}]{provider.id}[/]:{model.id}' 

510 if model.name: 

511 console.print(f'{prefix}: {model.name}', highlight=False) 

512 else: 

513 console.print(prefix, highlight=False) 

514 

515 

516def _provider_style(provider_id: str) -> str: 

517 digest = hashlib.md5(provider_id.encode()).digest() 

518 return _PROVIDER_COLORS[digest[0] % len(_PROVIDER_COLORS)] 

519 

520 

521def _format_calc_value( 

522 key: str, 

523 value: str, 

524 price_calc: PriceCalculation, 

525 *, 

526 split_prices: bool, 

527 use_color: bool, 

528) -> Text: 

529 if key == 'Model Prices': 

530 return _format_model_prices(price_calc.model_price, split_lines=split_prices, use_color=use_color) 

531 if key == 'Provider': 

532 return Text(value, style=_provider_style(price_calc.provider.id)) if use_color else Text(value) 

533 if key == 'Input Price': 

534 return Text(value, style='sea_green3') if use_color else Text(value) 

535 if key == 'Output Price': 

536 return Text(value, style='dark_orange3') if use_color else Text(value) 

537 if key == 'Total Price': 

538 return Text(value, style='bold bright_white') if use_color else Text(value) 

539 return Text(value) 

540 

541 

542def _format_calc_label(key: str, *, use_color: bool) -> Text: 

543 if not use_color: 

544 return Text(key) 

545 if key == 'Input Price': 

546 return Text(key, style='bold sea_green3') 

547 if key == 'Output Price': 

548 return Text(key, style='bold dark_orange3') 

549 if key == 'Total Price': 

550 return Text(key, style='bold bright_white') 

551 return Text(key, style='bold cyan') 

552 

553 

554def _collect_model_price_fields(results: Sequence[PriceCalculation]) -> list[str]: 

555 ordered_fields = [field.name for field in dataclasses.fields(ModelPrice)] 

556 present_fields: list[str] = [] 

557 for field_name in ordered_fields: 

558 if any(getattr(result.model_price, field_name) is not None for result in results): 

559 present_fields.append(field_name) 

560 return present_fields 

561 

562 

563def _should_split_model_price_columns(console: Console, fields: Sequence[str]) -> bool: 

564 if not fields: 

565 return False 

566 base_headers = [ 

567 'Provider', 

568 'Model', 

569 'Context Window', 

570 'Input Price', 

571 'Output Price', 

572 'Total Price', 

573 ] 

574 base_width = sum(len(header) for header in base_headers) + len(base_headers) * 3 

575 price_width = sum(max(len(_price_field_label(field)), 10) + 3 for field in fields) 

576 required = base_width + price_width 

577 return console.width >= required 

578 

579 

580def _price_field_label(field_name: str) -> str: 

581 labels = { 

582 'input_mtok': 'Input/MTok', 

583 'cache_write_mtok': 'Cache Write/MTok', 

584 'cache_read_mtok': 'Cache Read/MTok', 

585 'output_mtok': 'Output/MTok', 

586 'input_audio_mtok': 'Input Audio/MTok', 

587 'cache_audio_read_mtok': 'Cache Audio Read/MTok', 

588 'output_audio_mtok': 'Output Audio/MTok', 

589 'requests_kcount': 'Requests/K', 

590 } 

591 return labels.get(field_name, field_name.replace('_mtok', '').replace('_', ' ').title()) 

592 

593 

594def _price_field_header_style(field_name: str) -> str: 

595 style = _PRICE_STYLES.get(field_name) 

596 return f'bold {style}' if style else 'bold cyan' 

597 

598 

599def _format_model_price_value(model_price: ModelPrice, field_name: str, *, use_color: bool) -> Text: 

600 value = getattr(model_price, field_name) 

601 style = _PRICE_STYLES.get(field_name) if use_color else None 

602 if value is None: 

603 return Text('') 

604 if field_name == 'requests_kcount': 

605 return Text(f'${value}', style=style) if style else Text(f'${value}') 

606 if isinstance(value, TieredPrices): 

607 return Text(f'${value.base} (+tiers)', style=style) if style else Text(f'${value.base} (+tiers)') 

608 return Text(f'${value}', style=style) if style else Text(f'${value}') 

609 

610 

611def _format_model_prices(model_price: ModelPrice, *, split_lines: bool, use_color: bool) -> Text: 

612 parts = Text() 

613 for field in dataclasses.fields(model_price): 

614 value = getattr(model_price, field.name) 

615 if value is None: 

616 continue 

617 if parts: 

618 parts.append('\n' if split_lines else ', ') 

619 

620 style = _PRICE_STYLES.get(field.name) if use_color else None 

621 if field.name == 'requests_kcount': 

622 if style: 622 ↛ 625line 622 didn't jump to line 625 because the condition on line 622 was always true

623 parts.append(f'${value} / K requests', style=style) 

624 else: 

625 parts.append(f'${value} / K requests') 

626 continue 

627 

628 name = field.name.replace('_mtok', '').replace('_', ' ') 

629 if isinstance(value, TieredPrices): 629 ↛ 630line 629 didn't jump to line 630 because the condition on line 629 was never true

630 text = f'${value.base}/{name} MTok (+tiers)' 

631 else: 

632 text = f'${value}/{name} MTok' 

633 if style: 

634 parts.append(text, style=style) 

635 else: 

636 parts.append(text) 

637 return parts 

638 

639 

640def _render_calc_error( 

641 console: Console, 

642 *, 

643 message: str, 

644 model_ref: str, 

645 provider_id: str | None, 

646 providers: list[Provider], 

647 plain: bool, 

648 use_color: bool, 

649) -> None: 

650 if plain: 

651 print(f'Error: {message}', file=sys.stderr) 

652 else: 

653 if use_color: 

654 console.print(f'[red]Error:[/] {escape(message)}', highlight=False) 

655 else: 

656 console.print(f'Error: {escape(message)}', highlight=False) 

657 

658 provider_ids = {provider.id for provider in providers} 

659 if provider_id and provider_id not in provider_ids: 

660 provider_suggestions = _suggest_values(provider_id, sorted(provider_ids)) 

661 if provider_suggestions: 661 ↛ 672line 661 didn't jump to line 672 because the condition on line 661 was always true

662 if plain: 

663 line = f'Did you mean provider: {", ".join(provider_suggestions)}' 

664 print(line, file=sys.stderr) 

665 else: 

666 if use_color: 

667 line = Text('Did you mean provider: ') 

668 line.append_text(_format_provider_suggestions(provider_suggestions)) 

669 console.print(line, highlight=False) 

670 else: 

671 console.print(f'Did you mean provider: {", ".join(provider_suggestions)}', highlight=False) 

672 return 

673 

674 model_suggestions = _suggest_models(model_ref, provider_id, providers) 

675 if model_suggestions: 

676 if plain: 

677 line = f'Did you mean: {", ".join(model_suggestions)}' 

678 print(line, file=sys.stderr) 

679 else: 

680 if use_color: 

681 line = Text('Did you mean: ') 

682 line.append_text(_format_model_suggestions(model_suggestions)) 

683 console.print(line, highlight=False) 

684 else: 

685 console.print(f'Did you mean: {", ".join(model_suggestions)}', highlight=False) 

686 

687 

688def _suggest_models(model_ref: str, provider_id: str | None, providers: list[Provider]) -> list[str]: 

689 if provider_id: 

690 provider = next((p for p in providers if p.id == provider_id), None) 

691 if provider is None: 

692 return [] 

693 candidates = [model.id for model in provider.models] 

694 matches = _suggest_values_case_insensitive(model_ref, candidates) 

695 return [f'{provider.id}:{model_id}' for model_id in matches] 

696 

697 candidates = [f'{provider.id}:{model.id}' for provider in providers for model in provider.models] 

698 return _suggest_values_case_insensitive(model_ref, candidates) 

699 

700 

701def _suggest_values(value: str, candidates: list[str]) -> list[str]: 

702 return difflib.get_close_matches(value, candidates, n=5, cutoff=0.6) 

703 

704 

705def _suggest_values_case_insensitive(value: str, candidates: list[str]) -> list[str]: 

706 lowered_candidates = [(candidate.lower(), candidate) for candidate in candidates] 

707 matches = _suggest_values(value.lower(), [lowered for lowered, _ in lowered_candidates]) 

708 return [candidate for match in matches for lowered, candidate in lowered_candidates if lowered == match] 

709 

710 

711def _format_provider_suggestions(suggestions: list[str]) -> Text: 

712 parts = Text() 

713 for index, suggestion in enumerate(suggestions): 

714 if index: 714 ↛ 715line 714 didn't jump to line 715 because the condition on line 714 was never true

715 parts.append(', ') 

716 parts.append(suggestion, style=_provider_style(suggestion)) 

717 return parts 

718 

719 

720def _format_model_suggestions(suggestions: list[str]) -> Text: 

721 parts = Text() 

722 for index, suggestion in enumerate(suggestions): 

723 if index: 

724 parts.append(', ') 

725 parts.append_text(_format_model_suggestion(suggestion)) 

726 return parts 

727 

728 

729def _format_model_suggestion(suggestion: str) -> Text: 

730 if ':' in suggestion: 730 ↛ 735line 730 didn't jump to line 735 because the condition on line 730 was always true

731 provider_id, model_id = suggestion.split(':', 1) 

732 text = Text(provider_id, style=_provider_style(provider_id)) 

733 text.append(f':{model_id}') 

734 return text 

735 return Text(suggestion)