claude-finops 0.16.0 → 0.17.1
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 +36 -41
- package/config/pricing.json +1 -141
- package/finops/actions.py +11 -18
- package/finops/analytics.py +34 -64
- package/finops/api.py +9 -19
- package/finops/cloud.py +10 -107
- package/finops/diagnose.py +12 -85
- package/finops/etl.py +6 -10
- package/finops/procs.py +1 -189
- package/finops/segments.py +2 -2
- package/package.json +2 -5
- package/web/app.js +297 -240
- package/web/styles.css +6 -0
- package/finops/agents.py +0 -271
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@ It answers, in a few clicks:
|
|
|
10
10
|
|
|
11
11
|
Everything runs on `127.0.0.1` with the Python standard library. No dependencies, and
|
|
12
12
|
no data leaves the machine. No outbound calls except a once-a-day version check
|
|
13
|
-
against the npm registry (disable with `--no-update-check`), and
|
|
14
|
-
if you add
|
|
13
|
+
against the npm registry (disable with `--no-update-check`), and Anthropic's cost API
|
|
14
|
+
only if you add an Admin key.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npx claude-finops
|
|
@@ -85,8 +85,8 @@ no reason — each with the estimated money attached.
|
|
|
85
85
|
|
|
86
86
|

|
|
87
87
|
|
|
88
|
-
**Whether the model you are on is the right one.** Per-model cost and efficiency,
|
|
89
|
-
|
|
88
|
+
**Whether the model you are on is the right one.** Per-model cost and efficiency, and
|
|
89
|
+
what each model really cost on the same kind of work, measured rather than repriced.
|
|
90
90
|
|
|
91
91
|

|
|
92
92
|
|
|
@@ -108,7 +108,7 @@ set.
|
|
|
108
108
|
or estimated.
|
|
109
109
|
- **Your data stays put.** It binds to `127.0.0.1`. No outbound calls except a
|
|
110
110
|
once-a-day version check against the npm registry (disable with
|
|
111
|
-
`--no-update-check`), and
|
|
111
|
+
`--no-update-check`), and Anthropic's cost API only if you add an Admin key. The warehouse lives
|
|
112
112
|
in `~/.claude-finops`, so upgrading or deleting the package never touches it.
|
|
113
113
|
- **It tells you what to change**, not just what happened — usually with a prompt you
|
|
114
114
|
can paste into Claude Code.
|
|
@@ -149,7 +149,7 @@ labelled as your own configured figures.
|
|
|
149
149
|
|
|
150
150
|
**Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
|
|
151
151
|
today vs yesterday) opening with the ⚡ Act now strip, running sessions you can
|
|
152
|
-
interrupt, compact, hand over or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
|
|
152
|
+
interrupt, compact, hand over, rename or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
|
|
153
153
|
0–100 FinOps scorecard with per-dimension reasoning.
|
|
154
154
|
|
|
155
155
|
**Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
|
|
@@ -167,8 +167,9 @@ latency and efficiency; five leaderboards; prompt intelligence (spend by activit
|
|
|
167
167
|
and a Claude Code view (tools, files, branches, cost per repository).
|
|
168
168
|
|
|
169
169
|
**Optimize** — A waste detector with seven rules, each showing the exact prompts or
|
|
170
|
-
sessions it flagged;
|
|
171
|
-
anomaly detection you can click through
|
|
170
|
+
sessions it flagged; Subagent models, measured per subagent type; a recommendation
|
|
171
|
+
engine that stays quiet without evidence; and anomaly detection you can click through
|
|
172
|
+
to the underlying sessions.
|
|
172
173
|
|
|
173
174
|
**Plan** — Forecast with conservative/expected/high scenario bands, and budgets with
|
|
174
175
|
Budget → Actual → Forecast → Variance and configurable alert thresholds.
|
|
@@ -177,6 +178,11 @@ Global search spans prompt text, sessions, projects, models, tools and dates. Gl
|
|
|
177
178
|
filters (date, model, project, category, cost/token thresholds, sandbox toggle) update
|
|
178
179
|
every chart and KPI. Everything exports to CSV/JSON, plus a printable PDF report.
|
|
179
180
|
|
|
181
|
+
Every page, and the side panel when it shows a session or a prompt, has its own URL, so a
|
|
182
|
+
refresh keeps you where you were and Back/Forward work. Next to the version, the header
|
|
183
|
+
says whether the dashboard runs from the npm install or from a source checkout (with its
|
|
184
|
+
commit).
|
|
185
|
+
|
|
180
186
|
---
|
|
181
187
|
|
|
182
188
|
## Configuration
|
|
@@ -187,9 +193,10 @@ Two files, both editable without touching code. The server picks up changes to
|
|
|
187
193
|
### `config/pricing.json`
|
|
188
194
|
|
|
189
195
|
Model prices per million tokens, kept strictly separate from usage data so the table
|
|
190
|
-
can be updated as prices change. A `claude-*` model with no entry is priced
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
can be updated as prices change. A new `claude-*` model with no entry yet is priced
|
|
197
|
+
like the newest listed model of its family and marked as such, instead of showing $0.
|
|
198
|
+
A model that isn't Claude (such as a free local model behind a `claude-qwen` launcher)
|
|
199
|
+
costs $0.
|
|
193
200
|
|
|
194
201
|
### `config/settings.json`
|
|
195
202
|
|
|
@@ -230,7 +237,7 @@ without it; it only adds the warning and "continue?" prompt inside Claude Code.
|
|
|
230
237
|
**Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
|
|
231
238
|
`~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
|
|
232
239
|
ever gets back whether a key is set, where from, and its last four characters. An environment
|
|
233
|
-
variable (`ANTHROPIC_ADMIN_KEY
|
|
240
|
+
variable (`ANTHROPIC_ADMIN_KEY`) takes priority over a stored key.
|
|
234
241
|
|
|
235
242
|
### Per-session token budget and the session guard
|
|
236
243
|
|
|
@@ -320,8 +327,8 @@ claude-finops start the dashboard
|
|
|
320
327
|
claude-finops --rebuild re-read transcripts, then start
|
|
321
328
|
claude-finops --stop stop it
|
|
322
329
|
claude-finops --where where your data, settings and keys live
|
|
323
|
-
claude-finops --set-key store
|
|
324
|
-
claude-finops --keys
|
|
330
|
+
claude-finops --set-key store the Anthropic Admin API key (hidden prompt, 0600)
|
|
331
|
+
claude-finops --keys whether that key is configured
|
|
325
332
|
claude-finops --install-statusline model, context % and 5-hour limit under every prompt
|
|
326
333
|
claude-finops --uninstall-statusline remove it again
|
|
327
334
|
claude-finops --install-guard warn near, and ask at, your per-session token budget
|
|
@@ -344,7 +351,7 @@ Everything the app writes lives outside the install folder, in one state directo
|
|
|
344
351
|
data/cloud_cache.json cached billing figures
|
|
345
352
|
data/server.pid|.log running server
|
|
346
353
|
settings.local.json budgets and limits set in the UI
|
|
347
|
-
secrets.local.json
|
|
354
|
+
secrets.local.json Anthropic Admin API key (0600)
|
|
348
355
|
```
|
|
349
356
|
|
|
350
357
|
Days, "today" and the billing period follow this computer's time zone (Claude Code
|
|
@@ -374,8 +381,7 @@ place for you to delete once you are happy.
|
|
|
374
381
|
**What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
|
|
375
382
|
if the Claude desktop app is installed, its Cowork transcripts and
|
|
376
383
|
`plan-usage-history.json` (`~/Library/Application Support/Claude` on macOS,
|
|
377
|
-
`%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux)
|
|
378
|
-
agents listed under Multi-agent.
|
|
384
|
+
`%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux).
|
|
379
385
|
|
|
380
386
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
381
387
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
@@ -383,15 +389,16 @@ generate as sensitive.
|
|
|
383
389
|
|
|
384
390
|
**Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
|
|
385
391
|
for when you share your screen. Session titles fall back to their short id. The
|
|
386
|
-
setting is remembered in that browser.
|
|
392
|
+
setting is remembered in that browser. The side panel has the same button, and using it there
|
|
393
|
+
keeps the panel open. It only changes what the page shows: the
|
|
387
394
|
database and exports still contain the full text.
|
|
388
395
|
|
|
389
396
|
The app makes exactly one outbound request of its own: once a day it asks
|
|
390
397
|
`registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
|
|
391
398
|
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
392
399
|
about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
|
|
393
|
-
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`.
|
|
394
|
-
|
|
400
|
+
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Anthropic's cost API is called only if you
|
|
401
|
+
add an Admin key (with `--set-key` or on the Settings page), and only
|
|
395
402
|
when you click Refresh on Billed vs local.
|
|
396
403
|
|
|
397
404
|
---
|
|
@@ -450,13 +457,18 @@ files on Windows.
|
|
|
450
457
|
Code saves the name itself. In herdr, the agents sidebar gets the same name. Anywhere it can't
|
|
451
458
|
type, the command lands on your clipboard.
|
|
452
459
|
- **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
453
|
-
with Resume, Compact and Stop (Interrupt), then past sessions
|
|
454
|
-
|
|
455
|
-
|
|
460
|
+
with Resume, Compact and Stop (Interrupt), working ones first, then past sessions by their last
|
|
461
|
+
prompt. The sort button at the top puts the last-used past session on top or at the bottom;
|
|
462
|
+
running sessions always stay on top. Click a session to list its prompts, newest first; its own
|
|
463
|
+
sort button flips that list to oldest first. Click a prompt for its full details. Search matches your prompt text.
|
|
464
|
+
It covers all dates but keeps the project filters. Close and Force kill stay on
|
|
456
465
|
Running sessions.
|
|
457
466
|
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
|
|
458
467
|
(in herdr, Interrupt presses Esc in the session's pane instead). Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
459
|
-
interrupt one console process without hitting the others in its window.
|
|
468
|
+
interrupt one console process without hitting the others in its window. A session that is
|
|
469
|
+
working is marked **Working now**, and closing it, or one used in the last 50 minutes, warns you
|
|
470
|
+
first. **Close selected** closes several idle sessions at once; a session that starts working
|
|
471
|
+
before you confirm is skipped.
|
|
460
472
|
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
461
473
|
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
|
462
474
|
already have.
|
|
@@ -508,23 +520,6 @@ The **Jev (fast decisions)** page (sidebar, Optimize):
|
|
|
508
520
|
tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
|
|
509
521
|
published price ($0.042 per million input tokens). Read-only.
|
|
510
522
|
|
|
511
|
-
---
|
|
512
|
-
|
|
513
|
-
## Multi-agent
|
|
514
|
-
|
|
515
|
-
Besides Claude Code, the warehouse loads every other coding agent it finds on the machine:
|
|
516
|
-
|
|
517
|
-
| Agent | Read from | What it gives |
|
|
518
|
-
|---|---|---|
|
|
519
|
-
| Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
|
|
520
|
-
| Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
|
|
521
|
-
| Cursor | Cursor IDE `state.vscdb` (read-only) | Prompts, times and tokens as the IDE stored them; no model, not priced. Agent transcripts are skipped: they carry no times or tokens |
|
|
522
|
-
|
|
523
|
-
Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
|
|
524
|
-
everything. Every page follows the selection, and **Agents** shows them side by side.
|
|
525
|
-
Prices for the other providers live in `config/pricing.json` with their source URLs.
|
|
526
|
-
|
|
527
|
-
|
|
528
523
|
---
|
|
529
524
|
|
|
530
525
|
## License and trademarks
|
package/config/pricing.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"unit": "per_million_tokens",
|
|
5
5
|
"updated": "2026-10-02",
|
|
6
|
-
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02)
|
|
6
|
+
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02)",
|
|
7
7
|
"models": {
|
|
8
8
|
"claude-opus-5": {
|
|
9
9
|
"display_name": "Claude Opus 5",
|
|
@@ -237,149 +237,9 @@
|
|
|
237
237
|
"cache_write_1h": 0.0,
|
|
238
238
|
"cache_read": 0.0,
|
|
239
239
|
"context_window": 0
|
|
240
|
-
},
|
|
241
|
-
"gpt-5.5": {
|
|
242
|
-
"display_name": "GPT-5.5 (Codex)",
|
|
243
|
-
"tier": "frontier",
|
|
244
|
-
"provider": "openai",
|
|
245
|
-
"input": 5.0,
|
|
246
|
-
"output": 30.0,
|
|
247
|
-
"cache_read": 0.5,
|
|
248
|
-
"cache_write_5m": 5.0,
|
|
249
|
-
"cache_write_1h": 5.0,
|
|
250
|
-
"context_window": 400000
|
|
251
|
-
},
|
|
252
|
-
"gpt-5.3-codex": {
|
|
253
|
-
"display_name": "GPT-5.3 Codex",
|
|
254
|
-
"tier": "balanced",
|
|
255
|
-
"provider": "openai",
|
|
256
|
-
"input": 1.75,
|
|
257
|
-
"output": 14.0,
|
|
258
|
-
"cache_read": 0.175,
|
|
259
|
-
"cache_write_5m": 1.75,
|
|
260
|
-
"cache_write_1h": 1.75,
|
|
261
|
-
"context_window": 400000
|
|
262
|
-
},
|
|
263
|
-
"gpt-5.2-codex": {
|
|
264
|
-
"display_name": "GPT-5.2 Codex",
|
|
265
|
-
"tier": "balanced",
|
|
266
|
-
"provider": "openai",
|
|
267
|
-
"input": 1.75,
|
|
268
|
-
"output": 14.0,
|
|
269
|
-
"cache_read": 0.175,
|
|
270
|
-
"cache_write_5m": 1.75,
|
|
271
|
-
"cache_write_1h": 1.75,
|
|
272
|
-
"context_window": 400000,
|
|
273
|
-
"_note": "Not on OpenAI's price list; priced as GPT-5.2"
|
|
274
|
-
},
|
|
275
|
-
"gpt-5.2": {
|
|
276
|
-
"display_name": "GPT-5.2",
|
|
277
|
-
"tier": "balanced",
|
|
278
|
-
"provider": "openai",
|
|
279
|
-
"input": 1.75,
|
|
280
|
-
"output": 14.0,
|
|
281
|
-
"cache_read": 0.175,
|
|
282
|
-
"cache_write_5m": 1.75,
|
|
283
|
-
"cache_write_1h": 1.75,
|
|
284
|
-
"context_window": 400000
|
|
285
|
-
},
|
|
286
|
-
"gpt-5.1": {
|
|
287
|
-
"display_name": "GPT-5.1",
|
|
288
|
-
"tier": "balanced",
|
|
289
|
-
"provider": "openai",
|
|
290
|
-
"input": 1.25,
|
|
291
|
-
"output": 10.0,
|
|
292
|
-
"cache_read": 0.125,
|
|
293
|
-
"cache_write_5m": 1.25,
|
|
294
|
-
"cache_write_1h": 1.25,
|
|
295
|
-
"context_window": 400000
|
|
296
|
-
},
|
|
297
|
-
"gpt-5": {
|
|
298
|
-
"display_name": "GPT-5",
|
|
299
|
-
"tier": "balanced",
|
|
300
|
-
"provider": "openai",
|
|
301
|
-
"input": 1.25,
|
|
302
|
-
"output": 10.0,
|
|
303
|
-
"cache_read": 0.125,
|
|
304
|
-
"cache_write_5m": 1.25,
|
|
305
|
-
"cache_write_1h": 1.25,
|
|
306
|
-
"context_window": 400000
|
|
307
|
-
},
|
|
308
|
-
"gpt-5-mini": {
|
|
309
|
-
"display_name": "GPT-5 mini",
|
|
310
|
-
"tier": "economy",
|
|
311
|
-
"provider": "openai",
|
|
312
|
-
"input": 0.25,
|
|
313
|
-
"output": 2.0,
|
|
314
|
-
"cache_read": 0.025,
|
|
315
|
-
"cache_write_5m": 0.25,
|
|
316
|
-
"cache_write_1h": 0.25,
|
|
317
|
-
"context_window": 400000
|
|
318
|
-
},
|
|
319
|
-
"gemini-2.5-flash": {
|
|
320
|
-
"display_name": "Gemini 2.5 Flash",
|
|
321
|
-
"tier": "economy",
|
|
322
|
-
"provider": "google",
|
|
323
|
-
"input": 0.3,
|
|
324
|
-
"output": 2.5,
|
|
325
|
-
"cache_read": 0.03,
|
|
326
|
-
"cache_write_5m": 0.3,
|
|
327
|
-
"cache_write_1h": 0.3,
|
|
328
|
-
"context_window": 1048576,
|
|
329
|
-
"_note": "Cache read: lower bound of Google's published range"
|
|
330
|
-
},
|
|
331
|
-
"gemini-2.5-pro": {
|
|
332
|
-
"display_name": "Gemini 2.5 Pro",
|
|
333
|
-
"tier": "frontier",
|
|
334
|
-
"provider": "google",
|
|
335
|
-
"input": 1.25,
|
|
336
|
-
"output": 10.0,
|
|
337
|
-
"cache_read": 0.125,
|
|
338
|
-
"cache_write_5m": 1.25,
|
|
339
|
-
"cache_write_1h": 1.25,
|
|
340
|
-
"context_window": 1048576,
|
|
341
|
-
"_note": "<=200K prompt; $2.50/$15 above"
|
|
342
|
-
},
|
|
343
|
-
"gemini-3.5-flash": {
|
|
344
|
-
"display_name": "Gemini 3.5 Flash",
|
|
345
|
-
"tier": "balanced",
|
|
346
|
-
"provider": "google",
|
|
347
|
-
"input": 1.5,
|
|
348
|
-
"output": 9.0,
|
|
349
|
-
"cache_read": 0.15,
|
|
350
|
-
"cache_write_5m": 1.5,
|
|
351
|
-
"cache_write_1h": 1.5,
|
|
352
|
-
"context_window": 1048576
|
|
353
|
-
},
|
|
354
|
-
"gemini-3.8-flash": {
|
|
355
|
-
"display_name": "Gemini 3.8 Flash",
|
|
356
|
-
"tier": "economy",
|
|
357
|
-
"provider": "google",
|
|
358
|
-
"input": 0.75,
|
|
359
|
-
"output": 3.75,
|
|
360
|
-
"cache_read": 0.075,
|
|
361
|
-
"cache_write_5m": 0.75,
|
|
362
|
-
"cache_write_1h": 0.75,
|
|
363
|
-
"context_window": 1048576,
|
|
364
|
-
"_note": "Promo price through 2026-12-31; $1.50/$7.50 after"
|
|
365
|
-
},
|
|
366
|
-
"cursor": {
|
|
367
|
-
"display_name": "Cursor (model not recorded)",
|
|
368
|
-
"tier": "none",
|
|
369
|
-
"provider": "cursor",
|
|
370
|
-
"input": 0,
|
|
371
|
-
"output": 0,
|
|
372
|
-
"cache_read": 0,
|
|
373
|
-
"cache_write_5m": 0,
|
|
374
|
-
"cache_write_1h": 0,
|
|
375
|
-
"_note": "Cursor bills by subscription and doesn't record the model locally: no cost is estimated"
|
|
376
240
|
}
|
|
377
241
|
},
|
|
378
242
|
"aliases": {
|
|
379
243
|
"claude-haiku-4-5": "claude-haiku-4-5-20251001"
|
|
380
|
-
},
|
|
381
|
-
"_sources": {
|
|
382
|
-
"openai": "https://developers.openai.com/api/docs/pricing (standard tier, 2026-09-18)",
|
|
383
|
-
"google": "https://ai.google.dev/gemini-api/docs/pricing (paid tier, 2026-09-18)"
|
|
384
244
|
}
|
|
385
245
|
}
|
package/finops/actions.py
CHANGED
|
@@ -440,9 +440,7 @@ def _slug(s):
|
|
|
440
440
|
def suggestions(a, f=None):
|
|
441
441
|
"""Skills and MCP servers worth adding, from recurring work (actual transcripts).
|
|
442
442
|
|
|
443
|
-
|
|
444
|
-
MCP servers; another agent's templated prompts would suggest things Claude never
|
|
445
|
-
repeats), within the date range when one is given. Scanning is cached per range on
|
|
443
|
+
Claude Code's own prompts and commands count, within the date range when one is given. Scanning is cached per range on
|
|
446
444
|
the warehouse (reset by sync); installed-state is re-checked on every call.
|
|
447
445
|
"""
|
|
448
446
|
f = f or {}
|
|
@@ -648,36 +646,32 @@ def free_model_test(log, mid):
|
|
|
648
646
|
return {"ok": True, "command": m["command"]}
|
|
649
647
|
|
|
650
648
|
|
|
651
|
-
def compare(a,
|
|
652
|
-
"""
|
|
653
|
-
|
|
649
|
+
def compare(a, f=None):
|
|
650
|
+
"""Claude models (with your usage) side by side with the free models that plug into
|
|
651
|
+
Claude Code."""
|
|
654
652
|
with open(os.path.join(ROOT, "config", "model_compare.json")) as fh:
|
|
655
653
|
ratings = json.load(fh)["ratings"]
|
|
656
|
-
|
|
657
|
-
prov = {"claude": "anthropic", "codex": "openai", "gemini": "google"}
|
|
658
|
-
want = {prov[x] for x in agents if x in prov}
|
|
659
|
-
where = {"anthropic": "Anthropic cloud", "openai": "OpenAI cloud", "google": "Google cloud"}
|
|
660
|
-
w, wp = a.where(dict(f or {}, agents=agents))
|
|
654
|
+
w, wp = a.where(f or {})
|
|
661
655
|
used = {r["model"]: r for r in a.q(f"""SELECT r.model, COUNT(*) requests, SUM(r.est_cost_usd) cost,
|
|
662
656
|
SUM(r.billable_tokens) tokens FROM requests r
|
|
663
657
|
WHERE {w} GROUP BY r.model""", wp)}
|
|
664
658
|
rows = []
|
|
665
659
|
for mid, p in a.pricing.models.items():
|
|
666
|
-
|
|
667
|
-
|
|
660
|
+
if p.get("tier") in (None, "none", "other") or p.get("provider", "anthropic") != "anthropic" \
|
|
661
|
+
or mid.endswith("[1m]"):
|
|
668
662
|
continue
|
|
669
663
|
u = used.get(mid) or {}
|
|
670
664
|
# An unrated Claude model still belongs in the table when you used it; unused
|
|
671
665
|
# unrated ones (old ids kept for pricing history) would only add noise.
|
|
672
|
-
if
|
|
666
|
+
if mid not in ratings and not u:
|
|
673
667
|
continue
|
|
674
668
|
rt = ratings.get(mid) or {"tools": None, "reasoning": None, "multifile": None,
|
|
675
669
|
"speed": "—", "best_for": p.get("tier", "").capitalize() + " tier"}
|
|
676
|
-
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude"
|
|
677
|
-
"where":
|
|
670
|
+
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude",
|
|
671
|
+
"where": "Anthropic cloud", "price_in": p.get("input"), "price_out": p.get("output"),
|
|
678
672
|
"context": p.get("context_window"), "requests": u.get("requests") or 0,
|
|
679
673
|
"cost": u.get("cost") or 0, "tokens": u.get("tokens") or 0, "tier": p.get("tier"), **rt})
|
|
680
|
-
fm = free_models()
|
|
674
|
+
fm = free_models()
|
|
681
675
|
for m in fm["models"]:
|
|
682
676
|
if m["id"] not in ratings:
|
|
683
677
|
continue
|
|
@@ -691,7 +685,6 @@ def compare(a, agents=None, f=None):
|
|
|
691
685
|
-rank.get(r.get("tier"), 3))
|
|
692
686
|
rows.sort(key=lambda r: score(r), reverse=True)
|
|
693
687
|
return {"rows": rows, "os": fm["os"], "ram_gb": fm["models"][0]["ram_gb"] if fm["models"] else None,
|
|
694
|
-
"agents": agents,
|
|
695
688
|
"note": "Ratings are judgement, not benchmarks; edit config/model_compare.json. "
|
|
696
689
|
"Models you used that have no rating show — until you add them there. Tool use matters most: coding "
|
|
697
690
|
"agents work by calling tools, and weak tool use means loops, broken edits "
|
package/finops/analytics.py
CHANGED
|
@@ -64,6 +64,21 @@ def _version():
|
|
|
64
64
|
return ""
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _served_from():
|
|
68
|
+
"""Which copy is serving: a git checkout (with its commit) or an npm install."""
|
|
69
|
+
from .paths import ROOT
|
|
70
|
+
out = {"kind": "npm", "path": ROOT, "commit": None}
|
|
71
|
+
if os.path.exists(os.path.join(ROOT, ".git")):
|
|
72
|
+
out["kind"] = "source"
|
|
73
|
+
try:
|
|
74
|
+
import subprocess
|
|
75
|
+
out["commit"] = subprocess.run(["git", "-C", ROOT, "rev-parse", "--short", "HEAD"],
|
|
76
|
+
capture_output=True, text=True, timeout=3).stdout.strip() or None
|
|
77
|
+
except (OSError, subprocess.SubprocessError):
|
|
78
|
+
pass
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
67
82
|
def detect_account():
|
|
68
83
|
"""Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
|
|
69
84
|
|
|
@@ -257,8 +272,6 @@ class Analytics:
|
|
|
257
272
|
cl.append("r.day >= ?"); p.append(f["start"])
|
|
258
273
|
if f.get("end"):
|
|
259
274
|
cl.append("r.day <= ?"); p.append(f["end"])
|
|
260
|
-
if f.get("agents"):
|
|
261
|
-
cl.append("r.agent IN (%s)" % ",".join("?" * len(f["agents"]))); p += f["agents"]
|
|
262
275
|
if f.get("models"):
|
|
263
276
|
cl.append("r.model IN (%s)" % ",".join("?" * len(f["models"]))); p += f["models"]
|
|
264
277
|
if f.get("projects"):
|
|
@@ -353,8 +366,8 @@ class Analytics:
|
|
|
353
366
|
p).get("n", 0)
|
|
354
367
|
tot["tool_calls"] = self.one(
|
|
355
368
|
f"SELECT COALESCE(SUM(r.tool_call_count),0) n FROM requests r WHERE {w}", p).get("n", 0)
|
|
356
|
-
# Requests with tokens but no price (
|
|
357
|
-
#
|
|
369
|
+
# Requests with tokens but no price (a model missing from pricing.json):
|
|
370
|
+
# they count, but at $0.
|
|
358
371
|
tot["unpriced_requests"] = self.one(
|
|
359
372
|
f"SELECT COUNT(*) n FROM requests r WHERE {w} AND r.est_cost_usd = 0 AND r.billable_tokens > 0",
|
|
360
373
|
p).get("n", 0)
|
|
@@ -659,11 +672,12 @@ class Analytics:
|
|
|
659
672
|
def sessions(self, f=None, limit=500, order="cost", offset=0):
|
|
660
673
|
w, p = self.where(f)
|
|
661
674
|
ob = {"cost": "cost DESC", "tokens": "tokens DESC", "duration": "s.duration_s DESC",
|
|
662
|
-
"recent": "s.started_at DESC", "prompts": "prompts DESC"
|
|
675
|
+
"recent": "s.started_at DESC", "prompts": "prompts DESC",
|
|
676
|
+
"last_active": "last_ts DESC", "last_active_asc": "last_ts ASC"}.get(order, "cost DESC")
|
|
663
677
|
rows = self.q(f"""
|
|
664
678
|
SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
|
|
665
679
|
s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
|
|
666
|
-
s.agent, s.source_file, pr.path project_path,
|
|
680
|
+
MAX(r.ts) last_ts, s.agent, s.source_file, pr.path project_path,
|
|
667
681
|
s.billable_tokens session_tokens,
|
|
668
682
|
COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
|
|
669
683
|
SUM(r.tool_call_count) tool_calls,
|
|
@@ -683,18 +697,12 @@ class Analytics:
|
|
|
683
697
|
r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
|
|
684
698
|
cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
|
|
685
699
|
r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
|
|
686
|
-
r["budget_tokens"] = self.session_budget(r
|
|
700
|
+
r["budget_tokens"] = self.session_budget(r.pop("project_path"), r["session_id"])
|
|
687
701
|
r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
|
|
688
702
|
return rows
|
|
689
703
|
|
|
690
|
-
def session_budget(self,
|
|
691
|
-
"""The per-session token budget that applies to a session, or None.
|
|
692
|
-
|
|
693
|
-
Claude Code sessions only: the guard hook runs there, and other agents'
|
|
694
|
-
sessions are not what the budget was set for.
|
|
695
|
-
"""
|
|
696
|
-
if agent != "claude":
|
|
697
|
-
return None
|
|
704
|
+
def session_budget(self, project_path, session_id=None):
|
|
705
|
+
"""The per-session token budget that applies to a session, or None."""
|
|
698
706
|
from .guard import budget_for
|
|
699
707
|
return budget_for(self.settings.get("guard") or {}, project_path, session_id)
|
|
700
708
|
|
|
@@ -1228,8 +1236,7 @@ class Analytics:
|
|
|
1228
1236
|
ORDER BY cost DESC LIMIT 20""", p + [thr])
|
|
1229
1237
|
agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
|
|
1230
1238
|
# Utilisation against each request's own model window, weighted by requests —
|
|
1231
|
-
# not against the largest window anywhere in the price table
|
|
1232
|
-
# would set the scale for Claude-only usage).
|
|
1239
|
+
# not against the largest window anywhere in the price table.
|
|
1233
1240
|
num = den = 0.0
|
|
1234
1241
|
wins = defaultdict(int)
|
|
1235
1242
|
for v in self.q(f"""SELECT COALESCE(r.priced_as, r.model) m, COUNT(*) n, AVG(r.context_tokens) a
|
|
@@ -1792,8 +1799,8 @@ class Analytics:
|
|
|
1792
1799
|
out["lines"].append(self._session_budget_line(f, bp, line))
|
|
1793
1800
|
else:
|
|
1794
1801
|
out["lines"].append(line("Per-session tokens", None, 0, None, unit="tokens"))
|
|
1795
|
-
# Per-project and per-model lines
|
|
1796
|
-
wa, pa = self.where({
|
|
1802
|
+
# Per-project and per-model lines cover the whole budget period, not the date filter.
|
|
1803
|
+
wa, pa = self.where({})
|
|
1797
1804
|
for proj, bud in (b.get("per_project_usd") or {}).items():
|
|
1798
1805
|
act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1799
1806
|
JOIN projects pr ON pr.id=r.project_id
|
|
@@ -1850,7 +1857,7 @@ class Analytics:
|
|
|
1850
1857
|
p + [bp["start"], bp["end"]])
|
|
1851
1858
|
scored = []
|
|
1852
1859
|
for r in rows:
|
|
1853
|
-
bud = self.session_budget(
|
|
1860
|
+
bud = self.session_budget(r.pop("path"), r["session_id"])
|
|
1854
1861
|
if bud:
|
|
1855
1862
|
r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
|
|
1856
1863
|
scored.append(r)
|
|
@@ -1877,8 +1884,8 @@ class Analytics:
|
|
|
1877
1884
|
series = [d for d in self.daily_series(f, end=last_full.isoformat())]
|
|
1878
1885
|
priced = [d for d in series if d["cost"] > 0]
|
|
1879
1886
|
if len(priced) >= 14:
|
|
1880
|
-
# Median/MAD, not mean/stdev: unpriced $0 days
|
|
1881
|
-
# data
|
|
1887
|
+
# Median/MAD, not mean/stdev: unpriced $0 days (models without pricing
|
|
1888
|
+
# data) would otherwise pollute the mean/sd baseline and
|
|
1882
1889
|
# either mask real spikes or manufacture fake ones. MAD is scaled by
|
|
1883
1890
|
# 1.4826 so it estimates the same thing a standard deviation would under
|
|
1884
1891
|
# a normal distribution, without a few extreme days inflating it the way
|
|
@@ -1905,8 +1912,8 @@ class Analytics:
|
|
|
1905
1912
|
# against — which understates how far out they really are. Candidates are
|
|
1906
1913
|
# ranked by tokens too, since that is the metric being tested; ordering by
|
|
1907
1914
|
# cost hid token-heavy work on cheap models.
|
|
1908
|
-
# Sessions with no token data
|
|
1909
|
-
#
|
|
1915
|
+
# Sessions with no token data are not comparable and would drag the
|
|
1916
|
+
# baseline down.
|
|
1910
1917
|
vals = [s["tokens"] for s in sess if (s["tokens"] or 0) > 0]
|
|
1911
1918
|
mean = (statistics.median(vals) if vals else 0) or 1
|
|
1912
1919
|
# Session sizes are heavy-tailed enough that any fixed multiple of the
|
|
@@ -2142,48 +2149,10 @@ class Analytics:
|
|
|
2142
2149
|
}
|
|
2143
2150
|
|
|
2144
2151
|
# ---------------- filter option lists ----------------
|
|
2145
|
-
def agents(self):
|
|
2146
|
-
"""Every agent detected on this machine, with what its local data can show."""
|
|
2147
|
-
from .agents import AGENTS, detect
|
|
2148
|
-
found = detect()
|
|
2149
|
-
used = {r["agent"]: r for r in self.q("""SELECT agent, COUNT(*) requests,
|
|
2150
|
-
COUNT(DISTINCT session_id) sessions, SUM(billable_tokens) tokens,
|
|
2151
|
-
SUM(est_cost_usd) cost, MIN(day) first, MAX(day) last FROM requests GROUP BY agent""")}
|
|
2152
|
-
out = []
|
|
2153
|
-
for k, a in AGENTS.items():
|
|
2154
|
-
u = used.get(k) or {}
|
|
2155
|
-
if not found.get(k) and not u:
|
|
2156
|
-
continue
|
|
2157
|
-
out.append({"id": k, "name": a["name"], "data": a["data"], "note": a["note"],
|
|
2158
|
-
"requests": u.get("requests") or 0, "sessions": u.get("sessions") or 0,
|
|
2159
|
-
"tokens": u.get("tokens") or 0, "cost": u.get("cost") or 0,
|
|
2160
|
-
"first": u.get("first"), "last": u.get("last")})
|
|
2161
|
-
return out
|
|
2162
|
-
|
|
2163
|
-
def by_agent(self, f=None):
|
|
2164
|
-
"""Side-by-side totals per agent for the multi-agent view."""
|
|
2165
|
-
f = dict(f or {})
|
|
2166
|
-
w, p = self.where(f)
|
|
2167
|
-
rows = self.q(f"""SELECT r.agent, COUNT(*) requests, COUNT(DISTINCT r.session_id) sessions,
|
|
2168
|
-
COUNT(DISTINCT r.prompt_id) prompts, SUM(r.billable_tokens) tokens,
|
|
2169
|
-
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
2170
|
-
SUM(r.cache_read_tokens) cache_read_tokens, SUM(r.est_cost_usd) cost,
|
|
2171
|
-
SUM(r.tool_call_count) tool_calls, COUNT(DISTINCT r.day) active_days,
|
|
2172
|
-
GROUP_CONCAT(DISTINCT r.model) models
|
|
2173
|
-
FROM requests r WHERE {w} GROUP BY r.agent ORDER BY cost DESC, requests DESC""", p)
|
|
2174
|
-
daily = self.q(f"""SELECT r.day, r.agent, SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
2175
|
-
COUNT(*) requests FROM requests r WHERE {w} GROUP BY 1, 2 ORDER BY 1""", p)
|
|
2176
|
-
from .agents import AGENTS
|
|
2177
|
-
for r in rows:
|
|
2178
|
-
a = AGENTS.get(r["agent"], {})
|
|
2179
|
-
r["name"], r["data"], r["note"] = a.get("name", r["agent"]), a.get("data"), a.get("note")
|
|
2180
|
-
return {"agents": rows, "daily": daily, "basis": "estimated"}
|
|
2181
|
-
|
|
2182
2152
|
def options(self):
|
|
2183
2153
|
return {
|
|
2184
|
-
"
|
|
2185
|
-
"
|
|
2186
|
-
"projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, pr.agent, COUNT(r.id) n"
|
|
2154
|
+
"models": self.q("SELECT model, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 2 DESC"),
|
|
2155
|
+
"projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, COUNT(r.id) n"
|
|
2187
2156
|
" FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
|
|
2188
2157
|
" GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
|
|
2189
2158
|
"categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
|
|
@@ -2199,5 +2168,6 @@ class Analytics:
|
|
|
2199
2168
|
"SELECT DISTINCT model FROM requests")} & set(self.pricing.models))},
|
|
2200
2169
|
"settings": self.settings,
|
|
2201
2170
|
"version": _version(),
|
|
2171
|
+
"served_from": _served_from(),
|
|
2202
2172
|
"unavailable_label": UNAVAILABLE,
|
|
2203
2173
|
}
|