magic-lab-observer-cli 0.1.1__tar.gz

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.
@@ -0,0 +1,68 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ .venv/
25
+ venv/
26
+ ENV/
27
+
28
+ # IDE
29
+ .vscode/
30
+ .idea/
31
+ *.swp
32
+ *.swo
33
+ *~
34
+
35
+ # Testing
36
+ .coverage
37
+ htmlcov/
38
+ .pytest_cache/
39
+ .mypy_cache/
40
+
41
+ # Node.js
42
+ node_modules/
43
+ npm-debug.log*
44
+ yarn-debug.log*
45
+ yarn-error.log*
46
+
47
+ # Build
48
+ dist/
49
+ build/
50
+ *.tsbuildinfo
51
+
52
+ # Environment
53
+ .env
54
+ .env.local
55
+ .env.*.local
56
+
57
+ # Podman
58
+ podman-compose.override.yml
59
+
60
+ # Database
61
+ *.db
62
+ *.sqlite3
63
+ .observer-state/
64
+
65
+ # OS
66
+ .DS_Store
67
+ Thumbs.db
68
+
@@ -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,32 @@
1
+ # LLM Observatory CLI
2
+
3
+ Command-line interface for LLM Observatory.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install magic-lab-observer-cli
9
+ ```
10
+
11
+ Before the distribution is published, install this directory from the checked-out
12
+ repository with `python -m pip install -e ./cli`.
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ # Start the server
18
+ llm-observatory serve
19
+
20
+ # Run an evaluation
21
+ llm-observatory evaluate --trace-id abc123 --evaluator llm_judge --criteria accuracy,safety
22
+
23
+ # Export traces
24
+ llm-observatory export --format json --output traces.json
25
+
26
+ # Check server status
27
+ llm-observatory status
28
+ ```
29
+
30
+ ## License
31
+
32
+ Apache License 2.0
@@ -0,0 +1 @@
1
+ """LLM Observatory CLI - Command-line interface for LLM Observatory."""
@@ -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()
@@ -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,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "magic-lab-observer-cli"
7
+ version = "0.1.1"
8
+ description = "CLI for LLM Observatory"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "click>=8.1.0",
14
+ "rich>=13.0.0",
15
+ "httpx>=0.27.0",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/Magic-Lab-Studio/observer"
20
+ Repository = "https://github.com/Magic-Lab-Studio/observer.git"
21
+ Issues = "https://github.com/Magic-Lab-Studio/observer/issues"
22
+
23
+ [project.scripts]
24
+ llm-observatory = "cli.main:cli"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["cli"]
File without changes
@@ -0,0 +1,526 @@
1
+ """Tests for LLM Observatory CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from cli.db import (
10
+ get_connection,
11
+ get_stats,
12
+ get_trace,
13
+ get_trace_spans,
14
+ list_evaluations,
15
+ list_traces,
16
+ )
17
+ from cli.main import cli
18
+ from click.testing import CliRunner
19
+
20
+ # ── Helpers ──────────────────────────────────────────────────────────
21
+
22
+ def _seed_db(db: Path, *, traces: int = 3, spans_per_trace: int = 2) -> dict:
23
+ """Insert test data and return summary."""
24
+ conn = get_connection(db)
25
+ trace_ids = []
26
+ for i in range(traces):
27
+ tid = f"trace-{i:04d}"
28
+ conn.execute(
29
+ "INSERT INTO traces (id, name, status, start_time, end_time, created_at, session_id) "
30
+ "VALUES (?, ?, 'ok', '2025-01-01T00:00:00', '2025-01-01T00:00:01', ?, ?)",
31
+ (tid, f"test-trace-{i}", f"2025-01-01T00:00:0{i}", f"session-{i}"),
32
+ )
33
+ trace_ids.append(tid)
34
+ for j in range(spans_per_trace):
35
+ span_id = f"span-{i:04d}-{j:02d}"
36
+ conn.execute(
37
+ "INSERT INTO spans (id, trace_id, name, span_type, start_time, end_time, "
38
+ "status, tokens_input, tokens_output, cost_usd) "
39
+ "VALUES (?, ?, ?, 'llm_call', '2025-01-01T00:00:00', '2025-01-01T00:00:01', 'ok', ?, ?, ?)",
40
+ (span_id, tid, f"span-{j}", 100 + j, 200 + j, 0.001 * (j + 1)),
41
+ )
42
+ # Add one error trace
43
+ conn.execute(
44
+ "INSERT INTO traces (id, name, status, start_time, end_time, created_at) "
45
+ "VALUES ('trace-err', 'error-trace', 'error', '2025-01-01T00:00:00', '2025-01-01T00:00:01', '2025-01-01T00:00:02')"
46
+ )
47
+ conn.execute(
48
+ "INSERT INTO spans (id, trace_id, name, span_type, start_time, end_time, status, tokens_input, tokens_output, cost_usd) "
49
+ "VALUES ('span-err', 'trace-err', 'fail-span', 'llm_call', '2025-01-01T00:00:00', '2025-01-01T00:00:01', 'error', 50, 10, 0.005)"
50
+ )
51
+ # Add evaluation
52
+ conn.execute(
53
+ "INSERT INTO evaluations (id, trace_id, evaluator_type, score, created_at) "
54
+ "VALUES ('eval-1', 'trace-0000', 'rule_based', 0.85, '2025-01-01T00:00:03')"
55
+ )
56
+ conn.commit()
57
+ conn.close()
58
+ return {"traces": traces + 1, "total_spans": traces * spans_per_trace + 1}
59
+
60
+
61
+ @pytest.fixture()
62
+ def db_file(tmp_path: Path) -> Path:
63
+ """Create a seeded database in a temp directory."""
64
+ db = tmp_path / "test.db"
65
+ _seed_db(db)
66
+ return db
67
+
68
+
69
+ @pytest.fixture()
70
+ def empty_db(tmp_path: Path) -> Path:
71
+ """Create an empty (but schema-initialized) database."""
72
+ db = tmp_path / "empty.db"
73
+ get_connection(db).close()
74
+ return db
75
+
76
+
77
+ @pytest.fixture()
78
+ def runner() -> CliRunner:
79
+ return CliRunner()
80
+
81
+
82
+ # ── db.py unit tests ─────────────────────────────────────────────────
83
+
84
+ class TestDBHelpers:
85
+ def test_get_connection_creates_schema(self, tmp_path: Path) -> None:
86
+ db = tmp_path / "new.db"
87
+ conn = get_connection(db)
88
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
89
+ tables = {row[0] for row in cursor.fetchall()}
90
+ conn.close()
91
+ assert "traces" in tables
92
+ assert "spans" in tables
93
+ assert "evaluations" in tables
94
+
95
+ def test_get_connection_idempotent(self, tmp_path: Path) -> None:
96
+ db = tmp_path / "idempotent.db"
97
+ get_connection(db).close()
98
+ get_connection(db).close()
99
+
100
+ def test_list_traces_empty(self, empty_db: Path) -> None:
101
+ assert list_traces(empty_db) == []
102
+
103
+ def test_list_traces_default(self, db_file: Path) -> None:
104
+ rows = list_traces(db_file)
105
+ assert len(rows) == 4 # 3 ok + 1 error
106
+
107
+ def test_list_traces_limit(self, db_file: Path) -> None:
108
+ rows = list_traces(db_file, limit=2)
109
+ assert len(rows) == 2
110
+
111
+ def test_list_traces_status_filter(self, db_file: Path) -> None:
112
+ rows = list_traces(db_file, status="error")
113
+ assert len(rows) == 1
114
+ assert rows[0]["name"] == "error-trace"
115
+
116
+ def test_list_traces_name_search(self, db_file: Path) -> None:
117
+ rows = list_traces(db_file, name_search="test-trace")
118
+ assert len(rows) == 3
119
+
120
+ def test_list_traces_name_search_no_match(self, db_file: Path) -> None:
121
+ rows = list_traces(db_file, name_search="nonexistent")
122
+ assert rows == []
123
+
124
+ def test_get_trace_found(self, db_file: Path) -> None:
125
+ t = get_trace("trace-0000", db_file)
126
+ assert t is not None
127
+ assert t["name"] == "test-trace-0"
128
+ assert t["status"] == "ok"
129
+
130
+ def test_get_trace_not_found(self, db_file: Path) -> None:
131
+ assert get_trace("no-such-id", db_file) is None
132
+
133
+ def test_get_trace_spans(self, db_file: Path) -> None:
134
+ spans = get_trace_spans("trace-0000", db_file)
135
+ assert len(spans) == 2
136
+ assert spans[0]["trace_id"] == "trace-0000"
137
+
138
+ def test_get_trace_spans_empty(self, db_file: Path) -> None:
139
+ spans = get_trace_spans("trace-err", db_file)
140
+ assert len(spans) == 1
141
+
142
+ def test_list_evaluations(self, db_file: Path) -> None:
143
+ evals = list_evaluations(db_file)
144
+ assert len(evals) == 1
145
+ assert evals[0]["evaluator_type"] == "rule_based"
146
+
147
+ def test_list_evaluations_filter_by_trace(self, db_file: Path) -> None:
148
+ evals = list_evaluations(db_file, trace_id="trace-0000")
149
+ assert len(evals) == 1
150
+
151
+ def test_list_evaluations_filter_no_match(self, db_file: Path) -> None:
152
+ evals = list_evaluations(db_file, trace_id="trace-nope")
153
+ assert evals == []
154
+
155
+ def test_get_stats(self, db_file: Path) -> None:
156
+ stats = get_stats(db_file)
157
+ assert stats["total_traces"] == 4
158
+ assert stats["ok_traces"] == 3
159
+ assert stats["error_traces"] == 1
160
+ assert stats["total_spans"] == 7
161
+ assert stats["total_evaluations"] == 1
162
+ assert stats["total_input_tokens"] > 0
163
+ assert stats["total_output_tokens"] > 0
164
+ assert stats["total_cost_usd"] > 0
165
+
166
+ def test_get_stats_empty(self, empty_db: Path) -> None:
167
+ stats = get_stats(empty_db)
168
+ assert stats["total_traces"] == 0
169
+ assert stats["total_spans"] == 0
170
+ assert stats["total_input_tokens"] == 0
171
+ assert stats["total_cost_usd"] == 0.0
172
+
173
+
174
+ # ── CLI command tests ────────────────────────────────────────────────
175
+
176
+ class TestCLIHelp:
177
+ def test_version(self, runner: CliRunner) -> None:
178
+ r = runner.invoke(cli, ["--version"])
179
+ assert r.exit_code == 0
180
+ assert "0.1.1" in r.output
181
+
182
+ def test_help(self, runner: CliRunner) -> None:
183
+ r = runner.invoke(cli, ["--help"])
184
+ assert r.exit_code == 0
185
+ assert "LLM Observatory CLI" in r.output
186
+
187
+ def test_traces_help(self, runner: CliRunner) -> None:
188
+ r = runner.invoke(cli, ["traces", "--help"])
189
+ assert r.exit_code == 0
190
+ assert "--limit" in r.output
191
+
192
+ def test_inspect_help(self, runner: CliRunner) -> None:
193
+ r = runner.invoke(cli, ["inspect", "--help"])
194
+ assert r.exit_code == 0
195
+ assert "TRACE_ID" in r.output
196
+
197
+ def test_stats_help(self, runner: CliRunner) -> None:
198
+ r = runner.invoke(cli, ["stats", "--help"])
199
+ assert r.exit_code == 0
200
+
201
+ def test_spans_help(self, runner: CliRunner) -> None:
202
+ r = runner.invoke(cli, ["spans", "--help"])
203
+ assert r.exit_code == 0
204
+ assert "--format" in r.output
205
+
206
+ def test_export_help(self, runner: CliRunner) -> None:
207
+ r = runner.invoke(cli, ["export", "--help"])
208
+ assert r.exit_code == 0
209
+ assert "--output" in r.output
210
+
211
+ def test_serve_help(self, runner: CliRunner) -> None:
212
+ r = runner.invoke(cli, ["serve", "--help"])
213
+ assert r.exit_code == 0
214
+ assert "--reload" in r.output
215
+
216
+ def test_evaluate_help(self, runner: CliRunner) -> None:
217
+ r = runner.invoke(cli, ["evaluate", "--help"])
218
+ assert r.exit_code == 0
219
+ assert "--trace-id" in r.output
220
+
221
+ def test_status_help(self, runner: CliRunner) -> None:
222
+ r = runner.invoke(cli, ["status", "--help"])
223
+ assert r.exit_code == 0
224
+ assert "--endpoint" in r.output
225
+
226
+
227
+ class TestTracesCommand:
228
+ def test_traces_shows_table(self, runner: CliRunner, db_file: Path) -> None:
229
+ r = runner.invoke(cli, ["traces", "--db", str(db_file)])
230
+ assert r.exit_code == 0
231
+ assert "test-trace-0" in r.output
232
+ assert "Traces (4 shown)" in r.output
233
+
234
+ def test_traces_empty(self, runner: CliRunner, empty_db: Path) -> None:
235
+ r = runner.invoke(cli, ["traces", "--db", str(empty_db)])
236
+ assert r.exit_code == 0
237
+ assert "No traces found" in r.output
238
+
239
+ def test_traces_limit(self, runner: CliRunner, db_file: Path) -> None:
240
+ r = runner.invoke(cli, ["traces", "--db", str(db_file), "--limit", "1"])
241
+ assert r.exit_code == 0
242
+ assert "Traces (1 shown)" in r.output
243
+
244
+ def test_traces_filter_status(self, runner: CliRunner, db_file: Path) -> None:
245
+ r = runner.invoke(cli, ["traces", "--db", str(db_file), "--status", "error"])
246
+ assert r.exit_code == 0
247
+ assert "error-trace" in r.output
248
+
249
+ def test_traces_search(self, runner: CliRunner, db_file: Path) -> None:
250
+ r = runner.invoke(cli, ["traces", "--db", str(db_file), "--search", "error"])
251
+ assert r.exit_code == 0
252
+ assert "error-trace" in r.output
253
+ assert "Traces (1 shown)" in r.output
254
+
255
+ def test_traces_missing_db(self, runner: CliRunner) -> None:
256
+ r = runner.invoke(cli, ["traces", "--db", "/nonexistent/path.db"])
257
+ assert r.exit_code != 0
258
+
259
+
260
+ class TestInspectCommand:
261
+ def test_inspect_found(self, runner: CliRunner, db_file: Path) -> None:
262
+ r = runner.invoke(cli, ["inspect", "trace-0000", "--db", str(db_file)])
263
+ assert r.exit_code == 0
264
+ assert "test-trace-0" in r.output
265
+ assert "Spans (2)" in r.output
266
+
267
+ def test_inspect_not_found(self, runner: CliRunner, db_file: Path) -> None:
268
+ r = runner.invoke(cli, ["inspect", "no-such", "--db", str(db_file)])
269
+ assert r.exit_code == 0
270
+ assert "not found" in r.output
271
+
272
+ def test_inspect_shows_status(self, runner: CliRunner, db_file: Path) -> None:
273
+ r = runner.invoke(cli, ["inspect", "trace-err", "--db", str(db_file)])
274
+ assert r.exit_code == 0
275
+ assert "error" in r.output
276
+
277
+
278
+ class TestStatsCommand:
279
+ def test_stats_output(self, runner: CliRunner, db_file: Path) -> None:
280
+ r = runner.invoke(cli, ["stats", "--db", str(db_file)])
281
+ assert r.exit_code == 0
282
+ assert "Total Traces:" in r.output
283
+ assert "4" in r.output
284
+ assert "OK:" in r.output
285
+ assert "3" in r.output
286
+ assert "Error:" in r.output
287
+
288
+ def test_stats_empty(self, runner: CliRunner, empty_db: Path) -> None:
289
+ r = runner.invoke(cli, ["stats", "--db", str(empty_db)])
290
+ assert r.exit_code == 0
291
+ assert "Total Traces:" in r.output
292
+ assert "0" in r.output
293
+
294
+ def test_stats_shows_tokens(self, runner: CliRunner, db_file: Path) -> None:
295
+ r = runner.invoke(cli, ["stats", "--db", str(db_file)])
296
+ assert r.exit_code == 0
297
+ assert "Input Tokens:" in r.output
298
+ assert "Output Tokens:" in r.output
299
+ assert "Total Cost:" in r.output
300
+
301
+
302
+ class TestSpansCommand:
303
+ def test_spans_table(self, runner: CliRunner, db_file: Path) -> None:
304
+ r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
305
+ assert r.exit_code == 0
306
+ assert "span-0" in r.output
307
+ assert "span-1" in r.output
308
+
309
+ def test_spans_json(self, runner: CliRunner, db_file: Path) -> None:
310
+ r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file), "--format", "json"])
311
+ assert r.exit_code == 0
312
+ data = json.loads(r.output)
313
+ assert len(data) == 2
314
+ assert data[0]["trace_id"] == "trace-0000"
315
+
316
+ def test_spans_empty(self, runner: CliRunner, db_file: Path) -> None:
317
+ r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
318
+ assert r.exit_code == 0
319
+
320
+ def test_spans_no_match(self, runner: CliRunner, db_file: Path) -> None:
321
+ r = runner.invoke(cli, ["spans", "no-such-trace", "--db", str(db_file)])
322
+ assert r.exit_code == 0
323
+ assert "No spans found" in r.output
324
+
325
+ def test_spans_shows_duration(self, runner: CliRunner, db_file: Path) -> None:
326
+ r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
327
+ assert r.exit_code == 0
328
+ assert "Duration (ms)" in r.output
329
+
330
+ def test_spans_shows_tokens(self, runner: CliRunner, db_file: Path) -> None:
331
+ r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
332
+ assert r.exit_code == 0
333
+ assert "Tokens" in r.output
334
+
335
+
336
+ class TestExportCommand:
337
+ def test_export_stdout(self, runner: CliRunner, db_file: Path) -> None:
338
+ r = runner.invoke(cli, ["export", "--db", str(db_file)])
339
+ assert r.exit_code == 0
340
+ data = json.loads(r.output)
341
+ assert data["format"] == "llm-observatory-export"
342
+ assert data["version"] == "0.1.1"
343
+ assert data["trace_count"] == 4
344
+
345
+ def test_export_to_file(self, runner: CliRunner, db_file: Path, tmp_path: Path) -> None:
346
+ out = tmp_path / "export.json"
347
+ r = runner.invoke(cli, ["export", "--db", str(db_file), "-o", str(out)])
348
+ assert r.exit_code == 0
349
+ assert out.exists()
350
+ data = json.loads(out.read_text())
351
+ assert data["trace_count"] == 4
352
+
353
+ def test_export_empty(self, runner: CliRunner, empty_db: Path) -> None:
354
+ r = runner.invoke(cli, ["export", "--db", str(empty_db)])
355
+ assert r.exit_code == 0
356
+ assert "No traces to export" in r.output
357
+
358
+ def test_export_filter_status(self, runner: CliRunner, db_file: Path) -> None:
359
+ r = runner.invoke(cli, ["export", "--db", str(db_file), "--status", "error"])
360
+ assert r.exit_code == 0
361
+ data = json.loads(r.output)
362
+ assert data["trace_count"] == 1
363
+ assert data["traces"][0]["status"] == "error"
364
+
365
+ def test_export_includes_spans(self, runner: CliRunner, db_file: Path) -> None:
366
+ r = runner.invoke(cli, ["export", "--db", str(db_file)])
367
+ data = json.loads(r.output)
368
+ trace_with_spans = next(t for t in data["traces"] if t["id"] == "trace-0000")
369
+ assert len(trace_with_spans["spans"]) == 2
370
+
371
+ def test_export_limit(self, runner: CliRunner, db_file: Path) -> None:
372
+ r = runner.invoke(cli, ["export", "--db", str(db_file), "--limit", "2"])
373
+ data = json.loads(r.output)
374
+ assert data["trace_count"] == 2
375
+
376
+ def test_export_roundtrip(self, runner: CliRunner, db_file: Path, tmp_path: Path) -> None:
377
+ """Export then re-import preserves data structure."""
378
+ out = tmp_path / "roundtrip.json"
379
+ r = runner.invoke(cli, ["export", "--db", str(db_file), "-o", str(out)])
380
+ assert r.exit_code == 0
381
+ data = json.loads(out.read_text())
382
+ for trace in data["traces"]:
383
+ assert "id" in trace
384
+ assert "name" in trace
385
+ assert "spans" in trace
386
+ for span in trace["spans"]:
387
+ assert "trace_id" in span
388
+ assert "name" in span
389
+
390
+
391
+ class TestEvaluateCommand:
392
+ def test_evaluate_help_shows_options(self, runner: CliRunner) -> None:
393
+ r = runner.invoke(cli, ["evaluate", "--help"])
394
+ assert r.exit_code == 0
395
+ assert "llm_judge" in r.output
396
+ assert "rule_based" in r.output
397
+ assert "human" in r.output
398
+
399
+ def test_evaluate_requires_trace_id(self, runner: CliRunner) -> None:
400
+ r = runner.invoke(cli, ["evaluate", "--evaluator", "rule_based"])
401
+ assert r.exit_code != 0
402
+
403
+ def test_evaluate_requires_evaluator(self, runner: CliRunner) -> None:
404
+ r = runner.invoke(cli, ["evaluate", "--trace-id", "t1"])
405
+ assert r.exit_code != 0
406
+
407
+
408
+ class TestStatusCommand:
409
+ def test_status_server_down(self, runner: CliRunner) -> None:
410
+ r = runner.invoke(cli, ["status", "--endpoint", "http://127.0.0.1:19999"])
411
+ assert r.exit_code == 0
412
+ assert "not responding" in r.output
413
+
414
+
415
+ class TestAPIKeyOption:
416
+ def test_api_key_from_option(self, runner: CliRunner) -> None:
417
+ r = runner.invoke(cli, ["--api-key", "test-key", "status", "--endpoint", "http://127.0.0.1:19999"])
418
+ assert r.exit_code == 0
419
+ assert "not responding" in r.output
420
+
421
+ def test_api_key_none_by_default(self, runner: CliRunner) -> None:
422
+ r = runner.invoke(cli, ["status", "--endpoint", "http://127.0.0.1:19999"])
423
+ assert r.exit_code == 0
424
+
425
+
426
+ class TestImportCommand:
427
+ def test_import_help(self, runner: CliRunner) -> None:
428
+ r = runner.invoke(cli, ["import", "--help"])
429
+ assert r.exit_code == 0
430
+ assert "Dry run" in r.output or "dry-run" in r.output
431
+
432
+ def test_import_file_not_found(self, runner: CliRunner, tmp_path: Path) -> None:
433
+ r = runner.invoke(cli, ["import", str(tmp_path / "nonexistent.json")])
434
+ assert r.exit_code != 0
435
+
436
+ def test_import_dry_run(self, runner: CliRunner, tmp_path: Path) -> None:
437
+ export_file = tmp_path / "export.json"
438
+ export_data = {
439
+ "format": "llm-observatory-export",
440
+ "version": "0.1.0",
441
+ "trace_count": 2,
442
+ "traces": [
443
+ {
444
+ "id": "imp-001",
445
+ "name": "imported-trace-1",
446
+ "status": "ok",
447
+ "session_id": "s1",
448
+ "start_time": "2025-06-01T00:00:00",
449
+ "end_time": "2025-06-01T00:00:01",
450
+ "created_at": "2025-06-01T00:00:00",
451
+ "spans": [
452
+ {"id": "sp-001", "name": "llm-call", "span_type": "llm",
453
+ "start_time": "2025-06-01T00:00:00", "end_time": "2025-06-01T00:00:01",
454
+ "status": "ok", "tokens_input": 100, "tokens_output": 50,
455
+ "cost_usd": 0.001},
456
+ ],
457
+ },
458
+ {
459
+ "id": "imp-002",
460
+ "name": "imported-trace-2",
461
+ "status": "error",
462
+ "spans": [],
463
+ },
464
+ ],
465
+ }
466
+ export_file.write_text(json.dumps(export_data))
467
+
468
+ r = runner.invoke(cli, ["import", str(export_file), "--dry-run"])
469
+ assert r.exit_code == 0
470
+ assert "imported-trace-1" in r.output
471
+ assert "Dry run" in r.output
472
+
473
+ # Nothing should be written
474
+ db = tmp_path / "test.db"
475
+ conn = get_connection(db)
476
+ cursor = conn.execute("SELECT COUNT(*) FROM traces")
477
+ assert cursor.fetchone()[0] == 0
478
+ conn.close()
479
+
480
+ def test_import_writes_to_db(self, runner: CliRunner, tmp_path: Path) -> None:
481
+ export_file = tmp_path / "export.json"
482
+ export_data = {
483
+ "format": "llm-observatory-export",
484
+ "version": "0.1.0",
485
+ "trace_count": 1,
486
+ "traces": [
487
+ {
488
+ "id": "imp-100",
489
+ "name": "imported-ok",
490
+ "status": "ok",
491
+ "session_id": "s1",
492
+ "start_time": "2025-06-01T00:00:00",
493
+ "end_time": "2025-06-01T00:00:01",
494
+ "created_at": "2025-06-01T00:00:00",
495
+ "spans": [
496
+ {"id": "sp-100", "name": "call-1", "span_type": "llm",
497
+ "start_time": "2025-06-01T00:00:00", "end_time": "2025-06-01T00:00:01",
498
+ "status": "ok", "tokens_input": 200, "tokens_output": 100,
499
+ "cost_usd": 0.005},
500
+ ],
501
+ },
502
+ ],
503
+ }
504
+ export_file.write_text(json.dumps(export_data))
505
+
506
+ db = tmp_path / "import.db"
507
+ r = runner.invoke(cli, ["import", str(export_file), "--db", str(db)])
508
+ assert r.exit_code == 0
509
+ assert "Imported 1 traces" in r.output
510
+
511
+ # Verify data was written
512
+ conn = get_connection(db)
513
+ cursor = conn.execute("SELECT COUNT(*) FROM traces")
514
+ assert cursor.fetchone()[0] == 1
515
+ cursor = conn.execute("SELECT name FROM traces WHERE id='imp-100'")
516
+ assert cursor.fetchone()[0] == "imported-ok"
517
+ cursor = conn.execute("SELECT COUNT(*) FROM spans WHERE trace_id='imp-100'")
518
+ assert cursor.fetchone()[0] == 1
519
+ conn.close()
520
+
521
+ def test_import_empty_file(self, runner: CliRunner, tmp_path: Path) -> None:
522
+ export_file = tmp_path / "empty.json"
523
+ export_file.write_text(json.dumps({"format": "llm-observatory-export", "traces": []}))
524
+ r = runner.invoke(cli, ["import", str(export_file)])
525
+ assert r.exit_code == 0
526
+ assert "No traces" in r.output