claude-finops 0.5.0 → 0.6.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/README.md +22 -0
- package/finops/advisor.py +188 -0
- package/finops/api.py +11 -0
- package/finops/integrate.py +168 -0
- package/finops/procs.py +3 -1
- package/package.json +1 -1
- package/run.py +42 -0
- package/web/app.js +4 -0
- package/web/styles.css +8 -0
package/README.md
CHANGED
|
@@ -287,6 +287,28 @@ place for you to delete once you are happy.
|
|
|
287
287
|
|
|
288
288
|
---
|
|
289
289
|
|
|
290
|
+
## Live model advice
|
|
291
|
+
|
|
292
|
+
The back-test tells you what to use next time. These tell you mid-session, while
|
|
293
|
+
you can still act on it:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
claude-finops --advise what every running session should switch to
|
|
297
|
+
claude-finops --install-hook suggest a cheaper model as you send each prompt
|
|
298
|
+
claude-finops --install-statusline model, context pressure and advice in your statusline
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The hook and statusline read the same evidence as the dashboard, cached for an
|
|
302
|
+
hour, and stay quiet unless your own history shows a cheaper model doing that
|
|
303
|
+
category of work without taking more turns. Neither can block or slow a prompt:
|
|
304
|
+
they fail silent and always exit 0. Undo with `--uninstall-hook` /
|
|
305
|
+
`--uninstall-statusline`; your `~/.claude/settings.json` is backed up first.
|
|
306
|
+
|
|
307
|
+
The Running sessions view shows the same line per live session, with the
|
|
308
|
+
`/model` command ready to copy.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
290
312
|
## Privacy
|
|
291
313
|
|
|
292
314
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Live model advice: what to switch to, while the session is still running.
|
|
2
|
+
|
|
3
|
+
The back-test in analytics.model_evidence() answers "what should I have used?",
|
|
4
|
+
which is the wrong tense once a session is under way. This module answers it in
|
|
5
|
+
the present: given the prompts a session has actually sent and the model it is
|
|
6
|
+
on, say whether your own history already shows a cheaper model doing this kind
|
|
7
|
+
of work without taking more turns.
|
|
8
|
+
|
|
9
|
+
It is built to run three ways, so the advice is the same wherever you meet it:
|
|
10
|
+
|
|
11
|
+
* the dashboard's Running sessions view (per live session)
|
|
12
|
+
* a UserPromptSubmit hook (per prompt, before the turn runs)
|
|
13
|
+
* a statusline command (continuously, in a few characters)
|
|
14
|
+
|
|
15
|
+
The hook and statusline run on every prompt, so the evidence is computed once
|
|
16
|
+
and cached; a warehouse query per keystroke would be felt. Everything here fails
|
|
17
|
+
soft and silent — advice that breaks your terminal is worse than no advice.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .classify import classify
|
|
24
|
+
from .paths import DATA_DIR, DB_PATH, ensure_dirs
|
|
25
|
+
|
|
26
|
+
CACHE = os.path.join(DATA_DIR, "advice_cache.json")
|
|
27
|
+
TTL_S = 3600
|
|
28
|
+
RECENT_PROMPTS = 12 # how much of the session counts as "what it is doing now"
|
|
29
|
+
MIN_SAVING_PCT = 25 # below this, interrupting someone is not worth it
|
|
30
|
+
|
|
31
|
+
# `/model <name>` takes a family name, not the pricing table's id.
|
|
32
|
+
ALIASES = ("opus", "sonnet", "haiku", "fable")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def model_alias(model_id):
|
|
36
|
+
"""'claude-fable-5-1' -> 'fable'. Falls back to the full id we were given."""
|
|
37
|
+
low = str(model_id or "").lower()
|
|
38
|
+
for a in ALIASES:
|
|
39
|
+
if a in low:
|
|
40
|
+
return a
|
|
41
|
+
return model_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------- evidence ----
|
|
45
|
+
|
|
46
|
+
def _build_evidence():
|
|
47
|
+
"""Pull the back-test into the small shape the live surfaces need."""
|
|
48
|
+
from .analytics import Analytics
|
|
49
|
+
a = Analytics(DB_PATH)
|
|
50
|
+
ev = a.model_evidence({})
|
|
51
|
+
out = {}
|
|
52
|
+
for c in ev["categories"]:
|
|
53
|
+
cands = [{"model": x["model"], "name": x["name"], "verdict": x["verdict"],
|
|
54
|
+
"cost_per_prompt": round(x["cost_per_prompt"], 3),
|
|
55
|
+
"savings_pct": x["savings_pct"], "turn_ratio": x["turn_ratio"],
|
|
56
|
+
"prompts": x["prompts"], "why": x["why"]}
|
|
57
|
+
for x in c["candidates"]]
|
|
58
|
+
out[c["category"]] = {"current": c["current"]["model"],
|
|
59
|
+
"current_name": c["current"]["name"],
|
|
60
|
+
"current_cost_per_prompt": round(c["current"]["cost_per_prompt"], 3),
|
|
61
|
+
"agent": c.get("agent", "claude"),
|
|
62
|
+
"candidates": cands}
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def evidence(force=False):
|
|
67
|
+
"""Cached evidence table, keyed by category. Never raises."""
|
|
68
|
+
try:
|
|
69
|
+
with open(CACHE) as fh:
|
|
70
|
+
c = json.load(fh)
|
|
71
|
+
if not force and time.time() - c.get("built_at", 0) < TTL_S:
|
|
72
|
+
return c.get("categories") or {}
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
pass
|
|
75
|
+
try:
|
|
76
|
+
cats = _build_evidence()
|
|
77
|
+
except Exception:
|
|
78
|
+
return {}
|
|
79
|
+
try:
|
|
80
|
+
ensure_dirs()
|
|
81
|
+
tmp = CACHE + ".tmp"
|
|
82
|
+
with open(tmp, "w") as fh:
|
|
83
|
+
json.dump({"built_at": int(time.time()), "categories": cats}, fh)
|
|
84
|
+
os.replace(tmp, CACHE)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
return cats
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- session ----
|
|
91
|
+
|
|
92
|
+
def recent_prompts(transcript, n=RECENT_PROMPTS):
|
|
93
|
+
"""The last n human prompts in a live transcript, newest last.
|
|
94
|
+
|
|
95
|
+
Reads the tail only: an active session's JSONL runs to tens of megabytes and
|
|
96
|
+
this is on the path of every prompt you type.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
size = os.path.getsize(transcript)
|
|
100
|
+
with open(transcript, "rb") as fh:
|
|
101
|
+
fh.seek(max(0, size - 400_000))
|
|
102
|
+
lines = fh.read().decode("utf-8", "replace").splitlines()[1:]
|
|
103
|
+
except OSError:
|
|
104
|
+
return []
|
|
105
|
+
out = []
|
|
106
|
+
for line in reversed(lines):
|
|
107
|
+
if '"type":"user"' not in line and '"type": "user"' not in line:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
d = json.loads(line)
|
|
111
|
+
except ValueError:
|
|
112
|
+
continue
|
|
113
|
+
if d.get("isMeta") or d.get("isSidechain"):
|
|
114
|
+
continue
|
|
115
|
+
msg = d.get("message") or {}
|
|
116
|
+
content = msg.get("content")
|
|
117
|
+
if isinstance(content, list):
|
|
118
|
+
content = " ".join(b.get("text", "") for b in content
|
|
119
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
120
|
+
if not isinstance(content, str) or not content.strip():
|
|
121
|
+
continue
|
|
122
|
+
if content.lstrip().startswith(("<", "[Request interrupted")):
|
|
123
|
+
continue # tool results and interrupt markers are not prompts
|
|
124
|
+
out.append(content)
|
|
125
|
+
if len(out) >= n:
|
|
126
|
+
break
|
|
127
|
+
return list(reversed(out))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def category_of(texts):
|
|
131
|
+
"""The category this stretch of work is in, by weight of confidence."""
|
|
132
|
+
scores = {}
|
|
133
|
+
for t in texts:
|
|
134
|
+
cat, conf, _ = classify(t)
|
|
135
|
+
scores[cat] = scores.get(cat, 0) + max(conf, 0.1)
|
|
136
|
+
if not scores:
|
|
137
|
+
return "other", 0.0
|
|
138
|
+
cat = max(scores, key=scores.get)
|
|
139
|
+
return cat, round(scores[cat] / sum(scores.values()), 2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def advise(model=None, transcript=None, prompt=None, ev=None):
|
|
143
|
+
"""What to say about a session that is running right now.
|
|
144
|
+
|
|
145
|
+
model the model the session is on (pricing-table id)
|
|
146
|
+
transcript path to the live JSONL, for reading what it has been doing
|
|
147
|
+
prompt the prompt about to be sent, when we are called from a hook
|
|
148
|
+
Returns None when there is nothing worth saying.
|
|
149
|
+
"""
|
|
150
|
+
ev = evidence() if ev is None else ev
|
|
151
|
+
if not ev:
|
|
152
|
+
return None
|
|
153
|
+
texts = list(recent_prompts(transcript)) if transcript else []
|
|
154
|
+
if prompt:
|
|
155
|
+
# The prompt in hand is what the next turn will cost, so it leads.
|
|
156
|
+
texts = texts[-4:] + [prompt] * 3
|
|
157
|
+
if not texts:
|
|
158
|
+
return None
|
|
159
|
+
cat, conf = category_of(texts)
|
|
160
|
+
row = ev.get(cat)
|
|
161
|
+
if not row:
|
|
162
|
+
return None
|
|
163
|
+
# Only speak when they are on the model the evidence is about. Advising a
|
|
164
|
+
# switch away from a model you already left would be noise.
|
|
165
|
+
if model and row["current"] and model_alias(model) != model_alias(row["current"]):
|
|
166
|
+
return None
|
|
167
|
+
usable = [c for c in row["candidates"]
|
|
168
|
+
if c["verdict"] in ("supported", "caution") and c["savings_pct"] >= MIN_SAVING_PCT]
|
|
169
|
+
if not usable:
|
|
170
|
+
return None
|
|
171
|
+
best = usable[0]
|
|
172
|
+
trial = best["verdict"] == "caution"
|
|
173
|
+
return {
|
|
174
|
+
"category": cat, "confidence": conf,
|
|
175
|
+
"current_model": row["current"], "current_name": row["current_name"],
|
|
176
|
+
"current_cost_per_prompt": row["current_cost_per_prompt"],
|
|
177
|
+
"model": best["model"], "name": best["name"], "alias": model_alias(best["model"]),
|
|
178
|
+
"cost_per_prompt": best["cost_per_prompt"], "savings_pct": best["savings_pct"],
|
|
179
|
+
"turn_ratio": best["turn_ratio"], "prompts": best["prompts"],
|
|
180
|
+
"verdict": best["verdict"], "trial": trial, "why": best["why"],
|
|
181
|
+
"command": f"/model {model_alias(best['model'])}",
|
|
182
|
+
"line": (f"{cat.replace('_', ' ')} on {row['current_name']} "
|
|
183
|
+
f"(${row['current_cost_per_prompt']:.2f}/prompt). Your last {best['prompts']} "
|
|
184
|
+
f"ran on {best['name']} at ${best['cost_per_prompt']:.2f} in "
|
|
185
|
+
f"{best['turn_ratio']}x the turns"
|
|
186
|
+
+ (" — worth a trial here." if trial else ".")),
|
|
187
|
+
"short": f"try {model_alias(best['model'])} (-{best['savings_pct']:.0f}%)",
|
|
188
|
+
}
|
package/finops/api.py
CHANGED
|
@@ -323,6 +323,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
323
323
|
others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
|
|
324
324
|
if not want or any(x != "claude" for x in want) else []
|
|
325
325
|
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
326
|
+
# Live model advice, while the session can still act on it. One cached
|
|
327
|
+
# evidence read for the whole list, and never fatal to the view.
|
|
328
|
+
try:
|
|
329
|
+
from .advisor import advise, evidence
|
|
330
|
+
ev = evidence()
|
|
331
|
+
for r in rows:
|
|
332
|
+
r["advice"] = advise(model=r.get("model"), transcript=r.get("transcript"),
|
|
333
|
+
ev=ev) if ev else None
|
|
334
|
+
except Exception:
|
|
335
|
+
for r in rows:
|
|
336
|
+
r.setdefault("advice", None)
|
|
326
337
|
return self.send_json({"sessions": rows})
|
|
327
338
|
if route == "cloud":
|
|
328
339
|
from .cloud import report
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Live advice inside Claude Code itself: a prompt hook and a statusline.
|
|
2
|
+
|
|
3
|
+
The dashboard can only advise you if you are looking at it. These two run where
|
|
4
|
+
the decision is actually made — the terminal you are typing in.
|
|
5
|
+
|
|
6
|
+
claude-finops --hook reads a UserPromptSubmit payload on stdin
|
|
7
|
+
claude-finops --statusline reads a statusline payload on stdin
|
|
8
|
+
claude-finops --install-hook / --install-statusline wire them into settings
|
|
9
|
+
|
|
10
|
+
Both are on the path of every prompt, so both are built to be boring: fail
|
|
11
|
+
silent, never block, never take long. A hook that erases someone's prompt
|
|
12
|
+
because a database was locked is a far worse bug than missing advice, so the
|
|
13
|
+
hook never returns a blocking exit code — it exits 0 whatever happens.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
SETTINGS = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _stdin_json():
|
|
24
|
+
try:
|
|
25
|
+
raw = sys.stdin.read()
|
|
26
|
+
return json.loads(raw) if raw.strip() else {}
|
|
27
|
+
except (ValueError, OSError):
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dig(d, *paths, default=None):
|
|
32
|
+
"""First present value among dotted paths.
|
|
33
|
+
|
|
34
|
+
The payload shapes are not identical across Claude Code versions, and a
|
|
35
|
+
statusline that breaks on upgrade is worse than one that misses a field, so
|
|
36
|
+
every read tries the spellings we know of.
|
|
37
|
+
"""
|
|
38
|
+
for path in paths:
|
|
39
|
+
cur = d
|
|
40
|
+
for part in path.split("."):
|
|
41
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
42
|
+
cur = None
|
|
43
|
+
break
|
|
44
|
+
cur = cur[part]
|
|
45
|
+
if cur not in (None, ""):
|
|
46
|
+
return cur
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _payload_common(d):
|
|
51
|
+
return (_dig(d, "model", "session.model", "model.id", "model.display_name"),
|
|
52
|
+
_dig(d, "transcript_path", "session.transcript_path", "transcriptPath"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# -------------------------------------------------------------------- hook ----
|
|
56
|
+
|
|
57
|
+
def hook():
|
|
58
|
+
"""UserPromptSubmit: judge the prompt in hand, before it is paid for.
|
|
59
|
+
|
|
60
|
+
Advice goes to the user as `systemMessage`, not into Claude's context: it is
|
|
61
|
+
for the person deciding which model to use, and feeding it to the model
|
|
62
|
+
would just spend tokens telling it about its own price.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
d = _stdin_json()
|
|
66
|
+
prompt = _dig(d, "user_prompt", "prompt", default="")
|
|
67
|
+
model, transcript = _payload_common(d)
|
|
68
|
+
from .advisor import advise
|
|
69
|
+
a = advise(model=model, transcript=transcript, prompt=prompt)
|
|
70
|
+
if a:
|
|
71
|
+
print(json.dumps({"systemMessage": f"finops: {a['line']} → {a['command']}"}))
|
|
72
|
+
except Exception:
|
|
73
|
+
pass # never let advice interfere with the prompt
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -------------------------------------------------------------- statusline ----
|
|
78
|
+
|
|
79
|
+
def statusline():
|
|
80
|
+
"""One line, refreshed constantly, so: what you are on, and what to try."""
|
|
81
|
+
try:
|
|
82
|
+
d = _stdin_json()
|
|
83
|
+
model, transcript = _payload_common(d)
|
|
84
|
+
pct = _dig(d, "context.percentUsed", "context.percent_used")
|
|
85
|
+
name = _dig(d, "model.display_name", "session.model", "model") or "claude"
|
|
86
|
+
bits = [str(name)]
|
|
87
|
+
if isinstance(pct, (int, float)):
|
|
88
|
+
bits.append(f"{pct:.0f}% ctx")
|
|
89
|
+
from .advisor import advise
|
|
90
|
+
a = advise(model=model, transcript=transcript)
|
|
91
|
+
if a:
|
|
92
|
+
bits.append(a["short"])
|
|
93
|
+
print(" · ".join(bits))
|
|
94
|
+
except Exception:
|
|
95
|
+
print("") # an empty statusline beats a stack trace under the prompt
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------- installing ----
|
|
100
|
+
|
|
101
|
+
def _command():
|
|
102
|
+
"""How to invoke us from settings: the installed CLI if it is on PATH."""
|
|
103
|
+
exe = shutil.which("claude-finops")
|
|
104
|
+
if exe:
|
|
105
|
+
return exe
|
|
106
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
107
|
+
return f"{sys.executable} {os.path.join(root, 'run.py')}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_settings():
|
|
111
|
+
try:
|
|
112
|
+
with open(SETTINGS) as fh:
|
|
113
|
+
return json.load(fh)
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _save_settings(data):
|
|
119
|
+
os.makedirs(os.path.dirname(SETTINGS), exist_ok=True)
|
|
120
|
+
if os.path.exists(SETTINGS):
|
|
121
|
+
# Their settings file is not ours to lose.
|
|
122
|
+
shutil.copy2(SETTINGS, SETTINGS + ".finops-backup")
|
|
123
|
+
with open(SETTINGS, "w") as fh:
|
|
124
|
+
json.dump(data, fh, indent=2)
|
|
125
|
+
fh.write("\n")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def install_hook(remove=False):
|
|
129
|
+
cmd = f"{_command()} --hook"
|
|
130
|
+
s = _load_settings()
|
|
131
|
+
hooks = s.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
|
|
132
|
+
for group in hooks: # drop any earlier copy of ours
|
|
133
|
+
group["hooks"] = [h for h in group.get("hooks", [])
|
|
134
|
+
if "--hook" not in str(h.get("command", ""))
|
|
135
|
+
or "finops" not in str(h.get("command", ""))]
|
|
136
|
+
hooks[:] = [g for g in hooks if g.get("hooks")]
|
|
137
|
+
if not remove:
|
|
138
|
+
hooks.append({"matcher": "", "hooks": [{"type": "command", "command": cmd,
|
|
139
|
+
"timeout": 10}]})
|
|
140
|
+
if not hooks:
|
|
141
|
+
s["hooks"].pop("UserPromptSubmit", None)
|
|
142
|
+
if not s["hooks"]:
|
|
143
|
+
s.pop("hooks")
|
|
144
|
+
_save_settings(s)
|
|
145
|
+
print(("Removed" if remove else "Installed") + f" the prompt hook in {SETTINGS}")
|
|
146
|
+
if not remove:
|
|
147
|
+
print(" It suggests a cheaper model when your own history backs one, before the turn runs.")
|
|
148
|
+
print(" Start a new Claude Code session to pick it up. Undo: claude-finops --uninstall-hook")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def install_statusline(remove=False):
|
|
152
|
+
cmd = f"{_command()} --statusline"
|
|
153
|
+
s = _load_settings()
|
|
154
|
+
if remove:
|
|
155
|
+
if "finops" in str(s.get("statusLine", "")):
|
|
156
|
+
s.pop("statusLine", None)
|
|
157
|
+
else:
|
|
158
|
+
prev = s.get("statusLine")
|
|
159
|
+
if prev and "finops" not in str(prev):
|
|
160
|
+
print(f"You already have a statusLine configured:\n {prev}")
|
|
161
|
+
print("Leaving it alone. Remove it first if you want ours.")
|
|
162
|
+
return
|
|
163
|
+
s["statusLine"] = cmd
|
|
164
|
+
_save_settings(s)
|
|
165
|
+
print(("Removed" if remove else "Installed") + f" the statusline in {SETTINGS}")
|
|
166
|
+
if not remove:
|
|
167
|
+
print(" Shows the model, context pressure, and a cheaper model when one is warranted.")
|
|
168
|
+
print(" Undo: claude-finops --uninstall-statusline")
|
package/finops/procs.py
CHANGED
|
@@ -85,6 +85,7 @@ def _transcript_stats(session_id, pricing):
|
|
|
85
85
|
steps = tokens = 0
|
|
86
86
|
ctx = None
|
|
87
87
|
cost = 0.0
|
|
88
|
+
model = None # the model of the most recent assistant turn: what you are on now
|
|
88
89
|
with open(fp, errors="replace") as fh:
|
|
89
90
|
for line in fh:
|
|
90
91
|
if '"usage"' not in line:
|
|
@@ -108,7 +109,8 @@ def _transcript_stats(session_id, pricing):
|
|
|
108
109
|
if not (c5 or c1):
|
|
109
110
|
c5 = cw
|
|
110
111
|
cost += pricing.estimate(m.get("model"), inp, out, cr, c5, c1)
|
|
111
|
-
|
|
112
|
+
model = m.get("model") or model
|
|
113
|
+
return {"transcript": fp, "steps": steps, "tokens": tokens, "context": ctx, "model": model,
|
|
112
114
|
"est_cost_usd": cost, "last_write_s": time.time() - os.path.getmtime(fp)}
|
|
113
115
|
|
|
114
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/run.py
CHANGED
|
@@ -171,6 +171,9 @@ HELP = """Claude FinOps Command Center
|
|
|
171
171
|
claude-finops --keys list which provider keys are configured
|
|
172
172
|
claude-finops --share write ../claude-finops.zip (code only, never your data)
|
|
173
173
|
claude-finops --version print the installed version, and whether a newer one is out
|
|
174
|
+
claude-finops --advise what to switch to in the sessions running right now
|
|
175
|
+
claude-finops --install-hook suggest a cheaper model in Claude Code, as you send each prompt
|
|
176
|
+
claude-finops --install-statusline show model, context and advice in your statusline
|
|
174
177
|
claude-finops --help this message
|
|
175
178
|
|
|
176
179
|
Environment:
|
|
@@ -182,6 +185,28 @@ Environment:
|
|
|
182
185
|
"""
|
|
183
186
|
|
|
184
187
|
|
|
188
|
+
def advise_now():
|
|
189
|
+
"""What every session running right now should consider switching to."""
|
|
190
|
+
from finops.advisor import advise, evidence
|
|
191
|
+
from finops.procs import list_sessions
|
|
192
|
+
from finops.analytics import Analytics
|
|
193
|
+
ev = evidence()
|
|
194
|
+
if not ev:
|
|
195
|
+
return print("No model evidence yet — you need two models run on the same kind of "
|
|
196
|
+
"work before there is anything to compare.")
|
|
197
|
+
rows = list_sessions(Analytics(DB_PATH).pricing)
|
|
198
|
+
if not rows:
|
|
199
|
+
return print("No Claude Code sessions are running.")
|
|
200
|
+
for r in rows:
|
|
201
|
+
a = advise(model=r.get("model"), transcript=r.get("transcript"), ev=ev)
|
|
202
|
+
head = f" {r.get('project') or r.get('session_id') or 'session'}"
|
|
203
|
+
if a:
|
|
204
|
+
print(f"{head}: {a['line']}")
|
|
205
|
+
print(f"{' ' * len(head)} run {a['command']}")
|
|
206
|
+
else:
|
|
207
|
+
print(f"{head}: nothing to change.")
|
|
208
|
+
|
|
209
|
+
|
|
185
210
|
def _version():
|
|
186
211
|
from finops.update import _installed
|
|
187
212
|
return _installed()
|
|
@@ -292,6 +317,23 @@ def main():
|
|
|
292
317
|
return print(HELP)
|
|
293
318
|
if "--version" in args or "-v" in args or "-V" in args:
|
|
294
319
|
return version()
|
|
320
|
+
# Integration entry points. --hook and --statusline are invoked by Claude Code
|
|
321
|
+
# with a JSON payload on stdin, many times a session; they print one line and
|
|
322
|
+
# never fail loudly.
|
|
323
|
+
if "--hook" in args:
|
|
324
|
+
from finops.integrate import hook
|
|
325
|
+
return sys.exit(hook())
|
|
326
|
+
if "--statusline" in args:
|
|
327
|
+
from finops.integrate import statusline
|
|
328
|
+
return sys.exit(statusline())
|
|
329
|
+
if "--install-hook" in args or "--uninstall-hook" in args:
|
|
330
|
+
from finops.integrate import install_hook
|
|
331
|
+
return install_hook(remove="--uninstall-hook" in args)
|
|
332
|
+
if "--install-statusline" in args or "--uninstall-statusline" in args:
|
|
333
|
+
from finops.integrate import install_statusline
|
|
334
|
+
return install_statusline(remove="--uninstall-statusline" in args)
|
|
335
|
+
if "--advise" in args:
|
|
336
|
+
return advise_now()
|
|
295
337
|
if "--where" in args:
|
|
296
338
|
return where()
|
|
297
339
|
if "--keys" in args:
|
package/web/app.js
CHANGED
|
@@ -1644,6 +1644,10 @@ VIEWS.live = async (page) => {
|
|
|
1644
1644
|
<div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
|
|
1645
1645
|
last activity ${x.last_write_s == null ? '—' : dur(x.last_write_s)} ago${x.memory_mb == null ? '' : ` · ${fmtInt(x.memory_mb)} MB`} ·
|
|
1646
1646
|
<span class="note">${esc(x.cwd)}</span></div>
|
|
1647
|
+
${x.advice ? `<div class="dt switch-tip"><b>${x.advice.trial ? 'Worth trying' : 'Cheaper model'}:</b>
|
|
1648
|
+
${esc(x.advice.line)}
|
|
1649
|
+
<button class="act ghost" data-copy="${esc(x.advice.command)}"
|
|
1650
|
+
title="${esc(x.advice.why)}">Copy ${esc(x.advice.command)}</button></div>` : ''}
|
|
1647
1651
|
${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
|
|
1648
1652
|
? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
|
|
1649
1653
|
: 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
|
package/web/styles.css
CHANGED
|
@@ -367,6 +367,9 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
367
367
|
.live-msg.ok { color: var(--good-ink); } .live-msg.err { color: var(--critical-ink); }
|
|
368
368
|
.item.gone { opacity: .45; }
|
|
369
369
|
|
|
370
|
+
/* An explicit display beats the hidden attribute, so the panel has to opt back
|
|
371
|
+
out or it is permanently open. */
|
|
372
|
+
.handover[hidden] { display: none; }
|
|
370
373
|
.handover { margin-top: 8px; padding: 10px; border: 1px dashed var(--border); border-radius: 8px; display: grid; gap: 8px; }
|
|
371
374
|
.handover textarea { width: 100%; box-sizing: border-box; font: inherit; font-size: 12.5px; padding: 8px; border-radius: 6px;
|
|
372
375
|
border: 1px solid var(--border); background: transparent; color: inherit; resize: vertical; }
|
|
@@ -417,3 +420,8 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
417
420
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
418
421
|
.who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
|
|
419
422
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
423
|
+
|
|
424
|
+
/* Live model advice on a running session: it is an opportunity, not a problem,
|
|
425
|
+
so it reads as a note with an accent edge rather than a warning. */
|
|
426
|
+
.switch-tip { border-left: 2px solid var(--accent, #eb6834); padding-left: 9px; }
|
|
427
|
+
.switch-tip .act { margin-left: 8px; }
|