claude-finops 0.7.2 → 0.8.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 +12 -47
- package/config/pricing.json +62 -42
- package/config/settings.json +11 -5
- package/finops/actions.py +1 -1
- package/finops/analytics.py +690 -611
- package/finops/api.py +174 -55
- package/finops/diagnose.py +17 -20
- package/finops/etl.py +149 -52
- package/finops/integrate.py +22 -57
- package/finops/pricing.py +57 -6
- package/finops/procs.py +7 -5
- package/finops/report.py +29 -23
- package/finops/segments.py +149 -0
- package/package.json +1 -1
- package/run.cmd +3 -2
- package/run.py +26 -59
- package/web/app.js +253 -329
- package/web/charts.js +26 -15
- package/finops/advisor.py +0 -188
- package/finops/trial.py +0 -169
package/finops/report.py
CHANGED
|
@@ -29,7 +29,7 @@ def build_report(a, f):
|
|
|
29
29
|
md = a.models(f); pj = a.projects(f); wt = a.waste(f)
|
|
30
30
|
rc = a.recommendations(f); ad = a.advisor(f); ef = a.efficiency(f)
|
|
31
31
|
cat = a.categories(f); bp = ov["billing_period"]
|
|
32
|
-
lb = a.leaderboards(f, 10)
|
|
32
|
+
lb = a.leaderboards(f, 10); hy = a.hygiene(f)
|
|
33
33
|
|
|
34
34
|
def table(headers, rows):
|
|
35
35
|
h = "".join(f"<th>{html.escape(x)}</th>" for x in headers)
|
|
@@ -82,52 +82,61 @@ transcripts contain no billed amounts, so no figure here is an actual invoice va
|
|
|
82
82
|
<div class="kpi"><div class="l">Avg / active day</div><div class="v">{_f(ov['avg_cost_per_active_day'])}</div></div>
|
|
83
83
|
</div>
|
|
84
84
|
|
|
85
|
-
<h2>2.
|
|
86
|
-
|
|
85
|
+
<h2>2. Context hygiene</h2>
|
|
86
|
+
{table(["Threshold", "Requests", "Cost", "Sessions", "Cost after first cross"],
|
|
87
|
+
[(f"{int(thr):,}", _f(v['requests'],'int'), _f(v['cost_usd']), _f(v['sessions'],'int'),
|
|
88
|
+
_f(v['cost_after_first_cross_usd'])) for thr, v in hy['above'].items()])}
|
|
89
|
+
<div class="note">"Cost after first cross" is the spend on requests made once a session first passed the
|
|
90
|
+
threshold in that column — not a saving, an observation of where spend concentrates.</div>
|
|
91
|
+
|
|
92
|
+
<h2>3. FinOps scorecard</h2>
|
|
93
|
+
<p><span class="score">{sc['score']}</span> / 100</p>
|
|
87
94
|
{table(["Dimension","Score","Detail"], [(d['name'], d['score'], html.escape(d['detail'])) for d in sc['dimensions']])}
|
|
88
95
|
<b>What is good</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['what_is_good']) or '<li>—</li>'}</ul>
|
|
89
96
|
<b>Needs attention</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['needs_attention']) or '<li>—</li>'}</ul>
|
|
90
97
|
|
|
91
|
-
<h2>
|
|
98
|
+
<h2>4. What should I do? <span class="badge">Advisor</span></h2>
|
|
92
99
|
<ol>{''.join(f"<li><b>{html.escape(x['text'])}</b><br><span style='color:#5b6570'>{html.escape(x['detail'])}</span></li>" for x in ad['actions']) or '<li>No actions.</li>'}</ol>
|
|
93
100
|
|
|
94
|
-
<h2>
|
|
101
|
+
<h2>5. Model breakdown <span class="badge est">Estimated cost</span></h2>
|
|
95
102
|
{table(["Model","Requests","Input","Output","Cache read","Cache write","Billable tokens","Est. cost","% cost"],
|
|
96
103
|
[(html.escape(r['display_name']), _f(r['requests'],'int'), _f(r['input_tokens'],'int'),
|
|
97
104
|
_f(r['output_tokens'],'int'), _f(r['cache_read_tokens'],'int'), _f(r['cache_write_tokens'],'int'),
|
|
98
105
|
_f(r['tokens'],'int'), _f(r['cost']), _f(r['cost_pct'],'pct')) for r in md['rows']])}
|
|
99
106
|
|
|
100
|
-
<h2>
|
|
107
|
+
<h2>6. Project breakdown</h2>
|
|
101
108
|
{table(["Project","Sessions","Prompts","Requests","Tokens","Est. cost","Avg / session"],
|
|
102
109
|
[(html.escape(r['name']), _f(r['sessions'],'int'), _f(r['prompts'],'int'), _f(r['requests'],'int'),
|
|
103
110
|
_f(r['tokens'],'int'), _f(r['cost']), _f(r['avg_cost_per_session'])) for r in pj[:20]])}
|
|
104
111
|
|
|
105
|
-
<h2>
|
|
112
|
+
<h2>7. Spend by activity</h2>
|
|
106
113
|
{table(["Category","Prompts","Tokens","Est. cost","% of spend"],
|
|
107
114
|
[(html.escape(r['category']), _f(r['prompts'],'int'), _f(r['tokens'],'int'), _f(r['cost']),
|
|
108
115
|
_f(r['cost_pct'],'pct')) for r in cat['rows']])}
|
|
109
116
|
<div class="note">{html.escape(cat['note'])}</div>
|
|
110
117
|
|
|
111
|
-
<h2>
|
|
118
|
+
<h2>8. Efficiency & caching</h2>
|
|
112
119
|
{table(["Metric","Value"], [
|
|
113
120
|
("Output share of billable tokens", f"{ef['output_ratio']*100:.2f}%"),
|
|
114
121
|
("Output per prompt-side token", f"{ef['output_per_input']*100:.2f}%"),
|
|
115
|
-
("Cache hit ratio", (f"{ef['cache_hit_ratio']*100:.1f}%" if ef['cache_hit_ratio'] is not None else "—")),
|
|
122
|
+
("Cache hit ratio (by token)", (f"{ef['cache_hit_ratio']*100:.1f}%" if ef['cache_hit_ratio'] is not None else "—")),
|
|
123
|
+
("Cache reads as share of cache cost", (f"{ef['cache_read_cost_share']*100:.1f}%"
|
|
124
|
+
if ef.get('cache_read_cost_share') is not None else "—")),
|
|
116
125
|
("Tokens per request", _f(ef['tokens_per_request'],'int')),
|
|
117
126
|
("Avg context per request", _f(ef['avg_context_tokens'],'int')),
|
|
118
127
|
("Est. cost per 1K output tokens", f"${ef['cost_per_1k_output']:.3f}"),
|
|
119
128
|
("Est. cost with caching", _f(ef['cache']['cost_with_cache'])),
|
|
120
129
|
("Est. cost without caching", _f(ef['cache']['cost_without_cache'])),
|
|
121
|
-
("
|
|
130
|
+
("Uncached counterfactual — not a saving", f"{_f(ef['cache']['uncached_counterfactual_delta_usd'])} ({ef['cache']['uncached_counterfactual_pct']}%)"),
|
|
122
131
|
])}
|
|
123
132
|
|
|
124
|
-
<h2>
|
|
133
|
+
<h2>9. Top 10 most expensive prompts <span class="badge est">Estimated</span></h2>
|
|
125
134
|
{table(["#","Prompt","Model","Tokens","Est. cost","Date"],
|
|
126
135
|
[(i, html.escape((r['preview'] or '')[:120]), html.escape((r['models'] or '')[:40]),
|
|
127
136
|
_f(r['ptokens'],'int'), _f(r['pcost']), r['day'])
|
|
128
137
|
for i, r in enumerate(lb['most_expensive'], 1)])}
|
|
129
138
|
|
|
130
|
-
<h2>
|
|
139
|
+
<h2>10. Waste detection <span class="badge est">Estimated exposure</span></h2>
|
|
131
140
|
<p><b>Estimated excess: {_f(wt['estimated_excess_usd'])} ({wt['excess_pct']}%)</b> of
|
|
132
141
|
{_f(wt['total_cost_usd'])} — how much more the flagged work cost than a reasonable baseline.
|
|
133
142
|
It sits inside {_f(wt['exposed_cost_usd'])} ({wt['exposed_pct']}%) of exposed spend across
|
|
@@ -138,24 +147,21 @@ It sits inside {_f(wt['exposed_cost_usd'])} ({wt['exposed_pct']}%) of exposed sp
|
|
|
138
147
|
for x in wt['findings']])}
|
|
139
148
|
<div class="note">{html.escape(wt['note'])}</div>
|
|
140
149
|
|
|
141
|
-
<h2>
|
|
142
|
-
{table(["Recommendation","
|
|
143
|
-
[(html.escape(r['title']), _f(r.get('actual_cost_usd')),
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<div class="note">Estimated savings assume identical token usage on the alternative and do not model
|
|
147
|
-
output quality. They are opportunities to evaluate, not booked savings.</div>
|
|
150
|
+
<h2>11. Optimization recommendations <span class="badge rec">Recommendation</span></h2>
|
|
151
|
+
{table(["Recommendation","Spend involved","Basis"],
|
|
152
|
+
[(html.escape(r['title']), _f(r.get('actual_cost_usd')), html.escape(r.get('basis','')))
|
|
153
|
+
for r in rc['recommendations']]) if rc['recommendations'] else '<p>No recommendation met the evidence threshold.</p>'}
|
|
154
|
+
<div class="note">Observations only. No saving is estimated: what an alternative would have cost is a counterfactual.</div>
|
|
148
155
|
|
|
149
|
-
<h2>
|
|
156
|
+
<h2>12. Forecast <span class="badge fc">Forecast</span></h2>"""]
|
|
150
157
|
|
|
151
158
|
if fc.get("available"):
|
|
152
159
|
parts.append(f"""<p>Method: {html.escape(fc['method'])} over {fc['sample_days']} days.
|
|
153
160
|
Period to date {_f(fc['period_used'])} with {fc['remaining_days']} days remaining.</p>
|
|
154
161
|
{table(["Scenario","Daily rate","Projected end of period"],
|
|
155
162
|
[(k.title(), _f(v['daily_rate']), _f(v['end_of_period_cost'])) for k, v in fc['scenarios'].items()])}
|
|
163
|
+
{'<p class="note">Fewer than 7 priced days in the window: bands not shown.</p>' if fc.get('insufficient_history') else ''}
|
|
156
164
|
{table(["Horizon","Projection"], [
|
|
157
|
-
("End of day", _f(fc['end_of_day_cost'])),
|
|
158
|
-
("End of week", _f(fc['end_of_week_cost'])),
|
|
159
165
|
("End of billing period (expected)", _f(fc['scenarios']['expected']['end_of_period_cost'])),
|
|
160
166
|
("Limit exhaustion date", html.escape(str(fc['limit_exhaustion_date']))),
|
|
161
167
|
])}""")
|
|
@@ -163,7 +169,7 @@ Period to date {_f(fc['period_used'])} with {fc['remaining_days']} days remainin
|
|
|
163
169
|
parts.append("<p>Not enough data in range to forecast.</p>")
|
|
164
170
|
|
|
165
171
|
parts.append(f"""
|
|
166
|
-
<h2>
|
|
172
|
+
<h2>13. Data provenance</h2>
|
|
167
173
|
{table(["Field","Value"], [
|
|
168
174
|
("Source", html.escape(str(a.meta.get('source_dir')))),
|
|
169
175
|
("Transcript files parsed", html.escape(str(a.meta.get('transcript_files')))),
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Cache segments and cache-TTL replay.
|
|
2
|
+
|
|
3
|
+
A prompt cache belongs to one model and one continuous run of turns. The moment the
|
|
4
|
+
run breaks — a new session, a subagent, a model change — the next turn pays to write
|
|
5
|
+
the whole prefix again. Those breaks are the only places a model switch is free, so
|
|
6
|
+
they are the unit this module produces.
|
|
7
|
+
|
|
8
|
+
Everything here is arithmetic over token counts and timestamps that the transcripts
|
|
9
|
+
already carry. No behavioural assumption is made about what a different model would
|
|
10
|
+
have done, which is what separates these numbers from the model-switch estimates.
|
|
11
|
+
|
|
12
|
+
Boundaries this can see:
|
|
13
|
+
* session start
|
|
14
|
+
* subagent (sidechain) start
|
|
15
|
+
* a model change between consecutive turns
|
|
16
|
+
* a compaction: context dropping by more than half (auto-compaction is not
|
|
17
|
+
recorded directly, but this is its trace; a typed /compact is recorded too)
|
|
18
|
+
"""
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
# Multipliers are derived per model from the price table rather than hardcoded: the
|
|
22
|
+
# 0.1x read / 1.25x 5m / 2.0x 1h shape holds for Anthropic, but Gemini reads at 0.25x
|
|
23
|
+
# and OpenAI writes at 1.0x, so a fixed constant would quietly corrupt those agents.
|
|
24
|
+
READ = "cache_read"
|
|
25
|
+
W5M = "cache_write_5m"
|
|
26
|
+
W1H = "cache_write_1h"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_ts(ts):
|
|
30
|
+
try:
|
|
31
|
+
return datetime.fromisoformat((ts or "").replace("Z", "+00:00"))
|
|
32
|
+
except (ValueError, AttributeError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def multipliers(pricing, model):
|
|
37
|
+
"""(read, write_5m, write_1h) as multiples of the model's base input price."""
|
|
38
|
+
r = pricing.rates(model) or {}
|
|
39
|
+
base = float(r.get("input") or 0)
|
|
40
|
+
if not base:
|
|
41
|
+
return 0.0, 0.0, 0.0
|
|
42
|
+
g = lambda k: float(r.get(k) or 0) / base
|
|
43
|
+
return g(READ), g(W5M), g(W1H)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_compaction(prev_ctx, ctx, threshold=100_000):
|
|
47
|
+
"""A context drop of more than half from above `threshold` is a compaction or a
|
|
48
|
+
/clear. Claude Code does not record auto-compaction; this is the only trace."""
|
|
49
|
+
return bool(prev_ctx) and prev_ctx >= threshold and ctx < prev_ctx * 0.5
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def split_segments(turns):
|
|
53
|
+
"""Split one session's turns into cache segments.
|
|
54
|
+
|
|
55
|
+
`turns` must be ordered by timestamp and carry: session_id, model, is_sidechain,
|
|
56
|
+
agent_id. Returns a list of lists, preserving order.
|
|
57
|
+
"""
|
|
58
|
+
segments, current, prev = [], [], None
|
|
59
|
+
for t in turns:
|
|
60
|
+
boundary = (
|
|
61
|
+
prev is None
|
|
62
|
+
or t.get("session_id") != prev.get("session_id")
|
|
63
|
+
or t.get("model") != prev.get("model")
|
|
64
|
+
# a sidechain turn runs against its own prefix; entering or leaving one,
|
|
65
|
+
# or moving between two different subagents, rebuilds the cache
|
|
66
|
+
or (t.get("agent_id") or None) != (prev.get("agent_id") or None)
|
|
67
|
+
or bool(t.get("is_sidechain")) != bool(prev.get("is_sidechain"))
|
|
68
|
+
or is_compaction(prev.get("ctx") if prev else None, t.get("ctx"))
|
|
69
|
+
)
|
|
70
|
+
if boundary and current:
|
|
71
|
+
segments.append(current)
|
|
72
|
+
current = []
|
|
73
|
+
current.append(t)
|
|
74
|
+
prev = t
|
|
75
|
+
if current:
|
|
76
|
+
segments.append(current)
|
|
77
|
+
return segments
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ttl_cost(segment, ttl_minutes, write_mult, pricing):
|
|
81
|
+
"""Replay one segment's cost under a given cache TTL.
|
|
82
|
+
|
|
83
|
+
Within the TTL the prefix is still resident: the turn pays the read rate for it and
|
|
84
|
+
the write rate only for the delta it adds. Past the TTL the prefix is gone and the
|
|
85
|
+
whole thing is written again. A turn refreshes the TTL whether it read or wrote.
|
|
86
|
+
"""
|
|
87
|
+
total = 0.0
|
|
88
|
+
last_cache_time = None
|
|
89
|
+
for t in segment:
|
|
90
|
+
# Price with the list that actually applied (a long-context variant bills at a
|
|
91
|
+
# premium), while segmentation keys off the plain model name, since that is what
|
|
92
|
+
# identifies the cache.
|
|
93
|
+
model = t.get("priced_as") or t.get("model")
|
|
94
|
+
rates = pricing.rates(model) or {}
|
|
95
|
+
base = float(rates.get("input") or 0) / 1e6
|
|
96
|
+
out_rate = float(rates.get("output") or 0) / 1e6
|
|
97
|
+
read_m, _, _ = multipliers(pricing, model)
|
|
98
|
+
|
|
99
|
+
prefix = t.get("cache_read_tokens") or 0
|
|
100
|
+
delta = (t.get("cache_write_5m") or 0) + (t.get("cache_write_1h") or 0)
|
|
101
|
+
when = parse_ts(t.get("ts"))
|
|
102
|
+
gap = None
|
|
103
|
+
if last_cache_time and when:
|
|
104
|
+
gap = (when - last_cache_time).total_seconds() / 60.0
|
|
105
|
+
|
|
106
|
+
if gap is not None and gap > ttl_minutes:
|
|
107
|
+
# The prefix expired between turns, so it has to be written again before it
|
|
108
|
+
# can be read. Charged on top of the delta this turn added.
|
|
109
|
+
total += prefix * write_mult * base
|
|
110
|
+
total += delta * write_mult * base
|
|
111
|
+
else:
|
|
112
|
+
# Either still inside the TTL, or the first turn we can see — where the
|
|
113
|
+
# tokens themselves say what happened, so they are charged as recorded
|
|
114
|
+
# rather than assumed cold. A segment often opens warm (a resumed session
|
|
115
|
+
# reads a prefix it did not pay to write inside this segment).
|
|
116
|
+
total += prefix * read_m * base
|
|
117
|
+
total += delta * write_mult * base
|
|
118
|
+
|
|
119
|
+
total += (t.get("input_tokens") or 0) * base
|
|
120
|
+
total += (t.get("output_tokens") or 0) * out_rate
|
|
121
|
+
if when:
|
|
122
|
+
last_cache_time = when
|
|
123
|
+
return total
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def replay(segments, pricing, tolerance=0.05):
|
|
127
|
+
"""Compare the actual 1h TTL against a 5m TTL across every segment.
|
|
128
|
+
|
|
129
|
+
Reconciles the 1h replay against the cost actually logged first. A replay that
|
|
130
|
+
cannot reproduce the real bill is not evidence about a counterfactual one, so the
|
|
131
|
+
caller is handed `reconciled=False` and should show nothing.
|
|
132
|
+
"""
|
|
133
|
+
actual = at_1h = at_5m = 0.0
|
|
134
|
+
for seg in segments:
|
|
135
|
+
actual += sum((t.get("est_cost_usd") or 0) for t in seg)
|
|
136
|
+
at_1h += ttl_cost(seg, 60, 2.0, pricing)
|
|
137
|
+
at_5m += ttl_cost(seg, 5, 1.25, pricing)
|
|
138
|
+
drift = abs(at_1h - actual) / actual if actual else 0.0
|
|
139
|
+
return {
|
|
140
|
+
"segments": len(segments),
|
|
141
|
+
"logged_cost_usd": actual,
|
|
142
|
+
"replay_1h_usd": at_1h,
|
|
143
|
+
"replay_5m_usd": at_5m,
|
|
144
|
+
"difference_usd": at_1h - at_5m, # positive => 5m is cheaper
|
|
145
|
+
"cheaper_ttl": "5m" if at_5m < at_1h else "1h",
|
|
146
|
+
"reconciliation_drift_pct": round(100 * drift, 2),
|
|
147
|
+
"reconciled": drift <= tolerance,
|
|
148
|
+
"basis": "arithmetic",
|
|
149
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.cmd
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@echo off
|
|
2
|
-
rem Claude FinOps Command Center (Windows). Same as: python run.py [--rebuild|--stop
|
|
2
|
+
rem Claude FinOps Command Center (Windows). Same as: python run.py [--rebuild|--stop]
|
|
3
3
|
cd /d "%~dp0"
|
|
4
|
-
where py >nul 2>nul
|
|
4
|
+
where py >nul 2>nul
|
|
5
|
+
if %errorlevel%==0 (py -3 "%~dp0run.py" %*) else (python "%~dp0run.py" %*)
|
package/run.py
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
python3 run.py build the warehouse if missing, then serve
|
|
5
5
|
python3 run.py --rebuild re-read transcripts first
|
|
6
6
|
python3 run.py --stop stop a running dashboard
|
|
7
|
-
python3 run.py --share write ../claude-finops.zip (code + defaults, never your data)
|
|
8
7
|
python3 run.py --set-key store a provider API key
|
|
9
8
|
python3 run.py --where print where your data and keys live
|
|
10
9
|
python3 run.py --help all commands
|
|
@@ -18,7 +17,6 @@ import signal
|
|
|
18
17
|
import subprocess
|
|
19
18
|
import sys
|
|
20
19
|
import time
|
|
21
|
-
import zipfile
|
|
22
20
|
|
|
23
21
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
24
22
|
sys.path.insert(0, ROOT)
|
|
@@ -91,7 +89,9 @@ def _is_ours(pid):
|
|
|
91
89
|
try:
|
|
92
90
|
if IS_WIN:
|
|
93
91
|
out = subprocess.run(
|
|
94
|
-
["
|
|
92
|
+
["powershell", "-NoProfile", "-Command",
|
|
93
|
+
f'Get-CimInstance Win32_Process -Filter "ProcessId={pid}" | '
|
|
94
|
+
"Select-Object -ExpandProperty CommandLine"],
|
|
95
95
|
capture_output=True, text=True, timeout=10).stdout
|
|
96
96
|
else:
|
|
97
97
|
out = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
|
|
@@ -142,25 +142,6 @@ def stop_pidfile():
|
|
|
142
142
|
return False
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
def share(out=None):
|
|
146
|
-
out = out or os.path.join(os.path.dirname(ROOT), "claude-finops.zip")
|
|
147
|
-
skip_dirs = {"data", "__pycache__", ".git"}
|
|
148
|
-
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
|
149
|
-
for base, dirs, files in os.walk(ROOT):
|
|
150
|
-
dirs[:] = [d for d in dirs if d not in skip_dirs]
|
|
151
|
-
for f in files:
|
|
152
|
-
if f.endswith((".pyc", ".zip", ".tgz")) or f in ("settings.local.json", "secrets.local.json"):
|
|
153
|
-
continue
|
|
154
|
-
full = os.path.join(base, f)
|
|
155
|
-
arc = os.path.join("claude-finops", os.path.relpath(full, ROOT)).replace(os.sep, "/")
|
|
156
|
-
info = zipfile.ZipInfo.from_file(full, arc)
|
|
157
|
-
info.external_attr = (0o755 if f.endswith((".sh", ".py")) else 0o644) << 16
|
|
158
|
-
info.compress_type = zipfile.ZIP_DEFLATED
|
|
159
|
-
with open(full, "rb") as fh:
|
|
160
|
-
z.writestr(info, fh.read())
|
|
161
|
-
print(f"Wrote {out} (code + default config only; your data/ and local settings are excluded)")
|
|
162
|
-
|
|
163
|
-
|
|
164
145
|
HELP = """Claude FinOps Command Center
|
|
165
146
|
|
|
166
147
|
claude-finops start the dashboard (builds the warehouse first run)
|
|
@@ -169,11 +150,10 @@ HELP = """Claude FinOps Command Center
|
|
|
169
150
|
claude-finops --where print where your data, settings and keys live
|
|
170
151
|
claude-finops --set-key store a provider API key (prompts, never echoes)
|
|
171
152
|
claude-finops --keys list which provider keys are configured
|
|
172
|
-
claude-finops --share write ../claude-finops.zip (code only, never your data)
|
|
173
153
|
claude-finops --version print the installed version, and whether a newer one is out
|
|
174
|
-
claude-finops --
|
|
175
|
-
claude-finops --install-hook
|
|
176
|
-
claude-finops --install-statusline show model
|
|
154
|
+
claude-finops --no-update-check skip the once-a-day npm version check
|
|
155
|
+
claude-finops --install-hook retired no-op (prints a message, changes nothing)
|
|
156
|
+
claude-finops --install-statusline show model and context % in your statusline
|
|
177
157
|
claude-finops --help this message
|
|
178
158
|
|
|
179
159
|
Environment:
|
|
@@ -182,31 +162,10 @@ Environment:
|
|
|
182
162
|
CLAUDE_PROJECTS=/path where to read transcripts from
|
|
183
163
|
CLAUDE_FINOPS_PYTHON=/path which Python the npm wrapper should use
|
|
184
164
|
NO_UPDATE_NOTIFIER=1 never check npm for a newer release
|
|
165
|
+
CLAUDE_FINOPS_NO_UPDATE_CHECK=1 same as --no-update-check
|
|
185
166
|
"""
|
|
186
167
|
|
|
187
168
|
|
|
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
|
-
|
|
210
169
|
def _version():
|
|
211
170
|
from finops.update import _installed
|
|
212
171
|
return _installed()
|
|
@@ -219,16 +178,20 @@ def version():
|
|
|
219
178
|
a repo checkout side by side, and the usual confusion is not "what version
|
|
220
179
|
am I on" but "why does the one I am looking at not have the feature".
|
|
221
180
|
"""
|
|
222
|
-
from finops.update import check, disabled, _key
|
|
181
|
+
from finops.update import check, disabled, _key, _installed
|
|
182
|
+
# Print what we know locally first: the network check can be slow, disabled,
|
|
183
|
+
# or simply fail, and none of that should delay the one line people actually
|
|
184
|
+
# came here for.
|
|
185
|
+
print(f" claude-finops {_installed() or 'unknown'}")
|
|
186
|
+
print(f" installed at {ROOT}")
|
|
187
|
+
if disabled():
|
|
188
|
+
print(" update check is off (NO_UPDATE_NOTIFIER)")
|
|
189
|
+
return
|
|
223
190
|
# Asking outright is worth a fresh request: a day-old cached answer is the
|
|
224
191
|
# one thing this command must not give you.
|
|
225
192
|
u = check(force=True)
|
|
226
|
-
print(f" claude-finops {u['current'] or 'unknown'}")
|
|
227
|
-
print(f" installed at {ROOT}")
|
|
228
193
|
if u.get("update_available"):
|
|
229
194
|
print(f" update {u['latest']} is out - {u['command']}")
|
|
230
|
-
elif disabled():
|
|
231
|
-
print(" update check is off (NO_UPDATE_NOTIFIER)")
|
|
232
195
|
elif u.get("latest") and _key(u["current"]) > _key(u["latest"]):
|
|
233
196
|
# A checkout mid-release is ahead of what is published. Saying "up to
|
|
234
197
|
# date" there would hide exactly the gap you are looking for.
|
|
@@ -313,6 +276,8 @@ def main():
|
|
|
313
276
|
os.chdir(ROOT)
|
|
314
277
|
ensure_dirs()
|
|
315
278
|
migrate()
|
|
279
|
+
if "--no-update-check" in args:
|
|
280
|
+
os.environ["CLAUDE_FINOPS_NO_UPDATE_CHECK"] = "1"
|
|
316
281
|
if "--help" in args or "-h" in args:
|
|
317
282
|
return print(HELP)
|
|
318
283
|
if "--version" in args or "-v" in args or "-V" in args:
|
|
@@ -332,8 +297,6 @@ def main():
|
|
|
332
297
|
if "--install-statusline" in args or "--uninstall-statusline" in args:
|
|
333
298
|
from finops.integrate import install_statusline
|
|
334
299
|
return install_statusline(remove="--uninstall-statusline" in args)
|
|
335
|
-
if "--advise" in args:
|
|
336
|
-
return advise_now()
|
|
337
300
|
if "--where" in args:
|
|
338
301
|
return where()
|
|
339
302
|
if "--keys" in args:
|
|
@@ -344,12 +307,10 @@ def main():
|
|
|
344
307
|
if "--stop" in args:
|
|
345
308
|
print("Stopped." if stop() else "Not running.")
|
|
346
309
|
return
|
|
347
|
-
if "--share" in args:
|
|
348
|
-
return share()
|
|
349
310
|
# A flag we do not know used to fall straight through and start the
|
|
350
311
|
# dashboard, so a typo (or a flag from a newer release than the one you
|
|
351
312
|
# have installed) looked like the command silently doing nothing.
|
|
352
|
-
known = {"--rebuild", "--detach", "--foreground"}
|
|
313
|
+
known = {"--rebuild", "--detach", "--foreground", "--no-update-check"}
|
|
353
314
|
unknown = [a for a in args if a.startswith("-") and a not in known]
|
|
354
315
|
if unknown:
|
|
355
316
|
print(f"Unknown option: {unknown[0]}")
|
|
@@ -381,9 +342,15 @@ def main():
|
|
|
381
342
|
else f"lsof -iTCP:{port} -sTCP:LISTEN"))
|
|
382
343
|
sys.exit(1)
|
|
383
344
|
source = os.environ.get("CLAUDE_PROJECTS", os.path.join(os.path.expanduser("~"), ".claude", "projects"))
|
|
384
|
-
|
|
345
|
+
from finops.etl import needs_rebuild
|
|
346
|
+
db_existed = os.path.exists(DB_PATH)
|
|
347
|
+
rebuild_needed = needs_rebuild(DB_PATH)
|
|
348
|
+
if "--rebuild" in args or rebuild_needed:
|
|
385
349
|
if not os.path.isdir(source):
|
|
386
350
|
sys.exit(f"No Claude Code transcripts at {source}. Use Claude Code once, or set CLAUDE_PROJECTS.")
|
|
351
|
+
if db_existed and rebuild_needed and "--rebuild" not in args:
|
|
352
|
+
print("Warehouse schema changed (pricing and request counting were corrected); rebuilding…",
|
|
353
|
+
flush=True)
|
|
387
354
|
print(f"Building warehouse from {source} …", flush=True)
|
|
388
355
|
subprocess.run(_python() + ["-m", "finops.etl", source], check=True)
|
|
389
356
|
rest = [a for a in args if a != "--rebuild"]
|