bonito-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bonito_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Bonito CLI — Unified multi-cloud AI management from your terminal."""
2
+
3
+ __version__ = "0.1.0"
bonito_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Allow running as `python -m bonito_cli`."""
2
+ from bonito_cli.app import main
3
+
4
+ main()
bonito_cli/api.py ADDED
@@ -0,0 +1,167 @@
1
+ """Synchronous HTTP API client for the Bonito backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, Generator, Optional
7
+
8
+ import httpx
9
+ from rich.console import Console
10
+
11
+ from .config import get_api_key, get_api_url
12
+
13
+ console = Console()
14
+
15
+
16
+ class APIError(Exception):
17
+ """Raised when an API request fails."""
18
+
19
+ def __init__(self, message: str, status_code: Optional[int] = None):
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+
23
+
24
+ class BonitoAPI:
25
+ """Synchronous Bonito API client backed by ``httpx.Client``."""
26
+
27
+ def __init__(self) -> None:
28
+ self.base_url: str = get_api_url()
29
+ self._client: Optional[httpx.Client] = None
30
+
31
+ # ── internal helpers ────────────────────────────────────────
32
+
33
+ @property
34
+ def client(self) -> httpx.Client:
35
+ if self._client is None or self._client.is_closed:
36
+ self._client = httpx.Client(
37
+ base_url=self.base_url,
38
+ timeout=30.0,
39
+ follow_redirects=True,
40
+ )
41
+ return self._client
42
+
43
+ def _headers(self) -> Dict[str, str]:
44
+ headers: Dict[str, str] = {
45
+ "Content-Type": "application/json",
46
+ "User-Agent": "bonito-cli/0.1.0",
47
+ }
48
+ token = get_api_key()
49
+ if token:
50
+ headers["Authorization"] = f"Bearer {token}"
51
+ return headers
52
+
53
+ def _request(
54
+ self,
55
+ method: str,
56
+ endpoint: str,
57
+ data: Any = None,
58
+ params: Optional[Dict[str, Any]] = None,
59
+ ) -> Any:
60
+ url = endpoint if endpoint.startswith("http") else f"/api{endpoint}"
61
+ try:
62
+ resp = self.client.request(
63
+ method, url, json=data, params=params, headers=self._headers()
64
+ )
65
+ except httpx.RequestError as exc:
66
+ raise APIError(f"Connection failed: {exc}") from exc
67
+
68
+ if resp.status_code == 401:
69
+ raise APIError(
70
+ "Authentication failed — run [cyan]bonito auth login[/cyan].", 401
71
+ )
72
+ if resp.status_code == 204:
73
+ return {"status": "ok"}
74
+ if resp.status_code >= 400:
75
+ try:
76
+ body = resp.json()
77
+ detail = body.get(
78
+ "detail",
79
+ body.get("error", {}).get("message", f"HTTP {resp.status_code}"),
80
+ )
81
+ except Exception:
82
+ detail = f"HTTP {resp.status_code}: {resp.text[:200]}"
83
+
84
+ # Parse Pydantic 422 validation errors into friendly messages
85
+ if resp.status_code == 422 and isinstance(detail, list):
86
+ parts: list[str] = []
87
+ for err in detail:
88
+ if isinstance(err, dict):
89
+ err_type = err.get("type", "")
90
+ err_msg = err.get("msg", "Validation error")
91
+ loc = err.get("loc", [])
92
+ field = str(loc[-1]).replace("_", " ") if loc else "input"
93
+ if err_type == "uuid_parsing":
94
+ parts.append(
95
+ f"Invalid {field} format. "
96
+ "Expected a UUID (e.g. a1b2c3d4-e5f6-...)"
97
+ )
98
+ else:
99
+ parts.append(f"{err_msg} (field: {field})")
100
+ else:
101
+ parts.append(str(err))
102
+ msg = "; ".join(parts) if parts else "Validation error"
103
+ elif isinstance(detail, list):
104
+ msg = "; ".join(str(d) for d in detail)
105
+ else:
106
+ msg = str(detail)
107
+
108
+ raise APIError(msg, resp.status_code)
109
+
110
+ try:
111
+ return resp.json()
112
+ except Exception:
113
+ return {"status": "ok", "text": resp.text}
114
+
115
+ # ── public verbs ────────────────────────────────────────────
116
+
117
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
118
+ return self._request("GET", endpoint, params=params)
119
+
120
+ def post(self, endpoint: str, data: Any = None) -> Any:
121
+ return self._request("POST", endpoint, data=data)
122
+
123
+ def put(self, endpoint: str, data: Any = None) -> Any:
124
+ return self._request("PUT", endpoint, data=data)
125
+
126
+ def patch(self, endpoint: str, data: Any = None) -> Any:
127
+ return self._request("PATCH", endpoint, data=data)
128
+
129
+ def delete(self, endpoint: str) -> Any:
130
+ return self._request("DELETE", endpoint)
131
+
132
+ # ── streaming (SSE) ─────────────────────────────────────────
133
+
134
+ def stream_post(
135
+ self,
136
+ url: str,
137
+ data: dict,
138
+ headers: Optional[Dict[str, str]] = None,
139
+ ) -> Generator[dict, None, None]:
140
+ """Stream a POST request that returns SSE ``data:`` lines."""
141
+ hdrs = self._headers()
142
+ if headers:
143
+ hdrs.update(headers)
144
+
145
+ full_url = url if url.startswith("http") else f"{self.base_url}{url}"
146
+
147
+ with httpx.stream(
148
+ "POST", full_url, json=data, headers=hdrs, timeout=120.0
149
+ ) as resp:
150
+ if resp.status_code >= 400:
151
+ raise APIError(
152
+ f"HTTP {resp.status_code}: {resp.read().decode()[:200]}",
153
+ resp.status_code,
154
+ )
155
+ for line in resp.iter_lines():
156
+ if line.startswith("data: "):
157
+ chunk = line[6:]
158
+ if chunk.strip() == "[DONE]":
159
+ break
160
+ try:
161
+ yield json.loads(chunk)
162
+ except json.JSONDecodeError:
163
+ continue
164
+
165
+
166
+ # ── module-level singleton ──────────────────────────────────────
167
+ api = BonitoAPI()
bonito_cli/app.py ADDED
@@ -0,0 +1,118 @@
1
+ """Main Bonito CLI application with Typer."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.text import Text
7
+ from rich.columns import Columns
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .commands.auth import app as auth_app
12
+ from .commands.providers import app as providers_app
13
+ from .commands.models import app as models_app
14
+ from .commands.chat import app as chat_app
15
+ from .commands.gateway import app as gateway_app
16
+ from .commands.policies import app as policies_app
17
+ from .commands.analytics import app as analytics_app
18
+ from .commands.deployments import app as deployments_app
19
+
20
+ console = Console()
21
+
22
+ # ── Bonito fish ASCII art ──────────────────────────────────────
23
+ FISH_ART = (
24
+ "[bold cyan] ___...---\"\"\"---...__ [/bold cyan]\n"
25
+ "[bold cyan] .-\"\" \"\"-._ [/bold cyan]\n"
26
+ "[bold cyan] / \\ [/bold cyan]\n"
27
+ "[bold cyan] | [bold white]●[/bold white][bold cyan] __.._ | [/bold cyan]\n"
28
+ "[bold cyan] | _.-\" \"-. /\\ [/bold cyan]\n"
29
+ "[bold cyan] \\ .-\" \"-. _.' / [/bold cyan]\n"
30
+ "[bold cyan] \"---.-\" [dim cyan]bonito[/dim cyan] \"-\" _/ [/bold cyan]\n"
31
+ "[bold cyan] \\ [dim cyan] CLI [/dim cyan] _.-\" [/bold cyan]\n"
32
+ "[bold cyan] \"-.__ __.-\" [/bold cyan]\n"
33
+ "[bold cyan] \"\"\"---\"\"\" [/bold cyan]"
34
+ )
35
+
36
+ LOGO_COMPACT = "[bold cyan] ><(((º> [/bold cyan][bold white]Bonito CLI[/bold white] [dim]v{version}[/dim]"
37
+
38
+
39
+ def _get_banner() -> str:
40
+ """Get the full CLI banner with fish art."""
41
+ return (
42
+ FISH_ART
43
+ + f"\n [bold white]Bonito CLI[/bold white] [dim]v{__version__}[/dim]"
44
+ + "\n [dim]Unified multi-cloud AI management from your terminal[/dim]\n"
45
+ )
46
+
47
+
48
+ def _get_mini_banner() -> str:
49
+ """Get compact one-line banner."""
50
+ return LOGO_COMPACT.format(version=__version__)
51
+
52
+
53
+ # ── Main Typer app ─────────────────────────────────────────────
54
+ app = typer.Typer(
55
+ name="bonito",
56
+ help="🐟 Bonito CLI — Unified multi-cloud AI management from your terminal",
57
+ rich_markup_mode="rich",
58
+ context_settings={"help_option_names": ["-h", "--help"]},
59
+ no_args_is_help=True,
60
+ )
61
+
62
+ # ── Subcommand groups ──────────────────────────────────────────
63
+ app.add_typer(auth_app, name="auth", help="🔐 Authentication & API keys")
64
+ app.add_typer(providers_app, name="providers", help="☁️ Cloud provider management")
65
+ app.add_typer(models_app, name="models", help="🤖 AI model catalogue")
66
+ app.add_typer(deployments_app, name="deployments", help="🚀 Deployment management")
67
+ app.add_typer(chat_app, name="chat", help="💬 Interactive AI chat")
68
+ app.add_typer(gateway_app, name="gateway", help="🌐 API gateway management")
69
+ app.add_typer(policies_app, name="policies", help="🎯 Routing policies")
70
+ app.add_typer(analytics_app, name="analytics", help="📊 Usage analytics & costs")
71
+
72
+
73
+ # ── Callbacks ──────────────────────────────────────────────────
74
+ def version_callback(value: bool):
75
+ """Show version and exit."""
76
+ if value:
77
+ console.print(_get_banner())
78
+ raise typer.Exit()
79
+
80
+
81
+ @app.callback(invoke_without_command=True)
82
+ def main(
83
+ ctx: typer.Context,
84
+ version: bool = typer.Option(
85
+ None,
86
+ "--version",
87
+ "-v",
88
+ help="Show version and exit",
89
+ callback=version_callback,
90
+ is_eager=True,
91
+ ),
92
+ ):
93
+ """
94
+ [bold cyan]🐟 Bonito CLI[/bold cyan] — Unified multi-cloud AI management from your terminal.
95
+
96
+ Bonito gives enterprise AI teams a single CLI to manage models, costs,
97
+ and workloads across AWS Bedrock, Azure OpenAI, Google Vertex AI, and more.
98
+
99
+ [bold]Quick start:[/bold]
100
+
101
+ bonito auth login
102
+ bonito models list
103
+ bonito chat
104
+
105
+ [bold]Get help on any command:[/bold]
106
+
107
+ bonito providers --help
108
+ bonito chat --help
109
+ """
110
+ # When invoked with no subcommand and not --version, show help
111
+ if ctx.invoked_subcommand is None:
112
+ console.print(_get_banner())
113
+ console.print(ctx.get_help())
114
+ raise typer.Exit()
115
+
116
+
117
+ if __name__ == "__main__":
118
+ app()
@@ -0,0 +1 @@
1
+ """Bonito CLI commands."""
@@ -0,0 +1,337 @@
1
+ """Analytics and usage insights commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from ..api import api, APIError
13
+ from ..utils.auth import ensure_authenticated
14
+ from ..utils.display import (
15
+ format_cost,
16
+ format_tokens,
17
+ get_output_format,
18
+ print_dict_as_table,
19
+ print_error,
20
+ print_info,
21
+ print_table,
22
+ )
23
+
24
+ console = Console()
25
+ app = typer.Typer(help="📊 Usage analytics & costs")
26
+
27
+
28
+ # ── overview ────────────────────────────────────────────────────
29
+
30
+
31
+ @app.command("overview")
32
+ def overview(
33
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
34
+ ):
35
+ """
36
+ Show the analytics dashboard overview.
37
+
38
+ Displays key metrics, top models, and recent activity.
39
+ """
40
+ fmt = get_output_format(json_output)
41
+ ensure_authenticated()
42
+
43
+ try:
44
+ with console.status("[cyan]Fetching analytics…[/cyan]"):
45
+ data = api.get("/analytics/overview")
46
+
47
+ if fmt == "json":
48
+ console.print_json(_json.dumps(data, default=str))
49
+ return
50
+
51
+ # Key metrics
52
+ m = data.get("metrics", data)
53
+ metrics_text = (
54
+ f"[bold green]Requests:[/bold green] {m.get('total_requests', 0):,}\n"
55
+ f"[bold green]Cost:[/bold green] ${m.get('total_cost', 0):.2f}\n"
56
+ f"[bold green]Tokens:[/bold green] {format_tokens(m.get('total_tokens', 0))}\n"
57
+ f"[bold green]Success rate:[/bold green] {m.get('success_rate', 0):.1f}%\n"
58
+ f"[bold green]Avg latency:[/bold green] {m.get('avg_latency_ms', 0):.0f}ms"
59
+ )
60
+ console.print(Panel(metrics_text, title="📊 Key Metrics (30 days)", border_style="green"))
61
+
62
+ # Top models
63
+ top = data.get("top_models", [])
64
+ if top:
65
+ rows = [
66
+ {
67
+ "Model": tm.get("id", tm.get("model", "—")),
68
+ "Requests": f"{tm.get('requests', 0):,}",
69
+ "Cost": format_cost(tm.get("cost", 0)),
70
+ }
71
+ for tm in top[:5]
72
+ ]
73
+ print_table(rows, title="🔥 Top Models")
74
+
75
+ # Recent activity
76
+ recent = data.get("recent_activity", {})
77
+ if recent:
78
+ ra = {
79
+ "Requests (24h)": f"{recent.get('requests_24h', 0):,}",
80
+ "Cost (24h)": format_cost(recent.get("cost_24h", 0)),
81
+ "Peak hour": recent.get("peak_hour", "—"),
82
+ }
83
+ print_dict_as_table(ra, title="📈 Recent Activity")
84
+
85
+ except APIError as exc:
86
+ print_error(f"Failed to get overview: {exc}")
87
+
88
+
89
+ # ── usage ───────────────────────────────────────────────────────
90
+
91
+
92
+ @app.command("usage")
93
+ def usage(
94
+ period: str = typer.Option("week", "--period", "-p", help="day / week / month"),
95
+ json_output: bool = typer.Option(False, "--json"),
96
+ ):
97
+ """
98
+ Show detailed usage analytics.
99
+
100
+ Examples:
101
+ bonito analytics usage
102
+ bonito analytics usage --period month
103
+ """
104
+ fmt = get_output_format(json_output)
105
+ ensure_authenticated()
106
+
107
+ if period not in ("day", "week", "month"):
108
+ print_error("--period must be day, week, or month")
109
+ return
110
+
111
+ try:
112
+ with console.status("[cyan]Fetching usage data…[/cyan]"):
113
+ data = api.get("/analytics/usage", params={"period": period})
114
+
115
+ if fmt == "json":
116
+ console.print_json(_json.dumps(data, default=str))
117
+ return
118
+
119
+ summary = data.get("summary", data)
120
+ info = {
121
+ "Period": period.title(),
122
+ "Total Requests": f"{summary.get('total_requests', 0):,}",
123
+ "Total Tokens": format_tokens(summary.get("total_tokens", 0)),
124
+ "Total Cost": format_cost(summary.get("total_cost", 0)),
125
+ "Unique Models": summary.get("unique_models", "—"),
126
+ }
127
+ print_dict_as_table(info, title=f"📈 Usage ({period.title()})")
128
+
129
+ ts = data.get("time_series", [])
130
+ if ts:
131
+ rows = [
132
+ {
133
+ "Date": p.get("date", "—"),
134
+ "Requests": f"{p.get('requests', 0):,}",
135
+ "Tokens": format_tokens(p.get("tokens", 0)),
136
+ "Cost": format_cost(p.get("cost", 0)),
137
+ }
138
+ for p in ts[-10:]
139
+ ]
140
+ print_table(rows, title="Time Series")
141
+
142
+ except APIError as exc:
143
+ print_error(f"Failed to get usage: {exc}")
144
+
145
+
146
+ # ── costs ───────────────────────────────────────────────────────
147
+
148
+
149
+ @app.command("costs")
150
+ def costs(
151
+ period: Optional[str] = typer.Option(None, "--period", help="daily / weekly / monthly"),
152
+ json_output: bool = typer.Option(False, "--json"),
153
+ ):
154
+ """
155
+ Show cost analytics and breakdown.
156
+
157
+ Examples:
158
+ bonito analytics costs
159
+ bonito analytics costs --period monthly
160
+ """
161
+ fmt = get_output_format(json_output)
162
+ ensure_authenticated()
163
+
164
+ try:
165
+ params = {"period": period} if period else None
166
+ with console.status("[cyan]Fetching cost data…[/cyan]"):
167
+ data = api.get("/analytics/costs", params=params)
168
+
169
+ if fmt == "json":
170
+ console.print_json(_json.dumps(data, default=str))
171
+ return
172
+
173
+ summary = data.get("summary", data)
174
+ total = summary.get("total_cost", 0)
175
+ console.print(f"\n[bold green]💰 Total cost:[/bold green] ${total:.2f}")
176
+
177
+ # By model
178
+ breakdown = data.get("breakdown", {})
179
+ by_model = breakdown.get("by_model", [])
180
+ if by_model:
181
+ rows = [
182
+ {
183
+ "Model": mc.get("model", "—"),
184
+ "Cost": format_cost(mc.get("cost", 0)),
185
+ "Requests": f"{mc.get('requests', 0):,}",
186
+ "Tokens": format_tokens(mc.get("tokens", 0)),
187
+ "Share": f"{(mc.get('cost', 0) / total * 100):.1f}%" if total else "0%",
188
+ }
189
+ for mc in by_model
190
+ ]
191
+ print_table(rows, title="💸 Cost by Model")
192
+
193
+ # By provider
194
+ by_prov = breakdown.get("by_provider", [])
195
+ if by_prov:
196
+ rows = [
197
+ {
198
+ "Provider": pc.get("provider", "—"),
199
+ "Cost": format_cost(pc.get("cost", 0)),
200
+ "Share": f"{(pc.get('cost', 0) / total * 100):.1f}%" if total else "0%",
201
+ }
202
+ for pc in by_prov
203
+ ]
204
+ print_table(rows, title="☁️ Cost by Provider")
205
+
206
+ # Trends
207
+ trends = data.get("trends", {})
208
+ if trends:
209
+ ti = {
210
+ "This Period": format_cost(trends.get("current_period", 0)),
211
+ "Previous": format_cost(trends.get("previous_period", 0)),
212
+ "Change": f"{trends.get('change_percent', 0):+.1f}%",
213
+ "Projected Monthly": format_cost(trends.get("projected_monthly", 0)),
214
+ }
215
+ print_dict_as_table(ti, title="📊 Trends")
216
+
217
+ # Suggestions
218
+ suggestions = data.get("optimization_suggestions", [])
219
+ if suggestions:
220
+ console.print("\n[bold yellow]💡 Optimisation tips:[/bold yellow]")
221
+ for s in suggestions:
222
+ console.print(f" • {s.get('message', s)}")
223
+
224
+ except APIError as exc:
225
+ print_error(f"Failed to get cost data: {exc}")
226
+
227
+
228
+ # ── trends ──────────────────────────────────────────────────────
229
+
230
+
231
+ @app.command("trends")
232
+ def trends(
233
+ json_output: bool = typer.Option(False, "--json"),
234
+ ):
235
+ """Show usage and performance trend analysis."""
236
+ fmt = get_output_format(json_output)
237
+ ensure_authenticated()
238
+
239
+ try:
240
+ with console.status("[cyan]Analysing trends…[/cyan]"):
241
+ data = api.get("/analytics/trends")
242
+
243
+ if fmt == "json":
244
+ console.print_json(_json.dumps(data, default=str))
245
+ return
246
+
247
+ console.print("\n[bold cyan]📈 Trend Analysis[/bold cyan]\n")
248
+
249
+ ut = data.get("usage_trends", {})
250
+ if ut:
251
+ info = {
252
+ "Requests": f"{ut.get('requests_trend', 0):+.1f}% vs last month",
253
+ "Tokens": f"{ut.get('tokens_trend', 0):+.1f}% vs last month",
254
+ "Growth": f"{ut.get('growth_rate', 0):.1f}% monthly",
255
+ }
256
+ print_dict_as_table(info, title="Usage Trends")
257
+
258
+ mt = data.get("model_trends", [])
259
+ if mt:
260
+ rows = []
261
+ for t in mt:
262
+ arrow = "📈" if t.get("change_percent", 0) > 0 else "📉"
263
+ rows.append({
264
+ "Model": t.get("model", "—"),
265
+ "Trend": f"{arrow} {abs(t.get('change_percent', 0)):.1f}%",
266
+ "Share": f"{t.get('current_share', 0):.1f}%",
267
+ })
268
+ print_table(rows, title="🤖 Model Adoption")
269
+
270
+ pt = data.get("performance_trends", {})
271
+ if pt:
272
+ pi = {
273
+ "Latency": f"{pt.get('latency_trend', 0):+.1f}%",
274
+ "Success rate": f"{pt.get('success_rate_trend', 0):+.1f}%",
275
+ "Error rate": f"{pt.get('error_rate_trend', 0):+.1f}%",
276
+ }
277
+ print_dict_as_table(pi, title="⚡ Performance")
278
+
279
+ insights = data.get("insights", [])
280
+ if insights:
281
+ console.print("\n[bold blue]🔍 Insights:[/bold blue]")
282
+ for i in insights:
283
+ console.print(f" • {i.get('message', i)}")
284
+
285
+ except APIError as exc:
286
+ print_error(f"Failed to get trends: {exc}")
287
+
288
+
289
+ # ── digest ──────────────────────────────────────────────────────
290
+
291
+
292
+ @app.command("digest")
293
+ def digest(
294
+ json_output: bool = typer.Option(False, "--json"),
295
+ ):
296
+ """Generate a weekly analytics digest report."""
297
+ fmt = get_output_format(json_output)
298
+ ensure_authenticated()
299
+
300
+ try:
301
+ with console.status("[cyan]Generating digest…[/cyan]"):
302
+ data = api.get("/analytics/digest")
303
+
304
+ if fmt == "json":
305
+ console.print_json(_json.dumps(data, default=str))
306
+ return
307
+
308
+ console.print(f"\n[bold cyan]📋 Weekly Digest[/bold cyan]")
309
+ console.print(f"[dim]{data.get('period', 'Last 7 days')}[/dim]\n")
310
+
311
+ s = data.get("executive_summary", {})
312
+ if s:
313
+ text = (
314
+ f"[bold green]Requests:[/bold green] {s.get('total_requests', 0):,}"
315
+ f" ({s.get('requests_change', 0):+.1f}%)\n"
316
+ f"[bold green]Cost:[/bold green] ${s.get('total_cost', 0):.2f}"
317
+ f" ({s.get('cost_change', 0):+.1f}%)\n"
318
+ f"[bold green]Success rate:[/bold green] {s.get('success_rate', 0):.1f}%\n"
319
+ f"[bold green]Avg latency:[/bold green] {s.get('avg_latency', 0):.0f}ms"
320
+ )
321
+ console.print(Panel(text, title="Week at a Glance", border_style="green"))
322
+
323
+ tp = data.get("top_performers", {})
324
+ if tp:
325
+ console.print("[bold]🏆 Top Performers:[/bold]")
326
+ console.print(f" Most used: [cyan]{tp.get('most_used_model', '—')}[/cyan]")
327
+ console.print(f" Fastest: [cyan]{tp.get('fastest_model', '—')}[/cyan]")
328
+ console.print(f" Most cost-effective:[cyan]{tp.get('most_cost_effective', '—')}[/cyan]")
329
+
330
+ recs = data.get("recommendations", [])
331
+ if recs:
332
+ console.print("\n[bold blue]💡 Recommendations:[/bold blue]")
333
+ for r in recs:
334
+ console.print(f" • {r.get('message', r)}")
335
+
336
+ except APIError as exc:
337
+ print_error(f"Failed to generate digest: {exc}")