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
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
1from __future__ import annotations
3import argparse
4import dataclasses
5import difflib
6import hashlib
7import sys
8from collections.abc import Sequence
9from datetime import datetime
10from typing import Any, cast
12from pydantic import AliasChoices, Field
13from pydantic.fields import FieldInfo
15from . import Usage, __version__, calc_price, update_prices
16from .types import ModelPrice, PriceCalculation, Provider, TieredPrices
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
47PROGRAM_NAME = 'voice-prices'
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}
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 )
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 )
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'
127class CalcCLI(_CLIBase):
128 """calculate prices"""
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 )
196class ListCLI(_CLIBase):
197 """list providers and models"""
199 provider: CliPositionalArg[str | None] = Field(
200 None,
201 description='Only list models for the provider.',
202 )
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 )
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 )
245def cli() -> int: # pragma: no cover
246 """Run the CLI."""
247 sys.exit(cli_logic())
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
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
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
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)
276 _build_root_parser().print_help()
277 return 1
280def _parse_cli(args_list: Sequence[str] | None) -> CLIRoot:
281 if args_list is None:
282 return CliApp.run(CLIRoot)
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
292def calc_prices(args: CalcCLI, *, plain: bool) -> int:
293 from .data import providers
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)
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
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))
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
378def list_models(args: ListCLI, *, plain: bool) -> int:
379 from .data import providers
381 console = Console(soft_wrap=True)
382 err_console = Console(stderr=True, soft_wrap=True)
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
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
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
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('')
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
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 )
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('')
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)
516def _provider_style(provider_id: str) -> str:
517 digest = hashlib.md5(provider_id.encode()).digest()
518 return _PROVIDER_COLORS[digest[0] % len(_PROVIDER_COLORS)]
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)
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')
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
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
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())
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'
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}')
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 ', ')
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
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
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)
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
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)
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]
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)
701def _suggest_values(value: str, candidates: list[str]) -> list[str]:
702 return difflib.get_close_matches(value, candidates, n=5, cutoff=0.6)
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]
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
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
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)