From 2bfa7e9a2d3cb793ff15fe017b13bcc78a6e334b Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Mon, 14 Sep 2026 00:56:38 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g --- claude-obsidian/README.md | 18 ++ .../claude-obsidian-synccpython-312.pyc | Bin 0 -> 61341 bytes claude-obsidian/claude-obsidian-sync | 168 ++++++++++++++++-- claude-obsidian/config.env.example | 2 + 4 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc diff --git a/claude-obsidian/README.md b/claude-obsidian/README.md index c7522d6..877937d 100644 --- a/claude-obsidian/README.md +++ b/claude-obsidian/README.md @@ -36,6 +36,8 @@ claude.ai export .zip ────────┼─ condense → redact → sum claude-obsidian-sync sync # what the timer runs claude-obsidian-sync sync --session # 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://: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 diff --git a/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc b/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d91fad68668cb49a985b326c0f151c4771927750 GIT binary patch literal 61341 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 literal 0 HcmV?d00001 diff --git a/claude-obsidian/claude-obsidian-sync b/claude-obsidian/claude-obsidian-sync index 5b0041d..7352e2d 100755 --- a/claude-obsidian/claude-obsidian-sync +++ b/claude-obsidian/claude-obsidian-sync @@ -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: - s = llm.summarize(meta) + # 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__": diff --git a/claude-obsidian/config.env.example b/claude-obsidian/config.env.example index 7531d71..e7dcce5 100644 --- a/claude-obsidian/config.env.example +++ b/claude-obsidian/config.env.example @@ -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