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 consumemonthly_budget_usd. usd=0/ units-only rows — rerank-fusion/rerank+/retrieveand every groundedness scoring call emitunits=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 chunks | retrieval 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-ingest | per-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 set | POST /kg-service/api/v1/search/entity-match (pgvector, no LLM); ranking is deterministic Knoll code |
| Projektgenerator (5.5) | 1 structured completion per Projektsteckbrief | Kalkulation 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).
| Variable | Meaning | Source |
|---|---|---|
P_emb | USD 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 m | TODO-VERIFY (same call) |
P_out(m) | USD per 1M completion tokens for model m | TODO-VERIFY (same call) |
2.2 Scale constants (canonical / measured) and planning assumptions
| Constant | Value | Source |
|---|---|---|
F — Fragen im Fragenkatalog | 151 (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-Positionen | 46 (Gruppe A + B.1–B.11, Anlage C.I.3) | apps/web/lib/mock-data/checkliste.ts |
H — success levers | 11 | plan resource mapping / expert-reports.ts leverScores |
Assumptions (tune after the 8.3 pilot measures real values — these are planning inputs, not platform facts):
| Assumption | Default | Note |
|---|---|---|
D_up — documents actually uploaded | 30 of 46 | some Positionen are answered "liegt nicht vor" |
p̄ — pages per document | 12 | Jahresabschlüsse pull the average up |
c̄ — chunks per page | 3 | TODO-VERIFY: depends on chunking.default_* tenant settings (1.7); measure results.<chunkNode>.chunks on dev ingests |
t̄ — tokens per chunk | 400 | ≈ chunk size / ~4 chars per token |
k — retrieval top_k | 8 | platform default WORKSPACES_CHAT_DEFAULT_TOP_K=8 |
r — chat.context_neighbor_radius | 1 | tenant setting, range 0–2 |
T — turns per chat conversation | 15 | KIU steering conversations run long |
2.3 Formula rows
A. Ingest (embeddings, model = embedding model):
| Row | Formula | With defaults |
|---|---|---|
| Chunks per Analyse | N = D_up × p̄ × c̄ | 30 × 12 × 3 = 1 080 chunks |
| Embed tokens, corpus | N × t̄ | 1 080 × 400 = 432 000 tok |
| Embed tokens, Fragebogen-doc (4.5) | ≈ F × 80 | ≈ 12 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):
| Row | Formula | With defaults |
|---|---|---|
| Condense, per turn | ≈ 1 200 in / 50 out | — |
| Answer prompt, per turn | k × (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):
| Step | Calls | Prompt tokens (est.) | Completion tokens (est.) |
|---|---|---|---|
| 1 Interview (digest + Widersprüche + Rückfragen) | 1 | F × 80 + 1 500 ≈ 13 600 | 2 500 |
| 2 Dokument (per uploaded doc) | D_up = 30 | k × t̄ + 1 000 ≈ 4 200 each → 126 000 | 600 each → 18 000 |
| 3 SWOT (synthesis over 1+2) | 1 | ≈ 15 000 | 1 500 |
| 4 Scoring (per-Hebel scores + rationale; caps applied in code) | 1 | ≈ 12 000 | 1 200 |
| 5 Gutachten (long-form per Hebel) | H = 11 | ≈ 6 000 each → 66 000 | 1 500 each → 16 500 |
| 6 Report (assembly; optional polish call) | 0–1 | ≈ 8 000 | 2 000 |
Groundedness per step (usd=0) | 6+ | — | — |
| Pipeline totals | ~45 calls | ≈ 241 k in | ≈ 42 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
| Lane | pipeline_id in ledger | Notes |
|---|---|---|
| Direct ai-gateway calls from Knoll backend | ✅ knoll-<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 USD | Chat 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).
| Endpoint | Grouping | Use |
|---|---|---|
GET /ledger/api/v1/ledger/totals?pipeline_id=<id> | one pipeline/run | Per-run / per-Analyse attribution (7.3) |
GET /ledger/api/v1/ledger/spend?group_by=service|provider | tenant-level only — those two groupings are all that exist (Literal["service","provider"], api.py:231); no per-pipeline grouping here | Budget 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:
- Every LLM-consuming unit of work is an
ai_runsrow (2.1:analysis_idnullable,agent_id,step,status,model,tokens,cost, …). - On terminal status (
succeeded/failed/cancelledper 06 §2.2ai_run_status— budget-402 runs land asfailed+error='budget_exceeded', 08 §7.1), the Knoll backend calls/ledger/totals?pipeline_id=<ai_runs.pipeline_id>(03 §8 format) once and writestotal_cost_usd→ai_runs.cost,total_tokens→ai_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.) - Ingest jobs are attributed via their orchestrator
jobIdfromgrag_refs(3.2) and rolled into the owning analysis the same way (either as anai_runsrow of typeingestor afiles-level cost column — pick one in 2.1 implementation). - Per-analysis cost = Σ
ai_runs.costwhereanalysis_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. - Nightly reconcile job: re-fetch
/ledger/totalsfor 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 | enrichment — service 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):
| Condition | HTTP | Meaning |
|---|---|---|
month-to-date USD ≥ monthly_budget_usd | 402 budget_exceeded | Hard 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_rpm | 429 rate_limited | Fixed-window per-minute admission |
expires_at passed | 403 expired | Rotate 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_keyin 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-clientshould refuse theapi_keybody 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):
- Never blind-retry a 402/429. 429 → honor
Retry-Afterwith bounded backoff. 402 → terminal for the current attempt. - On 402 the state machine sets
ai_runs.status = 'failed'witherror = 'budget_exceeded'(per 08 §7.1; 06 §2.2'sai_run_statusenum has no dedicated pause state — "pausiert" is presentation-level only). Keepstep, keep all completed step outputs — they are persisted in Knoll DB per step, so nothing is lost. - 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.").
- 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. - Resume re-enters at the failed
step(newai_runsrow, 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
| Lever | Where | Effect / guidance |
|---|---|---|
| Model choice per step | model per request; defaults via tenant setting chat.default_model (1.7) and per-step config in ki_agenten | Biggest 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 chains | fallback: [≤5 models], fallback_policy | Resilience, not savings: order fallbacks cheap→expensive; each fallback attempt bills again (ledger rows carry attempt_index/fallback_of) |
top_k | request 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_radius | tenant setting, 0–2 | Prompt 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_enabled | tenant settings | History window feeds every answer prompt; condense adds one small completion per turn but shrinks retrieval noise — keep on |
| Chunk size / strategy | chunking.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 cache | per-node (service_type, payload) cache, TTL 3600 s; opt out per job cache:false | Identical re-submissions within 1 h are free (cache_hit=true events). Helps re-runs after transient failures |
| ai-gateway response cache | GATEWAY_CACHE_ENABLED (default on in compose), cache field on the request | Content-keyed (canonical body hash) — deterministic prompts (temperature 0, stable ordering) maximize hits across retries |
max_tokens caps | per 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 PageIndex | ingest.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 / rerank | — | Free (usd=0 rows) — never "optimize" these away for cost; they are the QA layer |
rate_limit_rpm | keyvault | Damage 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 element | Source |
|---|---|
| Monatsbudget + Verbrauch (progress bar, 80 %/95 % markers) | GET /ai-gateway/api/v1/keys → monthly_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 / caps | knoll_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).