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 +3 -0
- bonito_cli/__main__.py +4 -0
- bonito_cli/api.py +167 -0
- bonito_cli/app.py +118 -0
- bonito_cli/commands/__init__.py +1 -0
- bonito_cli/commands/analytics.py +337 -0
- bonito_cli/commands/auth.py +171 -0
- bonito_cli/commands/chat.py +380 -0
- bonito_cli/commands/deployments.py +240 -0
- bonito_cli/commands/gateway.py +311 -0
- bonito_cli/commands/models.py +223 -0
- bonito_cli/commands/policies.py +299 -0
- bonito_cli/commands/providers.py +274 -0
- bonito_cli/config.py +183 -0
- bonito_cli/utils/__init__.py +0 -0
- bonito_cli/utils/auth.py +49 -0
- bonito_cli/utils/display.py +264 -0
- bonito_cli-0.1.0.dist-info/METADATA +108 -0
- bonito_cli-0.1.0.dist-info/RECORD +21 -0
- bonito_cli-0.1.0.dist-info/WHEEL +4 -0
- bonito_cli-0.1.0.dist-info/entry_points.txt +2 -0
bonito_cli/__init__.py
ADDED
bonito_cli/__main__.py
ADDED
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}")
|