spooling 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.
- spooling/__init__.py +2 -0
- spooling/agent.py +213 -0
- spooling/classifiers.py +147 -0
- spooling/cli.py +522 -0
- spooling/cloud.py +768 -0
- spooling/config.py +44 -0
- spooling/db.py +21 -0
- spooling/embeddings.py +60 -0
- spooling/evals.py +611 -0
- spooling/experiments.py +407 -0
- spooling/ingest.py +496 -0
- spooling/mcp_server.py +312 -0
- spooling/parser.py +614 -0
- spooling/pricing.py +307 -0
- spooling/providers/__init__.py +46 -0
- spooling/providers/antigravity.py +312 -0
- spooling/providers/base.py +166 -0
- spooling/providers/codex.py +230 -0
- spooling/providers/copilot.py +294 -0
- spooling/providers/cortex_code.py +234 -0
- spooling/providers/cursor.py +307 -0
- spooling/providers/gemini.py +476 -0
- spooling/providers/github.py +241 -0
- spooling/providers/gitlab.py +186 -0
- spooling/providers/kiro.py +240 -0
- spooling/providers/opencode.py +282 -0
- spooling/providers/session_file.py +36 -0
- spooling/providers/windsurf.py +355 -0
- spooling/redact.py +284 -0
- spooling/remote_otel.py +257 -0
- spooling/sdk.py +364 -0
- spooling/search.py +68 -0
- spooling/server.py +1291 -0
- spooling/stats.py +180 -0
- spooling/subscription_pricing.py +131 -0
- spooling/tracing.py +451 -0
- spooling/watcher.py +125 -0
- spooling-0.1.1.dist-info/METADATA +28 -0
- spooling-0.1.1.dist-info/RECORD +43 -0
- spooling-0.1.1.dist-info/WHEEL +5 -0
- spooling-0.1.1.dist-info/entry_points.txt +2 -0
- spooling-0.1.1.dist-info/licenses/LICENSE +21 -0
- spooling-0.1.1.dist-info/top_level.txt +1 -0
spooling/cli.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Spooling CLI - track and search your AI coding assistant sessions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _clean_project(name: str) -> str:
|
|
14
|
+
"""Turn '-Users-username-path-to-project' into '~/path/to/project'."""
|
|
15
|
+
return re.sub(r"-Users-[^-]+-", "~/", name).replace("-", "/")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
@click.version_option(package_name="spooling")
|
|
20
|
+
def cli():
|
|
21
|
+
"""Spooling - local session tracker for AI coding assistants."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cli.command()
|
|
26
|
+
def init():
|
|
27
|
+
"""Check database connection and show provider status."""
|
|
28
|
+
from spooling.db import check_db
|
|
29
|
+
from spooling.config import DATABASE_URL
|
|
30
|
+
from spooling.providers import get_all_providers
|
|
31
|
+
|
|
32
|
+
console.print(Panel("[bold]Spooling[/bold] - Session Tracker", style="blue"))
|
|
33
|
+
|
|
34
|
+
# Check DB
|
|
35
|
+
if check_db():
|
|
36
|
+
console.print("[green]Database connected[/green]")
|
|
37
|
+
else:
|
|
38
|
+
console.print("[red]Cannot connect to database.[/red]")
|
|
39
|
+
console.print(f" URL: {DATABASE_URL}")
|
|
40
|
+
console.print(" Run: [bold]docker compose up -d[/bold]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
# Check all providers
|
|
44
|
+
providers = get_all_providers()
|
|
45
|
+
table = Table(show_lines=False, title="Providers")
|
|
46
|
+
table.add_column("Provider", style="cyan")
|
|
47
|
+
table.add_column("Status")
|
|
48
|
+
table.add_column("Path", style="dim")
|
|
49
|
+
|
|
50
|
+
for type_id, provider in providers.items():
|
|
51
|
+
available = provider.is_available()
|
|
52
|
+
status = "[green]available[/green]" if available else "[dim]not found[/dim]"
|
|
53
|
+
if provider.is_remote:
|
|
54
|
+
path_str = "[dim](remote API — connect via GUI)[/dim]"
|
|
55
|
+
else:
|
|
56
|
+
if available:
|
|
57
|
+
files = provider.discover_session_files()
|
|
58
|
+
status = f"[green]{len(files)} session files[/green]"
|
|
59
|
+
path_str = str(provider.resolved_data_path())
|
|
60
|
+
table.add_row(provider.name, status, path_str)
|
|
61
|
+
|
|
62
|
+
console.print(table)
|
|
63
|
+
console.print("\nRun [bold]spooling sync[/bold] to ingest sessions from all available providers.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cli.command()
|
|
67
|
+
@click.option("--no-embed", is_flag=True, help="Skip embedding (faster sync)")
|
|
68
|
+
@click.option("--provider", "-p", default=None, help="Only sync a specific provider (jsonl-session, codex, cursor, copilot, windsurf)")
|
|
69
|
+
def sync(no_embed, provider):
|
|
70
|
+
"""Sync AI coding sessions to the database."""
|
|
71
|
+
from spooling.ingest import sync as do_sync
|
|
72
|
+
do_sync(embed=not no_embed, provider_filter=provider)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cli.command()
|
|
76
|
+
def watch():
|
|
77
|
+
"""Watch for new session data and auto-sync."""
|
|
78
|
+
from spooling.watcher import watch as do_watch
|
|
79
|
+
do_watch()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cli.command()
|
|
83
|
+
@click.argument("query")
|
|
84
|
+
@click.option("-n", "--limit", default=10, help="Number of results")
|
|
85
|
+
@click.option("-p", "--project", default=None, help="Filter by project")
|
|
86
|
+
def search(query, limit, project):
|
|
87
|
+
"""Semantic search across session history."""
|
|
88
|
+
from spooling.search import search as do_search
|
|
89
|
+
|
|
90
|
+
results = do_search(query, limit=limit, project=project)
|
|
91
|
+
|
|
92
|
+
if not results:
|
|
93
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
for i, r in enumerate(results, 1):
|
|
97
|
+
similarity = f"{r['similarity']:.1%}"
|
|
98
|
+
project_name = r["project"] or "unknown"
|
|
99
|
+
role = r["role"]
|
|
100
|
+
ts = r["timestamp"] or ""
|
|
101
|
+
|
|
102
|
+
console.print(
|
|
103
|
+
f"\n[bold]{i}.[/bold] [{similarity}] "
|
|
104
|
+
f"[dim]{project_name}[/dim] "
|
|
105
|
+
f"[{'green' if role == 'user' else 'blue'}]{role}[/{'green' if role == 'user' else 'blue'}] "
|
|
106
|
+
f"[dim]{ts[:19]}[/dim]"
|
|
107
|
+
)
|
|
108
|
+
if r["title"]:
|
|
109
|
+
console.print(f" [dim]Session:[/dim] {r['title']}")
|
|
110
|
+
console.print(f" {r['content']}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@cli.command()
|
|
114
|
+
@click.option("--week", is_flag=True, help="Show weekly breakdown")
|
|
115
|
+
@click.option("--days", default=7, help="Number of days for daily stats")
|
|
116
|
+
def stats(week, days):
|
|
117
|
+
"""Show usage statistics."""
|
|
118
|
+
from spooling.stats import get_overview, get_daily_stats
|
|
119
|
+
|
|
120
|
+
overview = get_overview()
|
|
121
|
+
s = overview["summary"]
|
|
122
|
+
|
|
123
|
+
if not s or s.get("total_sessions", 0) == 0:
|
|
124
|
+
console.print("[yellow]No sessions synced yet. Run 'spooling sync' first.[/yellow]")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Overview panel
|
|
128
|
+
total_tokens = s["total_input_tokens"] + s["total_output_tokens"]
|
|
129
|
+
console.print(Panel(
|
|
130
|
+
f"Sessions: [bold]{s['total_sessions']}[/bold] | "
|
|
131
|
+
f"Messages: [bold]{s['total_messages']}[/bold] | "
|
|
132
|
+
f"Tool calls: [bold]{s['total_tool_calls']}[/bold]\n"
|
|
133
|
+
f"Tokens: [bold]{total_tokens:,}[/bold] est. | "
|
|
134
|
+
f"Cost: [bold]${float(s['total_cost_usd']):.2f}[/bold] est.",
|
|
135
|
+
title="[bold]Spooling Overview[/bold]",
|
|
136
|
+
style="blue",
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
# Projects table
|
|
140
|
+
if overview["projects"]:
|
|
141
|
+
table = Table(title="Projects", show_lines=False)
|
|
142
|
+
table.add_column("Project", style="cyan")
|
|
143
|
+
table.add_column("Sessions", justify="right")
|
|
144
|
+
table.add_column("Messages", justify="right")
|
|
145
|
+
table.add_column("Est. Cost", justify="right")
|
|
146
|
+
for p in overview["projects"][:10]:
|
|
147
|
+
proj = _clean_project(p["project"])
|
|
148
|
+
table.add_row(
|
|
149
|
+
proj,
|
|
150
|
+
str(p["sessions"]),
|
|
151
|
+
str(int(p["messages"] or 0)),
|
|
152
|
+
f"${float(p['cost'] or 0):.2f}",
|
|
153
|
+
)
|
|
154
|
+
console.print(table)
|
|
155
|
+
|
|
156
|
+
# Top tools
|
|
157
|
+
if overview["top_tools"]:
|
|
158
|
+
table = Table(title="Top Tools", show_lines=False)
|
|
159
|
+
table.add_column("Tool", style="magenta")
|
|
160
|
+
table.add_column("Uses", justify="right")
|
|
161
|
+
for t in overview["top_tools"][:10]:
|
|
162
|
+
table.add_row(t["tool_name"], str(t["uses"]))
|
|
163
|
+
console.print(table)
|
|
164
|
+
|
|
165
|
+
# Daily stats
|
|
166
|
+
if week or days:
|
|
167
|
+
daily = get_daily_stats(days=days if not week else 7)
|
|
168
|
+
if daily:
|
|
169
|
+
table = Table(title=f"Daily Usage (last {days if not week else 7} days)", show_lines=False)
|
|
170
|
+
table.add_column("Date")
|
|
171
|
+
table.add_column("Sessions", justify="right")
|
|
172
|
+
table.add_column("Messages", justify="right")
|
|
173
|
+
table.add_column("Tool Calls", justify="right")
|
|
174
|
+
table.add_column("Tokens", justify="right")
|
|
175
|
+
table.add_column("Cost", justify="right")
|
|
176
|
+
for d in daily:
|
|
177
|
+
table.add_row(
|
|
178
|
+
str(d["day"]),
|
|
179
|
+
str(d["sessions"]),
|
|
180
|
+
str(int(d["messages"])),
|
|
181
|
+
str(int(d["tool_calls"])),
|
|
182
|
+
f"{int(d['total_tokens']):,}",
|
|
183
|
+
f"${float(d['cost']):.2f}",
|
|
184
|
+
)
|
|
185
|
+
console.print(table)
|
|
186
|
+
|
|
187
|
+
# Recent sessions
|
|
188
|
+
if overview["recent_sessions"]:
|
|
189
|
+
table = Table(title="Recent Sessions", show_lines=False)
|
|
190
|
+
table.add_column("Started", style="dim")
|
|
191
|
+
table.add_column("Project", style="cyan")
|
|
192
|
+
table.add_column("Title")
|
|
193
|
+
table.add_column("Msgs", justify="right")
|
|
194
|
+
table.add_column("Cost", justify="right")
|
|
195
|
+
for r in overview["recent_sessions"]:
|
|
196
|
+
proj = _clean_project(r["project"] or "")
|
|
197
|
+
ts = r["started_at"].strftime("%m/%d %H:%M") if r["started_at"] else ""
|
|
198
|
+
title = (r["title"] or "")[:50]
|
|
199
|
+
table.add_row(
|
|
200
|
+
ts, proj, title,
|
|
201
|
+
str(r["message_count"]),
|
|
202
|
+
f"${float(r['estimated_cost_usd'] or 0):.2f}",
|
|
203
|
+
)
|
|
204
|
+
console.print(table)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@cli.group()
|
|
208
|
+
def eval():
|
|
209
|
+
"""Run eval rubrics over traces/spans."""
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@eval.command("list")
|
|
214
|
+
def eval_list():
|
|
215
|
+
"""List all eval rubrics."""
|
|
216
|
+
from spooling.db import get_connection
|
|
217
|
+
conn = get_connection()
|
|
218
|
+
rows = conn.execute(
|
|
219
|
+
"SELECT id, name, kind, target_kind, description FROM eval_rubrics ORDER BY id"
|
|
220
|
+
).fetchall()
|
|
221
|
+
conn.close()
|
|
222
|
+
table = Table(title="Eval Rubrics")
|
|
223
|
+
table.add_column("ID", style="cyan")
|
|
224
|
+
table.add_column("Name")
|
|
225
|
+
table.add_column("Kind")
|
|
226
|
+
table.add_column("Target")
|
|
227
|
+
table.add_column("Description", style="dim")
|
|
228
|
+
for r in rows:
|
|
229
|
+
table.add_row(r["id"], r["name"], r["kind"], r["target_kind"], r["description"] or "")
|
|
230
|
+
console.print(table)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@eval.command("run")
|
|
234
|
+
@click.option("--rubric", required=True, help="Rubric id")
|
|
235
|
+
@click.option("--trace", default=None, help="Run against a single trace id")
|
|
236
|
+
@click.option("--days", default=None, type=int, help="Run against all traces from the last N days")
|
|
237
|
+
def eval_run(rubric, trace, days):
|
|
238
|
+
"""Run a rubric against one trace or a batch."""
|
|
239
|
+
from spooling.evals import run_rubric, run_rubric_bulk
|
|
240
|
+
from datetime import datetime, timezone, timedelta
|
|
241
|
+
|
|
242
|
+
if trace:
|
|
243
|
+
result = run_rubric(rubric, trace)
|
|
244
|
+
if result is None:
|
|
245
|
+
console.print(f"[yellow]No eval recorded for {trace}[/yellow]")
|
|
246
|
+
else:
|
|
247
|
+
console.print(f"[green]Eval {result} recorded for {trace}[/green]")
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
since = None
|
|
251
|
+
if days:
|
|
252
|
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
|
253
|
+
result = run_rubric_bulk(rubric, since=since)
|
|
254
|
+
console.print(result)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@cli.command()
|
|
258
|
+
@click.option("--host", default=None, help="Host to bind to")
|
|
259
|
+
@click.option("--port", default=None, type=int, help="Port to bind to")
|
|
260
|
+
def serve(host, port):
|
|
261
|
+
"""Start the API server."""
|
|
262
|
+
from spooling.config import UI_HOST
|
|
263
|
+
from spooling.server import app
|
|
264
|
+
import uvicorn
|
|
265
|
+
|
|
266
|
+
h = host or UI_HOST
|
|
267
|
+
p = port or 3002
|
|
268
|
+
console.print(f"[bold]Spooling API[/bold] at http://{h}:{p}")
|
|
269
|
+
console.print("Start the UI with: [bold]cd ui && npm run dev[/bold]")
|
|
270
|
+
uvicorn.run(app, host=h, port=p, log_level="warning")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@cli.group()
|
|
274
|
+
def otel():
|
|
275
|
+
"""Ingest OTel/Strands spans from external sources into Spooling."""
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@otel.command("ingest")
|
|
280
|
+
@click.option("--file", "path", required=True, type=click.Path(exists=True), help="OTLP JSON export file")
|
|
281
|
+
@click.option("--provider", "provider_id", default="otel-remote", help="Provider id to tag the trace with")
|
|
282
|
+
@click.option("--project", default=None, help="Project name")
|
|
283
|
+
def otel_ingest(path, provider_id, project):
|
|
284
|
+
"""Ingest an OTLP/JSON spans file as a Spooling trace."""
|
|
285
|
+
from spooling.remote_otel import ingest_otlp_json_file
|
|
286
|
+
try:
|
|
287
|
+
trace_id = ingest_otlp_json_file(path, provider_id=provider_id, project=project)
|
|
288
|
+
console.print(f"[green]Ingested:[/green] {trace_id}")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
console.print(f"[red]Ingest failed:[/red] {e}")
|
|
291
|
+
raise SystemExit(1)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@cli.group()
|
|
295
|
+
def experiment():
|
|
296
|
+
"""Create and run Strands experiments (cases + evaluators)."""
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@experiment.command("create")
|
|
301
|
+
@click.option("--file", "path", required=True, type=click.Path(exists=True), help="Path to a JSON spec")
|
|
302
|
+
def experiment_create(path):
|
|
303
|
+
"""Register an experiment from a JSON file."""
|
|
304
|
+
from spooling.experiments import load_spec_from_file, create_experiment
|
|
305
|
+
spec = load_spec_from_file(path)
|
|
306
|
+
eid = create_experiment(spec)
|
|
307
|
+
console.print(f"[green]Experiment created: {eid}[/green] ({spec.name})")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@experiment.command("list")
|
|
311
|
+
def experiment_list():
|
|
312
|
+
"""List experiments."""
|
|
313
|
+
from spooling.experiments import list_experiments
|
|
314
|
+
rows = list_experiments()
|
|
315
|
+
if not rows:
|
|
316
|
+
console.print("[yellow]No experiments yet. Create one with 'spooling experiment create --file ...'[/yellow]")
|
|
317
|
+
return
|
|
318
|
+
table = Table(title="Experiments")
|
|
319
|
+
table.add_column("ID", style="cyan")
|
|
320
|
+
table.add_column("Name")
|
|
321
|
+
table.add_column("Cases", justify="right")
|
|
322
|
+
table.add_column("Evaluators", justify="right")
|
|
323
|
+
table.add_column("Created", style="dim")
|
|
324
|
+
for r in rows:
|
|
325
|
+
table.add_row(
|
|
326
|
+
r["id"], r["name"],
|
|
327
|
+
str(r["case_count"]), str(r["evaluator_count"]),
|
|
328
|
+
str(r["created_at"])[:19],
|
|
329
|
+
)
|
|
330
|
+
console.print(table)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@experiment.command("run")
|
|
334
|
+
@click.option("--id", "experiment_id", required=True, help="Experiment id")
|
|
335
|
+
def experiment_run(experiment_id):
|
|
336
|
+
"""Run an experiment and persist the report."""
|
|
337
|
+
from spooling.experiments import run_experiment, load_run
|
|
338
|
+
console.print(f"[bold]Running experiment {experiment_id}...[/bold]")
|
|
339
|
+
try:
|
|
340
|
+
run_id = run_experiment(experiment_id)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
console.print(f"[red]Run failed:[/red] {e}")
|
|
343
|
+
raise SystemExit(1)
|
|
344
|
+
run = load_run(run_id)
|
|
345
|
+
console.print(f"[green]Run complete: {run_id}[/green]")
|
|
346
|
+
if run and run.get("overall_scores"):
|
|
347
|
+
console.print("Scores:")
|
|
348
|
+
for name, score in run["overall_scores"].items():
|
|
349
|
+
console.print(f" - {name}: {score}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@experiment.command("show")
|
|
353
|
+
@click.option("--run", "run_id", required=True, help="Run id")
|
|
354
|
+
def experiment_show(run_id):
|
|
355
|
+
"""Show the report for a past run."""
|
|
356
|
+
from spooling.experiments import load_run
|
|
357
|
+
run = load_run(run_id)
|
|
358
|
+
if not run:
|
|
359
|
+
console.print(f"[red]Run not found: {run_id}[/red]")
|
|
360
|
+
return
|
|
361
|
+
console.print(f"[bold]Run {run_id}[/bold] ({run['status']})")
|
|
362
|
+
console.print(f"Experiment: {run['experiment_id']}")
|
|
363
|
+
console.print(f"Started: {run['started_at']}")
|
|
364
|
+
console.print(f"Finished: {run['finished_at']}")
|
|
365
|
+
if run.get("error"):
|
|
366
|
+
console.print(f"[red]Error:[/red] {run['error']}")
|
|
367
|
+
if run.get("overall_scores"):
|
|
368
|
+
table = Table(title="Overall scores")
|
|
369
|
+
table.add_column("Evaluator", style="cyan")
|
|
370
|
+
table.add_column("Score", justify="right")
|
|
371
|
+
for k, v in run["overall_scores"].items():
|
|
372
|
+
table.add_row(k, f"{v:.3f}" if isinstance(v, (int, float)) else str(v))
|
|
373
|
+
console.print(table)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@cli.group()
|
|
377
|
+
def pricing():
|
|
378
|
+
"""Manage the LiteLLM-backed model pricing table."""
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@pricing.command("refresh")
|
|
383
|
+
def pricing_refresh():
|
|
384
|
+
"""Force-fetch the LiteLLM model pricing table into ~/.spool/model_prices.json."""
|
|
385
|
+
from spool import pricing as _pricing
|
|
386
|
+
try:
|
|
387
|
+
data = _pricing.refresh()
|
|
388
|
+
console.print(f"[green]Pricing refreshed:[/green] {len(data)} models cached at {_pricing.CACHE_FILE}")
|
|
389
|
+
except Exception as e:
|
|
390
|
+
console.print(f"[red]Pricing refresh failed:[/red] {e}")
|
|
391
|
+
raise SystemExit(1)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@pricing.command("show")
|
|
395
|
+
@click.argument("model", required=False)
|
|
396
|
+
def pricing_show(model):
|
|
397
|
+
"""Show the cached pricing for one model, or the source status if no model given."""
|
|
398
|
+
from spool import pricing as _pricing
|
|
399
|
+
|
|
400
|
+
if not model:
|
|
401
|
+
status = _pricing.table_status()
|
|
402
|
+
table = Table(title="Pricing source")
|
|
403
|
+
table.add_column("Key", style="cyan")
|
|
404
|
+
table.add_column("Value")
|
|
405
|
+
for k, v in status.items():
|
|
406
|
+
table.add_row(k, str(v))
|
|
407
|
+
console.print(table)
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
rates = _pricing.get_rates(model)
|
|
411
|
+
table = Table(title=f"Rates for {model}")
|
|
412
|
+
table.add_column("Component", style="cyan")
|
|
413
|
+
table.add_column("$/Mtok", justify="right")
|
|
414
|
+
for label, rate in [
|
|
415
|
+
("Input", rates.input),
|
|
416
|
+
("Output", rates.output),
|
|
417
|
+
("Cache write", rates.cache_write),
|
|
418
|
+
("Cache read", rates.cache_read),
|
|
419
|
+
]:
|
|
420
|
+
table.add_row(label, f"${rate * 1_000_000:.2f}")
|
|
421
|
+
console.print(table)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@cli.command()
|
|
425
|
+
@click.option("--stdio", is_flag=True, help="Use stdio transport (default is streamable-HTTP)")
|
|
426
|
+
def mcp(stdio):
|
|
427
|
+
"""Launch the Spooling MCP server.
|
|
428
|
+
|
|
429
|
+
Defaults to streamable-HTTP at http://127.0.0.1:3004/mcp so any
|
|
430
|
+
MCP-compatible agent (Codex, Cursor, web agents) can
|
|
431
|
+
connect by URL. Pass --stdio for stdio-only clients.
|
|
432
|
+
"""
|
|
433
|
+
if stdio:
|
|
434
|
+
from spooling.mcp_server import serve_stdio
|
|
435
|
+
console.print("[bold]Spooling MCP[/bold] over stdio")
|
|
436
|
+
serve_stdio()
|
|
437
|
+
else:
|
|
438
|
+
from spooling.mcp_server import serve_http, MCP_URL
|
|
439
|
+
console.print(f"[bold]Spooling MCP[/bold] at {MCP_URL}")
|
|
440
|
+
serve_http()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _check_ollama_preflight() -> None:
|
|
444
|
+
"""Ping Ollama at the configured URL and warn if it's down.
|
|
445
|
+
|
|
446
|
+
Non-blocking: Spooling still starts because the user may have switched
|
|
447
|
+
the chat agent to Anthropic, and LLM-judge evals are opt-in. The
|
|
448
|
+
warning makes it obvious why evals/chat would fail with "All
|
|
449
|
+
connection attempts failed" otherwise.
|
|
450
|
+
"""
|
|
451
|
+
import urllib.request
|
|
452
|
+
import urllib.error
|
|
453
|
+
|
|
454
|
+
ollama_url = "http://localhost:11434"
|
|
455
|
+
try:
|
|
456
|
+
from spooling.db import get_connection
|
|
457
|
+
conn = get_connection()
|
|
458
|
+
try:
|
|
459
|
+
row = conn.execute(
|
|
460
|
+
"SELECT config FROM providers WHERE id = 'spooling-agent'"
|
|
461
|
+
).fetchone()
|
|
462
|
+
finally:
|
|
463
|
+
conn.close()
|
|
464
|
+
cfg = (row["config"] if row and isinstance(row.get("config"), dict) else {}) if row else {}
|
|
465
|
+
ollama_url = cfg.get("ollama_url") or ollama_url
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
urllib.request.urlopen(f"{ollama_url}/api/tags", timeout=1.5).read()
|
|
471
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
472
|
+
console.print(
|
|
473
|
+
f"[yellow] ! Ollama is not reachable at {ollama_url}.[/yellow]"
|
|
474
|
+
)
|
|
475
|
+
console.print(
|
|
476
|
+
"[dim] Chat and LLM-judge evals will fail until you run:[/dim] "
|
|
477
|
+
"[bold]ollama serve[/bold]"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@cli.command()
|
|
482
|
+
def ui():
|
|
483
|
+
"""Launch the API server, MCP HTTP server, and Next.js UI together."""
|
|
484
|
+
import subprocess
|
|
485
|
+
import os
|
|
486
|
+
import sys
|
|
487
|
+
|
|
488
|
+
ui_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ui")
|
|
489
|
+
|
|
490
|
+
console.print("[bold]Starting Spooling...[/bold]")
|
|
491
|
+
console.print(" API: http://127.0.0.1:3002")
|
|
492
|
+
console.print(" MCP: http://127.0.0.1:3004/mcp")
|
|
493
|
+
console.print(" UI: http://localhost:3003")
|
|
494
|
+
|
|
495
|
+
# Preflight: warn if Ollama is down — otherwise chat + judge will fail
|
|
496
|
+
# silently with "All connection attempts failed" from httpx.
|
|
497
|
+
_check_ollama_preflight()
|
|
498
|
+
|
|
499
|
+
api_proc = subprocess.Popen(
|
|
500
|
+
[sys.executable, "-m", "uvicorn", "spool.server:app", "--host", "127.0.0.1", "--port", "3002", "--log-level", "warning"],
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
mcp_proc = subprocess.Popen(
|
|
504
|
+
[sys.executable, "-m", "spool.mcp_server"],
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
subprocess.run(["npm", "run", "dev"], cwd=ui_dir)
|
|
509
|
+
except KeyboardInterrupt:
|
|
510
|
+
pass
|
|
511
|
+
finally:
|
|
512
|
+
api_proc.terminate()
|
|
513
|
+
mcp_proc.terminate()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
from spooling.cloud import cloud as _cloud_group, push as _push_cmd
|
|
517
|
+
cli.add_command(_cloud_group)
|
|
518
|
+
cli.add_command(_push_cmd)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
if __name__ == "__main__":
|
|
522
|
+
cli()
|