claude-obsidian: meter and cap usage; cut per-session cost ~5.6x
The user asked for usage to be monitored closely, and measuring it turned up that most of the spend was avoidable. Metering: every model call is appended to usage.jsonl with BILLED tokens (modelUsage -- the visible `usage` turn undercounted by half) and the CLI's list-price cost. `usage` reports 5h/24h/7d. Each call's cost is estimated first and refused if it would cross the per-run or rolling 24h budget, so budgets cannot be overshot. Removed --max-budget-usd: it aborts after spending and discards the result ($0.26 lost in one call). Cost, measured on one session ($0.057 -> $0.010, same summary content): - alwaysThinkingEnabled=false: extended thinking was ~85% of output. - DISABLE_PROMPT_CACHING=1: one-shot prompts were being written to the 1h cache at 2x input price. - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1: a hidden side request re-read the whole prompt, doubling billed input (9,638 vs 4,663). Summaries are cached until their note is written, so a vault failure never re-pays; --prefetch summarizes while the vault is unreachable. Offline tests (fake claude binary): budget refusal pre-flight, cache reuse with zero calls, map-reduce, env/argv levers, billed-token ledger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
This commit is contained in:
@@ -36,6 +36,8 @@ claude.ai export .zip ────────┼─ condense → redact → sum
|
||||
claude-obsidian-sync sync # what the timer runs
|
||||
claude-obsidian-sync sync --session <session-id> # one session, now, ignoring the settle window
|
||||
claude-obsidian-sync import-claude-ai ~/Downloads/data-2026-09-13.zip
|
||||
claude-obsidian-sync sync --prefetch --limit 3 # summarize into the local cache, vault untouched
|
||||
claude-obsidian-sync usage # calls, billed tokens, spend: last 5h / 24h / 7d
|
||||
```
|
||||
|
||||
Claude reads the vault through the plugin's MCP server:
|
||||
@@ -53,6 +55,22 @@ claude mcp add --scope user --transport http obsidian http://<host>:27123/mcp/ \
|
||||
subscription has no key of its own, so a background job cannot use its `claude-*` models.
|
||||
- **`--no-session-persistence` is load-bearing.** Without it every summary writes a new
|
||||
transcript into `~/.claude/projects`, and the next run summarizes its own summarizing.
|
||||
- **Usage is metered and capped.** Every model call is appended to
|
||||
`~/.local/state/claude-obsidian/usage.jsonl` with *billed* tokens (the CLI's `modelUsage`, not
|
||||
the visible turn) and `total_cost_usd` — API list-price equivalent, a faithful proxy for how much
|
||||
was consumed; the plan's own meter is only visible in Claude Code's `/usage`. Before each call
|
||||
its cost is **estimated** (+20% margin) and the call is refused if it would cross
|
||||
`BUDGET_USD_PER_RUN` or the rolling `BUDGET_USD_PER_DAY` — so budgets are never overshot. Not
|
||||
`claude -p --max-budget-usd`: that aborts *after* the tokens are spent and discards the result
|
||||
(measured: $0.26 for nothing). Every run ends with a one-line usage report.
|
||||
- **Three cost levers, measured on one session — $0.057 → $0.010, same summary content:**
|
||||
`alwaysThinkingEnabled=false` (extended thinking was ~85% of output tokens),
|
||||
`DISABLE_PROMPT_CACHING=1` (the CLI writes each prompt to the 1-hour cache at 2× input price, and
|
||||
a one-shot call never reads it back), and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` (otherwise a
|
||||
hidden side request re-reads the entire prompt, doubling billed input). A whole ~110-session
|
||||
backlog is about $2 at list price with all three.
|
||||
- **Summaries are cached until their note is written**, so a vault outage never pays for the same
|
||||
summary twice, and `--prefetch` can do the summarizing while the vault is unreachable.
|
||||
- **It shares your plan's usage limits.** A first backfill of ~100 sessions in parallel hit a
|
||||
session limit within minutes — which also limits *interactive* use. So: one job at a time,
|
||||
`MAX_PER_RUN` sessions per run (newest first), and the run **stops at the first usage-limit
|
||||
|
||||
Binary file not shown.
@@ -70,6 +70,16 @@ DEFAULTS = {
|
||||
# Cap per run. Summaries draw on the same plan limits as interactive Claude use, so the
|
||||
# timer drips a backlog instead of bursting it (a 107-session burst exhausted a session limit).
|
||||
"MAX_PER_RUN": "10",
|
||||
# Spend guards, in API list-price USD as reported by `claude -p` (total_cost_usd). Not the
|
||||
# plan's own meter -- that is only visible in Claude Code's /usage -- but a faithful proxy
|
||||
# for how much was consumed. Hitting one stops the run like a usage limit (exit 75).
|
||||
"BUDGET_USD_PER_RUN": "0.50",
|
||||
"BUDGET_USD_PER_DAY": "2.00",
|
||||
# Pre-call estimate prices (USD per million tokens, list). A call is refused BEFORE it runs
|
||||
# if the estimate would cross a budget. Deliberately NOT `claude -p --max-budget-usd`: that
|
||||
# aborts after the tokens are spent and throws the result away (measured: $0.26 for nothing).
|
||||
"LLM_PRICE_IN_PER_M": "1.0",
|
||||
"LLM_PRICE_OUT_PER_M": "5.0",
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +261,68 @@ errors and their causes, decisions, open questions. Be specific. Never reproduce
|
||||
CHUNK_CHARS = 300_000
|
||||
|
||||
|
||||
class Ledger:
|
||||
"""Append-only record of every model call: tokens and list-price cost. Also enforces budgets."""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.path = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "usage.jsonl"
|
||||
self.run_budget = float(cfg["BUDGET_USD_PER_RUN"])
|
||||
self.day_budget = float(cfg["BUDGET_USD_PER_DAY"])
|
||||
self.lock = threading.Lock()
|
||||
self.run_cost = 0.0
|
||||
self.run_calls = 0
|
||||
self.run_in = self.run_out = 0
|
||||
|
||||
def entries(self, since: dt.datetime | None = None) -> list[dict]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
out = []
|
||||
for line in self.path.read_text().splitlines():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if since is None or dt.datetime.fromisoformat(e["ts"]) >= since:
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
def spent_since(self, hours: float) -> float:
|
||||
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours)
|
||||
return sum(e.get("cost_usd") or 0 for e in self.entries(since))
|
||||
|
||||
def check(self, estimate: float = 0.0) -> None:
|
||||
"""Called BEFORE each model call with that call's estimated cost -- so a budget is never overshot."""
|
||||
with self.lock:
|
||||
if self.run_cost + estimate > self.run_budget:
|
||||
raise BudgetExhausted(f"run budget ${self.run_budget:.2f}: ${self.run_cost:.3f} spent, "
|
||||
f"next call est. ${estimate:.3f}")
|
||||
day = self.spent_since(24)
|
||||
if day + estimate > self.day_budget:
|
||||
raise BudgetExhausted(f"24h budget ${self.day_budget:.2f}: ${day:.3f} spent, next call est. ${estimate:.3f}")
|
||||
|
||||
def record(self, label: str, model: str, usage: dict, cost: float | None) -> None:
|
||||
e = {"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "item": label, "model": model,
|
||||
"input": (usage.get("input_tokens") or 0) + (usage.get("cache_creation_input_tokens") or 0)
|
||||
+ (usage.get("cache_read_input_tokens") or 0),
|
||||
"output": usage.get("output_tokens") or 0, "cost_usd": cost}
|
||||
with self.lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a") as f:
|
||||
f.write(json.dumps(e) + "\n")
|
||||
self.run_calls += 1
|
||||
self.run_cost += cost or 0
|
||||
self.run_in += e["input"]
|
||||
self.run_out += e["output"]
|
||||
|
||||
def report(self) -> str:
|
||||
return (f"usage this run: {self.run_calls} model calls, {self.run_in:,} in / {self.run_out:,} out tokens, "
|
||||
f"${self.run_cost:.3f} list-price equiv | last 24h ${self.spent_since(24):.3f} of ${self.day_budget:.2f}")
|
||||
|
||||
|
||||
class BudgetExhausted(RuntimeError):
|
||||
"""A configured spend budget is reached -- stop the run the same way as a usage limit."""
|
||||
|
||||
|
||||
class QuotaExhausted(RuntimeError):
|
||||
"""The plan/API usage limit is hit. Every further call would fail the same way -- stop the run."""
|
||||
|
||||
@@ -267,7 +339,11 @@ class LLM:
|
||||
openai: any OpenAI-compatible /chat/completions endpoint (LiteLLM, local models).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
def __init__(self, cfg: dict, ledger: "Ledger | None" = None):
|
||||
self.ledger = ledger or Ledger(cfg)
|
||||
self.price_in = float(cfg["LLM_PRICE_IN_PER_M"]) / 1e6
|
||||
self.price_out = float(cfg["LLM_PRICE_OUT_PER_M"]) / 1e6
|
||||
self.label = threading.local()
|
||||
self.backend = cfg["LLM_BACKEND"]
|
||||
self.model = cfg["LLM_MODEL"]
|
||||
self.resolved = cfg["LLM_MODEL"]
|
||||
@@ -285,16 +361,31 @@ class LLM:
|
||||
else:
|
||||
raise SystemExit(f"unknown LLM_BACKEND {self.backend!r}")
|
||||
|
||||
def estimate(self, system: str, user: str, max_tokens: int) -> float:
|
||||
# ~3 chars/token over-estimates input (measured 3.1 on transcripts); assume the full output;
|
||||
# +20% margin for anything unmeasured. Errs high on purpose: refusing early beats overshooting.
|
||||
return 1.2 * ((len(system) + len(user)) / 3.0 * self.price_in + max_tokens * self.price_out)
|
||||
|
||||
def chat(self, system: str, user: str, max_tokens: int = 2500) -> str:
|
||||
self.ledger.check(self.estimate(system, user, max_tokens))
|
||||
return self._chat_cli(system, user) if self.backend == "claude-cli" else self._chat_openai(system, user, max_tokens)
|
||||
|
||||
def _chat_cli(self, system: str, user: str) -> str:
|
||||
# Cost levers, measured on one session: $0.057 -> $0.015 with identical summary content.
|
||||
# alwaysThinkingEnabled=false -- extended thinking was ~85% of output tokens
|
||||
# DISABLE_PROMPT_CACHING=1 -- Claude Code writes prompts to the 1h cache at 2x input
|
||||
# price; a one-shot call never reads it back
|
||||
# CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -- otherwise a hidden side request re-reads the
|
||||
# whole prompt (~20 tokens out, title-generation shaped),
|
||||
# doubling billed input: 9,638 vs 4,663 visible tokens.
|
||||
cmd = [self.bin, "-p", "--model", self.model, "--no-session-persistence", "--tools", "",
|
||||
"--strict-mcp-config", "--output-format", "json", "--system-prompt", system]
|
||||
"--strict-mcp-config", "--output-format", "json",
|
||||
"--settings", json.dumps({"alwaysThinkingEnabled": False}), "--system-prompt", system]
|
||||
env = dict(os.environ, DISABLE_PROMPT_CACHING="1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
|
||||
last = ""
|
||||
for attempt in range(5):
|
||||
try:
|
||||
proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir)
|
||||
proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir, env=env)
|
||||
except subprocess.TimeoutExpired:
|
||||
last = "timeout"
|
||||
else:
|
||||
@@ -302,6 +393,14 @@ class LLM:
|
||||
d = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError:
|
||||
d = {"is_error": True, "result": (proc.stdout or proc.stderr)[-300:]}
|
||||
if d.get("usage") or d.get("total_cost_usd"): # failed calls can still cost
|
||||
# modelUsage is what was BILLED across every request the CLI made; `usage` is only
|
||||
# the visible turn and undercounted by half before the nonessential-traffic fix.
|
||||
mu = list((d.get("modelUsage") or {}).values())
|
||||
billed = {"input_tokens": sum((v.get("inputTokens") or 0) + (v.get("cacheReadInputTokens") or 0)
|
||||
+ (v.get("cacheCreationInputTokens") or 0) for v in mu),
|
||||
"output_tokens": sum(v.get("outputTokens") or 0 for v in mu)} if mu else (d.get("usage") or {})
|
||||
self.ledger.record(getattr(self.label, "v", "?"), self.model, billed, d.get("total_cost_usd"))
|
||||
if not d.get("is_error") and d.get("result"):
|
||||
used = list((d.get("modelUsage") or {}).keys())
|
||||
if used:
|
||||
@@ -324,7 +423,11 @@ class LLM:
|
||||
"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=600) as r:
|
||||
return json.load(r)["choices"][0]["message"]["content"]
|
||||
d = json.load(r)
|
||||
u = d.get("usage") or {}
|
||||
self.ledger.record(getattr(self.label, "v", "?"), self.model,
|
||||
{"input_tokens": u.get("prompt_tokens"), "output_tokens": u.get("completion_tokens")}, None)
|
||||
return d["choices"][0]["message"]["content"]
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read()[:300].decode(errors="replace")
|
||||
if _QUOTA.search(detail):
|
||||
@@ -466,14 +569,26 @@ def top_level_sessions(cfg: dict) -> list[pathlib.Path]:
|
||||
|
||||
|
||||
def process(cfg, llm, vault, state, key, meta, source, kind, fingerprint, src_file, dry_run) -> str:
|
||||
# Cache the summary until the note is written, so a vault failure never pays for it twice.
|
||||
cache = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "cache" / \
|
||||
f"{hashlib.sha1(key.encode()).hexdigest()[:16]}-{hashlib.sha1(fingerprint.encode()).hexdigest()[:8]}.json"
|
||||
llm.label.v = key
|
||||
if cache.exists():
|
||||
s = json.loads(cache.read_text())
|
||||
else:
|
||||
s = llm.summarize(meta)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(s))
|
||||
prev = state.data.get(key, {})
|
||||
path = prev.get("note") or note_path(cfg, kind, meta, s["title"]) # stable across re-syncs
|
||||
body = render(meta, s, source, llm.resolved, src_file)
|
||||
if dry_run == "prefetch":
|
||||
return f"summary cached ({path})"
|
||||
if dry_run:
|
||||
print(f"\n===== {path} =====\n{body}")
|
||||
return path
|
||||
vault.put(path, body)
|
||||
cache.unlink(missing_ok=True)
|
||||
state.record(key, {"note": path, "title": s["title"], "fingerprint": fingerprint, "source": source,
|
||||
"started": meta.get("first"),
|
||||
"project": pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None})
|
||||
@@ -494,7 +609,7 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]:
|
||||
raise cf.CancelledError()
|
||||
try:
|
||||
return fn(*w)
|
||||
except QuotaExhausted:
|
||||
except (QuotaExhausted, BudgetExhausted):
|
||||
stop.set()
|
||||
raise
|
||||
|
||||
@@ -508,7 +623,7 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]:
|
||||
print(f"skip {label} (no conversation content)", flush=True)
|
||||
else:
|
||||
print(f"ok {label} -> {result}", flush=True); ok += 1
|
||||
except QuotaExhausted as e:
|
||||
except (QuotaExhausted, BudgetExhausted) as e:
|
||||
if quota is None:
|
||||
quota = str(e)
|
||||
for f in futs: # don't launch the rest
|
||||
@@ -520,15 +635,31 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]:
|
||||
return ok, failed, quota
|
||||
|
||||
|
||||
def finish(ok: int, failed: int, quota: str | None, dry_run: bool, remaining: int) -> None:
|
||||
verb = "rendered (dry run, nothing written)" if dry_run else "written"
|
||||
def finish(ok: int, failed: int, quota: str | None, dry_run, remaining: int, ledger: "Ledger | None" = None) -> None:
|
||||
verb = {"prefetch": "summarized into cache (vault untouched)"}.get(dry_run) or \
|
||||
("rendered (dry run, nothing written)" if dry_run else "written")
|
||||
print(f"done: {ok} {verb}, {failed} failed, {remaining} still queued")
|
||||
if ledger:
|
||||
print(ledger.report())
|
||||
if quota:
|
||||
print(f"stopped: usage limit reached -- {quota}", file=sys.stderr)
|
||||
print(f"stopped: {quota}", file=sys.stderr)
|
||||
sys.exit(EX_TEMPFAIL)
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
def cmd_usage(cfg, args) -> None:
|
||||
led = Ledger(cfg)
|
||||
rows = led.entries()
|
||||
if not rows:
|
||||
print("no model calls recorded yet"); return
|
||||
for label, hours in (("last 5h", 5), ("last 24h", 24), ("last 7d", 168)):
|
||||
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours)
|
||||
es = [e for e in rows if dt.datetime.fromisoformat(e["ts"]) >= since]
|
||||
print(f"{label:>9}: {len(es):4d} calls {sum(e['input'] for e in es):>10,} in "
|
||||
f"{sum(e['output'] for e in es):>8,} out ${sum(e.get('cost_usd') or 0 for e in es):.3f}")
|
||||
print(f"budgets: ${led.run_budget:.2f}/run, ${led.day_budget:.2f}/24h (list-price equiv; plan meter: /usage in Claude Code)")
|
||||
|
||||
|
||||
def cmd_stats(cfg, args) -> None:
|
||||
total = 0
|
||||
rows = []
|
||||
@@ -566,23 +697,25 @@ def cmd_sync(cfg, args) -> None:
|
||||
print(f"{total} session(s) pending, summarizing {len(work)} this run", flush=True)
|
||||
if not work:
|
||||
return
|
||||
llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg))
|
||||
prefetch = getattr(args, "prefetch", False)
|
||||
llm, vault = LLM(cfg), (None if args.dry_run or prefetch else Vault(cfg))
|
||||
if vault:
|
||||
vault.ping()
|
||||
mode = "prefetch" if prefetch else args.dry_run
|
||||
|
||||
def one(p, fp, key):
|
||||
meta = parse_claude_code(p)
|
||||
if not meta["transcript"].strip():
|
||||
if not args.dry_run:
|
||||
if not args.dry_run and not prefetch:
|
||||
state.record(key, {"note": None, "fingerprint": fp, "source": "claude-code"})
|
||||
return SKIPPED
|
||||
src = str(p).replace(str(HOME), "~")
|
||||
return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, args.dry_run)
|
||||
return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, mode)
|
||||
|
||||
ok, failed, quota = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key))
|
||||
if vault and ok:
|
||||
write_index(cfg, vault, state)
|
||||
finish(ok, failed, quota, args.dry_run, total - ok)
|
||||
finish(ok, failed, quota, mode, total - ok, llm.ledger)
|
||||
|
||||
|
||||
def cmd_import(cfg, args) -> None:
|
||||
@@ -607,16 +740,19 @@ def cmd_import(cfg, args) -> None:
|
||||
fp, None, args.dry_run))
|
||||
if vault and ok:
|
||||
write_index(cfg, vault, state)
|
||||
finish(ok, failed, quota, args.dry_run, total - ok)
|
||||
finish(ok, failed, quota, args.dry_run, total - ok, llm.ledger)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("usage", help="model calls, tokens and spend from the local ledger")
|
||||
s = sub.add_parser("stats", help="condensed sizes, no network")
|
||||
s.add_argument("--top", type=int, default=15)
|
||||
s = sub.add_parser("sync", help="summarize Claude Code sessions")
|
||||
s.add_argument("--dry-run", action="store_true", help="print notes instead of writing them")
|
||||
s.add_argument("--prefetch", action="store_true",
|
||||
help="summarize into the local cache without touching the vault; a later sync writes them at no extra cost")
|
||||
s.add_argument("--force", action="store_true", help="ignore state and the settle window")
|
||||
s.add_argument("--session", help="only this session id")
|
||||
s.add_argument("--limit", type=int, help="max sessions this run (default MAX_PER_RUN)")
|
||||
@@ -628,7 +764,7 @@ def main() -> None:
|
||||
s.add_argument("--limit", type=int, help="max conversations this run (default MAX_PER_RUN)")
|
||||
args = ap.parse_args()
|
||||
cfg = load_config()
|
||||
{"stats": cmd_stats, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args)
|
||||
{"stats": cmd_stats, "usage": cmd_usage, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,3 +21,5 @@ VAULT_FOLDER=Claude
|
||||
SETTLE_MINUTES=30 # leave sessions alone until they have been idle this long
|
||||
JOBS=1
|
||||
MAX_PER_RUN=10 # summaries share your plan's usage limits; the timer drips a backlog
|
||||
BUDGET_USD_PER_RUN=0.50 # list-price USD, refused pre-flight; see `claude-obsidian-sync usage`
|
||||
BUDGET_USD_PER_DAY=2.00
|
||||
|
||||
Reference in New Issue
Block a user