claude-finops 0.1.0

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.
package/finops/api.py ADDED
@@ -0,0 +1,426 @@
1
+ """Stdlib HTTP server: JSON API + static dashboard. Binds to localhost only —
2
+ the warehouse contains your full prompt text.
3
+ """
4
+ import json
5
+ import io
6
+ import csv
7
+ import os
8
+ import sqlite3
9
+ import threading
10
+ import traceback
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from urllib.parse import urlparse, parse_qs
13
+
14
+ from .analytics import Analytics, load_settings
15
+ from .paths import DB_PATH, LOCAL_SETTINGS_PATH, LOGFILE, PIDFILE, WEB_DIR, ensure_dirs
16
+
17
+ WEB = WEB_DIR
18
+ _lock = threading.Lock()
19
+ A = None
20
+
21
+ MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8", ".json": "application/json",
23
+ ".svg": "image/svg+xml", ".ico": "image/x-icon"}
24
+
25
+
26
+ def filters_from(qs):
27
+ def lst(k):
28
+ v = qs.get(k, [])
29
+ out = []
30
+ for item in v:
31
+ out += [x for x in item.split(",") if x]
32
+ return out
33
+ f = {
34
+ "start": qs.get("start", [None])[0] or None,
35
+ "end": qs.get("end", [None])[0] or None,
36
+ "agents": lst("agents"), "models": lst("models"), "projects": lst("projects"),
37
+ "sessions": lst("sessions"), "categories": lst("categories"),
38
+ "include_sandbox": qs.get("include_sandbox", ["1"])[0] != "0",
39
+ "min_cost": qs.get("min_cost", [None])[0] or None,
40
+ "max_cost": qs.get("max_cost", [None])[0] or None,
41
+ "min_tokens": qs.get("min_tokens", [None])[0] or None,
42
+ }
43
+ return f
44
+
45
+
46
+ def flatten(rows):
47
+ keys = []
48
+ for r in rows:
49
+ for k in r:
50
+ if k not in keys:
51
+ keys.append(k)
52
+ buf = io.StringIO()
53
+ wtr = csv.DictWriter(buf, fieldnames=keys, extrasaction="ignore")
54
+ wtr.writeheader()
55
+ for r in rows:
56
+ wtr.writerow({k: (json.dumps(v) if isinstance(v, (dict, list)) else v)
57
+ for k, v in r.items()})
58
+ return buf.getvalue()
59
+
60
+
61
+ class Handler(BaseHTTPRequestHandler):
62
+ protocol_version = "HTTP/1.1"
63
+
64
+ def log_message(self, *a):
65
+ pass
66
+
67
+ def send_json(self, obj, code=200):
68
+ body = json.dumps(obj, default=str).encode()
69
+ self.send_response(code)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", str(len(body)))
72
+ self.end_headers()
73
+ self.wfile.write(body)
74
+
75
+ def send_text(self, body, ctype, filename=None, code=200):
76
+ if isinstance(body, str):
77
+ body = body.encode()
78
+ self.send_response(code)
79
+ self.send_header("Content-Type", ctype)
80
+ if filename:
81
+ self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
82
+ self.send_header("Content-Length", str(len(body)))
83
+ self.end_headers()
84
+ self.wfile.write(body)
85
+
86
+ def do_POST(self):
87
+ n = int(self.headers.get("Content-Length") or 0)
88
+ payload = json.loads(self.rfile.read(n) or b"{}")
89
+ path = urlparse(self.path).path
90
+ try:
91
+ if path.startswith("/api/live/"):
92
+ self._payload = payload
93
+ return self.session_action(path)
94
+ if path.startswith("/api/do/"):
95
+ if not self._same_origin():
96
+ return self.send_json({"ok": False, "error": "forbidden"}, 403)
97
+ return self.do_action(path[len("/api/do/"):].strip("/").split("/"), payload)
98
+ if path == "/api/settings":
99
+ # UI edits go to the gitignored per-machine file, never the shared defaults
100
+ local = {}
101
+ if os.path.exists(LOCAL_SETTINGS_PATH):
102
+ with open(LOCAL_SETTINGS_PATH) as fh:
103
+ local = json.load(fh)
104
+ for k, v in payload.items():
105
+ if k in ("budgets", "limits", "alert_thresholds_pct", "waste_rules",
106
+ "anomaly", "scorecard", "account", "billing_period"):
107
+ if isinstance(v, dict) and isinstance(local.get(k), dict):
108
+ local[k].update(v)
109
+ else:
110
+ local[k] = v
111
+ with open(LOCAL_SETTINGS_PATH, "w") as fh:
112
+ json.dump(local, fh, indent=2)
113
+ cur = load_settings()
114
+ with _lock:
115
+ A.settings = cur
116
+ return self.send_json({"ok": True, "settings": cur})
117
+ self.send_json({"error": "unknown endpoint"}, 404)
118
+ except Exception:
119
+ self.send_json({"error": traceback.format_exc()}, 500)
120
+
121
+ def _same_origin(self):
122
+ origin = self.headers.get("Origin")
123
+ return self.headers.get("X-FinOps-Action") == "1" and (
124
+ not origin or origin == f"http://{self.headers.get('Host', '')}")
125
+
126
+ def do_action(self, parts, payload):
127
+ """POST /api/do/...: actions that change this machine. Always user-initiated."""
128
+ from . import actions as X
129
+ if parts == ["sync"]:
130
+ cur = X.JOBS.get(_SYNC["job"] or "")
131
+ if not cur or cur["state"] != "running":
132
+ _SYNC["job"] = X._job(_sync_job)["id"]
133
+ return self.send_json({"job": _SYNC["job"]})
134
+ if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "install":
135
+ j = X._job(X.free_model_install, parts[1], bool(payload.get("consent")),
136
+ payload.get("api_key"))
137
+ return self.send_json({"job": j["id"]})
138
+ if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "test":
139
+ return self.send_json({"job": X._job(X.free_model_test, parts[1])["id"]})
140
+ if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "remove":
141
+ return self.send_json(X.free_model_remove(parts[1]))
142
+ if parts == ["cloud_sync"]:
143
+ from . import cloud
144
+ j = X._job(lambda log: cloud.sync(int(payload.get("days") or 30), log))
145
+ return self.send_json({"job": j["id"]})
146
+ if parts == ["skill"]:
147
+ return self.send_json(X.create_skill(payload))
148
+ if len(parts) == 2 and parts[0] == "mcp":
149
+ return self.send_json(X.add_mcp(parts[1], payload))
150
+ return self.send_json({"error": "unknown action"}, 404)
151
+
152
+ def actions_get(self, route):
153
+ from . import actions as X
154
+ parts = route.strip("/").split("/")
155
+ if parts == ["free_models"]:
156
+ return self.send_json(X.free_models())
157
+ if len(parts) == 3 and parts[0] == "free_models" and parts[2] == "plan":
158
+ return self.send_json(X.free_model_plan(parts[1]))
159
+ if parts == ["compare"]:
160
+ with _lock:
161
+ a = A
162
+ qs = parse_qs(urlparse(self.path).query)
163
+ ags = [x for x in (qs.get("agents", [""])[0]).split(",") if x]
164
+ return self.send_json(X.compare(a, ags or None))
165
+ if parts == ["suggestions"]:
166
+ with _lock:
167
+ a = A
168
+ return self.send_json(X.suggestions(a))
169
+ if parts[0] == "job" and len(parts) == 2:
170
+ return self.send_json(X.job_status(parts[1]))
171
+ if parts == ["sync"]:
172
+ with _lock:
173
+ a = A
174
+ built = a.q("SELECT value FROM meta WHERE key='built_at'")
175
+ j = X.job_status(_SYNC["job"]) if _SYNC["job"] else None
176
+ return self.send_json({"built_at": built[0]["value"] if built else None, "job": j})
177
+ return self.send_json({"error": "unknown route"}, 404)
178
+
179
+ def session_action(self, path):
180
+ """POST /api/live/<pid>/<interrupt|close|kill>.
181
+
182
+ Guarded against cross-site requests: a custom header can't be sent by another
183
+ origin without a CORS preflight (which this server never approves), and any
184
+ Origin present must be this dashboard.
185
+ """
186
+ origin = self.headers.get("Origin")
187
+ host = self.headers.get("Host", "")
188
+ if self.headers.get("X-FinOps-Action") != "1" or (
189
+ origin and origin not in (f"http://{host}",)):
190
+ return self.send_json({"ok": False, "error": "forbidden"}, 403)
191
+ from .procs import act
192
+ parts = path.strip("/").split("/")
193
+ if len(parts) != 4 or not parts[2].isdigit():
194
+ return self.send_json({"ok": False, "error": "bad request"}, 400)
195
+ agent = (self._payload or {}).get("agent") or "claude"
196
+ if agent != "claude":
197
+ from .procs import act_agent
198
+ if parts[3] == "handover":
199
+ return self.send_json({"ok": False, "error": "Hand over is only available for Claude Code."})
200
+ return self.send_json(act_agent(int(parts[2]), parts[3], agent))
201
+ if parts[3] == "handover":
202
+ from .procs import handover
203
+ return self.send_json(handover(int(parts[2]), self._payload))
204
+ return self.send_json(act(int(parts[2]), parts[3]))
205
+
206
+ def do_GET(self):
207
+ u = urlparse(self.path)
208
+ path, qs = u.path, parse_qs(u.query)
209
+ try:
210
+ if path.startswith("/api/"):
211
+ return self.api(path[5:], qs)
212
+ rel = "index.html" if path in ("/", "") else path.lstrip("/")
213
+ fp = os.path.normpath(os.path.join(WEB, rel))
214
+ if not fp.startswith(WEB) or not os.path.isfile(fp):
215
+ return self.send_text("not found", "text/plain", code=404)
216
+ ext = os.path.splitext(fp)[1]
217
+ with open(fp, "rb") as fh:
218
+ self.send_text(fh.read(), MIME.get(ext, "application/octet-stream"))
219
+ except BrokenPipeError:
220
+ pass
221
+ except Exception:
222
+ self.send_json({"error": traceback.format_exc()}, 500)
223
+
224
+ def api(self, route, qs):
225
+ if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
226
+ return self.actions_get(route)
227
+ f = filters_from(qs)
228
+ g = lambda k, d=None: qs.get(k, [d])[0]
229
+ with _lock:
230
+ a = A
231
+ if route == "options":
232
+ from .cloud import configured
233
+ o = a.options()
234
+ o["cloud"] = configured() # nav hides "Billed vs local" until a key exists
235
+ return self.send_json(o)
236
+ if route == "by_agent":
237
+ return self.send_json(a.by_agent(f))
238
+ if route == "overview":
239
+ return self.send_json(a.overview(f))
240
+ if route == "burn":
241
+ return self.send_json(a.burn(f))
242
+ if route == "timeline":
243
+ return self.send_json(a.timeline(f, g("grain", "day")))
244
+ if route == "models":
245
+ return self.send_json(a.models(f))
246
+ if route == "projects":
247
+ return self.send_json(a.projects(f))
248
+ if route == "sessions":
249
+ return self.send_json(a.sessions(f, int(g("limit", 200)), g("order", "cost")))
250
+ if route == "prompts":
251
+ return self.send_json(a.prompts(f, int(g("limit", 200)), int(g("offset", 0)),
252
+ g("order", "cost"), g("q")))
253
+ if route == "leaderboards":
254
+ return self.send_json(a.leaderboards(f, int(g("n", 20))))
255
+ if route == "categories":
256
+ return self.send_json(a.categories(f))
257
+ if route == "efficiency":
258
+ return self.send_json(a.efficiency(f))
259
+ if route == "context":
260
+ return self.send_json(a.context_analysis(f))
261
+ if route == "waste":
262
+ return self.send_json(a.waste(f))
263
+ if route == "model_switch":
264
+ return self.send_json(a.model_switch(f))
265
+ if route == "recommendations":
266
+ return self.send_json(a.recommendations(f))
267
+ if route == "forecast":
268
+ return self.send_json(a.forecast(f))
269
+ if route == "budgets":
270
+ return self.send_json(a.budgets(f))
271
+ if route == "anomalies":
272
+ return self.send_json(a.anomalies(f))
273
+ if route == "scorecard":
274
+ return self.send_json(a.scorecard(f))
275
+ if route == "advisor":
276
+ return self.send_json(a.advisor(f))
277
+ if route == "diagnose":
278
+ from .diagnose import Diagnoser
279
+ return self.send_json(Diagnoser(a).run(f))
280
+ if route == "breakdown":
281
+ from .diagnose import Diagnoser
282
+ return self.send_json(Diagnoser(a).breakdown(f))
283
+ if route == "live":
284
+ from .procs import list_sessions, list_agent_sessions
285
+ want = [x for x in (g("agents", "") or "").split(",") if x]
286
+ claude = [dict(s, agent="claude", signalable=True,
287
+ resume=f"claude --resume {s.get('session_id')}")
288
+ for s in list_sessions(a.pricing)] if not want or "claude" in want else []
289
+ others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
290
+ if not want or any(x != "claude" for x in want) else []
291
+ rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
292
+ return self.send_json({"sessions": rows})
293
+ if route == "cloud":
294
+ from .cloud import report
295
+ return self.send_json(report(a, int(g("days", 30))))
296
+ if route == "developer":
297
+ return self.send_json(a.developer(f))
298
+ if route == "search":
299
+ return self.send_json(a.search(g("q", ""), int(g("limit", 40))))
300
+ if route.startswith("prompt/"):
301
+ return self.send_json(a.prompt_detail(int(route.split("/")[1])))
302
+ if route.startswith("session/"):
303
+ return self.send_json(a.session_detail(route.split("/", 1)[1]))
304
+ if route == "bundle":
305
+ return self.send_json({
306
+ "overview": a.overview(f), "burn": a.burn(f), "timeline": a.timeline(f),
307
+ "models": a.models(f), "projects": a.projects(f)[:40],
308
+ "categories": a.categories(f), "efficiency": a.efficiency(f),
309
+ "context": a.context_analysis(f), "waste": a.waste(f),
310
+ "recommendations": a.recommendations(f), "forecast": a.forecast(f),
311
+ "budgets": a.budgets(f), "anomalies": a.anomalies(f),
312
+ "scorecard": a.scorecard(f), "advisor": a.advisor(f),
313
+ "leaderboards": a.leaderboards(f), "developer": a.developer(f),
314
+ })
315
+ if route.startswith("export/"):
316
+ return self.export(route.split("/", 1)[1], f, g)
317
+ self.send_json({"error": f"unknown route {route}"}, 404)
318
+
319
+ def export(self, what, f, g):
320
+ with _lock:
321
+ a = A
322
+ fmt = g("format", "csv")
323
+ data = {
324
+ "prompts": lambda: a.prompts(dict(f, _full_text=True), limit=100000, order="cost"),
325
+ "sessions": lambda: a.sessions(f, limit=100000, order="cost"),
326
+ "usage": lambda: a.timeline(f),
327
+ "models": lambda: a.models(f)["rows"],
328
+ "projects": lambda: a.projects(f),
329
+ "costs": lambda: a.timeline(f),
330
+ "waste": lambda: [dict(x, evidence=len(x["evidence"])) for x in a.waste(f)["findings"]],
331
+ "recommendations": lambda: a.recommendations(f)["recommendations"],
332
+ "forecast": lambda: [dict(name=k, **v) for k, v in
333
+ (a.forecast(f).get("scenarios") or {}).items()],
334
+ }
335
+ if what == "report":
336
+ return self.report(a, f)
337
+ if what not in data:
338
+ return self.send_json({"error": "unknown export"}, 404)
339
+ rows = data[what]()
340
+ if fmt == "json":
341
+ return self.send_text(json.dumps(rows, indent=2, default=str),
342
+ "application/json", f"claude-finops-{what}.json")
343
+ return self.send_text(flatten(rows), "text/csv", f"claude-finops-{what}.csv")
344
+
345
+ def report(self, a, f):
346
+ """Self-contained printable HTML report (Cmd/Ctrl+P -> PDF)."""
347
+ from .report import build_report
348
+ self.send_text(build_report(a, f), "text/html; charset=utf-8")
349
+
350
+
351
+
352
+
353
+
354
+ def _under_claude():
355
+ if os.name == "nt":
356
+ return False
357
+ from .procs import _ps, _ancestors, _is_claude
358
+ procs = _ps()
359
+ return any(_is_claude(procs[p]) for p in _ancestors(os.getpid(), procs) if p in procs)
360
+
361
+
362
+ def detach(port):
363
+ """Re-parent the server into its own process session so closing the Claude
364
+ session (or terminal) that launched it doesn't take the dashboard down too."""
365
+ import signal
366
+ ensure_dirs()
367
+ if os.fork():
368
+ print(f"Claude FinOps Command Center -> http://127.0.0.1:{port} (detached)")
369
+ print(f" log: {LOGFILE} stop: ./run.sh --stop")
370
+ os._exit(0)
371
+ os.setsid()
372
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
373
+ if os.fork():
374
+ os._exit(0)
375
+ fd = os.open(LOGFILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
376
+ os.dup2(os.open(os.devnull, os.O_RDONLY), 0)
377
+ os.dup2(fd, 1)
378
+ os.dup2(fd, 2)
379
+ with open(PIDFILE, "w") as fh:
380
+ fh.write(str(os.getpid()))
381
+
382
+
383
+ _SYNC = {"job": None}
384
+
385
+
386
+ def _sync_job(log):
387
+ """Rebuild, then swap the live Analytics over to the fresh warehouse."""
388
+ global A
389
+ from . import actions as X
390
+ res = X.sync(log)
391
+ with _lock:
392
+ try:
393
+ A.db.close() # Windows can't replace a file that's still open
394
+ except Exception:
395
+ pass
396
+ X.finish_sync(res["tmp"])
397
+ A = Analytics(DB_PATH)
398
+ log("Synced.")
399
+ return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
400
+
401
+
402
+ def serve(port=8787, db=DB_PATH, background=None):
403
+ global A
404
+ if not os.path.exists(db):
405
+ raise SystemExit(f"No warehouse at {db}. Run: python3 -m finops.etl")
406
+ if background is None:
407
+ background = os.environ.get("FINOPS_DETACH") == "1" or _under_claude()
408
+ if background and hasattr(os, "fork"):
409
+ detach(port)
410
+ A = Analytics(db)
411
+ srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
412
+ print(f"Claude FinOps Command Center -> http://127.0.0.1:{port}")
413
+ print(f" warehouse: {db}")
414
+ print(f" data : {A.first_day} .. {A.last_day}")
415
+ print(" Ctrl-C to stop.")
416
+ try:
417
+ srv.serve_forever()
418
+ except KeyboardInterrupt:
419
+ print("\nbye")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ import sys
424
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
425
+ bg = True if "--detach" in sys.argv else False if "--foreground" in sys.argv else None
426
+ serve(int(args[0]) if args else 8787, background=bg)
@@ -0,0 +1,48 @@
1
+ """Heuristic prompt classification.
2
+
3
+ Deliberately transparent and rule-based: every prompt records WHY it landed in a
4
+ category so the dashboard can show the evidence rather than an opaque label.
5
+ """
6
+ import re
7
+
8
+ CATEGORIES = [
9
+ ("debugging", 3.0, r"\b(bug|debug|error|exception|traceback|stack ?trace|not working|broken|fails?|failing|failed|crash|fix this|why (is|does|isn'?t)|troubleshoot|502|500|null pointer|undefined is not)\b"),
10
+ ("code_review", 2.6, r"\b(review|code ?review|pr\b|pull request|feedback on|critique|lgtm|nitpick|approve)\b"),
11
+ ("refactoring", 2.5, r"\b(refactor|clean ?up|simplify|restructure|rename|extract (a )?(function|method|component)|dedupe|deduplicate|tech debt|tidy)\b"),
12
+ ("testing", 2.4, r"\b(test|tests|unit test|integration test|pytest|jest|coverage|assert|mock|spec file)\b"),
13
+ ("documentation", 2.3, r"\b(document|documentation|docs?|readme|changelog|comment(s)? (for|on)|docstring|write up|write-up)\b"),
14
+ ("architecture", 2.2, r"\b(architect|architecture|design (the|a) (system|schema|api)|system design|data model|schema|scal(e|ing|ability)|trade[- ]?offs?|high level design)\b"),
15
+ ("planning", 2.1, r"\b(plan|roadmap|break (this )?down|steps to|approach|strategy|estimate|milestone|backlog|prioriti[sz]e)\b"),
16
+ ("automation", 2.0, r"\b(script|automat|cron|pipeline|ci/?cd|workflow|deploy|jenkins|github action|makefile|bash script)\b"),
17
+ ("research", 1.9, r"\b(research|compare|find out|look up|investigate|what (is|are)|explore options|alternatives|pros and cons|benchmark|which (library|tool|framework))\b"),
18
+ ("learning", 1.8, r"\b(explain|how does|teach me|understand|what does .* mean|walk me through|help me learn|tutorial|eli5)\b"),
19
+ ("writing", 1.7, r"\b(write (an?|the)? ?(email|blog|post|copy|article|summary|message|slide)|draft|rephrase|proofread|tone|paraphrase)\b"),
20
+ ("data_analysis", 1.7, r"\b(analy[sz]e|dataset|csv|dataframe|sql query|aggregate|chart|visuali[sz]|report on|metrics)\b"),
21
+ ("coding", 1.5, r"\b(implement|build|create|add|write (a )?(function|class|component|endpoint|module)|code|feature|api|component|migrate|integrate|install|setup|set up|configure)\b"),
22
+ ("casual", 1.0, r"^\s*(hi|hey|hello|thanks|thank you|ok|okay|yes|no|cool|nice|great|continue|go ahead|proceed|yep|sure)\b"),
23
+ ]
24
+
25
+ _COMPILED = [(name, w, re.compile(pat, re.I)) for name, w, pat in CATEGORIES]
26
+
27
+
28
+ def classify(text):
29
+ """Return (category, confidence 0-1, matched_terms)."""
30
+ if not text or not text.strip():
31
+ return "other", 0.0, []
32
+ head = text[:4000]
33
+ scores = {}
34
+ evidence = {}
35
+ for name, weight, rx in _COMPILED:
36
+ hits = rx.findall(head)
37
+ if hits:
38
+ flat = []
39
+ for h in hits:
40
+ flat.append(h if isinstance(h, str) else next((x for x in h if x), ""))
41
+ n = len(flat)
42
+ scores[name] = weight * (1 + 0.25 * min(n - 1, 4))
43
+ evidence[name] = sorted({s.lower().strip() for s in flat if s})[:5]
44
+ if not scores:
45
+ return "other", 0.0, []
46
+ best = max(scores, key=scores.get)
47
+ total = sum(scores.values())
48
+ return best, round(scores[best] / total, 3), evidence.get(best, [])