14 — Cost Model & Budget Governance

Purpose. This document defines where LLM/embedding costs arise in the Knoll Analyzer × GRAG integration, provides a per-analysis cost-estimation worksheet at the planning scale (151 questions per ADR-010, 46 checklist positions, 11 success levers), specifies the cost-attribution architecture (X-Pipeline-Id → ledger), the budget-governance mechanics (keyvault monthly_budget_usd / rate_limit_rpm, hard 402/429), the 80 %-alert job, the cost-control levers, and the monthly firm reporting recipe. It implements plan 1.6, 7.3 and the budget/model parts of 6.6.

Status / verified against: 2026-07-06 · repo document-processing-pipelines @ db63a95 · plan tasks/todo.md rev. 2 (adversarial-review corrected). Ledger paths, keyvault fields, attribution headers and cache semantics verified in source (ledger/src/ledger/api.py, ai-gateway/src/ai_gateway/keyvault/*, orchestrator/src/orchestrator/worker.py:385).

Sibling docs: architecture in 01-architecture.md, request patterns in 02-grag-api-cookbook.md, run-id conventions in 03-id-conventions.md, keyvault provisioning in 04-provisioning-runbook.md, ai_runs schema in 06-knoll-db-schema.md, pipeline steps in 08-gutachten-pipeline-spec.md, chat lane in 09-chat-integration.md, ingest lane in 10-document-pipeline.md, 402/429 handling in 11-resilience-and-errors.md, attribution gaps in 13-platform-gaps-issues.md.


1. Where costs arise, per feature

The cost ledger records every paid call. Two row classes matter:

  • USD-bearing rows — ai-gateway LLM/embed calls (LiteLLM computes usd + tokens). These are the only rows that consume monthly_budget_usd.
  • usd=0 / units-only rows — rerank-fusion /rerank+/retrieve and every groundedness scoring call emit units=1, usd=0; docfold/scraping/enrichment record units only (pages, requests, characters). They appear in the ledger for observability but cost nothing at the gateway (docfold engines are local: Docling, PaddleOCR etc.).
Feature (plan task)Paid calls (USD rows)Free / units-only
Document ingest (4.1, 10-document-pipeline.md)Embedding per chunk: voyager_index_ingest posts text-only points to /ai-gateway/api/v1/retrieval/collections/{kb}/points, which auto-embeds server-side (openai/text-embedding-3-small, dim 1536, D6)docfold conversion (local engines, units-only); deterministic chunking strategies (no LLM); kg segment lane; GLiNER entity extraction (local Tier-2 model)
Fragebogen → corpus (4.5)Embedding of the rendered answers markdown (one small doc; re-embed on re-submit/supersede)
Chat: KB-Chat / Akquise / KIU (5.1a, 5.1b, 5.2, 09-chat-integration.md)Per turn: 1 condense completion (when chat.condense_enabled, default on) + 1 answer completion. Answer prompt size is dominated by top_k × (1+2·neighbor_radius) full-text chunksretrieval search/rerank (rerank rows usd=0); kg full-text expansion; groundedness (usd=0)
Gutachten-Pipeline (5.3, 08-gutachten-pipeline-spec.md)6 steps × chat/completions (step 2 fans out per checklist document, step 5 per Erfolgshebel); embedding of the final report PDF-markdown on re-ingestper-step groundedness (include_nli, include_per_sentence — all usd=0); search/rerank per document; segments/text:batch (kg-service, no ledger row)
Checklist classification (4.3)1 completion per indexed document (prompt-JSON per 1.14) — or zero if the gliner_structured enrichment option is chosen (local model)
Dienstleister-Matching (5.4)1 completion for the Begründungstext per match setPOST /kg-service/api/v1/search/entity-match (pgvector, no LLM); ranking is deterministic Knoll code
Projektgenerator (5.5)1 structured completion per ProjektsteckbriefKalkulation amounts computed in code
KG writes (3.5/3.6)Entity embedding at upsert when KG_EMBEDDINGS_ENABLED=true (small, one per new/changed entity)deterministic edge writes
PageIndex (only if ever added to ingest.dag_template)LLM-hungry: one completion per TOC-tree node — a 500-page PDF can request 200+ completions (PAGEINDEX_LLM_CONCURRENCY=4 caps parallelism, not volume)

⚠ Rule of thumb: completions dominate. Embeddings for a full Analyse corpus cost cents; the Gutachten pipeline and chat turns are where the Euro-relevant spend is.


2. Per-Analyse cost-estimation worksheet

2.1 Unit-price variables — resolve at runtime, never hardcode

TODO-VERIFY: pull current prices from the model catalog at runtime and fill this table per environment. Do not copy prices from memory or vendor websites — the catalog is what LiteLLM actually bills against.

curl -s "$GRAG_URL/ai-gateway/api/v1/models?provider=openai" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  | jq '.models[] | select(.model | test("gpt-4o-mini|text-embedding-3-small"))
        | {model, pricing, max_input_tokens, max_output_tokens}'

Each entry exposes pricing.input_cost_per_token and pricing.output_cost_per_token (USD per single token; multiply by 1e6 for the per-1M figure).

VariableMeaningSource
P_embUSD per 1M embedding tokens (openai/text-embedding-3-small, D6)TODO-VERIFY via GET /ai-gateway/api/v1/models
P_in(m)USD per 1M prompt tokens for model mTODO-VERIFY (same call)
P_out(m)USD per 1M completion tokens for model mTODO-VERIFY (same call)

2.2 Scale constants (canonical / measured) and planning assumptions

ConstantValueSource
F — Fragen im Fragenkatalog151 (canonical Kundenfragebogen count, Anlage C.I.1 raw numbering)ADR-010 (Knoll-Analyzer-Docs/wiki/adr/ADR-010-question-count-nodekey.md); matches 08 §5.1 / 09 §1.1. The prototype mock seeds only 105 of them (16 Kapitel, apps/web/lib/mock-data/fragebogen.ts) — planning uses 151
D — Checklist-Positionen46 (Gruppe A + B.1–B.11, Anlage C.I.3)apps/web/lib/mock-data/checkliste.ts
H — success levers11plan resource mapping / expert-reports.ts leverScores

Assumptions (tune after the 8.3 pilot measures real values — these are planning inputs, not platform facts):

AssumptionDefaultNote
D_up — documents actually uploaded30 of 46some Positionen are answered "liegt nicht vor"
— pages per document12Jahresabschlüsse pull the average up
— chunks per page3TODO-VERIFY: depends on chunking.default_* tenant settings (1.7); measure results.<chunkNode>.chunks on dev ingests
— tokens per chunk400≈ chunk size / ~4 chars per token
k — retrieval top_k8platform default WORKSPACES_CHAT_DEFAULT_TOP_K=8
rchat.context_neighbor_radius1tenant setting, range 0–2
T — turns per chat conversation15KIU steering conversations run long

2.3 Formula rows

A. Ingest (embeddings, model = embedding model):

RowFormulaWith defaults
Chunks per AnalyseN = D_up × p̄ × c̄30 × 12 × 3 = 1 080 chunks
Embed tokens, corpusN × t̄1 080 × 400 = 432 000 tok
Embed tokens, Fragebogen-doc (4.5)≈ F × 8012 100 tok
Embed tokens, report PDF re-ingest (5.3 step 6)≈ 30 pages × c̄ × t̄36 000 tok
Ingest USD≈ 0.48 M tok × P_emb≈ 0.48 × P_emb

B. Chat, per conversation (completion model m):

RowFormulaWith defaults
Condense, per turn≈ 1 200 in / 50 out
Answer prompt, per turnk × (1+2r) × t̄ + ~2 000 (system+history+question)8 × 3 × 400 + 2 000 ≈ 11 600 in
Answer completion, per turn≈ 500 out
Per conversation (T=15)T × (12 800 in + 550 out)192 k in / 8 k out

C. Gutachten-Pipeline, per run (completion model(s) per step; fallback retries bill again on the fallback model):

StepCallsPrompt tokens (est.)Completion tokens (est.)
1 Interview (digest + Widersprüche + Rückfragen)1F × 80 + 1 500 ≈ 13 6002 500
2 Dokument (per uploaded doc)D_up = 30k × t̄ + 1 000 ≈ 4 200 each → 126 000600 each → 18 000
3 SWOT (synthesis over 1+2)1≈ 15 0001 500
4 Scoring (per-Hebel scores + rationale; caps applied in code)1≈ 12 0001 200
5 Gutachten (long-form per Hebel)H = 11≈ 6 000 each → 66 0001 500 each → 16 500
6 Report (assembly; optional polish call)0–1≈ 8 0002 000
Groundedness per step (usd=0)6+
Pipeline totals~45 calls241 k in42 k out

D. Per-Analyse total:

Cost(Analyse) ≈ Σ_models [ T_in(m) × P_in(m)/1M + T_out(m) × P_out(m)/1M ]
             + T_emb × P_emb/1M

Token budget with defaults (1 pipeline run + 3 chat conversations):
  T_in  ≈ 241k + 3×192k ≈ 0.82 M prompt tokens
  T_out ≈  42k + 3×8k   ≈ 0.07 M completion tokens
  T_emb ≈ 0.48 M embedding tokens

Multiply by live prices from 2.1. Budget at least 2× this figure for the pilot: Partner-review gates between steps (5.3) cause step re-runs, prompt-JSON validation retries (1.14) re-bill, and knoll_ai_settings.detail_level can raise max_tokens on step 5. Record actuals per run (Section 3) and replace these estimates after 8.3.

TODO-VERIFY: whether the compound search/rerank on a dense collection emits a paid query-embedding ledger row per call (query-side embedding path for dense collections is not explicit in source) — check GET /ledger/api/v1/ledger?since=<t> after a dev chat turn; if yes, add T × ~30 tok × P_emb per conversation (negligible but should be known).


3. Attribution architecture: per-run → per-Analyse cost

3.1 The X-Pipeline-Id convention

The ledger keys per-run attribution on the pipeline_id column, populated from the X-Pipeline-Id request header (read by ai-gateway routes/completion.py:78,122, routes/responses.py:111, and by pipeline_common.ledger.record_cost). Per 03-id-conventions.md §8, Knoll stamps one X-Pipeline-Id per ai_runs row, format knoll-<scope>-<step>-<runid> (e.g. knoll-4b0c77de-scoring-4e21ac03), stored in ai_runs.pipeline_id at run start.

Every direct gateway call the Knoll backend makes (Gutachten steps, checklist classification, lead scoring, Begründungstexte, Projektsteckbriefe) carries it:

curl -s -X POST "$GRAG_URL/ai-gateway/api/v1/chat/completions" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Pipeline-Id: knoll-4b0c77de-scoring-4e21ac03" \
  -H "Content-Type: application/json" \
  -d '{
        "model": "openai/gpt-4o-mini",
        "messages": [{"role": "user", "content": "…"}],
        "max_tokens": 1500,
        "fallback": ["openai/gpt-4o"]
      }'

grag-client (2.5) must inject the header automatically whenever a call runs inside an ai_runs context — a missing header means the spend lands unattributed (tenant-level only).

3.2 Which lanes carry attribution — and which do not

Lanepipeline_id in ledgerNotes
Direct ai-gateway calls from Knoll backendknoll-<scope>-<step>-<runid> (03 §8; Knoll stamps it)Gutachten steps, extractions, drafts
Orchestrator ingest jobs (upload → chunking → embed)= jobId (worker stamps X-Pipeline-Id on every downstream call, orchestrator/worker.py:385; the points-upsert auto-embed bills under it)Store the ingest jobId in grag_refs (4.1); TODO-VERIFY on dev: GET /ledger/api/v1/ledger?pipeline_id=<jobId> shows the embed rows after one ingest
⚠ BFF chat lane (POST /next/api/chat/stream, D3)❌ none — the chat route stamps no X-Pipeline-Id on its gateway calls (verified: no occurrence in app/api/chat/stream/route.ts); the SSE done event's cost is {tokens, ms} only, no USDChat spend is tenant-level only today. Tracked in 13-platform-gaps-issues.md; candidate addition to the 1.9 contract work (pass-through attribution header). Until then, estimate chat cost Knoll-side from the done token counts × live prices
Enrichment Tier-3 extractors❌ (enrichment/cost.py: service does not thread X-Pipeline-Id down)Only relevant if 4.3 uses cloud extractors — prefer local gliner_structured

3.3 The ⚠ correct ledger endpoints

External base = https://app.grag.ai ($GRAG_URL); Traefik prefix /ledger + internal path /api/v1/ledger/... — note the doubled ledger segment. All reads are scoped to X-Tenant-ID (RLS); cross-tenant queries need ?tenant= plus an admin key in LEDGER_ADMIN_API_KEYS (403 otherwise).

EndpointGroupingUse
GET /ledger/api/v1/ledger/totals?pipeline_id=<id>one pipeline/runPer-run / per-Analyse attribution (7.3)
GET /ledger/api/v1/ledger/spend?group_by=service|providertenant-level only — those two groupings are all that exist (Literal["service","provider"], api.py:231); no per-pipeline grouping hereBudget display in 6.6, monthly report
GET /ledger/api/v1/ledger?pipeline_id=&job_id=&service=&provider=&since=&until=&limit=raw rows (≤1000)Drill-down/debug; since/until are epoch seconds

Per-run totals (one ai_runs row = one pipeline id; per-Analyse = sum over the Analyse's ids, 3.4):

curl -s "$GRAG_URL/ledger/api/v1/ledger/totals?pipeline_id=knoll-4b0c77de-scoring-4e21ac03" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT"
{
  "pipeline_id": "knoll-4b0c77de-scoring-4e21ac03",
  "calls": 1,
  "failed_calls": 0,
  "total_tokens": 13200,
  "total_cost_usd": 0.0,
  "max_latency_ms": 0,
  "avg_latency_ms": 0
}

(Field names are load-bearing — wire-compatible with the orchestrator budget poller; ledger/models.py:49.)

Tenant-level spend for the running month:

MONTH_START=$(date -d "$(date +%Y-%m-01)" +%s)
curl -s "$GRAG_URL/ledger/api/v1/ledger/spend?group_by=service&since=$MONTH_START" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT"
{
  "tenant": "knoll",
  "group_by": "service",
  "since": 1751760000,
  "until": null,
  "rows": [
    {"dimension": "ai-gateway", "calls": 812, "units": 2100000, "usd": 3.42, "failed_calls": 2}
  ]
}

TODO-VERIFY: the exact service dimension values that appear in the live rollup (expected: producer service names like ai-gateway, docfold, mediafold, enrichment, scraping) — run the spend query on dev after a full ingest+chat+pipeline cycle and record the observed set here.

Note: ai-gateway also serves a local mirror at GET /ai-gateway/api/v1/ledger + /ledger/totals (its anomaly/percentile source of truth). Knoll uses the ledger service endpoints above — the mirror covers only ai-gateway's own rows.

3.4 How Knoll aggregates per-analysis cost

Knoll DB (06-knoll-db-schema.md) is the source of truth for the roll-up; the ledger is the metering source:

  1. Every LLM-consuming unit of work is an ai_runs row (2.1: analysis_id nullable, agent_id, step, status, model, tokens, cost, …).
  2. On terminal status (succeeded/failed/cancelled per 06 §2.2 ai_run_status — budget-402 runs land as failed + error='budget_exceeded', 08 §7.1), the Knoll backend calls /ledger/totals?pipeline_id=<ai_runs.pipeline_id> (03 §8 format) once and writes total_cost_usdai_runs.cost, total_tokensai_runs.tokens. (Ledger writes are async via Redis streams — poll once ~30 s after terminal state, or accept eventual consistency and let the nightly job correct it.)
  3. Ingest jobs are attributed via their orchestrator jobId from grag_refs (3.2) and rolled into the owning analysis the same way (either as an ai_runs row of type ingest or a files-level cost column — pick one in 2.1 implementation).
  4. Per-analysis cost = Σ ai_runs.cost where analysis_id = :id (+ its ingest jobs). Cached, never recomputed from GRAG on page load — the 6.4 pipeline panel and 6.6 budget tab read Knoll SQL.
  5. Nightly reconcile job: re-fetch /ledger/totals for runs updated in the last 48 h and correct drift (late ledger rows, fallback retries).

4. Budget governance

4.1 Keyvault semantics (plan 1.6)

The Kanzlei's provider keys are stored per tenant in the ai-gateway keyvault (BYOK, ADR 0029; GATEWAY_KEYVAULT_ENABLED=true in the prod env example). Governance fields on each stored key: enabled, monthly_budget_usd, rate_limit_rpm, expires_at (keyvault/models.py:150-160).

curl -s -X POST "$GRAG_URL/ai-gateway/api/v1/keys" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
        "provider": "openai",
        "secret": "sk-…",
        "label": "kanzlei-knoll-openai",
        "allowed_services": ["ai-gateway"],
        "scope_level": "tenant",
        "monthly_budget_usd": 200,
        "rate_limit_rpm": 60
      }'

Verified allowed_services vocabulary (ai-gateway/src/ai_gateway/keyvault/models.py:28, SERVICES; enforced by the create route's validator in api/schemas/keys.py): ai-gateway | docfold | scraping | mediafold | enrichmentservice names, not capability names; an empty list [] means "any service". Values like "completion" / "embedding" are rejected with 422. For an LLM-provider key, ["ai-gateway"] (or []) is correct — all completion/embedding traffic bills through ai-gateway.

Enforcement semantics (verified keyvault/resolver.py, usage.py):

ConditionHTTPMeaning
month-to-date USD ≥ monthly_budget_usd402 budget_exceededHard reject at the gateway, before any provider call. fallback chains do NOT rescue it — governance rejects the request, not a provider attempt
requests this minute ≥ rate_limit_rpm429 rate_limitedFixed-window per-minute admission
expires_at passed403 expiredRotate via POST /keys/{id}/rotate

Fine print an operator must know:

  • The budget window is the UTC calendar month (%Y%m, usage.py:39) — not a rolling 30 days. Spend counters live in Redis (DB 5) and are fail-open: a Redis outage means requests pass and the month counter under-counts (budget can overshoot during the outage).
  • Governance applies only when a stored tenant key resolves for the provider. A per-request api_key in the body bypasses the vault; no stored key at all → the platform env key is used with no budget cap. Consequence: for the budget to be real, Knoll must store the Kanzlei keys in the vault and never pass per-request keys. grag-client should refuse the api_key body field.
  • ⚠ D5 caveat: BFF-lane chat traffic (D3) is billed to the tenant like everything else (the keyvault resolves on X-Tenant-ID, which the BFF forwards), so the budget cap does cover chat — only attribution is missing there (3.2), not governance.

4.2 Budget-exhausted mid-pipeline: designed UX (consumes 2.1 ai_runs, 2.7 email)

A 402 can land mid-Gutachten-run (e.g. after step 3 of 6). Required behavior (spec for 08-gutachten-pipeline-spec.md state machine and 11-resilience-and-errors.md):

  1. Never blind-retry a 402/429. 429 → honor Retry-After with bounded backoff. 402 → terminal for the current attempt.
  2. On 402 the state machine sets ai_runs.status = 'failed' with error = 'budget_exceeded' (per 08 §7.1; 06 §2.2's ai_run_status enum has no dedicated pause state — "pausiert" is presentation-level only). Keep step, keep all completed step outputs — they are persisted in Knoll DB per step, so nothing is lost.
  3. UI: pipeline panel (6.4) shows „Budget erschöpft — Lauf pausiert" with the month-to-date figure; chat UIs disable input with a German banner („Das monatliche KI-Budget der Kanzlei ist ausgeschöpft.").
  4. Notification: transactional email (2.7) to all Partner: Analyse, step reached, month-to-date spend, and the two remedies — raise monthly_budget_usd (PATCH /ai-gateway/api/v1/keys/{id}) or wait for the month rollover.
  5. Resume re-enters at the failed step (new ai_runs row, new pipeline id per 03 §8). Re-running an identical prior step is cheap anyway: ai-gateway's content-keyed cache (GATEWAY_CACHE_ENABLED, key = canonical body hash) and the orchestrator's per-node cache (1 h TTL, (service_type, payload)-keyed) short-circuit byte-identical calls — but do not rely on cache for correctness, only persistence of step outputs guarantees no re-spend.

4.3 80 %-alert job (Knoll-side cron)

There is no platform-side budget webhook — the gateway only rejects at 100 %. Knoll runs its own threshold alert:

Job: budget-alert            Schedule: hourly
For each stored key in GET /ai-gateway/api/v1/keys (X-Tenant-ID: $GRAG_TENANT):
    pct = key.usage.usd / key.monthly_budget_usd        # usage = month-to-date snapshot
    if pct >= 0.8 and no alert row (key_id, usage.month, "80") exists:
        insert alert row; email Partner + Ops ("80 % des KI-Budgets erreicht")
    if pct >= 0.95 and no alert row (key_id, usage.month, "95") exists:
        insert alert row; email Partner ("95 % — Läufe pausieren in Kürze")
Cross-check (belt & braces): /ledger/api/v1/ledger/spend?since=<month-start>
    summed over rows should ≈ Σ key.usage.usd; alert Ops on >10 % divergence
    (Redis usage counters are fail-open and can under-count).

GET /ai-gateway/api/v1/keys returns each key with a usage snapshot {month, usd, requests} (keyvault/models.py:141) — that is the primary source; the ledger cross-check catches counter drift. Dedupe key (key_id, month, threshold) lives in a small Knoll table; alerts respect benachrichtigungs_praeferenzen (2.1) except the 95 % Partner alert, which is mandatory.


5. Cost-control levers

LeverWhereEffect / guidance
Model choice per stepmodel per request; defaults via tenant setting chat.default_model (1.7) and per-step config in ki_agentenBiggest lever. Cheap model for extraction/digest steps (1, 2, 4, checklist 4.3); stronger model only for long-form German prose (steps 3, 5). Pick from GET /ai-gateway/api/v1/models by pricing × quality
Fallback chainsfallback: [≤5 models], fallback_policyResilience, not savings: order fallbacks cheap→expensive; each fallback attempt bills again (ledger rows carry attempt_index/fallback_of)
top_krequest field / chat.default_top_k (1–50, default 8)Linear on retrieval context size → answer-prompt tokens. 6–8 is usually enough for a scoped Analyse-KB
chat.context_neighbor_radiustenant setting, 0–2Prompt multiplier (1+2r) on every retrieved chunk's full text. r=1 triples context vs r=0 — measure quality before paying for r=2
chat.history_max_turns (default 6) / chat.condense_enabledtenant settingsHistory window feeds every answer prompt; condense adds one small completion per turn but shrinks retrieval noise — keep on
Chunk size / strategychunking.default_* (1.7)Larger chunks = fewer embed calls but fatter retrieval prompts. Avoid LLM-based strategies (contextual, proposition, agentic) — they call the LLM per chunk during ingest; stick to deterministic (structural/recursive)
Orchestrator node cacheper-node (service_type, payload) cache, TTL 3600 s; opt out per job cache:falseIdentical re-submissions within 1 h are free (cache_hit=true events). Helps re-runs after transient failures
ai-gateway response cacheGATEWAY_CACHE_ENABLED (default on in compose), cache field on the requestContent-keyed (canonical body hash) — deterministic prompts (temperature 0, stable ordering) maximize hits across retries
max_tokens capsper request; driven by knoll_ai_settings.detail_level (2.1)Caps completion spend per step; the /responses shim clamps to the model's real cap, chat/completions does not — set it explicitly
Avoid PageIndexingest.dag_template (1.7)Do NOT add a pageindex node unless a feature genuinely needs TOC-tree search — it is the single most LLM-hungry ingest option (one completion per tree node)
Groundedness / rerankFree (usd=0 rows) — never "optimize" these away for cost; they are the QA layer
rate_limit_rpmkeyvaultDamage limiter against runaway loops/retry storms, not a cost tuner. Size above the pipeline's legitimate step-2 fan-out burst

6. Reporting

6.1 Monthly firm cost report (recipe)

Once per month (1st, 02:00, for the previous month), the Knoll backend materializes a report row set:

SINCE=$(date -d "$(date -d 'last month' +%Y-%m-01)" +%s)
UNTIL=$(date -d "$(date +%Y-%m-01)" +%s)

# 1) Tenant spend by service (what kind of work cost money)
curl -s "$GRAG_URL/ledger/api/v1/ledger/spend?group_by=service&since=$SINCE&until=$UNTIL" \
  -H "Authorization: Bearer $GRAG_API_KEY" -H "X-Tenant-ID: $GRAG_TENANT"

# 2) Tenant spend by provider (which vendor invoice it maps to)
curl -s "$GRAG_URL/ledger/api/v1/ledger/spend?group_by=provider&since=$SINCE&until=$UNTIL" \
  -H "Authorization: Bearer $GRAG_API_KEY" -H "X-Tenant-ID: $GRAG_TENANT"

Per-analysis and per-client breakdown comes from Knoll SQL (Section 3.4), not from the ledger:

-- Cost per analysis / client, previous month
SELECT m.company_name     AS client,
       a.id                AS analysis_id,
       a.package,
       COUNT(r.id)         AS runs,
       SUM(r.tokens)       AS tokens,
       SUM(r.cost)         AS cost_usd
FROM   ai_runs r
JOIN   analyses a  ON a.id = r.analysis_id
JOIN   clients   m ON m.id = a.client_id
WHERE  r.finished_at >= date_trunc('month', now() - interval '1 month')
AND    r.finished_at <  date_trunc('month', now())
GROUP  BY m.company_name, a.id, a.package
ORDER  BY cost_usd DESC;

-- Unattributed remainder (chat lane + spill):
--   tenant_spend_usd (recipe above)  −  SUM(ai_runs.cost)

The unattributed remainder is expected to be mostly BFF chat (3.2). Report it as its own line ("Chat / unattributed") — if it exceeds ~30 % of total spend, prioritize the attribution-header addition to the 1.9 contract work.

Sanity checks in the job: failed_calls ratio per service (>2 % → investigate with GET /ledger/api/v1/ledger?service=…), and the keyvault vs. ledger divergence check from 4.3.

6.2 What lands in the 6.6 Einstellungen → KI tab

UI elementSource
Monatsbudget + Verbrauch (progress bar, 80 %/95 % markers)GET /ai-gateway/api/v1/keysmonthly_budget_usd, usage.usd (month-to-date)
Ausgaben nach Dienst / Anbieter (current month)`GET /ledger/api/v1/ledger/spend?group_by=service
Modell-Auswahl (Standardmodell, per-Step-Modelle)GET /ai-gateway/api/v1/models — filter available=true, show pricing per 1M tokens so the Partner sees the price of the choice
Cost per analysis (top list)Knoll SQL (6.1 query, current month)
Expert-report detail level / capsknoll_ai_settings (Knoll DB, 2.1) — feeds max_tokens and step behavior in 5.3
Budget/RPM ändern (Partner-only, 2.3)PATCH /ai-gateway/api/v1/keys/{id} {monthly_budget_usd, rate_limit_rpm}

All GRAG reads on this page go through the Knoll backend (server-side, with $GRAG_API_KEY + X-Tenant-ID) — the key never reaches the browser (7.7).