claude-finops 0.13.0 → 0.15.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 +115 -4
- package/config/model_compare.json +4 -2
- package/config/pricing.json +17 -6
- package/config/settings.json +17 -0
- package/finops/actions.py +54 -27
- package/finops/agents.py +13 -39
- package/finops/analytics.py +432 -135
- package/finops/api.py +199 -16
- package/finops/classify.py +6 -0
- package/finops/cloud.py +59 -7
- package/finops/diagnose.py +18 -5
- package/finops/etl.py +171 -7
- package/finops/guard.py +412 -0
- package/finops/integrate.py +78 -0
- package/finops/jev.py +177 -0
- package/finops/localtime.py +53 -0
- package/finops/pricing.py +27 -0
- package/finops/subagents.py +358 -0
- package/package.json +1 -1
- package/run.py +18 -12
- package/web/app.js +1046 -124
- package/web/charts.js +14 -6
- package/web/guide/budgets.png +0 -0
- package/web/styles.css +73 -0
package/README.md
CHANGED
|
@@ -204,14 +204,61 @@ billed at another model's rate.
|
|
|
204
204
|
},
|
|
205
205
|
"budgets": { "monthly_usd": null, "daily_usd": null, "per_project_usd": {} },
|
|
206
206
|
"alert_thresholds_pct": [50, 75, 90, 100],
|
|
207
|
+
"guard": { // per-session token budget + the session guard hook
|
|
208
|
+
"session_tokens": null, // billable tokens per Claude Code session; null => off
|
|
209
|
+
"warn_pct": [75, 80], // warn (never block) as a session crosses these
|
|
210
|
+
"after_approval": "step", // "step": ask again every step_pct; "once": never again
|
|
211
|
+
"step_pct": 25,
|
|
212
|
+
"projects": {} // {"/abs/path": {"session_tokens": n} | {"off": true}}
|
|
213
|
+
},
|
|
207
214
|
"waste_rules": { /* thresholds for each detector */ },
|
|
208
215
|
"anomaly": { /* z-score and ratio triggers */ },
|
|
209
216
|
"scorecard": { /* the reference points each dimension is graded against */ }
|
|
210
217
|
}
|
|
211
218
|
```
|
|
212
219
|
|
|
213
|
-
The **Budgets**
|
|
214
|
-
|
|
220
|
+
The **Budgets** page has three numbered blocks (① money, ② tokens, ③ one conversation) and an
|
|
221
|
+
Advanced section, folded away, for plan limits and alert thresholds. **How does this work?** (and
|
|
222
|
+
the small **?** on each block) opens a short picture guide; it also opens by itself the first
|
|
223
|
+
time. Values are saved to `~/.claude-finops/settings.local.json`. Each amount suggests values
|
|
224
|
+
from your own last 30 days, accepts shorthand (`20M`, `500k`, `$3,000`), and explains a bad
|
|
225
|
+
value before saving.
|
|
226
|
+
|
|
227
|
+
**Live warnings** (inside block ③) is the optional session guard hook. Your budgets work
|
|
228
|
+
without it; it only adds the warning and "continue?" prompt inside Claude Code.
|
|
229
|
+
|
|
230
|
+
**Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
|
|
231
|
+
`~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
|
|
232
|
+
ever gets back whether a key is set, where from, and its last four characters. An environment
|
|
233
|
+
variable (`ANTHROPIC_ADMIN_KEY`, `CURSOR_API_KEY`) takes priority over a stored key.
|
|
234
|
+
|
|
235
|
+
### Per-session token budget and the session guard
|
|
236
|
+
|
|
237
|
+
`guard.session_tokens` is a budget for one Claude Code session, in billable tokens (input +
|
|
238
|
+
output + cache read + cache write, the same figure as the Tokens column in Sessions). Cache
|
|
239
|
+
reads dominate long sessions, which is exactly what a session cap is for. A project override
|
|
240
|
+
(matched on the project path; the most specific path wins) sets a different budget or turns
|
|
241
|
+
it off for that project.
|
|
242
|
+
|
|
243
|
+
With a budget set, **Budgets** shows the largest session this billing period against its
|
|
244
|
+
budget and how many sessions went over, and the dots in **Sessions** mark sessions at 75%
|
|
245
|
+
(amber) and 100% (red) of theirs.
|
|
246
|
+
|
|
247
|
+
The **session guard** is an opt-in Claude Code `PreToolUse` hook (`--install-guard`, or
|
|
248
|
+
**Install** under Live warnings on the Budgets page, block ③). Before each tool call it counts the session's tokens
|
|
249
|
+
from its transcript (only the lines added since the last call), and:
|
|
250
|
+
|
|
251
|
+
- below the budget, warns you once at each `warn_pct` and suggests `/compact` to Claude;
|
|
252
|
+
- at 100%, asks you to approve the next tool call; after you approve, it asks again every
|
|
253
|
+
`step_pct` (`"step"`) or not at all for that session (`"once"`). If you decline, it asks
|
|
254
|
+
again on the next tool call.
|
|
255
|
+
|
|
256
|
+
Checked on Claude Code 2.1.284: the approval prompt appears in the default mode, in
|
|
257
|
+
`--dangerously-skip-permissions` (bypass) mode, and for tools on your allow list. In
|
|
258
|
+
non-interactive `claude -p` there is nobody to ask, so the tool call is refused. A hook cannot
|
|
259
|
+
end a session, and the guard fails open: if it errors or cannot read the transcript, the tool
|
|
260
|
+
call goes ahead. It keeps a small state file per session in `~/.claude-finops/data/guard/`
|
|
261
|
+
(deleted after 30 days).
|
|
215
262
|
|
|
216
263
|
Note on `waste_rules.low_output_ratio_vs_median`: sessions are flagged relative to
|
|
217
264
|
*your own* median output ratio rather than an absolute number, because a healthy ratio
|
|
@@ -277,6 +324,8 @@ claude-finops --set-key store a provider API key (hidden prompt, 0600)
|
|
|
277
324
|
claude-finops --keys which provider keys are configured
|
|
278
325
|
claude-finops --install-statusline model, context % and 5-hour limit under every prompt
|
|
279
326
|
claude-finops --uninstall-statusline remove it again
|
|
327
|
+
claude-finops --install-guard warn near, and ask at, your per-session token budget
|
|
328
|
+
claude-finops --uninstall-guard remove it again
|
|
280
329
|
claude-finops --help everything
|
|
281
330
|
```
|
|
282
331
|
|
|
@@ -298,6 +347,20 @@ Everything the app writes lives outside the install folder, in one state directo
|
|
|
298
347
|
secrets.local.json provider API keys (0600)
|
|
299
348
|
```
|
|
300
349
|
|
|
350
|
+
Days, "today" and the billing period follow this computer's time zone (Claude Code
|
|
351
|
+
stamps transcripts in UTC; finops converts them). Set `CLAUDE_FINOPS_TZ` to an IANA name
|
|
352
|
+
such as `Asia/Kolkata` to use another one, then press **Sync**.
|
|
353
|
+
|
|
354
|
+
Claude Code deletes transcripts older than its `cleanupPeriodDays` (30 by default). A
|
|
355
|
+
sync keeps the sessions it already had when their transcript disappears, re-costed at
|
|
356
|
+
current prices, so your history outlives that cleanup. If you delete transcripts on
|
|
357
|
+
purpose and want them gone from the dashboard too, set
|
|
358
|
+
`"history": {"keep_deleted_transcripts": false}` in `settings.local.json`.
|
|
359
|
+
|
|
360
|
+
While the dashboard runs it re-reads your transcripts by itself once the data is an hour
|
|
361
|
+
old, so recent days don't read as idle. `CLAUDE_FINOPS_AUTOSYNC_MINUTES` changes the
|
|
362
|
+
interval; `0` turns it off and leaves only the **Sync** button.
|
|
363
|
+
|
|
301
364
|
Set `CLAUDE_FINOPS_HOME=/some/path` to put it elsewhere. The install folder holds
|
|
302
365
|
only code and the shared defaults in `config/`, so it can be replaced on upgrade —
|
|
303
366
|
or shipped as a package — without touching your data. An older in-tree `data/`
|
|
@@ -328,7 +391,8 @@ The app makes exactly one outbound request of its own: once a day it asks
|
|
|
328
391
|
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
329
392
|
about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
|
|
330
393
|
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
|
|
331
|
-
configure a key with `--set-key
|
|
394
|
+
configure a key (with `--set-key` or on the Settings page), and only
|
|
395
|
+
when you click Refresh on Billed vs local.
|
|
332
396
|
|
|
333
397
|
---
|
|
334
398
|
|
|
@@ -390,6 +454,53 @@ files on Windows.
|
|
|
390
454
|
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
391
455
|
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
|
392
456
|
already have.
|
|
457
|
+
- **Install** under Live warnings (Budgets, block ③, or `--install-guard`) adds one `PreToolUse` hook entry to
|
|
458
|
+
`~/.claude/settings.json`, keeping a `.finops-backup` copy. Your other hooks are left
|
|
459
|
+
alone, and uninstalling removes only that entry.
|
|
460
|
+
- **Start experiment** (Subagent models) adds `env.CLAUDE_CODE_SUBAGENT_MODEL` to
|
|
461
|
+
`~/.claude/settings.json`, keeping a `.finops-backup` copy, after showing you the exact change.
|
|
462
|
+
It won't overwrite a value you set yourself. **Stop** removes it only if it still holds the
|
|
463
|
+
value finops wrote.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Subagent models
|
|
468
|
+
|
|
469
|
+
Subagents are the one place a model switch costs no cache rewrite: each run starts its own cache,
|
|
470
|
+
and Claude Code lets you choose its model. The **Subagent models** page (sidebar, Optimize):
|
|
471
|
+
|
|
472
|
+
- **Headline:** what subagents cost and their share of your Claude Code spend. That share is the
|
|
473
|
+
most routing could ever save, even if every run were free.
|
|
474
|
+
- **By type and model:** each subagent type, the models it ran on, and the median cost, turns,
|
|
475
|
+
output and context per run. Measured from runs that happened, never repriced, and no savings
|
|
476
|
+
total. A cheaper model is suggested only when both models have at least `subagents.min_runs`
|
|
477
|
+
runs of that type (5, in `config/settings.json`) and the cheaper one cost less per run. Your own
|
|
478
|
+
agent files get the exact `model:` line to add; built-in types point to an experiment. Forks get
|
|
479
|
+
context advice instead, since they always use your conversation's model and context.
|
|
480
|
+
- **Experiment:** run subagents on one model (`haiku`, `sonnet`, `opus` or `fable`) for a while,
|
|
481
|
+
then compare each type's runs before and after the start. Before is the same length of time
|
|
482
|
+
just before it, at most 30 days. Runs that used another model anyway are counted separately.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Jev (fast decisions)
|
|
487
|
+
|
|
488
|
+
[Jev](https://docs.typesafe.ai) is TypeSafe AI's "System One" model: very fast, very cheap typed
|
|
489
|
+
decisions (pick one, yes or no, a score, a value). It does not write text or code, so it does not
|
|
490
|
+
make Claude Code itself cheaper; it makes the apps and scripts you build cheaper where an LLM call
|
|
491
|
+
only decides something.
|
|
492
|
+
|
|
493
|
+
The **Jev (fast decisions)** page (sidebar, Optimize):
|
|
494
|
+
|
|
495
|
+
- **Install** runs Claude Code's own plugin manager, after showing you the commands:
|
|
496
|
+
`claude plugin marketplace add typesafe-ai/skills` then `claude plugin install typesafe@typesafe-ai`.
|
|
497
|
+
**Uninstall** runs `claude plugin uninstall typesafe@typesafe-ai`.
|
|
498
|
+
- **API key** is saved as `env.TYPESAFE_API_KEY` in `~/.claude/settings.json` (backed up first,
|
|
499
|
+
nothing else changed), so new Claude Code sessions can use it. The page only ever gets back
|
|
500
|
+
its last four characters. A key exported in your shell takes priority.
|
|
501
|
+
- **Where Jev fits** counts your Claude Code prompts that were really just a decision (short, no
|
|
502
|
+
tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
|
|
503
|
+
published price ($0.042 per million input tokens). Read-only.
|
|
393
504
|
|
|
394
505
|
---
|
|
395
506
|
|
|
@@ -401,7 +512,7 @@ Besides Claude Code, the warehouse loads every other coding agent it finds on th
|
|
|
401
512
|
|---|---|---|
|
|
402
513
|
| Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
|
|
403
514
|
| Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
|
|
404
|
-
| Cursor |
|
|
515
|
+
| 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 |
|
|
405
516
|
|
|
406
517
|
Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
|
|
407
518
|
everything. Every page follows the selection, and **Agents** shows them side by side.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Qualitative 1-5 ratings for the Compare view. Judgement from model size, age and known behaviour in agentic coding. Not benchmark scores; edit freely. Keys match model ids in pricing.json or ids in free_models.json.",
|
|
3
3
|
"ratings": {
|
|
4
|
+
"claude-fable-5-1": {"tools": 5, "reasoning": 5, "multifile": 5, "speed": "Slower; long turns", "best_for": "The hardest problems; priced well above Opus"},
|
|
5
|
+
"claude-opus-5-5": {"tools": 5, "reasoning": 5, "multifile": 5, "speed": "Fast", "best_for": "Hard bugs, architecture, reviews; cheaper than Opus 5"},
|
|
4
6
|
"claude-opus-5": {"tools": 5, "reasoning": 5, "multifile": 5, "speed": "Fast", "best_for": "Hard bugs, architecture, reviews"},
|
|
5
|
-
"claude-sonnet-5":
|
|
6
|
-
"claude-
|
|
7
|
+
"claude-sonnet-5-5": {"tools": 5, "reasoning": 4, "multifile": 5, "speed": "Fast", "best_for": "Everyday coding: best value"},
|
|
8
|
+
"claude-sonnet-5": {"tools": 5, "reasoning": 4, "multifile": 5, "speed": "Fast", "best_for": "Everyday coding (previous Sonnet)"},
|
|
7
9
|
"claude-haiku-4-5-20251001": {"tools": 4, "reasoning": 3, "multifile": 3, "speed": "Fastest", "best_for": "Lookups, Explore subagents, small edits"},
|
|
8
10
|
"qwen-openrouter": {"tools": 3, "reasoning": 3, "multifile": 3, "speed": "Medium, throttled on free tier", "best_for": "Free general coding, if sending code to OpenRouter is OK"},
|
|
9
11
|
"qwen38-local": {"tools": 3, "reasoning": 3, "multifile": 3, "speed": "Needs 32 GB+ RAM", "best_for": "Strongest local option on a big machine"},
|
package/config/pricing.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"_comment": "Model pricing in USD per 1,000,000 tokens. Kept SEPARATE from usage data so it can be updated without touching the dashboard. Any cost derived from these numbers is ESTIMATED, never actual billed cost.",
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"unit": "per_million_tokens",
|
|
5
|
-
"updated": "2026-
|
|
6
|
-
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28)",
|
|
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); OpenAI (developers.openai.com/api/docs/pricing) and Google (ai.google.dev/gemini-api/docs/pricing) checked 2026-10-02",
|
|
7
7
|
"models": {
|
|
8
8
|
"claude-opus-5": {
|
|
9
9
|
"display_name": "Claude Opus 5",
|
|
@@ -51,6 +51,17 @@
|
|
|
51
51
|
"provider": "anthropic",
|
|
52
52
|
"_note": "Research-preview fast mode, 2x standard. Selected when usage.speed == 'fast'."
|
|
53
53
|
},
|
|
54
|
+
"claude-sonnet-5-5": {
|
|
55
|
+
"display_name": "Claude Sonnet 5.5",
|
|
56
|
+
"tier": "balanced",
|
|
57
|
+
"input": 2.0,
|
|
58
|
+
"output": 10.0,
|
|
59
|
+
"cache_write_5m": 2.5,
|
|
60
|
+
"cache_write_1h": 4.0,
|
|
61
|
+
"cache_read": 0.2,
|
|
62
|
+
"context_window": 1000000,
|
|
63
|
+
"provider": "anthropic"
|
|
64
|
+
},
|
|
54
65
|
"claude-sonnet-5": {
|
|
55
66
|
"display_name": "Claude Sonnet 5",
|
|
56
67
|
"tier": "balanced",
|
|
@@ -311,7 +322,7 @@
|
|
|
311
322
|
"provider": "google",
|
|
312
323
|
"input": 0.3,
|
|
313
324
|
"output": 2.5,
|
|
314
|
-
"cache_read": 0.
|
|
325
|
+
"cache_read": 0.03,
|
|
315
326
|
"cache_write_5m": 0.3,
|
|
316
327
|
"cache_write_1h": 0.3,
|
|
317
328
|
"context_window": 1048576,
|
|
@@ -323,7 +334,7 @@
|
|
|
323
334
|
"provider": "google",
|
|
324
335
|
"input": 1.25,
|
|
325
336
|
"output": 10.0,
|
|
326
|
-
"cache_read": 0.
|
|
337
|
+
"cache_read": 0.125,
|
|
327
338
|
"cache_write_5m": 1.25,
|
|
328
339
|
"cache_write_1h": 1.25,
|
|
329
340
|
"context_window": 1048576,
|
|
@@ -335,7 +346,7 @@
|
|
|
335
346
|
"provider": "google",
|
|
336
347
|
"input": 1.5,
|
|
337
348
|
"output": 9.0,
|
|
338
|
-
"cache_read": 0.
|
|
349
|
+
"cache_read": 0.15,
|
|
339
350
|
"cache_write_5m": 1.5,
|
|
340
351
|
"cache_write_1h": 1.5,
|
|
341
352
|
"context_window": 1048576
|
|
@@ -346,7 +357,7 @@
|
|
|
346
357
|
"provider": "google",
|
|
347
358
|
"input": 0.75,
|
|
348
359
|
"output": 3.75,
|
|
349
|
-
"cache_read": 0.
|
|
360
|
+
"cache_read": 0.075,
|
|
350
361
|
"cache_write_5m": 0.75,
|
|
351
362
|
"cache_write_1h": 0.75,
|
|
352
363
|
"context_window": 1048576,
|
package/config/settings.json
CHANGED
|
@@ -30,6 +30,23 @@
|
|
|
30
30
|
90,
|
|
31
31
|
100
|
|
32
32
|
],
|
|
33
|
+
"guard": {
|
|
34
|
+
"_comment": "Per-session token budget (billable tokens) and the opt-in session guard hook (claude-finops --install-guard). session_tokens null => no per-session budget and the guard allows everything. projects maps an absolute project path to {\"session_tokens\": n} or {\"off\": true}; sessions maps one Claude Code session id to the same (set from Running sessions, forgotten after 30 days).",
|
|
35
|
+
"session_tokens": null,
|
|
36
|
+
"warn_pct": [75, 80],
|
|
37
|
+
"after_approval": "step",
|
|
38
|
+
"step_pct": 25,
|
|
39
|
+
"projects": {},
|
|
40
|
+
"sessions": {}
|
|
41
|
+
},
|
|
42
|
+
"history": {
|
|
43
|
+
"_comment": "Claude Code deletes old transcripts (cleanupPeriodDays, 30 days by default). With keep_deleted_transcripts on, a sync keeps the sessions it already had instead of dropping them; turn it off if you delete transcripts on purpose and want them gone from the dashboard too.",
|
|
44
|
+
"keep_deleted_transcripts": true
|
|
45
|
+
},
|
|
46
|
+
"subagents": {
|
|
47
|
+
"_comment": "Subagent models page: a cheaper model is only suggested, and an experiment only compared, once each side has at least min_runs subagent runs.",
|
|
48
|
+
"min_runs": 5
|
|
49
|
+
},
|
|
33
50
|
"waste_rules": {
|
|
34
51
|
"long_prompt_chars": 6000,
|
|
35
52
|
"large_context_tokens": 150000,
|
package/finops/actions.py
CHANGED
|
@@ -127,7 +127,9 @@ def sync(log, source=DEFAULT_SOURCE):
|
|
|
127
127
|
"""Rebuild the warehouse into a temp file, then swap it in (the dashboard keeps serving)."""
|
|
128
128
|
tmp = DB_PATH + ".sync"
|
|
129
129
|
log("Reading Claude transcripts…")
|
|
130
|
-
|
|
130
|
+
# the live warehouse is the "previous" one: sessions whose transcripts were deleted
|
|
131
|
+
# since are carried into the new build (history.keep_deleted_transcripts)
|
|
132
|
+
Loader(db_path=tmp, source=source, keep_from=DB_PATH if os.path.exists(DB_PATH) else None).build(verbose=False)
|
|
131
133
|
con = sqlite3.connect(tmp)
|
|
132
134
|
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
133
135
|
n = con.execute("SELECT COUNT(*) FROM requests").fetchone()[0]
|
|
@@ -210,6 +212,7 @@ def free_models():
|
|
|
210
212
|
have_model = os.path.exists(os.path.join(KEY_DIR, "openrouter.key"))
|
|
211
213
|
fits = not m.get("min_ram_gb") or ram is None or ram >= m["min_ram_gb"]
|
|
212
214
|
out.append({**m, "installed": installed and have_model, "launcher": launcher,
|
|
215
|
+
"downloaded": bool(have_model) and m["provider"] == "ollama",
|
|
213
216
|
"fits_ram": fits, "ram_gb": round(ram or 0), "free_disk_gb": round(disk)})
|
|
214
217
|
return {"models": out, "ollama_installed": bool(_ollama_bin()), "os": OS_NAME,
|
|
215
218
|
"ollama_running": pulled is not None, "bin_on_path": _on_path(BIN_DIR),
|
|
@@ -236,11 +239,18 @@ def free_model_plan(mid):
|
|
|
236
239
|
if ram and m.get("min_ram_gb") and ram < m["min_ram_gb"]:
|
|
237
240
|
steps.append({"do": f"Warning: this model wants {m['min_ram_gb']} GB RAM and this "
|
|
238
241
|
f"machine has {ram:.0f} GB. It may be very slow.", "warn": True})
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
pulled = _ollama_running() or []
|
|
243
|
+
have = any(n == m["model"] or n == m["model"] + ":latest" for n in pulled)
|
|
244
|
+
if have:
|
|
245
|
+
# ollama pull on a model you already have only checks it is current
|
|
246
|
+
steps.append({"do": f"{m['model']} is already downloaded; ollama pull only checks it is current",
|
|
247
|
+
"consent": False})
|
|
248
|
+
else:
|
|
249
|
+
if m.get("download_gb") and _free_disk_gb() < m["download_gb"] + 2:
|
|
250
|
+
return {"model": m, "blocked": f"Needs ~{m['download_gb']} GB free disk; only "
|
|
251
|
+
f"{_free_disk_gb():.0f} GB free."}
|
|
252
|
+
steps.append({"do": f"Download {m['model']} (~{m['download_gb']} GB) with ollama pull",
|
|
253
|
+
"consent": True})
|
|
244
254
|
else:
|
|
245
255
|
if not os.path.exists(os.path.join(KEY_DIR, "openrouter.key")):
|
|
246
256
|
needs.append({"field": "api_key", "label": "OpenRouter API key",
|
|
@@ -427,15 +437,22 @@ def _slug(s):
|
|
|
427
437
|
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:40] or "task"
|
|
428
438
|
|
|
429
439
|
|
|
430
|
-
def suggestions(a):
|
|
440
|
+
def suggestions(a, f=None):
|
|
431
441
|
"""Skills and MCP servers worth adding, from recurring work (actual transcripts).
|
|
432
442
|
|
|
433
|
-
|
|
434
|
-
|
|
443
|
+
Only Claude Code's own prompts and commands count (these are Claude Code skills and
|
|
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
|
|
446
|
+
the warehouse (reset by sync); installed-state is re-checked on every call.
|
|
435
447
|
"""
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
448
|
+
f = f or {}
|
|
449
|
+
key = (f.get("start"), f.get("end"))
|
|
450
|
+
cache = getattr(a, "_suggest", None)
|
|
451
|
+
if not isinstance(cache, dict):
|
|
452
|
+
cache = a._suggest = {}
|
|
453
|
+
if key not in cache:
|
|
454
|
+
cache[key] = _suggestions(a, f)
|
|
455
|
+
out = cache[key]
|
|
439
456
|
installed = _installed_mcp()
|
|
440
457
|
existing = set(os.listdir(SKILLS_DIR)) if os.path.isdir(SKILLS_DIR) else set()
|
|
441
458
|
for m in out["mcp"]:
|
|
@@ -445,13 +462,21 @@ def suggestions(a):
|
|
|
445
462
|
return out
|
|
446
463
|
|
|
447
464
|
|
|
448
|
-
def _suggestions(a):
|
|
465
|
+
def _suggestions(a, f=None):
|
|
466
|
+
f = f or {}
|
|
467
|
+
rng, p = "", []
|
|
468
|
+
if f.get("start"):
|
|
469
|
+
rng += " AND {t}.day >= ?"; p.append(f["start"])
|
|
470
|
+
if f.get("end"):
|
|
471
|
+
rng += " AND {t}.day <= ?"; p.append(f["end"])
|
|
449
472
|
used_tools = {r["name"] for r in a.q("SELECT DISTINCT name FROM tool_calls WHERE name LIKE 'mcp__%'")}
|
|
450
473
|
installed = _installed_mcp()
|
|
451
|
-
corpus = a.q("""SELECT p.session_id, p.text, p.source, pj.name project FROM prompts p
|
|
452
|
-
JOIN projects pj ON pj.id=p.project_id
|
|
453
|
-
|
|
454
|
-
|
|
474
|
+
corpus = a.q(f"""SELECT p.session_id, p.text, p.source, pj.name project FROM prompts p
|
|
475
|
+
JOIN projects pj ON pj.id=p.project_id
|
|
476
|
+
WHERE p.text IS NOT NULL AND p.agent='claude' {rng.format(t='p')}""", p)
|
|
477
|
+
bash = a.q(f"""SELECT t.session_id, t.target, pj.name project FROM tool_calls t
|
|
478
|
+
JOIN projects pj ON pj.id=t.project_id
|
|
479
|
+
WHERE t.name='Bash' AND t.agent='claude' {rng.format(t='t')}""", p)
|
|
455
480
|
|
|
456
481
|
mcps = []
|
|
457
482
|
for mid, name, what, rx, args, need in MCP_CATALOG:
|
|
@@ -527,8 +552,8 @@ def _suggestions(a):
|
|
|
527
552
|
"sessions": len(o["sessions"]), "runs": len(o["sessions"]),
|
|
528
553
|
"projects": sorted(o["projects"], key=o["projects"].get, reverse=True)[:3],
|
|
529
554
|
"examples": o["examples"], "installed": name in existing,
|
|
530
|
-
"what": f"You
|
|
531
|
-
f"as a skill and trigger it with /{name}."})
|
|
555
|
+
"what": f"You typed prompts starting \"{k}…\" in {len(o['sessions'])} sessions. "
|
|
556
|
+
f"Save it as a skill and trigger it with /{name}."})
|
|
532
557
|
skills.sort(key=lambda x: (x["installed"], -x["sessions"]))
|
|
533
558
|
return {"mcp": mcps[:12], "skills": skills[:15], "skills_dir": SKILLS_DIR,
|
|
534
559
|
"note": "Suggestions come from keywords in your prompts and the shell commands Claude "
|
|
@@ -623,7 +648,7 @@ def free_model_test(log, mid):
|
|
|
623
648
|
return {"ok": True, "command": m["command"]}
|
|
624
649
|
|
|
625
650
|
|
|
626
|
-
def compare(a, agents=None):
|
|
651
|
+
def compare(a, agents=None, f=None):
|
|
627
652
|
"""Each selected agent's priced models (with your usage) side by side; free models too
|
|
628
653
|
when Claude Code is selected, since they plug into Claude Code."""
|
|
629
654
|
with open(os.path.join(ROOT, "config", "model_compare.json")) as fh:
|
|
@@ -632,18 +657,20 @@ def compare(a, agents=None):
|
|
|
632
657
|
prov = {"claude": "anthropic", "codex": "openai", "gemini": "google"}
|
|
633
658
|
want = {prov[x] for x in agents if x in prov}
|
|
634
659
|
where = {"anthropic": "Anthropic cloud", "openai": "OpenAI cloud", "google": "Google cloud"}
|
|
635
|
-
|
|
636
|
-
used = {r["model"]: r for r in a.q(f"""SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost,
|
|
637
|
-
SUM(billable_tokens) tokens FROM requests
|
|
638
|
-
WHERE
|
|
660
|
+
w, wp = a.where(dict(f or {}, agents=agents))
|
|
661
|
+
used = {r["model"]: r for r in a.q(f"""SELECT r.model, COUNT(*) requests, SUM(r.est_cost_usd) cost,
|
|
662
|
+
SUM(r.billable_tokens) tokens FROM requests r
|
|
663
|
+
WHERE {w} GROUP BY r.model""", wp)}
|
|
639
664
|
rows = []
|
|
640
665
|
for mid, p in a.pricing.models.items():
|
|
641
666
|
pv = p.get("provider", "anthropic")
|
|
642
667
|
if p.get("tier") in (None, "none", "other") or pv not in want or mid.endswith("[1m]"):
|
|
643
668
|
continue
|
|
644
|
-
if pv == "anthropic" and mid not in ratings:
|
|
645
|
-
continue
|
|
646
669
|
u = used.get(mid) or {}
|
|
670
|
+
# An unrated Claude model still belongs in the table when you used it; unused
|
|
671
|
+
# unrated ones (old ids kept for pricing history) would only add noise.
|
|
672
|
+
if pv == "anthropic" and mid not in ratings and not u:
|
|
673
|
+
continue
|
|
647
674
|
rt = ratings.get(mid) or {"tools": None, "reasoning": None, "multifile": None,
|
|
648
675
|
"speed": "—", "best_for": p.get("tier", "").capitalize() + " tier"}
|
|
649
676
|
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude" if pv == "anthropic" else pv,
|
|
@@ -666,6 +693,6 @@ def compare(a, agents=None):
|
|
|
666
693
|
return {"rows": rows, "os": fm["os"], "ram_gb": fm["models"][0]["ram_gb"] if fm["models"] else None,
|
|
667
694
|
"agents": agents,
|
|
668
695
|
"note": "Ratings are judgement, not benchmarks; edit config/model_compare.json. "
|
|
669
|
-
"
|
|
696
|
+
"Models you used that have no rating show — until you add them there. Tool use matters most: coding "
|
|
670
697
|
"agents work by calling tools, and weak tool use means loops, broken edits "
|
|
671
698
|
"and early stops."}
|
package/finops/agents.py
CHANGED
|
@@ -17,6 +17,8 @@ import sqlite3
|
|
|
17
17
|
import sys
|
|
18
18
|
from datetime import datetime, timezone
|
|
19
19
|
|
|
20
|
+
from . import localtime
|
|
21
|
+
|
|
20
22
|
HOME = os.path.expanduser("~")
|
|
21
23
|
IS_WIN, IS_MAC = os.name == "nt", sys.platform == "darwin"
|
|
22
24
|
|
|
@@ -44,10 +46,10 @@ AGENTS = {
|
|
|
44
46
|
"note": "Tokens and model per reply. Cost estimated at Gemini API list prices; "
|
|
45
47
|
"the free tier costs nothing."},
|
|
46
48
|
"cursor": {"name": "Cursor", "data": "activity",
|
|
47
|
-
"paths": [
|
|
48
|
-
"note": "
|
|
49
|
-
"
|
|
50
|
-
"
|
|
49
|
+
"paths": [_cursor_state_db()],
|
|
50
|
+
"note": "Cursor IDE chats: prompts, times and token counts as the IDE stored "
|
|
51
|
+
"them, with no model. Cursor bills by subscription, so no cost is "
|
|
52
|
+
"estimated. Agent transcripts are not read: they have no times or tokens."},
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
|
|
@@ -61,7 +63,7 @@ def detect():
|
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def _day(ts):
|
|
64
|
-
return (ts
|
|
66
|
+
return localtime.day(ts)
|
|
65
67
|
|
|
66
68
|
|
|
67
69
|
def _iso_ms(ms):
|
|
@@ -71,10 +73,6 @@ def _iso_ms(ms):
|
|
|
71
73
|
return None
|
|
72
74
|
|
|
73
75
|
|
|
74
|
-
def _iso_mtime(path):
|
|
75
|
-
return datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat().replace("+00:00", "Z")
|
|
76
|
-
|
|
77
|
-
|
|
78
76
|
class AgentLoader:
|
|
79
77
|
"""Writes other agents' usage through the Claude Loader's tables and helpers."""
|
|
80
78
|
|
|
@@ -132,7 +130,7 @@ class AgentLoader:
|
|
|
132
130
|
" cache_write_5m, cache_write_1h, cache_write_tokens, billable_tokens,"
|
|
133
131
|
" context_tokens, est_cost_usd, est_cost_no_cache_usd, tool_call_count,"
|
|
134
132
|
" is_sidechain, agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0,0,0,?,?,?,?,?,0,?)",
|
|
135
|
-
(sid, pid, prompt_id, ts, _day(ts),
|
|
133
|
+
(sid, pid, prompt_id, ts, _day(ts), localtime.hour(ts), model,
|
|
136
134
|
1 if priced else 0, inp, out, think, cached, inp + out + cached, inp + cached,
|
|
137
135
|
cost, cost - c_part + nc_part, len(tools), agent))
|
|
138
136
|
rpk = cur.lastrowid
|
|
@@ -225,35 +223,11 @@ class AgentLoader:
|
|
|
225
223
|
|
|
226
224
|
# ---------- Cursor ----------
|
|
227
225
|
def cursor(self):
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
cwd = guess if os.path.isdir(guess) else None
|
|
234
|
-
pid = self.project("cursor", cwd, slug.rsplit("-", 1)[-1] or slug)
|
|
235
|
-
sid = "cursor-" + os.path.splitext(os.path.basename(fp))[0]
|
|
236
|
-
ts = _iso_mtime(fp) # transcripts carry no timestamps; use last write
|
|
237
|
-
self.session("cursor", sid, pid, fp)
|
|
238
|
-
prompt_id = None
|
|
239
|
-
with open(fp, encoding="utf-8", errors="replace") as fh:
|
|
240
|
-
for line in fh:
|
|
241
|
-
try:
|
|
242
|
-
o = json.loads(line)
|
|
243
|
-
except ValueError:
|
|
244
|
-
continue
|
|
245
|
-
content = (o.get("message") or {}).get("content") or []
|
|
246
|
-
if o.get("role") == "user":
|
|
247
|
-
text = " ".join(c.get("text", "") for c in content if isinstance(c, dict))
|
|
248
|
-
text = text.replace("<user_query>", "").replace("</user_query>", "").strip()
|
|
249
|
-
if text:
|
|
250
|
-
prompt_id = self.prompt("cursor", sid, pid, ts, text)
|
|
251
|
-
elif o.get("role") == "assistant":
|
|
252
|
-
tools = [(c.get("name"), (c.get("input") or "")[:300]
|
|
253
|
-
if isinstance(c.get("input"), str) else
|
|
254
|
-
json.dumps(c.get("input"))[:300])
|
|
255
|
-
for c in content if isinstance(c, dict) and c.get("type") == "tool_use"]
|
|
256
|
-
self.request("cursor", sid, pid, prompt_id, ts, "cursor", tools=tools)
|
|
226
|
+
# Cursor's agent transcripts (~/.cursor/projects/*/agent-transcripts) are not
|
|
227
|
+
# read: they carry no timestamps and no token counts, so every line would have
|
|
228
|
+
# been dated by the file's last write and counted as a request it never was.
|
|
229
|
+
# Only the IDE's own store, which records when each message was sent and its
|
|
230
|
+
# token counts, is loaded.
|
|
257
231
|
self.cursor_ide()
|
|
258
232
|
|
|
259
233
|
def cursor_ide(self):
|