magic-lab-observer-cli 0.1.1__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.
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM Observatory CLI - Command-line interface for LLM Observatory."""
|
cli/db.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Database helper for local SQLite access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
DEFAULT_DB_PATH = Path("observatory.db")
|
|
10
|
+
|
|
11
|
+
# DDL to create tables if the database is fresh/empty
|
|
12
|
+
_SCHEMA_SQL = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
session_id TEXT,
|
|
17
|
+
start_time TIMESTAMP NOT NULL,
|
|
18
|
+
end_time TIMESTAMP,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'unset',
|
|
20
|
+
metadata JSON,
|
|
21
|
+
created_at TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS ix_traces_session_id ON traces (session_id);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
trace_id TEXT NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
|
|
28
|
+
parent_span_id TEXT REFERENCES spans(id),
|
|
29
|
+
name TEXT NOT NULL,
|
|
30
|
+
span_type TEXT NOT NULL DEFAULT 'generic',
|
|
31
|
+
start_time TIMESTAMP NOT NULL,
|
|
32
|
+
end_time TIMESTAMP,
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'unset',
|
|
34
|
+
input JSON,
|
|
35
|
+
output JSON,
|
|
36
|
+
tokens_input INTEGER,
|
|
37
|
+
tokens_output INTEGER,
|
|
38
|
+
cost_usd NUMERIC(10, 6),
|
|
39
|
+
metadata JSON,
|
|
40
|
+
attributes JSON
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS ix_spans_trace_id ON spans (trace_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS ix_spans_parent_span_id ON spans (parent_span_id);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS evaluations (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
trace_id TEXT NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
|
|
48
|
+
span_id TEXT REFERENCES spans(id) ON DELETE CASCADE,
|
|
49
|
+
evaluator_type TEXT NOT NULL,
|
|
50
|
+
score NUMERIC(5, 4),
|
|
51
|
+
criteria JSON,
|
|
52
|
+
result JSON,
|
|
53
|
+
created_at TIMESTAMP
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS ix_evaluations_trace_id ON evaluations (trace_id);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS ix_evaluations_span_id ON evaluations (span_id);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_connection(db_path: Path | None = None) -> sqlite3.Connection:
|
|
61
|
+
"""Get a SQLite connection and ensure schema exists."""
|
|
62
|
+
path = db_path or DEFAULT_DB_PATH
|
|
63
|
+
conn = sqlite3.connect(str(path))
|
|
64
|
+
conn.row_factory = sqlite3.Row
|
|
65
|
+
conn.executescript(_SCHEMA_SQL)
|
|
66
|
+
return conn
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_traces(
|
|
70
|
+
db_path: Path | None = None,
|
|
71
|
+
limit: int = 100,
|
|
72
|
+
offset: int = 0,
|
|
73
|
+
status: str | None = None,
|
|
74
|
+
name_search: str | None = None,
|
|
75
|
+
) -> list[dict[str, Any]]:
|
|
76
|
+
"""List traces from local SQLite database."""
|
|
77
|
+
conn = get_connection(db_path)
|
|
78
|
+
try:
|
|
79
|
+
query = "SELECT * FROM traces WHERE 1=1"
|
|
80
|
+
params: list[Any] = []
|
|
81
|
+
|
|
82
|
+
if status:
|
|
83
|
+
query += " AND status = ?"
|
|
84
|
+
params.append(status)
|
|
85
|
+
|
|
86
|
+
if name_search:
|
|
87
|
+
query += " AND name LIKE ?"
|
|
88
|
+
params.append(f"%{name_search}%")
|
|
89
|
+
|
|
90
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
91
|
+
params.extend([limit, offset])
|
|
92
|
+
|
|
93
|
+
cursor = conn.execute(query, params)
|
|
94
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
95
|
+
finally:
|
|
96
|
+
conn.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_trace(trace_id: str, db_path: Path | None = None) -> dict[str, Any] | None:
|
|
100
|
+
"""Get a single trace by ID."""
|
|
101
|
+
conn = get_connection(db_path)
|
|
102
|
+
try:
|
|
103
|
+
cursor = conn.execute("SELECT * FROM traces WHERE id = ?", (trace_id,))
|
|
104
|
+
row = cursor.fetchone()
|
|
105
|
+
return dict(row) if row else None
|
|
106
|
+
finally:
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_trace_spans(trace_id: str, db_path: Path | None = None) -> list[dict[str, Any]]:
|
|
111
|
+
"""Get all spans for a trace."""
|
|
112
|
+
conn = get_connection(db_path)
|
|
113
|
+
try:
|
|
114
|
+
cursor = conn.execute(
|
|
115
|
+
"SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time",
|
|
116
|
+
(trace_id,),
|
|
117
|
+
)
|
|
118
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_evaluations(
|
|
124
|
+
db_path: Path | None = None,
|
|
125
|
+
trace_id: str | None = None,
|
|
126
|
+
limit: int = 100,
|
|
127
|
+
offset: int = 0,
|
|
128
|
+
) -> list[dict[str, Any]]:
|
|
129
|
+
"""List evaluations from local SQLite database."""
|
|
130
|
+
conn = get_connection(db_path)
|
|
131
|
+
try:
|
|
132
|
+
query = "SELECT * FROM evaluations WHERE 1=1"
|
|
133
|
+
params: list[Any] = []
|
|
134
|
+
|
|
135
|
+
if trace_id:
|
|
136
|
+
query += " AND trace_id = ?"
|
|
137
|
+
params.append(trace_id)
|
|
138
|
+
|
|
139
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
140
|
+
params.extend([limit, offset])
|
|
141
|
+
|
|
142
|
+
cursor = conn.execute(query, params)
|
|
143
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_stats(db_path: Path | None = None) -> dict[str, Any]:
|
|
149
|
+
"""Get summary statistics from the database."""
|
|
150
|
+
conn = get_connection(db_path)
|
|
151
|
+
try:
|
|
152
|
+
stats: dict[str, Any] = {}
|
|
153
|
+
|
|
154
|
+
# Trace counts
|
|
155
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces")
|
|
156
|
+
stats["total_traces"] = cursor.fetchone()[0]
|
|
157
|
+
|
|
158
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces WHERE status = 'ok'")
|
|
159
|
+
stats["ok_traces"] = cursor.fetchone()[0]
|
|
160
|
+
|
|
161
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces WHERE status = 'error'")
|
|
162
|
+
stats["error_traces"] = cursor.fetchone()[0]
|
|
163
|
+
|
|
164
|
+
# Span counts
|
|
165
|
+
cursor = conn.execute("SELECT COUNT(*) FROM spans")
|
|
166
|
+
stats["total_spans"] = cursor.fetchone()[0]
|
|
167
|
+
|
|
168
|
+
# Token totals
|
|
169
|
+
cursor = conn.execute(
|
|
170
|
+
"SELECT SUM(tokens_input), SUM(tokens_output) FROM spans WHERE tokens_input IS NOT NULL"
|
|
171
|
+
)
|
|
172
|
+
row = cursor.fetchone()
|
|
173
|
+
stats["total_input_tokens"] = row[0] or 0
|
|
174
|
+
stats["total_output_tokens"] = row[1] or 0
|
|
175
|
+
|
|
176
|
+
# Cost total
|
|
177
|
+
cursor = conn.execute(
|
|
178
|
+
"SELECT SUM(cost_usd) FROM spans WHERE cost_usd IS NOT NULL"
|
|
179
|
+
)
|
|
180
|
+
stats["total_cost_usd"] = cursor.fetchone()[0] or 0.0
|
|
181
|
+
|
|
182
|
+
# Evaluation counts
|
|
183
|
+
cursor = conn.execute("SELECT COUNT(*) FROM evaluations")
|
|
184
|
+
stats["total_evaluations"] = cursor.fetchone()[0]
|
|
185
|
+
|
|
186
|
+
return stats
|
|
187
|
+
finally:
|
|
188
|
+
conn.close()
|
cli/main.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Main CLI application for LLM Observatory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option(version="0.1.1", prog_name="llm-observatory")
|
|
17
|
+
@click.option("--api-key", envvar="OBSERVATORY_API_KEY", default=None, help="API key for backend authentication")
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def cli(ctx: click.Context, api_key: str | None) -> None:
|
|
20
|
+
"""LLM Observatory CLI - Open-source LLM observability."""
|
|
21
|
+
ctx.ensure_object(dict)
|
|
22
|
+
ctx.obj["api_key"] = api_key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Local Database Commands ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.command()
|
|
29
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
30
|
+
@click.option("--limit", default=20, help="Number of traces to show")
|
|
31
|
+
@click.option("--status", type=click.Choice(["ok", "error", "unset"]), help="Filter by status")
|
|
32
|
+
@click.option("--search", help="Search trace names")
|
|
33
|
+
def traces(db: str, limit: int, status: str | None, search: str | None) -> None:
|
|
34
|
+
"""List traces from local SQLite database."""
|
|
35
|
+
from cli.db import list_traces
|
|
36
|
+
|
|
37
|
+
rows = list_traces(Path(db), limit=limit, status=status, name_search=search)
|
|
38
|
+
|
|
39
|
+
if not rows:
|
|
40
|
+
console.print("[yellow]No traces found[/yellow]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
table = Table(title=f"Traces ({len(rows)} shown)")
|
|
44
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
45
|
+
table.add_column("Name", style="green")
|
|
46
|
+
table.add_column("Status", style="yellow")
|
|
47
|
+
table.add_column("Spans", justify="right")
|
|
48
|
+
table.add_column("Created", style="blue")
|
|
49
|
+
|
|
50
|
+
for row in rows:
|
|
51
|
+
table.add_row(
|
|
52
|
+
str(row["id"])[:8],
|
|
53
|
+
str(row["name"]),
|
|
54
|
+
str(row["status"]),
|
|
55
|
+
"",
|
|
56
|
+
str(row.get("created_at", "")),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
console.print(table)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@cli.command()
|
|
63
|
+
@click.argument("trace_id")
|
|
64
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
65
|
+
def inspect(trace_id: str, db: str) -> None:
|
|
66
|
+
"""Inspect a trace and its spans."""
|
|
67
|
+
from cli.db import get_trace, get_trace_spans
|
|
68
|
+
|
|
69
|
+
trace_data = get_trace(trace_id, Path(db))
|
|
70
|
+
if not trace_data:
|
|
71
|
+
console.print(f"[red]Trace {trace_id} not found[/red]")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Print trace info
|
|
75
|
+
console.print(f"\n[bold cyan]Trace: {trace_data['name']}[/bold cyan]")
|
|
76
|
+
console.print(f" ID: {trace_data['id']}")
|
|
77
|
+
console.print(f" Status: {trace_data['status']}")
|
|
78
|
+
console.print(f" Created: {trace_data.get('created_at', 'N/A')}")
|
|
79
|
+
|
|
80
|
+
# Print spans
|
|
81
|
+
spans = get_trace_spans(trace_id, Path(db))
|
|
82
|
+
if spans:
|
|
83
|
+
console.print(f"\n[bold]Spans ({len(spans)}):[/bold]")
|
|
84
|
+
table = Table()
|
|
85
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
86
|
+
table.add_column("Name", style="green")
|
|
87
|
+
table.add_column("Type", style="yellow")
|
|
88
|
+
table.add_column("Status")
|
|
89
|
+
table.add_column("Tokens In", justify="right")
|
|
90
|
+
table.add_column("Tokens Out", justify="right")
|
|
91
|
+
table.add_column("Cost", justify="right")
|
|
92
|
+
|
|
93
|
+
for span in spans:
|
|
94
|
+
table.add_row(
|
|
95
|
+
str(span["id"])[:8],
|
|
96
|
+
str(span["name"]),
|
|
97
|
+
str(span["span_type"]),
|
|
98
|
+
str(span["status"]),
|
|
99
|
+
str(span.get("tokens_input") or ""),
|
|
100
|
+
str(span.get("tokens_output") or ""),
|
|
101
|
+
f"${span['cost_usd']:.6f}" if span.get("cost_usd") else "",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
console.print(table)
|
|
105
|
+
else:
|
|
106
|
+
console.print("[yellow]No spans found[/yellow]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command(name="import")
|
|
110
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
111
|
+
@click.option("--db", type=click.Path(), default="observatory.db", help="SQLite database path")
|
|
112
|
+
@click.option("--dry-run", is_flag=True, help="Preview without writing")
|
|
113
|
+
def import_data(file: str, db: str, dry_run: bool) -> None:
|
|
114
|
+
"""Import traces from a JSON export file."""
|
|
115
|
+
import json
|
|
116
|
+
|
|
117
|
+
from cli.db import get_connection
|
|
118
|
+
|
|
119
|
+
raw = json.loads(Path(file).read_text())
|
|
120
|
+
traces = raw.get("traces", [])
|
|
121
|
+
|
|
122
|
+
if not traces:
|
|
123
|
+
console.print("[yellow]No traces in file[/yellow]")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
console.print(f"[blue]Importing {len(traces)} traces from {file}...[/blue]")
|
|
127
|
+
|
|
128
|
+
if dry_run:
|
|
129
|
+
for t in traces:
|
|
130
|
+
n = len(t.get("spans", []))
|
|
131
|
+
console.print(f" {t.get('name', '?')} — {n} spans")
|
|
132
|
+
console.print("[yellow]Dry run, no changes written[/yellow]")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
conn = get_connection(Path(db))
|
|
136
|
+
try:
|
|
137
|
+
imported = 0
|
|
138
|
+
for trace in traces:
|
|
139
|
+
conn.execute(
|
|
140
|
+
"INSERT OR IGNORE INTO traces (id, name, session_id, status, metadata, start_time, end_time, created_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
141
|
+
(
|
|
142
|
+
trace["id"],
|
|
143
|
+
trace["name"],
|
|
144
|
+
trace.get("session_id"),
|
|
145
|
+
trace.get("status", "unset"),
|
|
146
|
+
json.dumps(trace.get("metadata")),
|
|
147
|
+
trace.get("start_time"),
|
|
148
|
+
trace.get("end_time"),
|
|
149
|
+
trace.get("created_at"),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
for span in trace.get("spans", []):
|
|
153
|
+
conn.execute(
|
|
154
|
+
"INSERT OR IGNORE INTO spans (id, trace_id, parent_span_id, name, span_type, start_time, end_time, status, input, output, tokens_input, tokens_output, cost_usd, metadata, attributes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
155
|
+
(
|
|
156
|
+
span["id"],
|
|
157
|
+
span.get("trace_id", trace["id"]),
|
|
158
|
+
span.get("parent_span_id"),
|
|
159
|
+
span["name"],
|
|
160
|
+
span.get("span_type", "generic"),
|
|
161
|
+
span.get("start_time"),
|
|
162
|
+
span.get("end_time"),
|
|
163
|
+
span.get("status", "unset"),
|
|
164
|
+
json.dumps(span.get("input")) if span.get("input") else None,
|
|
165
|
+
json.dumps(span.get("output")) if span.get("output") else None,
|
|
166
|
+
span.get("tokens_input"),
|
|
167
|
+
span.get("tokens_output"),
|
|
168
|
+
span.get("cost_usd"),
|
|
169
|
+
json.dumps(span.get("metadata")) if span.get("metadata") else None,
|
|
170
|
+
json.dumps(span.get("attributes")) if span.get("attributes") else None,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
imported += 1
|
|
174
|
+
conn.commit()
|
|
175
|
+
console.print(f"[green]Imported {imported} traces ({sum(len(t.get('spans', [])) for t in traces)} spans)[/green]")
|
|
176
|
+
finally:
|
|
177
|
+
conn.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@cli.command()
|
|
181
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
182
|
+
def stats(db: str) -> None:
|
|
183
|
+
"""Show summary statistics."""
|
|
184
|
+
from cli.db import get_stats
|
|
185
|
+
|
|
186
|
+
data = get_stats(Path(db))
|
|
187
|
+
|
|
188
|
+
console.print("\n[bold cyan]LLM Observatory Statistics[/bold cyan]\n")
|
|
189
|
+
console.print(f" Total Traces: {data['total_traces']}")
|
|
190
|
+
console.print(f" OK: {data['ok_traces']}")
|
|
191
|
+
console.print(f" Error: {data['error_traces']}")
|
|
192
|
+
console.print(f" Total Spans: {data['total_spans']}")
|
|
193
|
+
console.print(f" Input Tokens: {data['total_input_tokens']:,}")
|
|
194
|
+
console.print(f" Output Tokens: {data['total_output_tokens']:,}")
|
|
195
|
+
console.print(f" Total Cost: ${data['total_cost_usd']:.6f}")
|
|
196
|
+
console.print(f" Evaluations: {data['total_evaluations']}")
|
|
197
|
+
console.print()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@cli.command()
|
|
201
|
+
@click.argument("trace_id")
|
|
202
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
203
|
+
@click.option("--format", "output_format", type=click.Choice(["json", "table"]), default="table")
|
|
204
|
+
def spans(trace_id: str, db: str, output_format: str) -> None:
|
|
205
|
+
"""List spans for a trace."""
|
|
206
|
+
from cli.db import get_trace_spans
|
|
207
|
+
|
|
208
|
+
rows = get_trace_spans(trace_id, Path(db))
|
|
209
|
+
|
|
210
|
+
if not rows:
|
|
211
|
+
console.print("[yellow]No spans found[/yellow]")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if output_format == "json":
|
|
215
|
+
import json
|
|
216
|
+
console.print(json.dumps(rows, indent=2, default=str))
|
|
217
|
+
else:
|
|
218
|
+
table = Table(title=f"Spans for {trace_id[:8]}")
|
|
219
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
220
|
+
table.add_column("Name", style="green")
|
|
221
|
+
table.add_column("Type", style="yellow")
|
|
222
|
+
table.add_column("Status")
|
|
223
|
+
table.add_column("Duration (ms)", justify="right")
|
|
224
|
+
table.add_column("Tokens", justify="right")
|
|
225
|
+
table.add_column("Cost", justify="right")
|
|
226
|
+
|
|
227
|
+
for row in rows:
|
|
228
|
+
duration = ""
|
|
229
|
+
if row.get("start_time") and row.get("end_time"):
|
|
230
|
+
from datetime import datetime
|
|
231
|
+
try:
|
|
232
|
+
start = datetime.fromisoformat(str(row["start_time"]))
|
|
233
|
+
end = datetime.fromisoformat(str(row["end_time"]))
|
|
234
|
+
duration = f"{(end - start).total_seconds() * 1000:.0f}"
|
|
235
|
+
except (ValueError, TypeError):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
tokens = ""
|
|
239
|
+
if row.get("tokens_input") or row.get("tokens_output"):
|
|
240
|
+
tokens = f"{row.get('tokens_input', 0)}→{row.get('tokens_output', 0)}"
|
|
241
|
+
|
|
242
|
+
table.add_row(
|
|
243
|
+
str(row["id"])[:8],
|
|
244
|
+
str(row["name"]),
|
|
245
|
+
str(row["span_type"]),
|
|
246
|
+
str(row["status"]),
|
|
247
|
+
duration,
|
|
248
|
+
tokens,
|
|
249
|
+
f"${row['cost_usd']:.6f}" if row.get("cost_usd") else "",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
console.print(table)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── Server Commands ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@cli.command()
|
|
259
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to")
|
|
260
|
+
@click.option("--port", default=8000, help="Port to bind to")
|
|
261
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload")
|
|
262
|
+
@click.option("--db", default="sqlite+aiosqlite:///./observatory.db", help="Database URL")
|
|
263
|
+
def serve(host: str, port: int, reload: bool, db: str) -> None:
|
|
264
|
+
"""Start the observatory backend server."""
|
|
265
|
+
try:
|
|
266
|
+
import uvicorn
|
|
267
|
+
except ImportError:
|
|
268
|
+
console.print("[red]Error: uvicorn is not installed.[/red]")
|
|
269
|
+
console.print("Install it with: pip install 'magic-lab-observer-backend[sqlite]'")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
os.environ["DATABASE_URL"] = db
|
|
273
|
+
|
|
274
|
+
console.print(f"[green]Starting LLM Observatory server on {host}:{port}[/green]")
|
|
275
|
+
console.print(f"[green]Database: {db}[/green]")
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
from app.main import app as fastapi_app
|
|
279
|
+
uvicorn.run(fastapi_app, host=host, port=port, reload=reload)
|
|
280
|
+
except ImportError:
|
|
281
|
+
console.print("[red]Error: backend app module not found.[/red]")
|
|
282
|
+
console.print("Make sure the backend is installed: pip install -e ../backend")
|
|
283
|
+
except (RuntimeError, OSError) as e:
|
|
284
|
+
console.print(f"[red]Server error: {e}[/red]")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@cli.command()
|
|
288
|
+
@click.option("--trace-id", required=True, help="Trace ID to evaluate")
|
|
289
|
+
@click.option(
|
|
290
|
+
"--evaluator",
|
|
291
|
+
type=click.Choice(["llm_judge", "rule_based", "human"]),
|
|
292
|
+
required=True,
|
|
293
|
+
help="Evaluator type",
|
|
294
|
+
)
|
|
295
|
+
@click.option("--criteria", help="Comma-separated criteria for evaluation")
|
|
296
|
+
@click.option("--endpoint", default="http://localhost:8000", help="API endpoint")
|
|
297
|
+
@click.pass_context
|
|
298
|
+
def evaluate(
|
|
299
|
+
ctx: click.Context,
|
|
300
|
+
trace_id: str,
|
|
301
|
+
evaluator: str,
|
|
302
|
+
criteria: str | None,
|
|
303
|
+
endpoint: str,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Run an evaluation on a trace."""
|
|
306
|
+
import httpx
|
|
307
|
+
|
|
308
|
+
console.print(f"[blue]Evaluating trace {trace_id} with {evaluator}...[/blue]")
|
|
309
|
+
|
|
310
|
+
criteria_list = criteria.split(",") if criteria else []
|
|
311
|
+
|
|
312
|
+
headers: dict[str, str] = {}
|
|
313
|
+
api_key = ctx.obj.get("api_key")
|
|
314
|
+
if api_key:
|
|
315
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
response = httpx.post(
|
|
319
|
+
f"{endpoint}/v1/evaluations/run",
|
|
320
|
+
json={
|
|
321
|
+
"trace_id": trace_id,
|
|
322
|
+
"evaluator_type": evaluator,
|
|
323
|
+
"criteria": [{"name": c.strip(), "description": c.strip()} for c in criteria_list] if criteria_list else [],
|
|
324
|
+
},
|
|
325
|
+
headers=headers,
|
|
326
|
+
)
|
|
327
|
+
response.raise_for_status()
|
|
328
|
+
|
|
329
|
+
result = response.json()
|
|
330
|
+
console.print("[green]Evaluation completed successfully![/green]")
|
|
331
|
+
console.print(f"Score: {result.get('score', 'N/A')}")
|
|
332
|
+
console.print(f"Passed: {result.get('passed', 'N/A')}")
|
|
333
|
+
if result.get("criteria"):
|
|
334
|
+
console.print("Criteria scores:")
|
|
335
|
+
for k, v in result["criteria"].items():
|
|
336
|
+
console.print(f" {k}: {v}")
|
|
337
|
+
except httpx.HTTPError as e:
|
|
338
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@cli.command()
|
|
342
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
343
|
+
@click.option("--format", "output_format", type=click.Choice(["json"]), default="json", help="Export format")
|
|
344
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file (default: stdout)")
|
|
345
|
+
@click.option("--status", type=click.Choice(["ok", "error", "unset"]), help="Filter by status")
|
|
346
|
+
@click.option("--limit", default=1000, help="Max traces to export")
|
|
347
|
+
def export(db: str, output_format: str, output: str | None, status: str | None, limit: int) -> None:
|
|
348
|
+
"""Export traces and spans to a file."""
|
|
349
|
+
import json
|
|
350
|
+
|
|
351
|
+
from cli.db import get_trace_spans, list_traces
|
|
352
|
+
|
|
353
|
+
rows = list_traces(Path(db), limit=limit, status=status)
|
|
354
|
+
|
|
355
|
+
if not rows:
|
|
356
|
+
console.print("[yellow]No traces to export[/yellow]")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
traces_data = []
|
|
360
|
+
for row in rows:
|
|
361
|
+
trace_id = str(row["id"])
|
|
362
|
+
spans = get_trace_spans(trace_id, Path(db))
|
|
363
|
+
traces_data.append({
|
|
364
|
+
"id": trace_id,
|
|
365
|
+
"name": row["name"],
|
|
366
|
+
"status": row["status"],
|
|
367
|
+
"session_id": row.get("session_id"),
|
|
368
|
+
"start_time": row.get("start_time"),
|
|
369
|
+
"end_time": row.get("end_time"),
|
|
370
|
+
"metadata": json.loads(row["metadata"]) if row.get("metadata") else None,
|
|
371
|
+
"created_at": row.get("created_at"),
|
|
372
|
+
"spans": spans,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
export_data = {
|
|
376
|
+
"format": "llm-observatory-export",
|
|
377
|
+
"version": "0.1.1",
|
|
378
|
+
"trace_count": len(traces_data),
|
|
379
|
+
"traces": traces_data,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
json_str = json.dumps(export_data, indent=2, default=str)
|
|
383
|
+
|
|
384
|
+
if output:
|
|
385
|
+
Path(output).write_text(json_str)
|
|
386
|
+
console.print(f"[green]Exported {len(traces_data)} traces to {output}[/green]")
|
|
387
|
+
else:
|
|
388
|
+
console.print(json_str)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@cli.command()
|
|
392
|
+
@click.option("--endpoint", default="http://localhost:8000", help="API endpoint")
|
|
393
|
+
@click.pass_context
|
|
394
|
+
def status(ctx: click.Context, endpoint: str) -> None:
|
|
395
|
+
"""Check the status of the observatory server."""
|
|
396
|
+
import httpx
|
|
397
|
+
|
|
398
|
+
headers: dict[str, str] = {}
|
|
399
|
+
api_key = ctx.obj.get("api_key")
|
|
400
|
+
if api_key:
|
|
401
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
response = httpx.get(f"{endpoint}/health", headers=headers)
|
|
405
|
+
response.raise_for_status()
|
|
406
|
+
|
|
407
|
+
console.print("[green]Server is healthy![/green]")
|
|
408
|
+
console.print(f"Endpoint: {endpoint}")
|
|
409
|
+
except httpx.HTTPError:
|
|
410
|
+
console.print("[red]Server is not responding[/red]")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
cli()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magic-lab-observer-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI for LLM Observatory
|
|
5
|
+
Project-URL: Homepage, https://github.com/Magic-Lab-Studio/observer
|
|
6
|
+
Project-URL: Repository, https://github.com/Magic-Lab-Studio/observer.git
|
|
7
|
+
Project-URL: Issues, https://github.com/Magic-Lab-Studio/observer/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Requires-Dist: rich>=13.0.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# LLM Observatory CLI
|
|
16
|
+
|
|
17
|
+
Command-line interface for LLM Observatory.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install magic-lab-observer-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Before the distribution is published, install this directory from the checked-out
|
|
26
|
+
repository with `python -m pip install -e ./cli`.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Start the server
|
|
32
|
+
llm-observatory serve
|
|
33
|
+
|
|
34
|
+
# Run an evaluation
|
|
35
|
+
llm-observatory evaluate --trace-id abc123 --evaluator llm_judge --criteria accuracy,safety
|
|
36
|
+
|
|
37
|
+
# Export traces
|
|
38
|
+
llm-observatory export --format json --output traces.json
|
|
39
|
+
|
|
40
|
+
# Check server status
|
|
41
|
+
llm-observatory status
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
Apache License 2.0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
cli/__init__.py,sha256=ktHp2A60HiiohlD1aa5ekArVz_BuHEWR1rebup9OvjA,72
|
|
2
|
+
cli/db.py,sha256=5FNEdcLbSou43INuDmwKZcUhNmCS6VTMBh_DShqIhH4,5691
|
|
3
|
+
cli/main.py,sha256=OeChnX9DwaCZTSWI7TBPkAcXoaz1PD9AoEI8sD37XRI,15543
|
|
4
|
+
magic_lab_observer_cli-0.1.1.dist-info/METADATA,sha256=lbl4OjoPI-YACLYQ9PJ7eoJ4Iv_PTXvvYOaJ4V0D4To,1090
|
|
5
|
+
magic_lab_observer_cli-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
magic_lab_observer_cli-0.1.1.dist-info/entry_points.txt,sha256=bhivRt8UqZcOl_XUoO94J3CdHw4ruCbLnXy5_c-XuFs,49
|
|
7
|
+
magic_lab_observer_cli-0.1.1.dist-info/RECORD,,
|