Skip to content

Commit b7f65d1

Browse files
Merge pull request #133 from MarkusNeusinger/claude/llm-backend-model-and-usage-reporting
llm: pass --model to every backend, replace gemini with agy, report real spend
2 parents d3054d6 + 61be84b commit b7f65d1

37 files changed

Lines changed: 1962 additions & 721 deletions

.claude/skills/bench-model/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
22
name: bench-model
3-
description: Benchmark an LLM (model and/or agent CLI) on citadel's wiki-building quality — the model-focused twin of verify-corpus (which tests the PIPELINE with a fixed model, while bench-model tests a MODEL with the fixed pipeline). Mode A ingests a corpus into a throwaway sandbox with the chosen CITADEL_INGEST_MODEL / CITADEL_LLM_CLI, grades it with verify-corpus's retrieval-first method, then applies a DISCRIMINATIVE tier (locator precision, oblique-query retrieval, merge quality, redundancy/cross-links, judgment delta on contradictions + planted-false claims) so runs by models of different strength never tie at the top — if two models both ace the grade, the test was too easy, which is itself a finding. Ends with a side-by-side metrics table and a verdict (is the cheaper model's wiki acceptable, where does it degrade first, what rule changes would close the gap). Use whenever the user wants to compare models on wiki creation (sonnet vs haiku, a new Claude model, gemini/copilot, or open/local models via the CITADEL_LLM_CLI seam), asks "does model X suffice for ingest?", wants a cost/quality trade-off measured, or wants to re-run the model bench from the 2026-07 audit — even if they do not say the word "skill". Takes a corpus name, a model id, and optionally a CLI and a baseline sandbox to compare against.
3+
description: Benchmark an LLM (model and/or agent CLI) on citadel's wiki-building quality — the model-focused twin of verify-corpus (which tests the PIPELINE with a fixed model, while bench-model tests a MODEL with the fixed pipeline). Mode A ingests a corpus into a throwaway sandbox with the chosen CITADEL_INGEST_MODEL / CITADEL_LLM_CLI, grades it with verify-corpus's retrieval-first method, then applies a DISCRIMINATIVE tier (locator precision, oblique-query retrieval, merge quality, redundancy/cross-links, judgment delta on contradictions + planted-false claims) so runs by models of different strength never tie at the top — if two models both ace the grade, the test was too easy, which is itself a finding. Ends with a side-by-side metrics table and a verdict (is the cheaper model's wiki acceptable, where does it degrade first, what rule changes would close the gap). Use whenever the user wants to compare models on wiki creation (sonnet vs haiku, a new Claude model, agy/copilot, or open/local models via the CITADEL_LLM_CLI seam), asks "does model X suffice for ingest?", wants a cost/quality trade-off measured, or wants to re-run the model bench from the 2026-07 audit — even if they do not say the word "skill". Takes a corpus name, a model id, and optionally a CLI and a baseline sandbox to compare against.
44
---
55

66
# Bench a model on wiki creation
77

8-
**Usage:** `bench-model <corpus> <model> [--cli claude|copilot|gemini] [--baseline SANDBOX] [--grade-only SANDBOX]`
8+
**Usage:** `bench-model <corpus> <model> [--cli claude|copilot|agy] [--baseline SANDBOX] [--grade-only SANDBOX]`
99

1010
Everything sandbox/grading-related follows **verify-corpus** (read its SKILL.md first — sandbox
1111
recipe, phase 1 structural gates, phase 2 retrieval-first grading, the creation-vs-retrieval miss
@@ -36,7 +36,7 @@ REPO="$(git rev-parse --show-toplevel)"
3636
SANDBOX="$(mktemp -d)/bench-<corpus>-<model>"
3737
uv run python -m citadel init "$SANDBOX"
3838
cat > "$SANDBOX/.env" <<EOF
39-
CITADEL_LLM_CLI=claude # or copilot / gemini — the seam for open/local models
39+
CITADEL_LLM_CLI=claude # or copilot / agy — the seam for open/local models
4040
CITADEL_INGEST_MODEL=<model>
4141
CITADEL_LLM_LOG_DIR=$SANDBOX/logs
4242
CITADEL_RAW_DIR=$REPO/corpora/<corpus>/raw

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ body:
99
version and how to reproduce it are the most helpful parts.
1010
1111
Reminder: cite-citadel ships no LLM SDK and no API key. Ingest shells out to a
12-
coding-agent CLI (`claude` / `copilot` / `gemini`) that *you* have installed and
12+
coding-agent CLI (`claude` / `copilot` / `agy`) that *you* have installed and
1313
logged in. If the problem is really about that CLI's own auth or billing, it may
1414
belong upstream — but file here if you are unsure.
1515
@@ -48,7 +48,7 @@ body:
4848
options:
4949
- claude
5050
- copilot
51-
- gemini
51+
- agy (Antigravity CLI)
5252
- other / local model
5353
- not applicable (read-only command)
5454
validations:

.github/copilot-instructions.md

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ this file by hand: change `CLAUDE.md` and regenerate with
1111
personal wiki in Google's [Open Knowledge Format](../docs/okf-reference.md), with an MCP server so an
1212
AI can search and read it. It implements Karpathy's LLM-Wiki pattern: drop arbitrary text-bearing
1313
files into `raw/`, and one agentic CLI session per source folds each into a cross-linked OKF wiki
14-
under `wiki/`. Pure Python 3.12, KISS. Runtime deps are only `mcp`, `pyyaml`, and `pypdf` (all
15-
pure-Python, no native/transitive weight) — **there is no LLM SDK and no API key**: ingest shells
14+
under `wiki/`. Pure Python 3.12, KISS. Runtime deps are only `mcp`, `pyyaml`, `pypdf`, and `rich`
15+
(all pure-Python, no native weight) — **there is no LLM SDK and no API key**: ingest shells
1616
out to a coding-agent CLI you already have logged in
17-
(`claude`/`copilot`/`gemini`).
17+
(`claude`/`copilot`/`agy`).
1818

1919
## Commands
2020

@@ -30,7 +30,7 @@ uv run python -m citadel <subcommand>
3030

3131
Subcommands: `init [DIR]` (scaffold a workspace: `citadel.toml` marker, `.env`, `raw/`, `wiki/`;
3232
idempotent), `ingest [paths…]` (fold raw/ into the wiki; `--verbose`/`-v` streams the agent
33-
session, `--log-dir DIR` writes a transcript per source, `--quiet` drops the progress spinner,
33+
session, `--log-dir DIR` writes a transcript per source, `--quiet` drops the live progress display,
3434
`--jobs N`/`-j` folds N sources in CONCURRENTLY (default 1 = serial; `CITADEL_JOBS`),
3535
`--full-rescan` distrusts the manifest's stat cache and re-hashes every tracked source,
3636
`--force <paths>` deliberately re-reads already-ingested sources as a reconcile — it requires
@@ -44,7 +44,7 @@ contradictions/fix locators — against a recomputed findings checklist), `statu
4444
per-source state table: ingested / failed / skipped-duplicate / ignored / oversized / pending; MCP twin
4545
`wiki_status`), `doctor`
4646
(read-only setup health check — OK/WARN/FAIL lines for workspace / rules / config-parse fallbacks /
47-
agent CLI / the inert-`CITADEL_INGEST_MODEL`-on-copilot/gemini advisory / raw roots / wiki placement
47+
agent CLI / the configured ingest model / raw roots / wiki placement
4848
(the wiki nested inside a raw root) / child paths (the UNC-vs-drive-letter cwd) /
4949
manifest / billing / the HTTP-serve posture / wiki-git state / a best-effort PyPI update check / workspace coherence; needs no workspace, exits 1 only on a FAIL),
5050
`serve [--http [--host H] [--port P] [--path /mcp] [--read-only]]` (the MCP server — stdio by
@@ -276,10 +276,21 @@ git repo folded as one digest), `image`/`image-reconcile` (an image source read
276276
raw source). A large source is split into segments and folded in over several passes
277277
(`segment=(part, total)` on `run_ingest_session`, telling later passes to MERGE into earlier ones).
278278
`run_ingest_session` is the single seam tests monkeypatch; it returns the session's best-effort
279-
`SessionUsage` (the backend's OWN cost/usage report: claude's result envelope, gemini's
280-
`--session-summary` behind a cached `--help` feature probe; None when nothing was reported —
281-
accounting is strictly passive and can never fail a session), which ingest sums per source into
282-
the manifest stamp and per run onto the reports.
279+
`SessionUsage` (the backend's OWN report of what it spent AND which model actually served the
280+
session: claude's result envelope - `total_cost_usd` plus the `modelUsage` map, whose PRIMARY
281+
entry is the one carrying the token volume, since claude routes cheap side work to a smaller
282+
model; copilot's `--output-format json` JSONL - no dollars, so its own billing unit is recorded
283+
instead: `totalNanoAiu`, the counter behind the `N AIC used` session footer (1 AIC = 1e9 nanoAiu),
284+
converted to `cost_usd` at GitHub's fixed published $0.01/credit so a mixed corpus keeps ONE
285+
comparable total while the un-derived credits stay stamped beside it (the retired
286+
`totalPremiumRequests` is deliberately ignored); agy's `--output-format stream-json` - the
287+
opening `init.model` plus the
288+
closing `result.usage` token totals; None when nothing was reported - accounting is strictly
289+
passive and can never fail a session), which ingest sums per source into the manifest stamp and
290+
per run onto the reports. **The REPORTED model wins**: `config.model_label_for(reported)` stamps
291+
the backend plus what actually ran, and falls back to the configured label only when the backend
292+
named nothing (an Ollama/proxy backend, or agy left on its own default), so the manifest never
293+
claims a model that never ran.
283294

284295
**Two checking layers, one implementation** (`validate.py`):
285296
- `citadel check` / `wiki_validate` — the **strict per-page gate** (required fields, honest/defined
@@ -324,11 +335,18 @@ repeated runs walk the corpus round-robin with NO persisted queue (the manifest
324335
The budget is always explicit (`limit >= 1` enforced, default 1), mirroring `--force`'s
325336
no-accidental-corpus-wide-run refusal. CLI-only, like curate.
326337

338+
An auth-shaped session failure under hermetic isolation is **retried once without the isolation
339+
flags**: on a machine whose CLI credentials live in exactly the personal config `--bare` skips,
340+
every session used to die on authentication until the user found `CITADEL_HERMETIC=0`. The retry
341+
is scoped to that signature (flags actually passed + an auth-shaped message), so a real credential
342+
problem still fails instead of looping.
343+
327344
**Status is the read-only corpus view** (`status.py`, `citadel status`): the manifest + failures
328345
catalog + one stat-only walk (never re-hashes) rendered as a per-source state table — ingested
329346
(model + rules_version, `(stale)` when it predates the current rulebook, `checked YYYY-MM-DD` from
330-
the `ingested_at` stamp, the last session's cost when recorded — with a `Recorded LLM cost` corpus
331-
total above the table), failed (reason, attempts),
347+
the `ingested_at` stamp, the last session's cost when recorded, with copilot's AI credits shown
348+
beside the dollars they converted into — with `Recorded LLM cost` / `Recorded AI credits` corpus
349+
totals above the table), failed (reason, attempts),
332350
skipped-duplicate, ignored (pattern), oversized (over `CITADEL_MAX_SOURCE_BYTES`, with the size),
333351
pending.
334352

@@ -390,6 +408,21 @@ setup health check (OK/WARN/FAIL lines over workspace resolution, the rules tree
390408
PATH, raw-root reachability, manifest parse + stamp, failures summary, the API-key/PDF/audio
391409
advisories, the wiki-git state, a best-effort PyPI update check naming the right upgrade command
392410
per install method, and workspace coherence).
411+
`progress.py` is the CLI's live console reporter (`rich`), wired in only by `cmd_ingest`/`cmd_refresh`
412+
— the MCP server passes no progress, so its stdio stays clean. On a terminal it renders a live
413+
region: one spinner row per IN-FLIGHT source (which is what makes `--jobs N` legible) plus an
414+
overall bar, while finished sources scroll away above it as permanent one-line verdicts carrying
415+
what the session actually spent (`[2/3] OK raw/notes.md 18.4s 2 created $0.0123 1.2k in / 456 out
416+
claude-opus-5` — only fields the backend genuinely reported). Off a TTY or under `--verbose` it
417+
degrades to a START line plus a verdict line per source. Two invariants are load-bearing: every
418+
composed string is ASCII-only and the spinner is pinned to rich's ASCII `line` frames (a cp1252
419+
Windows console must be able to encode it), and every write goes through a swallow-everything guard
420+
— console output must never be able to fail a run that already spent money. Source keys are
421+
shortened by `config.display_key`, which drops the whole prefix before the raw folder in three
422+
tiers: an exact prefix match, a cut at the last path segment NAMED like a configured root (this is
423+
what rescues a Windows drive letter mapped to a share, where `T:\proj\raw` and the key's
424+
`//fileserver.../proj/raw/...` are one folder but share no text), and finally a `.../`-marked tail
425+
clip, so no absolute key can ever flood the console.
393426
`wikigit.py` is the best-effort wiki-HISTORY layer: after every run that changed the wiki (ingest or
394427
curate) it commits the whole wiki dir as ONE commit (and pushes to `CITADEL_WIKI_GIT_REMOTE` when
395428
set), so every change is a reviewable diff; `auto` (default) only acts when the wiki dir is already
@@ -473,7 +506,9 @@ save-the-transcript-as-a-file lane for whole conversations). `rawsource.py` back
473506
Windows/SMB failures.
474507
- Config knobs live in the workspace-root `.env` (auto-loaded, gitignored; template:
475508
`citadel/templates/env.example`): `CITADEL_LLM_CLI`,
476-
`CITADEL_INGEST_MODEL`, `CITADEL_CURATE_MODEL` (model for `citadel curate` sessions; falls back to
509+
`CITADEL_INGEST_MODEL` (passed as `--model` to EVERY backend - claude, copilot and agy all
510+
honor it; unset (the default) means "run the CLI's own default model"),
511+
`CITADEL_CURATE_MODEL` (model for `citadel curate` sessions; falls back to
477512
`CITADEL_INGEST_MODEL`), `CITADEL_LLM_TIMEOUT`, `CITADEL_HERMETIC` (session isolation — append claude's `--bare` when the
478513
installed binary advertises it, so personal `~/.claude` config never leaks into ingest; default
479514
on, probe-gated), `CITADEL_PAGE_CACHE` (the serve-side page snapshot cache: `auto` = on in

CHANGELOG.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,65 @@ All notable changes to this project are documented here. The format is based on
66

77
## [Unreleased]
88

9+
### Changed (breaking)
10+
11+
- **The `gemini` backend is replaced by `agy` (Google's Antigravity CLI).** The Gemini CLI it
12+
wrapped no longer exists, so `CITADEL_LLM_CLI=gemini` now fails immediately with a migration
13+
hint instead of shelling out to a missing binary (`citadel doctor` FAILs on it too). The new
14+
backend runs `agy -p <prompt> --output-format stream-json --dangerously-skip-permissions`, grants
15+
out-of-workspace directories with a repeatable `--add-dir`, and reports its token usage through
16+
the closing `result` event — the `--session-summary` stats-file machinery is gone with the CLI it
17+
probed for. Rename `GEMINI_CLI_PATH` to `AGY_CLI_PATH` if you set it.
18+
- **`CITADEL_INGEST_MODEL` now applies to every backend, and defaults to unset.** It used to be a
19+
claude-only knob hard-defaulted to `sonnet`, inert on the others (`citadel doctor` actively warned
20+
about it). `claude`, `copilot` and `agy` all honor `--model`, so it is passed through to all
21+
three; leaving it unset runs the CLI's own default model, and the per-backend `COPILOT_MODEL` /
22+
`GEMINI_MODEL` guesswork is gone (the model recorded per source is now the one the backend
23+
*reported*). Set it explicitly if you were relying on the old implicit `sonnet`.
24+
925
### Added
1026

27+
- **The model a source was imported with is now the model that actually ran.** Each backend's own
28+
session envelope is parsed for the serving model — claude's `modelUsage` map (the PRIMARY entry
29+
by token volume, so the small model claude routes side work to never wins), copilot's JSONL
30+
`assistant.message`/`model.call_start` events, agy's opening `init` event — and that id is what
31+
gets stamped into the manifest as `<cli>:<model>`. The configured label is only a fallback for a
32+
backend that named nothing (an Ollama/proxy setup, or agy left on its own default), so the wiki
33+
can no longer claim a model that never served it.
34+
- **copilot spend is recorded in AI credits — and priced.** copilot quotes no dollars, so its
35+
`totalNanoAiu` counter (the `N AIC used` figure its interactive session footer shows;
36+
1 AIC = 1e9 nanoAiu) is stamped as a new `aic` field, and converted to `cost_usd` at GitHub's
37+
fixed published $0.01/credit — so a mixed claude+copilot corpus finally has ONE comparable cost
38+
total, with the un-derived credits always shown beside the dollars (`$0.0251 (2.5083 AIC)`).
39+
`citadel status` gained a `Recorded AI credits` total and a `--json` `aic_total`. The retired
40+
`premiumRequests` counter is deliberately not recorded — GitHub replaced it with credits, and it
41+
was far too coarse to describe a session (every ingest is "1"). copilot also runs under
42+
`--output-format json` now — `-s` trimmed away the very envelope that names the model and counts
43+
the spend.
44+
- **An authentication failure under hermetic isolation now retries once without it.** On machines
45+
where the CLI's credentials live in exactly the personal configuration `--bare` skips (managed
46+
containers, an `apiKeyHelper`, some WSL setups), *every* session failed with "Not logged in"
47+
until you found `CITADEL_HERMETIC=0` — while the same CLI worked fine interactively. Isolation is
48+
a hardening nicety; ingesting at all is the product. The retry is scoped to that exact signature
49+
(an isolation flag really was passed **and** the message is auth-shaped), so a genuinely
50+
logged-out CLI still fails instead of looping.
51+
- **Full provenance under every source in `citadel view` and `sources/index.md`.** The viewer's
52+
source reader and hover popover show the model, what the import cost (dollars or premium
53+
requests), the token split and the last-checked date; the generated sources catalog grew matching
54+
`Cost` / `Tokens` / `Checked` columns (`` where the backend reported nothing — an unknown figure
55+
is never rendered as a measured zero).
56+
- **The ingest console is now a live `rich` display, and it shows what each source cost.** On a
57+
terminal the run renders a live region — one spinner row per IN-FLIGHT source plus an overall
58+
bar — while finished sources scroll away above it as permanent verdict lines that now carry the
59+
spend and the model that actually ran (`[2/3] OK raw/notes.md 18.4s 2 created $0.0123
60+
1.2k in / 456 out claude-opus-5`; only fields the backend genuinely reported appear). This also
61+
repairs `--jobs N`, which used to switch the animation OFF entirely: a single
62+
carriage-return-rewritten line could name only one source, so several in flight had nothing to
63+
show. `rich` joins `mcp`/`pyyaml`/`pypdf` as a runtime dependency (pure-Python, two small
64+
transitive packages). ASCII-only output and the ASCII spinner frames are preserved deliberately,
65+
so a legacy Windows code page can still encode every line, and console failures are still
66+
swallowed — output must never break a run that already spent money.
67+
1168
- **`CITADEL_MAX_SOURCE_BYTES` — a discovery size ceiling.** Ignore patterns matched *names*;
1269
nothing matched **size**, so a raw root that also holds machine data (the reported case: 617
1370
`.tdms` sensor dumps, 10.6 GB in one folder) was stream-hashed in full on every first scan just to
@@ -25,6 +82,16 @@ All notable changes to this project are documented here. The format is based on
2582

2683
### Fixed
2784

85+
- **A long absolute source key no longer floods the ingest console.** `config.display_key` collapsed
86+
a path to `raw/<below>` only when it matched a configured root as a string — so a Windows drive
87+
letter mapped to a share (root configured as `T:\proj\raw`, key arriving as
88+
`//fileserver.corp.example/proj/raw/...`) matched nothing and printed the whole UNC path, FQDN
89+
host and all, on every line. It now falls back to cutting at the last path segment *named* like a
90+
configured root, and finally to a `.../`-marked tail clip for a key under no known root at all.
91+
Verdict lines additionally clip the key from the LEFT (keeping the identifying filename) rather
92+
than clipping the line from the right, so a narrow terminal never drops the cost and model at the
93+
end of the line.
94+
2895
- **Explicitly requested ingest paths now expand `~`.** Every other configured path already did
2996
(`config._resolve_dir_entry`, `workspace.init`, `CITADEL_WORKSPACE`); the `citadel ingest
3097
<paths…>` / `wiki_ingest` arguments were the outlier. A POSIX shell expands `~` before citadel

0 commit comments

Comments
 (0)