# V3.3 – Token- und Kosten-Tracking

**Status:** Implementierungsbereit – alle Code-Reads erledigt (siehe Schlussabschnitt)
**Erstellt:** 2026-05-08
**Aktualisiert:** 2026-05-08 (V6 nach fünftem Review)
**Autor:** Marcus (mit Claude als Mentor)

---

## Freigabe-Gate

> **Die Umsetzung darf nicht beginnen, solange einer der freigabe-blockierenden
> Code-Reads im Schlussabschnitt mit Status „offen" markiert ist.** Jeder
> dieser Reads muss vor Implementierungsstart auf „bestätigt" oder
> „abweichend (Spec angepasst auf: …)" gesetzt werden, einschließlich
> Anpassungen der betroffenen SQL-, Migrations-, Hook- und CLI-Abschnitte.
> Die Fertigstellungs-Checkliste enthält dafür einen eigenen Punkt:
> „Spec nach Code-Reads aktualisiert und freigegeben".

---

## Ziel

V3.3 bringt **#74 – Token- und Kosten-Tracking**: Die Anwendung erfasst pro
Verarbeitungsversuch die verbrauchten Input- und Output-Tokens beider
KI-Provider, persistiert sie zusammen mit einem Preis-Snapshot zum
Verarbeitungszeitpunkt in der Datenbank und stellt aggregierte Kosten
und Token-Statistiken in der GUI bereit – mit Sortierung und Pagination
zur Navigation auch in großen Datenbeständen. Zusätzlich enthält V3.3
das GUI-Bugfix **#98 – Modell-Combobox-Filterung**, da der korrekte
Modellname als Join-Key zur Preistabelle für #74 unverzichtbar ist.
Drittes Element ist eine schlanke **CLI-Schnittstelle für Modell-Preise**
(#99), damit Headless-Nutzer Preise pflegen können.

V3.3 ist ein **mittelgroßes Auswertungs-Release** (kein „kleines"
Token-Tracking-Release): Pagination, Sortierung, BigInteger-Aggregation,
CLI und Unknown-Provider-Handling kommen mit dazu. Es entstehen aber
keine Architekturausnahmen, keine neuen Module und keine
Bootstrap-Refactorings.

### Arbeitspakete in V3.3

V3.3 wird in drei Arbeitspakete strukturiert. Jedes Paket hat eine
eigene Test-Sektion in der Produkttest-Matrix und eine eigene
Abnahme-Liste in der Fertigstellungs-Checkliste.

| AP | Inhalt | Anteil |
|---|---|---|
| AP-A: Token- und Kosten-Tracking (#74 Kern) | Schema, Adapter-Token-Extraktion, Snapshot-Persistierung, History-Erweiterung, Summary-Banner | ~60% |
| AP-B: Kosten-Analyse mit Pagination (#74 GUI-Auswertung) | Tab Kosten-Analyse, Pagination, Sortierung, Mix-Status, Read-Transaction-Konsistenz | ~25% |
| AP-C: Modell-Filter (#98) und CLI (#99) | Filter im GUI-Catalog, CLI-Befehle, Headless-Hinweise | ~15% |

Innerhalb von V3.3 wird AP-A vor AP-B und AP-C umgesetzt, weil das
Schema und die Datenbasis Voraussetzung sind.

---

## Einordnung

V3.2 ist der abgeschlossene Ausgangspunkt. Hexagonale Architektur,
Modulstruktur, headless-Betrieb, `.properties`-Konfigurationswahrheit,
Flyway-DB-Evolution und Scheduler-Mechanik bleiben als Grundprinzipien
vollständig erhalten.

### Datenbankschema

V3.3 enthält **eine Flyway-Migration `V2__token_tracking.sql`**:

- Neue Token-Spalten und Preis-Snapshot-Spalten in `processing_attempt`
- Neue Tabelle `model_price` für persistierte Modell-Preise mit Composite Primary Key
- Vorausgefüllte Default-Preise für bekannte Modelle beider Provider

**Verifizierter Stand:** Im aktuellen V3.2-Codestand existiert nur
`V1__initial_schema.sql` als einzige Flyway-Migration im gesamten Projekt.
`V2` ist daher die korrekte nächste Versionsnummer.

### Keine neuen Maven-Module

Token-Tracking, Filter und CLI werden in bestehende Module integriert:

- `domain` / `application` → DTOs, Ports, Use Cases, `CostCalculator`, Read-Model in eigenem Subpaket
- `adapter-out` → SQLite-Implementierungen, Token-Extraktion in beiden KI-Adaptern
- `adapter-in-gui` → zwei neue Tabs, History-Erweiterung, Summary-Banner-Erweiterung, Modell-Filter
- `adapter-in-cli` (bestehend) → drei neue CLI-Befehle für Modell-Preise

### Währung: USD

Modell-Preise werden ausschließlich in **USD** gespeichert und angezeigt.
Begründung: Die Provider rechnen in USD ab. Eine Umrechnung in EUR ist
trügerisch, da der Wechselkurs zum Zeitpunkt des Token-Verbrauchs
irrelevant ist.

### Speicherformat für Preise: Nano-USD pro Token (INTEGER)

Modell-Preise werden in der DB als **Integer in Nano-USD pro Token**
gespeichert (1 Nano-USD = 10⁻⁹ USD).

| Anbieter-Notation | Umrechnung in Nano-USD/Token |
|---|---|
| `$0.15 / 1M Tokens` (gpt-4o-mini Input) | `150` |
| `$5.00 / 1M Tokens` (claude-opus-4-7 Input) | `5000` |
| `$25.00 / 1M Tokens` (claude-opus-4-7 Output) | `25000` |

### Eingabe-Umrechnungsregel `$/1M Tokens` ↔ Nano-USD/Token

- **GUI- und CLI-Eingabe:** Dezimalwert in `$ pro 1M Tokens`, maximal 6 Nachkommastellen
- **Konvertierung in Nano-USD/Token:** `nanoPerToken = round(amountUsdPer1M × 1000)` mit `RoundingMode.HALF_UP`
- **Validierung im Use Case:** mehr als 6 Nachkommastellen → Validierungsfehler; nicht parsebar / negativ / über Maximum → Validierungsfehler

### Wertgrenzen und Overflow-Strategie

V3.3 wählt eine **gestufte Aggregations-Strategie**: SQL liefert Pro-Group-
Aggregate als `long`, Java summiert über mehrere Groups mit
`BigInteger`/`BigDecimal`. Diese Strategie ist **robust für realistische
Datenmengen** unter folgender expliziter Annahme:

> **Annahme:** Pro `(fingerprint, ai_provider, model_name)`-Gruppe treten
> in der Praxis maximal etwa 100 Attempts auf. Bei dieser Größenordnung
> ist die SQL-`SUM`-Aggregation als `long` mit großer Reserve sicher.

**Wertgrenzen pro Feld (DB-CHECK + Adapter-Validierung):**

| Größe | Maximum | Verhalten bei Überschreitung |
|---|---|---|
| Token pro Feld (input/output/cache) | `10_000_000` (10 Mio) | Adapter setzt Feld auf NULL, WARN-Log |
| Nano-USD-Preis pro Token (input/output) | `100_000_000` (= $0,10/Token = $100.000/1M Tokens) | Use-Case-Validierung verweigert Speichern |

**Aggregations-Ebenen:**

| Ebene | Datentyp | Sicherheits-Reserve |
|---|---|---|
| Pro-Attempt-Kosten (`tokens × price`) | `long` (in SQL) | Max 10M × 100M = 10¹⁵ → Faktor ~9000 in long |
| Pro-Group-Aggregation (Σ über Attempts einer Group) | `long` (in SQL) | Bei 100 Attempts max 10¹⁷ → Faktor ~90 in long |
| Über-Group-Aggregation (Kopfzeile, Summary) | `BigInteger`/`BigDecimal` (in Java) | Strukturell unbegrenzt |

**Was passiert, wenn die Annahme verletzt wird?** SQLite-`SUM` über
`long` kann bei extrem vielen Attempts pro Group still überlaufen. Der
Test `SqliteGroupAggregationOverflowGuardTest` (siehe Testliste) prüft,
dass Maximalwerte in einer realistisch dimensionierten Group keine
falschen Ergebnisse erzeugen. Bei nicht-realistischen Datenmengen (>1000
Attempts pro Group) gilt das Verhalten als undefiniert; ein
entsprechender Fix wäre V3.x-Scope (Pro-Group-Aggregation in Java mit
BigInteger).

### Cache-Tokens (Anthropic)

V3.3 **persistiert** Cache-Tokens (`cache_creation_input_tokens`,
`cache_read_input_tokens`), berechnet aber **keine Cache-Kosten**.

**Konsequenz für die Anzeige:**

- Token-Spalten zeigen Standard-Tokens normal an (Cache nicht aufaddiert)
- Kostenspalte erhält Suffix `(ohne Cache-Anteil)` wenn Cache-Tokens vorkamen
- Banner über aggregierten Tabellen
- Tooltip listet alle gleichzeitigen Ursachen einzeln auf

**Cache-only Attempts:**

- **In Aggregations-Tabellen** (Kosten-Analyse, Run-Summary): in der Hauptaggregation **ausgeschlossen** (Einschlusskriterium: `input_tokens` ODER `output_tokens` nicht NULL)
- **Aber sichtbar gemacht über zusätzliche Zähl-Information:**
  - Im Summary-Banner: zusätzliche Zeile `ℹ Cache-only Versuche: {n} (in Kosten nicht enthalten)`
  - Im Kosten-Analyse-Tab: zusätzlicher Banner `ℹ {n} Cache-only Versuche im Zeitraum (in Kosten nicht enthalten)`
- **Im History-Tab:** sichtbar mit eigenem Status-Text „nur Cache-Tokens, keine Standardkosten"

### Kein automatischer Preis-Update-Mechanismus

Issue **#95** ist eigenständig angelegt, aber **nicht in V3.3**.

### Headless-Betrieb mit CLI-Preisverwaltung

Modell-Preise sind über GUI **und** CLI editierbar (siehe Abschnitt
„#99 – CLI für Modell-Preise" mit vollem CLI-Vertrag).

---

## Scope

### In V3.3 enthalten

| # | Thema | AP |
|---|---|---|
| #74 | Token- und Kosten-Tracking inkl. Pagination und Sortierung | AP-A + AP-B |
| #98 | Modell-Combobox: Filterung ungeeigneter Modelle | AP-C |
| #99 | CLI-Befehle für Modell-Preise | AP-C |

### Explizit nicht in V3.3

- Cache-Token-Auswertung in der Kostenberechnung → V3.x
- Automatischer Preis-Update-Mechanismus (#95) → V3.x
- Zeit-Kosten-Graph (#96) → V3.x
- Token-Schätzung für historische Einträge → bewusst nicht
- EUR-Umrechnung → bewusst nicht
- Letzte Konfiguration nicht geladen (#97) → wird **vor** V3.3 separat behoben
- Filter nach Banner-Status → V3.x
- Pro-Group-Aggregation in Java mit BigInteger (gegen extreme Datenmengen >1000 Attempts pro Group) → V3.x
- Cursor-basierte Pagination (Keyset-Pagination) → V3.x falls OFFSET-Performance kritisch wird

---

## Unverrückbare Leitplanken

- Java 21, Maven Multi-Module, hexagonale Architektur
- Shade-JAR als primäres Distributionsartefakt
- GUI ist Standardstart, `--headless` und CLI-Befehle bleiben vollständig erhalten
- `.properties` bleibt die einzige Konfigurationswahrheit
- Flyway ist die einzige Schema-Evolutionsquelle
- Kein JavaFX in `domain` oder `application`
- Modell-Preise sind **DB-only**, editierbar über GUI und CLI
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases, DTOs und öffentlichen Adapter-Methoden
- Notwendige Code-Kommentare auf Deutsch; Logging auf Deutsch
- Geldbeträge intern als `BigDecimal` mit voller Präzision; Persistierung als `INTEGER` in Nano-USD; Anzeigeformatierung **ausschließlich** im GUI-Layer auf vier Nachkommastellen
- Über-Group-Aggregationen erfolgen in Java mit `BigInteger`/`BigDecimal`
- Validierung von Domänen-Invarianten **immer** in der Application-Schicht (nicht ausschließlich GUI/CLI)
- Aggregierende Identität ist **(provider, model_name)**, nicht `model_name` allein
- Application-Read-Model-DTOs enthalten **keine** JavaFX-Typen, GUI-Strings oder Locale-Formatierungen
- **Lese-Konsistenz**: Eine vollständige Aktualisierung des Kosten-Analyse-Tabs erfolgt innerhalb **einer Read-Transaction** (siehe Abschnitt „Lese-Konsistenz")

---

## Kosten-Semantik (Snapshot-Modell)

> Zum Zeitpunkt jedes erfolgreichen KI-Aufrufs wird der dann gültige Modell-Preis
> aus `model_price` als **Snapshot** zusammen mit den Token-Counts in
> `processing_attempt` persistiert. Spätere Preisänderungen wirken **nicht**
> auf historische Kostenanzeigen.

### Aggregationsregel

Werden Versuche derselben **(Fingerprint × Provider × Modell)**-Gruppe oder
desselben `run_id` aggregiert, werden Kosten **pro Attempt mit dessen
eigenem Snapshot-Preis** berechnet und anschließend summiert:

```
Gesamtkosten = Σᵢ ( inputTokensᵢ × snapshotPriceInputᵢ + outputTokensᵢ × snapshotPriceOutputᵢ ) / 10⁹
```

Architektur:
- **SQL berechnet Pro-Attempt-Kosten und aggregiert pro Group** zu Long-Werten
- **Java aggregiert über alle Groups** in `BigInteger`, konvertiert zu `BigDecimal`-USD
- Der `CostCalculator` interpretiert die aggregierten Rohkosten und bestimmt Statusflags

### Provider als Aggregationsschlüssel

`(provider, model_name)` ist Composite Primary Key in `model_price`,
also auch Aggregationsschlüssel:

- **Cost-Analysis-Aggregation:** `(fingerprint, ai_provider, model_name)`
- **Run-Summary-Aggregation:** `(ai_provider, model_name)`
- DTOs tragen `provider` als Feld; GUI zeigt Provider als Tooltip am Modellnamen

### V3.3-Provider-Whitelist

V3.3 unterstützt fachlich **nur** `openai-compatible` und `claude`.
Die DB ist bewusst offen (keine CHECK-Constraint auf `provider`).
Falls in `model_price` ein unbekannter Provider-Wert vorhanden ist,
zeigt die GUI ihn **read-only** mit Hinweis „Unbekannter Provider" an.
Die CLI verhält sich analog (siehe „#99 – CLI für Modell-Preise").

---

## Lese-Konsistenz

Eine vollständige Aktualisierung des Kosten-Analyse-Tabs umfasst mehrere
Queries (Page, Total-Count, Totals, Cache-only-Count). Damit Kopfzeile
und Tabelle zueinander passen, läuft die Aktualisierung in **einer
Read-Transaction**:

```
connection.setAutoCommit(false);
connection.setReadOnly(true);
try {
    page         = readPage(...);
    totals       = readTotals(...);
    totalCount   = readTotalCount(...);
    cacheOnly    = readCacheOnlyCount(...);
    connection.commit();   // Read-Only: rein zur Snapshot-Beendigung
} catch (...) {
    connection.rollback();
    throw ...;
}
```

**Regeln:**

- WAL-Modus stellt sicher, dass Reader nicht durch Writer blockiert werden
- Innerhalb der Transaction sehen alle Queries denselben SQLite-Snapshot
- Die Transaction ist kurzlebig (typisch < 1 Sekunde); bei `database is locked`
  greift der `busy_timeout` von 5 Sekunden
- Failed-Transaction → deutsche GUI-Fehlermeldung, vorigen Tab-Zustand behalten
- Fortgesetzte Verarbeitung durch den Scheduler bleibt unbeeinträchtigt (WAL)

---

## Anzeige-Semantik (zentral, gilt überall in der GUI)

### Status-Tabelle pro Versuch / Aggregations-Zeile

| Zustand (Flags) | Tokenanzeige | Kostenanzeige | Tooltip-Ursachen |
|---|---|---|---|
| `exact` (alle Daten vorhanden) | `1.234` | `$0.0023` | – |
| `partialTokens` ohne `missingPriceSnapshot`, Preis vorhanden | `1.234 / –` oder `– / 80` | `~$0.0012` | „Untergrenze geschätzt – ein Token-Wert fehlte." |
| `missingPriceSnapshot` ohne `partialTokens`, kein berechenbarer Anteil | `1.234` | `Preis fehlt` | „Zum Zeitpunkt des Aufrufs war kein Modell-Preis konfiguriert." |
| `missingPriceSnapshot` mit teilweise berechenbaren Kosten | `412.230` | `~$0.0780 (unvollständig)` | „X Versuche ohne Preis-Snapshot." |
| `partialTokens` UND `missingPriceSnapshot`, kein berechenbarer Anteil | `1.234 / –` | `unvollständig` | beide Ursachen |
| `partialTokens` UND `missingPriceSnapshot` mit berechenbarem Anteil | wie oben | `~$0.0012 (unvollständig)` | beide Ursachen |
| `noTokens` | `keine Token-Daten` | `keine Token-Daten` | „Tokens wurden nicht erfasst." |
| Cache-only (nur in History) | `nur Cache: 1.234` | `nur Cache-Tokens, keine Standardkosten` | „Cache-Tokens werden in V3.3 nicht in die Kostenberechnung einbezogen." |
| Versuch fehlgeschlagen vor API-Aufruf (nur in History) | `–` | `–` | – |
| `cacheTokensIgnored` zusätzlich | wie oben + Suffix `(ohne Cache-Anteil)` | Tooltip-Ursache `cacheTokensIgnored` ergänzt |
| Sehr kleiner Betrag (< $0.0001) | – | `< $0.0001` | – |

### Banner über aggregierten Tabellen

| Bedingung | Banner-Text |
|---|---|
| Mind. eine Zeile mit `partialTokens` | „⚠ {n} Versuche mit unvollständigen Token-Daten – Kosten als Untergrenze geschätzt" |
| Mind. eine Zeile mit `missingPriceSnapshot` | „⚠ {n} Versuche ohne Preis-Snapshot – Kosten unvollständig" |
| Mind. eine Zeile mit `cacheTokensIgnored` | „⚠ {n} Versuche enthalten Cache-Tokens, die nicht in die Kosten eingerechnet sind" |
| Cache-only Versuche im Zeitraum vorhanden | „ℹ {n} Cache-only Versuche im Zeitraum (in Kosten nicht enthalten)" |
| Page automatisch korrigiert (siehe „Page-Navigation") | „ℹ Page automatisch angepasst" |
| Mehrere Bedingungen gleichzeitig | Eine Bannerzeile pro Bedingung |

**Banner sind reine Hinweisanzeigen ohne Klick-Interaktion in V3.3.**

### Gesamt-Kosten-Kopfzeile

Die Gesamt-Kosten-Kopfzeile bezieht sich auf den **vollständigen
Zeitraum** (nicht nur die aktuelle Page). Sie wird durch eine zweite
SQL-Aggregation pro `(ai_provider, model_name)` berechnet, deren
Ergebnisse in Java mit `BigInteger` summiert werden. Die Mix-Status-Logik
gilt analog zur Zell-Anzeige.

### Formatierungsregeln

- Tokenzahlen: deutsche Locale (`412.230`)
- Geldbeträge: US-Notation (`$0.0792`)
- 4 Nachkommastellen
- Beträge unterhalb $0.0001 als `< $0.0001`
- Niemals `$0.0000` für nicht-null-Beträge
- Tilde `~` markiert Untergrenzen
- Suffix `(ohne Cache-Anteil)` bei Cache-Token-Beteiligung
- Suffix `(unvollständig)` bei Mix-Status mit fehlendem Preis

---

## #74 – Token- und Kosten-Tracking

### Datenbankschema – `V2__token_tracking.sql`

```sql
-- V2: Token-Erfassung mit Preis-Snapshot in processing_attempt;
-- neue model_price-Tabelle mit Composite Primary Key.
-- Verifizierter Stand: V1__initial_schema.sql ist die einzige bisherige
-- Migration im Projekt.

ALTER TABLE processing_attempt
    ADD COLUMN input_tokens INTEGER
    CHECK (input_tokens IS NULL OR (input_tokens >= 0 AND input_tokens <= 10000000));

ALTER TABLE processing_attempt
    ADD COLUMN output_tokens INTEGER
    CHECK (output_tokens IS NULL OR (output_tokens >= 0 AND output_tokens <= 10000000));

ALTER TABLE processing_attempt
    ADD COLUMN cache_creation_input_tokens INTEGER
    CHECK (cache_creation_input_tokens IS NULL OR (cache_creation_input_tokens >= 0 AND cache_creation_input_tokens <= 10000000));

ALTER TABLE processing_attempt
    ADD COLUMN cache_read_input_tokens INTEGER
    CHECK (cache_read_input_tokens IS NULL OR (cache_read_input_tokens >= 0 AND cache_read_input_tokens <= 10000000));

ALTER TABLE processing_attempt
    ADD COLUMN price_input_per_token_nano_usd INTEGER
    CHECK (price_input_per_token_nano_usd IS NULL OR (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000));

ALTER TABLE processing_attempt
    ADD COLUMN price_output_per_token_nano_usd INTEGER
    CHECK (price_output_per_token_nano_usd IS NULL OR (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000));

CREATE TABLE model_price (
    provider                          TEXT    NOT NULL,
    model_name                        TEXT    NOT NULL,
    price_input_per_token_nano_usd    INTEGER NOT NULL CHECK (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000),
    price_output_per_token_nano_usd   INTEGER NOT NULL CHECK (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000),
    currency                          TEXT    NOT NULL DEFAULT 'USD' CHECK (currency = 'USD'),
    updated_at                        TEXT    NOT NULL,
    PRIMARY KEY (provider, model_name)
);

CREATE INDEX idx_processing_attempt_started_at_provider_fp_model
    ON processing_attempt (started_at, ai_provider, fingerprint, model_name);

CREATE INDEX idx_processing_attempt_run_id_provider_model
    ON processing_attempt (run_id, ai_provider, model_name);

-- Default-Preise (Stand 2026-05-08, in Nano-USD pro Token)
-- Quellen (abgerufen 2026-05-08):
--   OpenAI:    https://openai.com/api/pricing/
--   Anthropic: https://www.anthropic.com/pricing
-- ON CONFLICT DO NOTHING: schützt vor manuell vorhandenen Default-Zeilen.
INSERT INTO model_price
    (provider, model_name, price_input_per_token_nano_usd, price_output_per_token_nano_usd, currency, updated_at)
VALUES
    ('openai-compatible', 'gpt-4o-mini',                  150,    600, 'USD', '2026-05-08T00:00:00Z'),
    ('openai-compatible', 'gpt-4o',                      2500,  10000, 'USD', '2026-05-08T00:00:00Z'),
    ('openai-compatible', 'gpt-4.1',                     2000,   8000, 'USD', '2026-05-08T00:00:00Z'),
    ('openai-compatible', 'gpt-4.1-mini',                 400,   1600, 'USD', '2026-05-08T00:00:00Z'),
    ('openai-compatible', 'gpt-4.1-nano',                 100,    400, 'USD', '2026-05-08T00:00:00Z'),
    ('openai-compatible', 'gpt-5',                       1250,  10000, 'USD', '2026-05-08T00:00:00Z'),
    ('openai-compatible', 'gpt-5-mini',                   250,   2000, 'USD', '2026-05-08T00:00:00Z'),
    ('claude',            'claude-haiku-4-5-20251001',   1000,   5000, 'USD', '2026-05-08T00:00:00Z'),
    ('claude',            'claude-sonnet-4-6',           3000,  15000, 'USD', '2026-05-08T00:00:00Z'),
    ('claude',            'claude-opus-4-7',             5000,  25000, 'USD', '2026-05-08T00:00:00Z')
ON CONFLICT (provider, model_name) DO NOTHING;
```

**`updated_at`-Lesepfad:** Adapter parst `Instant.parse(...)`. Bei
`DateTimeParseException`: WARN-Log, Adapter erzeugt ein
`ModelPriceView`-Objekt mit `updatedAt = null` und `invalidUpdatedAt = true`.

### Architektur-Übersicht

```
[KI-Provider-Response]
       ↓ (Tokens + ggf. Cache-Tokens)
[AnthropicClaudeHttpAdapter / OpenAiHttpAdapter]
       ↓ extrahiert Tokens, validiert gegen Wertgrenzen
[AiInvocationSuccess (mit AiUsageMetadata)]
       ↓
[BatchRunProcessingUseCase]
       ↓ (1) lädt Preis-Snapshot via ModelPriceRepository
       ↓ (2) baut processing_attempt-Record mit Tokens + Snapshot-Preis
       ↓ (3) persistiert atomar
[SQLite: processing_attempt]


[GUI Kosten-Analyse-Tab / History-Tab / Summary-Banner]
       ↓ liest aggregierte Rohkosten + Flags + Pagination
[TokenStatisticsReadModelPort (application.reporting.costanalysis)]
       ↓ liefert pro Aktualisierung:
       ↓   - Page-Daten (Long-Aggregate je Group)
       ↓   - Total-Count
       ↓   - Gesamt-Aggregat (Pro-(Provider,Modell)-Long-Aggregate
       ↓     → in Java mit BigInteger zur Kopfzeile summiert)
       ↓   - Cache-only-Anzahl
       ↓   alle in EINER Read-Transaction
[CostCalculator (Application)]
       ↓ interpretiert Rohkosten:
       ↓   Long-/BigInteger-Werte → BigDecimal-USD volle Präzision
       ↓   setzt CostResult-Flags
[GUI rendert mit Anzeige-Semantik, Pagination, Sortierung]
       ↓ formatiert auf 4 Nachkommastellen (CostFormatter)
```

### Adapter-Erweiterungen

#### Anthropic-Adapter

Neue Methode `extractTokenUsageFromResponse(JSONObject root)` extrahiert
`usage.input_tokens`, `usage.output_tokens`,
`usage.cache_creation_input_tokens`, `usage.cache_read_input_tokens`.

Validierung: nicht numerisch / negativ / > 10 Mio → Feld NULL, WARN-Log.

#### OpenAI-kompatibler Adapter

Mapping `prompt_tokens → input_tokens`, `completion_tokens → output_tokens`.
Cache-Felder bleiben **immer NULL**.

### Domain- und Application-Erweiterungen

#### DTO `AiUsageMetadata`

```java
package de.gecheckt.pdf.umbenenner.application.dto;

public record AiUsageMetadata(
        Long inputTokens,
        Long outputTokens,
        Long cacheCreationInputTokens,
        Long cacheReadInputTokens
) {
    public static AiUsageMetadata empty() {
        return new AiUsageMetadata(null, null, null, null);
    }

    public boolean hasAnyTokenData() {
        return inputTokens != null || outputTokens != null;
    }

    public boolean hasCacheTokens() {
        return (cacheCreationInputTokens != null && cacheCreationInputTokens > 0)
            || (cacheReadInputTokens != null && cacheReadInputTokens > 0);
    }
}
```

#### Erweiterung `AiInvocationSuccess`

```java
public record AiInvocationSuccess(
        AiRequestRepresentation request,
        AiRawResponse rawResponse,
        AiUsageMetadata usageMetadata
) implements AiInvocationResult { ... }
```

#### DTOs `ModelPriceEntry` (Schreibpfad) und `ModelPriceView` (Lesepfad)

```java
/**
 * Schreib- und Validierungs-DTO. updatedAt non-null.
 * Wird in ManageModelPricesUseCase für Inserts/Updates verwendet.
 */
public record ModelPriceEntry(
        String provider,
        String modelName,
        long priceInputPerTokenNanoUsd,
        long priceOutputPerTokenNanoUsd,
        String currency,
        Instant updatedAt
) {
    public ModelPriceEntry {
        Objects.requireNonNull(provider, "provider");
        Objects.requireNonNull(modelName, "modelName");
        Objects.requireNonNull(updatedAt, "updatedAt");
        if (provider.isBlank()) throw new IllegalArgumentException("provider darf nicht leer sein");
        if (modelName.isBlank()) throw new IllegalArgumentException("modelName darf nicht leer sein");
        if (priceInputPerTokenNanoUsd  < 0) throw new IllegalArgumentException("Input-Preis darf nicht negativ sein");
        if (priceOutputPerTokenNanoUsd < 0) throw new IllegalArgumentException("Output-Preis darf nicht negativ sein");
        if (priceInputPerTokenNanoUsd  > 100_000_000L) throw new IllegalArgumentException("Input-Preis überschreitet Maximum");
        if (priceOutputPerTokenNanoUsd > 100_000_000L) throw new IllegalArgumentException("Output-Preis überschreitet Maximum");
        if (!"USD".equals(currency)) throw new IllegalArgumentException("Nur Währung USD unterstützt");
    }
}

/**
 * Lese-/Anzeige-DTO. updatedAt nullable bei beschädigten DB-Werten.
 * NICHT direkt im Schreibpfad verwendbar.
 */
public record ModelPriceView(
        String provider,
        String modelName,
        long priceInputPerTokenNanoUsd,
        long priceOutputPerTokenNanoUsd,
        String currency,
        Instant updatedAt,             // null möglich
        String invalidUpdatedAtRaw,    // gesetzt wenn DB-Wert nicht parsebar
        boolean invalidUpdatedAt
) {}
```

#### Outbound-Port `ModelPriceRepository`

```java
package de.gecheckt.pdf.umbenenner.application.port.out;

/**
 * Repository für Modell-Preise.
 *
 * Schreibpfad-Konvention: GUI und CLI nutzen ausschließlich
 * saveAllChanges(...) für transaktionale Batch-Speicherung.
 *
 * Lesemethoden liefern ModelPriceView (mit nullable updatedAt).
 * Schreibmethoden akzeptieren ausschließlich ModelPriceEntry.
 */
public interface ModelPriceRepository {
    List<ModelPriceView> findAll();
    Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName);

    /** @internal Nicht von GUI/CLI direkt verwenden – nur für Tests/Werkzeuge. */
    void upsert(ModelPriceEntry entry);

    /** @internal Nicht von GUI/CLI direkt verwenden – nur für Tests/Werkzeuge. */
    void delete(String provider, String modelName);

    /**
     * Speichert eine Sammlung von Preisänderungen atomar.
     * Validierung des ChangeSets erfolgt vor der Transaktion im Use Case.
     */
    void saveAllChanges(ModelPriceChangeSet changeSet);
}

public record ModelPriceChangeSet(
        List<ModelPriceEntry> upserts,
        List<ModelPriceKey> deletions
) {}

public record ModelPriceKey(String provider, String modelName) {}
```

**ChangeSet-Validierungsregeln (im `ManageModelPricesUseCase`):**

| Regel | Verhalten |
|---|---|
| Ein Key (provider, modelName) darf in genau einer Operation vorkommen | Konflikt → Validierungsfehler |
| Doppelte Keys innerhalb `upserts` oder `deletions` | Validierungsfehler |
| Leeres ChangeSet | No-op; INFO-Log; keine Transaktion |
| „Ersetzen" (Wert eines Eintrags aktualisieren) | Erfolgt als Upsert, nicht Delete+Insert |

#### Outbound-Port `TokenStatisticsReadModelPort`

```java
package de.gecheckt.pdf.umbenenner.application.reporting.costanalysis;

/**
 * Read-Model-Port für aggregierte Token-Statistiken und Kosten.
 * GUI-/Reporting-Read-Model. DTOs enthalten keine JavaFX-Typen,
 * Locale-Formatierung oder GUI-Strings.
 *
 * Aggregationsschlüssel:
 *   - Cost-Analysis: (fingerprint, ai_provider, model_name)
 *   - Run-Summary:   (ai_provider, model_name)
 *
 * Lese-Konsistenz: Eine Aktualisierung läuft in einer Read-Transaction
 * (siehe Spec-Abschnitt "Lese-Konsistenz").
 */
public interface TokenStatisticsReadModelPort {

    /**
     * Liefert Page, Total-Count, Totals und Cache-only-Count gemeinsam
     * in einer Read-Transaction. Damit ist die Gesamt-Kopfzeile
     * konsistent mit der angezeigten Page.
     *
     * Pagination: pageNumber 0-basiert; pageSize aus {50,100,250,500}.
     */
    CostAnalysisFullResult queryCostAnalysisFull(
            Instant from,
            Instant to,
            int pageNumber,
            int pageSize,
            CostAnalysisSortField sortField,
            SortDirection sortDirection);

    /**
     * Liefert nur Totals + Cache-only-Count, ohne Page.
     * Wird verwendet, wenn nur der Zeitraum geprüft werden soll.
     */
    CostAnalysisHeaderResult queryCostAnalysisHeaderOnly(Instant from, Instant to);

    /**
     * Liefert Token- und Kosten-Aggregation eines konkreten Laufs.
     */
    RunSummaryResult queryRunSummary(String runId);
}
```

**Sortier-Enum:**

```java
public enum CostAnalysisSortField {
    LAST_STARTED_AT,
    PROVIDER_AND_MODEL_NAME,
    SOURCE_FILE_NAME,
    NEWEST_TARGET_FILE_NAME,
    SUM_INPUT_TOKENS,
    SUM_OUTPUT_TOKENS,
    SUM_TOTAL_COST_NANO_USD
}

public enum SortDirection { ASC, DESC }
```

#### DTOs

```java
public record CostAnalysisFullResult(
        List<CostAnalysisRow> rows,
        int pageNumber,
        int pageSize,
        long totalRowCount,           // Gesamtanzahl Groups im Zeitraum
        CostAnalysisTotals totals     // über vollständigen Zeitraum
) {}

public record CostAnalysisHeaderResult(
        long totalRowCount,
        CostAnalysisTotals totals
) {}

public record CostAnalysisRow(
        String fingerprint,
        String provider,
        String modelName,
        Instant lastStartedAt,
        String sourceFileName,
        String newestTargetFileName,
        Long sumInputTokens,
        Long sumOutputTokens,
        Long sumCacheCreationInputTokens,
        Long sumCacheReadInputTokens,
        Long sumInputCostNanoUsd,
        Long sumOutputCostNanoUsd,
        boolean hasPartialTokenData,
        boolean hasMissingPriceSnapshot,
        boolean hasCacheTokensIgnored
) {}

/**
 * Über vollständigen Zeitraum aggregierte Werte.
 *
 * NULL-Semantik: BigInteger-Felder sind null, wenn KEIN Wert vorhanden war.
 * Die hasAny...-Flags ermöglichen klare Unterscheidung zwischen
 * "keine Werte", "Summe = 0", "alle Werte ohne Preis", "Mikrobetrag":
 *   - hasAnyInputTokens = false  → totalInputTokens null UND keine Token-Daten
 *   - hasAnyInputCost   = false  → totalInputCostNanoUsd null UND kein berechenbarer Anteil
 */
public record CostAnalysisTotals(
        BigInteger totalInputTokens,         // null wenn hasAnyInputTokens=false
        BigInteger totalOutputTokens,        // null wenn hasAnyOutputTokens=false
        BigInteger totalInputCostNanoUsd,    // null wenn hasAnyInputCost=false
        BigInteger totalOutputCostNanoUsd,   // null wenn hasAnyOutputCost=false
        boolean hasAnyInputTokens,
        boolean hasAnyOutputTokens,
        boolean hasAnyInputCost,
        boolean hasAnyOutputCost,
        long cacheOnlyAttemptCount,
        boolean hasPartialTokenData,
        boolean hasMissingPriceSnapshot,
        boolean hasCacheTokensIgnored
) {}

public record RunSummaryResult(
        List<RunModelTokenSummary> rows,
        long cacheOnlyAttemptCount
) {}

public record RunModelTokenSummary(
        String provider,
        String modelName,
        Long sumInputTokens,
        Long sumOutputTokens,
        Long sumInputCostNanoUsd,
        Long sumOutputCostNanoUsd,
        boolean hasPartialTokenData,
        boolean hasMissingPriceSnapshot,
        boolean hasCacheTokensIgnored
) {}
```

#### Application-Komponente `CostCalculator`

```java
package de.gecheckt.pdf.umbenenner.application.cost;

/**
 * Interpretiert aggregierte Rohkosten in BigDecimal-USD und bestimmt
 * Status-Flags. Führt KEINE Multiplikation Token×Preis durch.
 */
public final class CostCalculator {

    /** Pro Tabellen-Zeile: Long-Aggregate → CostResult. */
    public CostResult formatRow(
            Long sumInputCostNanoUsd,
            Long sumOutputCostNanoUsd,
            boolean hasPartialTokenData,
            boolean hasMissingPriceSnapshot,
            boolean hasCacheTokensIgnored,
            boolean hasAnyTokenData) { ... }

    /** Für Kopfzeile / Summary: BigInteger-Aggregate + hasAny-Flags → CostResult. */
    public CostResult formatTotal(
            BigInteger totalInputCostNanoUsd,
            BigInteger totalOutputCostNanoUsd,
            boolean hasAnyInputCost,
            boolean hasAnyOutputCost,
            boolean hasAnyTokenData,
            boolean hasPartialTokenData,
            boolean hasMissingPriceSnapshot,
            boolean hasCacheTokensIgnored) { ... }

    /** Für History-Tab: Einzelner Attempt. */
    public CostResult calculateAttempt(
            Long inputTokens,
            Long outputTokens,
            Long priceInputPerTokenNanoUsd,
            Long priceOutputPerTokenNanoUsd,
            boolean hasCacheTokens) { ... }
}
```

DTO `CostResult`:

```java
public record CostResult(
        BigDecimal amountUsd,
        boolean exact,
        boolean partialTokens,
        boolean missingPriceSnapshot,
        boolean noTokens,
        boolean cacheTokensIgnored,
        boolean hasAnyCalculatedCost
) { ... }
```

**Mix-Status-Logik (Anzeige-Mapping):** wie in V5.

**Keine interne Rundung.** Rundung auf 4 Stellen im GUI-`CostFormatter`.

### Use Cases

| Use Case | Zweck |
|---|---|
| `ManageModelPricesUseCase` | CRUD-Fassade, Validierung, transaktionaler Batch, ChangeSet-Konfliktregeln |
| `QueryCostAnalysisFullUseCase` | Ruft `queryCostAnalysisFull(...)`; mappt auf GUI-DTO; **validiert Page-Parameter (siehe unten)** |
| `QueryCostAnalysisHeaderOnlyUseCase` | Ruft `queryCostAnalysisHeaderOnly(...)`; ohne Page-Render |
| `QueryRunSummaryUseCase` | Ruft `queryRunSummary(...)`; aggregiert in BigInteger |

**Page-Parameter-Validierung in `QueryCostAnalysisFullUseCase`:**

| Regel | Verhalten |
|---|---|
| `pageNumber >= 0` | Sonst Validierungsfehler |
| `pageSize ∈ {50, 100, 250, 500}` | Sonst Validierungsfehler |
| `sortField != null`, `sortDirection != null` | Sonst Validierungsfehler |
| Offset-Berechnung mit `long` | `Math.multiplyExact(pageNumber, pageSize)` – wirft `ArithmeticException` bei Overflow |
| `pageNumber > letzte gültige Page` (nach Datenänderung) | Use Case lädt **letzte gültige Page**, setzt Hinweis-Flag `pageAdjusted=true` |
| Leerer Zeitraum (totalRowCount=0) | `pageNumber=0`, leere Page |

### Persistenz-Adapter

#### `SqliteModelPriceRepositoryAdapter`

UPSERT via `INSERT ... ON CONFLICT(provider, model_name) DO UPDATE SET ...`.
`saveAllChanges(...)` läuft in einer JDBC-Transaktion mit
`autoCommit=false`.

`updated_at`-Lesepfad: bei `DateTimeParseException` → `ModelPriceView` mit
`updatedAt=null`, `invalidUpdatedAt=true`, `invalidUpdatedAtRaw=originalString`.

#### `SqliteTokenStatisticsReadModelAdapter`

**Gemeinsame Query-Bausteine:**

Um Drift zwischen den Queries zu vermeiden, werden gemeinsame
SQL-Fragmente als private Methoden im Adapter zentralisiert:

```java
final class SqliteTokenStatisticsReadModelAdapter implements TokenStatisticsReadModelPort {

    /** Gemeinsame WHERE-Klausel für Zeitraum-Filter und Token-Einschluss. */
    private static final String WHERE_TIME_RANGE_AND_HAS_STD_TOKENS = """
            WHERE pa.started_at >= :from
              AND pa.started_at <  :to
              AND (pa.input_tokens IS NOT NULL OR pa.output_tokens IS NOT NULL)
            """;

    /** Gemeinsame CTE attempts_with_tokens. */
    private static final String CTE_ATTEMPTS_WITH_TOKENS = """
            WITH attempts_with_tokens AS (
                SELECT
                    pa.fingerprint, pa.ai_provider, pa.model_name,
                    pa.started_at, pa.id AS attempt_id,
                    pa.input_tokens, pa.output_tokens,
                    pa.cache_creation_input_tokens, pa.cache_read_input_tokens,
                    pa.price_input_per_token_nano_usd, pa.price_output_per_token_nano_usd,
                    pa.final_target_file_name,
                    CASE WHEN pa.input_tokens IS NOT NULL AND pa.price_input_per_token_nano_usd IS NOT NULL
                         THEN pa.input_tokens * pa.price_input_per_token_nano_usd
                         ELSE NULL
                    END AS attempt_cost_input_nano_usd,
                    CASE WHEN pa.output_tokens IS NOT NULL AND pa.price_output_per_token_nano_usd IS NOT NULL
                         THEN pa.output_tokens * pa.price_output_per_token_nano_usd
                         ELSE NULL
                    END AS attempt_cost_output_nano_usd
                FROM processing_attempt pa
            """ + WHERE_TIME_RANGE_AND_HAS_STD_TOKENS + ")";
}
```

**Vier Queries innerhalb der Read-Transaction:**

| Query | Zweck |
|---|---|
| (1) Page-Query | CTEs `attempts_with_tokens` + `latest_per_group` + `groups`; `ORDER BY <sort>; LIMIT :pageSize OFFSET ...` |
| (2) Total-Count-Query | `SELECT COUNT(*) FROM (groups)` |
| (3) Totals-Query | Aggregation pro `(ai_provider, model_name)` für Java-BigInteger-Aufsummierung |
| (4) Cache-only-Count | Anzahl Attempts ohne Standard-Tokens, aber mit Cache-Tokens |

**Sortier-Whitelist (Adapter):**

| `CostAnalysisSortField` | SQL |
|---|---|
| `LAST_STARTED_AT` | `last_started_at` |
| `PROVIDER_AND_MODEL_NAME` | `provider, model_name` |
| `SOURCE_FILE_NAME` | `source_file_name` (NULLS LAST per `CASE WHEN ... IS NULL THEN 1 ELSE 0 END`) |
| `NEWEST_TARGET_FILE_NAME` | `newest_target_file_name` (NULLS LAST) |
| `SUM_INPUT_TOKENS` | `sum_input` (NULLS LAST) |
| `SUM_OUTPUT_TOKENS` | `sum_output` (NULLS LAST) |
| `SUM_TOTAL_COST_NANO_USD` | **zweistufig**: `(CASE WHEN sum_input_cost_nano_usd IS NULL AND sum_output_cost_nano_usd IS NULL THEN 1 ELSE 0 END) ASC, (COALESCE(sum_input_cost_nano_usd, 0) + COALESCE(sum_output_cost_nano_usd, 0)) <direction>` |

Die zweistufige Sortierung für Gesamt-Kosten stellt sicher, dass nicht
berechenbare Kosten unabhängig von ASC/DESC am Ende landen – nicht
fälschlich als „$0" einsortiert.

Tie-Breaker bei gleichem Sort-Schlüssel: `fingerprint, provider, model_name` ASC.

**Java-Aggregation der Totals (Pseudocode):**

```java
BigInteger totalInputCostNanoUsd  = null;
BigInteger totalOutputCostNanoUsd = null;
BigInteger totalInputTokens       = null;
BigInteger totalOutputTokens      = null;
boolean hasPartial = false, hasMissingPrice = false, hasCacheIgnored = false;

for (ProviderModelTotalRow row : queryProviderModelTotals(...)) {
    if (row.sumInputCostNanoUsd() != null) {
        totalInputCostNanoUsd = (totalInputCostNanoUsd == null ? BigInteger.ZERO : totalInputCostNanoUsd)
                .add(BigInteger.valueOf(row.sumInputCostNanoUsd()));
    }
    if (row.sumOutputCostNanoUsd() != null) {
        totalOutputCostNanoUsd = (totalOutputCostNanoUsd == null ? BigInteger.ZERO : totalOutputCostNanoUsd)
                .add(BigInteger.valueOf(row.sumOutputCostNanoUsd()));
    }
    if (row.sumInput()  != null) totalInputTokens  = (totalInputTokens  == null ? BigInteger.ZERO : totalInputTokens) .add(BigInteger.valueOf(row.sumInput()));
    if (row.sumOutput() != null) totalOutputTokens = (totalOutputTokens == null ? BigInteger.ZERO : totalOutputTokens).add(BigInteger.valueOf(row.sumOutput()));
    hasPartial      |= row.hasPartial();
    hasMissingPrice |= row.hasMissingPrice();
    hasCacheIgnored |= row.hasCacheIgnored();
}

return new CostAnalysisTotals(
    totalInputTokens, totalOutputTokens,
    totalInputCostNanoUsd, totalOutputCostNanoUsd,
    /* hasAny... */ totalInputTokens != null,
                    totalOutputTokens != null,
                    totalInputCostNanoUsd != null,
                    totalOutputCostNanoUsd != null,
    cacheOnlyCount,
    hasPartial, hasMissingPrice, hasCacheIgnored
);
```

#### Performance-Hinweis Pagination

Bei sehr tiefen OFFSETs wird die Query langsamer (SQLite muss bis OFFSET
hochzählen). Realistisch werden Nutzer bei großen Ergebnismengen den
Zeitraum einschränken oder sortieren. Cursor-basierte Pagination wäre
performanter; V3.x falls Performance-Probleme in der Praxis auftreten.

#### SQLite-Concurrency mit Scheduler

| Aspekt | Festlegung |
|---|---|
| Journal-Modus | `WAL` |
| Busy-Timeout | 5 Sekunden, **pro Connection** (`PRAGMA busy_timeout=5000`) |
| Lese-Transaktionen | **Read-Transaction für vollständige Tab-Aktualisierung**; sofort schließen nach Query-Block |
| Schreibpfad | Atomare Einzel-Insert |
| GUI bei `database is locked` | Deutsche Fehlermeldung; UI nicht blockiert |
| Sichtbarkeit laufender Runs | Versuche werden sichtbar sobald persistiert |
| RunLock-Mechanismus | Bleibt unverändert |
| SQL-Boolean-Resultsets | Adapter mappt via `getInt(...) != 0` |

**Implementierungshinweis:** Code-Read hat ergeben, dass mehrere Repository-Adapter direkte
`DriverManager.getConnection`-Aufrufe ohne `SQLiteConfig` verwenden
(`SqliteProcessingAttemptRepositoryAdapter`, `SqliteUnitOfWorkAdapter` u.a.). Eine zentrale
DataSource würde diese nicht abdecken. AP-A umfasst daher zwingend die Einführung einer
zentralen Connection-Factory sowie den Umzug aller direkten `DriverManager.getConnection`-Stellen
auf diese Factory, bevor WAL und `busy_timeout` wirksam sind.

### Hook im `BatchRunProcessingUseCase`

```
1. AiInvocationPort.invoke(...) → AiInvocationResult
2. Bei AiInvocationSuccess:
     a. Versuch: ModelPriceRepository.findByProviderAndModelName(...)
          → Optional<ModelPriceView>
     b. Bei technischem Fehler beim Preis-Lookup:
          → ERROR-Log
          → Snapshot-Preis-Felder werden NULL gesetzt
          → Attempt wird trotzdem persistiert
     c. processing_attempt-Record bauen
     d. ProcessingAttemptRepository.save(...) (atomar)
3. Bei AiInvocationTechnicalFailure:
     - processing_attempt-Record mit Token- und Preis-Feldern auf NULL
4. Im Headless-Modus zusätzlich: Bei Modell ohne Preis-Eintrag
     WARN-Log mit Hinweis auf CLI-Befehl --upsert-model-price
```

### GUI – Tab „Modell-Preise"

```
┌── Modell-Preise ──────────────────────────────────────────────────┐
│  Provider              Modellname    In/1M    Out/1M   Währung   ⌫ │
│  ─────────────────────────────────────────────────────────────────│
│  openai-compatible     gpt-4o-mini   $0.15    $0.60     USD      ⌫ │
│  ...                                                               │
│  unknown-router  ⓘ     custom-model  $1.00    $5.00     USD       ⌫ │
│                                                                    │
│  [Modell hinzufügen]  [Speichern]                                  │
└────────────────────────────────────────────────────────────────────┘
```

- **Tabelle** in `$ pro 1M Tokens`; intern Nano-USD/Token. Eingabe max 6 Nachkommastellen.
- **Provider und Modellname** read-only nach Speichern (Composite Primary Key)
- **Unbekannte Provider** (nicht `openai-compatible`/`claude`): Zeile **read-only mit Tooltip-Icon** `ⓘ` „Unbekannter Provider – Bearbeitung in V3.3 nicht unterstützt"; **Löschen ist erlaubt**
- **Lösch-Button** mit Bestätigungsdialog
- **„Modell hinzufügen"**-Dialog: Provider-Combobox nur `openai-compatible` und `claude`
- **„Speichern"**: transaktionaler Batch
- **`updatedAt = null`** → Spalte zeigt „ungültig" mit Tooltip

### GUI – Konfigurations-Tab Erweiterung

Beim Speichern mit einem Modell ohne Preis-Eintrag: Warnung „Für dieses
Modell ist kein Preis hinterlegt; Tokens werden erfasst, Kosten können
jedoch nicht vollständig berechnet werden." Keine Blockade.

### GUI – Tab „Kosten-Analyse"

```
┌── Kosten-Analyse ─────────────────────────────────────────────────┐
│  Zeitraum:  ( ) 24h  (●) 7 Tage  ( ) 1 Monat  ( ) Benutzerdefiniert│
│             Von: [____________]  Bis einschließlich: [____________]│
│  [Aktualisieren]                                                   │
│                                                                    │
│  Gesamt-Tokens:  Input 412.230   Output 7.846                      │
│  Gesamt-Kosten:  ~$0.0792 (unvollständig)                          │
│  ⚠ 3 Versuche ohne Preis-Snapshot                                  │
│  ℹ 2 Cache-only Versuche (in Kosten nicht enthalten)               │
│                                                                    │
│  Page-Größe: [ 100 ▼ ]   Sortiert nach: Letztes Datum (absteigend) │
│                                                                    │
│  ┌────────────────────────────────────────────────────────────────┐│
│  │ Letztes Datum ▼│ Quellname │ Neuster Name │ Modell ⓘ │ In │ Out │ Kosten ││
│  │ ...                                                            ││
│  └────────────────────────────────────────────────────────────────┘│
│                                                                    │
│  ⏮  ⏪  Seite 1 / 24  ⏩  ⏭                                          │
└────────────────────────────────────────────────────────────────────┘
```

#### Pagination und Sortierung

- **Page-Größen**: 50 / 100 / 250 / 500 (Combobox)
- **Default**: 100
- **Pagination-Komponente**: Erste / Vorherige / Seitenanzeige / Nächste / Letzte
- **Sortierung**: Klick auf Spaltenkopf wechselt zwischen DESC → ASC → DESC; Sortierpfeil im Header
- **Default-Sortierung**: `LAST_STARTED_AT DESC`
- **NULL-Werte**: NULLS LAST per zweistufiger Sortierlogik

#### Page-Navigation (Trigger-Logik)

| Trigger | Page-Verhalten |
|---|---|
| Zeitraumwechsel | Page 0 |
| Sortierwechsel | Page 0 |
| Page-Größe-Wechsel | `neueSeite = floor(alteSeite × alteGröße / neueGröße)` (User bleibt grob beim selben Datenbereich) |
| Manuelles Blättern | Auf gewählte Seite |
| Refresh / „Aktualisieren" | Aktuelle Page beibehalten |
| Page out-of-range nach Refresh (z.B. Daten gelöscht oder eingefroren) | Auf **letzte gültige Page** korrigieren; Banner „Page automatisch angepasst" |

#### Modell-Spalte Tooltip `ⓘ`

„Provider: {provider}".

#### Zeitraum-Auswahl

Wie in V5: 24h / 7 Tage (Default) / 1 Monat / Benutzerdefiniert; „Bis
einschließlich" → Tagesbeginn des Folgetags; halboffener DB-Filter.

#### Aggregations-Logik

Eine Zeile pro **(Fingerprint × Provider × Modellname)**.
Pro-Attempt-Snapshot. Identischer Modellname zwei Provider → zwei Zeilen.

#### Status-Einschluss

`input_tokens` ODER `output_tokens` nicht NULL. Cache-only separat über
`cacheOnlyAttemptCount`.

#### Quellname / Neuster Name

`last_known_source_file_name` (mutabel – wird bei jedem Verarbeitungslauf auf
den aktuellen Quellpfad aktualisiert; zeigt daher den zuletzt bekannten Quellnamen,
nicht zwingend den ursprünglichen) (LEFT JOIN, NULL→„–"); `final_target_file_name`
des jüngsten Versuchs (NULL→„–").

Tooltip Quellname-Spalte: „letzter bekannter Quellname (wird bei Wiederholung aktualisiert)".

#### Banner und Gesamt-Kosten-Kopfzeile

Gemäß Anzeige-Semantik. Kopfzeile bezieht sich auf vollständigen Zeitraum.

#### Threading-Modell

- DB-Zugriff im Worker-Thread (`Task<CostAnalysisFullResult>`)
- UI-Updates auf JavaFX Application Thread
- Aktualisieren-Button und Pagination-Steuerung während Query deaktiviert
- **Stale-Result-Schutz:**
  - Beim Apply prüft GUI Query-Parameter (Zeitraum, pageNumber, pageSize, sortField, sortDirection)
  - Stimmen sie nicht mit aktuellem Tab-Zustand überein: Ergebnis verwerfen
  - `Task.cancel()` wird aufgerufen, ist aber nicht korrektheitsrelevant
- **Page-/Sort-Wechsel** triggert nur die `queryCostAnalysisFull(...)`-Methode; **Zeitraum-Wechsel** ebenso (alle Queries der Read-Transaction laufen gemeinsam)
- Bei `database is locked` oder anderen SQL-Exceptions: deutsche Inline-Fehlermeldung; vorigen Zustand behalten

### GUI – Erweiterung History-Tab

Drei zusätzliche Spalten: `Input-Tokens`, `Output-Tokens`, `Kosten`.
Cache-only Attempts mit Status-Text „nur Cache-Tokens, keine
Standardkosten". `updatedAt = null` → „ungültig".

### GUI – Erweiterung Summary-Banner

```
Lauf abgeschlossen: 12 erfolgreich / 1 fehlgeschlagen
Tokens: Input 13.812   Output 947
Kosten: $0.0023
ℹ 1 Cache-only Versuch (in Kosten nicht enthalten)
```

Mix-Status nach Anzeige-Semantik. Cache-only-Zeile nur wenn `count > 0`.

### Edge Cases und Fehlerbehandlung

| Fall | Verhalten |
|---|---|
| API liefert kein `usage`-Feld | Token-Felder NULL; KEIN Fehler |
| API liefert nur ein Token-Feld | Vorhandenes gespeichert, fehlendes NULL |
| API liefert Tokenwert > 10 Mio | Adapter setzt NULL, WARN |
| API liefert negativen Tokenwert | Adapter setzt NULL, WARN |
| `processing_attempt` aus V3.2-Bestand | Token-/Preis-Felder NULL; Anzeige „keine Token-Daten" |
| Modell verwendet, kein Preis-Eintrag | Tokens gespeichert, Snapshot NULL |
| Modell-Preis nachträglich geändert | Künftige Versuche mit neuem Preis; historische Snapshots stabil |
| Modell-Preis nachträglich gelöscht | Historische Anzeigen bleiben |
| Modell-Preis innerhalb eines Laufs geändert | Pro-Attempt-Snapshot greift |
| Datum-Filter Von > Bis | Inline-Validierung; Aktualisieren deaktiviert |
| Modell-Preis-Tabelle leer | Tab funktioniert |
| Validierung Preis-Tab fehlgeschlagen | Speichern blockiert; deutsche Meldung |
| Cache-Tokens vorhanden, keine Standard-Tokens | In Aggregation unsichtbar; in History sichtbar; im Banner gezählt |
| Cache-Tokens zusätzlich zu Standard-Tokens | Suffix `(ohne Cache-Anteil)` |
| Technischer Fehler beim Preis-Lookup | Tokens persistiert, Snapshot NULL, ERROR; Attempt geht NICHT verloren |
| Attempt ohne `document_record` | Sichtbar (LEFT JOIN); Quellname „–" |
| `database is locked` während Lese-Transaktion | Deutsche Fehlermeldung; vorigen Zustand behalten |
| Manuell gelöschte Tabelle `model_price` | Keine automatische Reparatur; siehe `betrieb.md` |
| `updated_at`-Wert in DB nicht parsebar | WARN; `updatedAt=null` im View; GUI „ungültig" |
| `pageNumber` außerhalb Bereich nach Refresh | Letzte gültige Page; Banner „Page automatisch angepasst" |
| Page-Größe-Wechsel | Approximative neue Page berechnet |
| Page-Parameter ungültig (negativ, falsche Größe) | Use-Case-Validierungsfehler; deutsche Meldung |
| Offset-Overflow (`pageNumber × pageSize > Long.MAX`) | `ArithmeticException`, deutsche Fehlermeldung |
| Sortierung nach Kosten mit gemischten Status | NULLS LAST greift; nicht-berechenbare Werte am Ende |
| Pro-Group-Aggregation bei extrem vielen Attempts (>1000) | Verhalten undefiniert; V3.x-Fix |

---

## #98 – Modell-Combobox: Filterung ungeeigneter Modelle

### Fachliche Beschreibung

Heuristischer UI-Vorfilter, keine Sicherheits- oder
Korrektheitsgarantie. Bekannte False-Negatives (Namespace-Notationen mit
`:`, achtstellige Datumssuffixe ohne Trennstriche) sind dokumentiert.

### Filterregeln

#### Normative Regel

Ausgeschlossen werden Modelle, deren primäre Aufgabe nicht
Textgenerierung oder Reasoning für die PDF-Umbenennung ist. Auch
textfähige, aber für den Use Case fachlich ungeeignete Spezialmodelle
(z.B. `instruct`-Legacy oder `codex`-Code-Spezialmodelle) werden
ausgeblendet.

#### Blacklist (Segment-Match, case-insensitive)

Trennzeichen: `-`, `_`, `.`, `/`. (Bewusst kein `:` – False-Negatives bei
Namespaces sind dokumentierter Trade-off.)

```
embedding, embeddings, embed,
dalle, dall, image, images,
tts, audio, voice, speech,
whisper, transcribe, transcription,
realtime, moderation,
sora, video,
babbage, davinci, instruct,
codex, search,
rerank, ranker, guardrail
```

#### Snapshot-Filter (Regex)

- `.*-\d{4}-\d{2}-\d{2}$` (ISO-Datum)
- `.*-\d{4}$` (Kurzformat)

Anthropic-Modell-IDs mit achtstelligem Datumssuffix ohne Trennstriche
(z.B. `claude-haiku-4-5-20251001`) bleiben erhalten.

#### Fallback bei leerer gefilterter Liste

- Combobox leer
- Hinweis „Keine geeigneten Modelle gefunden..."
- Button „Ungefilterte Liste anzeigen"
- Klick: ungefilterte Liste + dauerhafter Warn-Hinweis

### Implementierung

`ModelListFilter` als package-private Klasse in
`adapter-in-gui.modelcatalog`. Einhängepunkt:
`GuiModelCatalogCoordinator.applyResult()` bei `Success`.

---

## #99 – CLI für Modell-Preise

### CLI-Vertrag

**Befehle:**

| Befehl | Beschreibung |
|---|---|
| `--list-model-prices` | Listet alle persistierten Modell-Preise als Tabelle (Provider, Modellname, In/1M, Out/1M, Währung, Letzte Änderung) |
| `--upsert-model-price <provider> <modelName> <inputUsdPer1M> <outputUsdPer1M>` | Fügt einen Preis hinzu oder aktualisiert ihn |
| `--delete-model-price <provider> <modelName>` | Löscht einen Preis-Eintrag |

**Vertragspunkte:**

| Aspekt | Festlegung |
|---|---|
| Exklusivität | CLI-Preisbefehle sind **exklusiv**: Programm führt nur den Befehl aus und beendet sich. Keine Kombination mit `--headless` (Verarbeitungslauf). Bei kombinierter Angabe → Validierungsfehler, Exit-Code 1. |
| `--config <path>` | **Pflichtparameter**, identisch zur Headless-Lade-Logik. Fehlende oder defekte Config → Exit-Code 3. |
| Flyway-Migration | Läuft **vor** jedem CLI-DB-Zugriff (Schema-Aktualität sicherstellen) |
| Argumentquoting | Standard-Shell-Verhalten. Modellnamen mit Sonderzeichen oder Leerzeichen müssen vom User in Anführungszeichen gesetzt werden (z.B. `--upsert-model-price openai-compatible "gpt-4o-mini" 0.15 0.60`). Java-`args[]`-Parsing verwendet die bereits durch die Shell aufgespaltenen Argumente. |
| Validierung | Identisch zur GUI-Validierung (`ManageModelPricesUseCase`). Verstöße → deutsche Fehlermeldung auf STDERR, Exit-Code 1. |
| Whitelist-Validierung | `--upsert-model-price` lehnt unbekannte Provider ab (nur `openai-compatible`/`claude` erlaubt). `--delete-model-price` akzeptiert auch unbekannte Provider, damit verwaiste Einträge entfernbar bleiben. |
| Ausgabe-Format | `--list-model-prices`: feste Spaltenbreiten oder einfache Tabular-Ausgabe nach STDOUT, eine Zeile pro Eintrag, Header-Zeile. |

**Exit-Code-Matrix:**

| Code | Bedeutung |
|---|---|
| `0` | Erfolg |
| `1` | Validierungsfehler (ungültige Argumente, falsche Werte, falsche Anzahl Parameter, kombinierte unverträgliche Optionen) |
| `2` | DB-Fehler (Migration fehlgeschlagen, `database is locked` über Timeout, SQL-Fehler) |
| `3` | Konfigurationsfehler (`--config` fehlt, Datei nicht lesbar, Pflichtfelder fehlen) |
| `4` | Unbekannter Fehler (alle anderen Exceptions) |

### Headless-Lauf-Hinweis

Bei einem `--headless`-Lauf, der Modelle ohne Preis-Eintrag verwendet:
zusätzlich zur normalen WARN-Logmeldung ein einmaliger Hinweis:
„Hinweis: Modell-Preise können mit `--upsert-model-price` ergänzt werden.
Siehe `betrieb.md`."

### Implementation

CLI-Befehle nutzen den bestehenden Use Case `ManageModelPricesUseCase`
und das Repository. Anschluss an das bestehende
Befehls-Registrierungs-Pattern in `adapter-in-cli` (zu verifizieren per
Code-Read).

---

## Logging-Matrix

| Level | Wann | Beispiel |
|---|---|---|
| INFO | Token-Usage extrahiert | `Token-Usage erfasst: Provider={}, Modell={}, Input={}, Output={}, CacheCreation={}, CacheRead={}` |
| INFO | Preis-Snapshot persistiert | `Preis-Snapshot persistiert: Provider={}, Modell={}, ...` |
| INFO | Modell-Preis aktualisiert/gelöscht | – |
| INFO | Preis-Batch persistiert | `Modell-Preis-Batch persistiert: {} Upserts, {} Deletions` |
| INFO | Preis-Batch leer (No-op) | – |
| INFO | Kosten-Analyse aufgerufen | `Kosten-Analyse: Zeitraum=[{},{}), Page={}, Größe={}, Sort={} {}, Zeilen={}, Total={}` |
| INFO | Modellliste gefiltert | – |
| INFO | CLI: Preis-Aktion | `CLI {}: Provider={}, Modell={}` |
| WARN | API-Response ohne usage | – |
| WARN | Token-Wert ungültig | – |
| WARN | Filter ergibt 0 Einträge | – |
| WARN | Versuch ohne Preis-Snapshot | – |
| WARN | Headless-Lauf mit fehlendem Preis | – |
| WARN | `database is locked` | – |
| WARN | `updated_at` nicht parsebar | – |
| WARN | ChangeSet-Konflikt | – |
| WARN | Page automatisch korrigiert | `Page-Korrektur: angeforderte Page={}, korrigiert auf={} (totalRowCount={})` |
| ERROR | Flyway-Migration V2 fehlgeschlagen | – |
| ERROR | Modell-Preis-Batch gescheitert (Rollback) | – |
| ERROR | Preis-Lookup-Fehler vor Attempt-Insert | – |

---

## Fertigstellungs-Checkliste

### Vor Implementierungsstart (Freigabe-Gate)

- [ ] Alle freigabe-blockierenden Code-Reads im Schlussabschnitt mit „bestätigt" oder „abweichend (Spec angepasst auf: …)" markiert
- [ ] **Spec nach Code-Reads aktualisiert und freigegeben**

### AP-A: Token- und Kosten-Tracking (Schema, Adapter, Persistenz)

- [ ] `V2__token_tracking.sql` erstellt und auf V1-DB getestet
- [ ] WAL + `busy_timeout` 5s pro Connection verifiziert
- [ ] Zentrale Connection-Factory eingeführt; alle direkten `DriverManager.getConnection`-Aufrufe in Repository-Adaptern auf Factory umgezogen (Voraussetzung für wirksames WAL + `busy_timeout`)
- [ ] `AiUsageMetadata`, `AiInvocationSuccess` erweitert
- [ ] `ModelPriceEntry` (non-null updatedAt), `ModelPriceView` (nullable)
- [ ] `ModelPriceChangeSet`, `ModelPriceKey`
- [ ] `ModelPriceRepository` (View für Lese, Entry für Schreib)
- [ ] `ManageModelPricesUseCase` mit ChangeSet-Konfliktvalidierung
- [ ] Anthropic- und OpenAI-Adapter Token-Extraktion mit Validierung
- [ ] `processing_attempt`-Schreibpfad mit allen sechs neuen Spalten
- [ ] `BatchRunProcessingUseCase` lädt Snapshot, Lookup-Fehler verliert keinen Attempt
- [ ] `SqliteModelPriceRepositoryAdapter` mit UPSERT, transaktionalem Batch, View-Mapping
- [ ] History-Tab um drei Spalten erweitert
- [ ] Summary-Banner um Tokens, Kosten, Cache-only-Zeile erweitert
- [ ] Konfigurations-Tab Warnung bei Modell ohne Preis

### AP-B: Kosten-Analyse mit Pagination

- [ ] `TokenStatisticsReadModelPort` im Package `application.reporting.costanalysis`
- [ ] DTOs `CostAnalysisFullResult`, `CostAnalysisHeaderResult`, `CostAnalysisRow`, `CostAnalysisTotals` mit hasAny-Flags, `RunSummaryResult`, `RunModelTokenSummary`
- [ ] Enums `CostAnalysisSortField`, `SortDirection`
- [ ] `CostResult` mit Boolean-Flags inkl. `hasAnyCalculatedCost`
- [ ] `CostCalculator` mit `formatRow`, `formatTotal` (BigInteger-Eingabe, hasAny-Flags), `calculateAttempt`
- [ ] `QueryCostAnalysisFullUseCase` mit Page-Parameter-Validierung und Auto-Korrektur bei out-of-range
- [ ] `QueryCostAnalysisHeaderOnlyUseCase`
- [ ] `QueryRunSummaryUseCase`
- [ ] `SqliteTokenStatisticsReadModelAdapter` mit gemeinsamen CTEs/WHERE-Konstanten
- [ ] **Read-Transaction für vollständige Aktualisierung** (`autoCommit=false`, `setReadOnly=true`)
- [ ] BigInteger-Aggregation der Totals in Java
- [ ] Sortierspalten-Whitelist
- [ ] Zweistufige NULLS-LAST-Sortierung für Gesamt-Kosten
- [ ] LEFT JOIN document_record
- [ ] Tab „Kosten-Analyse" mit Pagination-Komponente, Spalten, Sortierung
- [ ] Page-Navigation gemäß Trigger-Logik
- [ ] Stale-Result-Schutz auf alle Page-Parameter
- [ ] Banner-Logik komplett (partial, missingPrice, cache, cacheOnly, pageAdjusted)
- [ ] Gesamt-Kosten-Kopfzeile mit Mix-Status

### AP-C: Modell-Filter (#98) und CLI (#99)

- [ ] `ModelListFilter` segmentbasiert
- [ ] Filter-Fallback bei leerer Liste mit Button und dauerhaftem Warn-Hinweis
- [ ] CLI-Befehle: `--list-model-prices`, `--upsert-model-price`, `--delete-model-price`
- [ ] CLI-Vertrag: Exklusivität, `--config`-Pflicht, Flyway vor DB-Zugriff, Exit-Code-Matrix
- [ ] CLI-Whitelist-Verhalten: Upsert nur Whitelist; Delete auch unbekannt
- [ ] Headless-Hinweis bei fehlendem Preis

### Logging und Doku

- [ ] Logging-Matrix vollständig
- [ ] Code-Kommentare auf Deutsch; Logging auf Deutsch
- [ ] JavaDoc auf neuen öffentlichen Ports/Use-Cases/DTOs
- [ ] `betrieb.md` mit CLI-Beispielen
- [ ] `gui-bedienanleitung.md` auf V3.3-Stand inkl. Pagination/Sortierung
- [ ] `freigabe-v3_3.md` erstellt
- [ ] Manueller Produkttest abgeschlossen

---

## Produkttest-Matrix (selbsttragend, ohne Verweis auf frühere Versionen)

### AP-A: Token-Erfassung und Persistenz

| Testfall | Erwartung |
|---|---|
| OpenAI-Lauf, Modell `gpt-4o-mini` | Tokens und Snapshot-Preise gefüllt; Cache NULL |
| Anthropic-Lauf, Modell `claude-haiku-4-5-20251001` | Standard- und Cache-Felder gefüllt; Snapshot-Preise gefüllt |
| OpenAI-Endpunkt liefert kein `usage` | Tokens und Snapshot NULL; Verarbeitung erfolgreich; WARN |
| API liefert negative Token-Zahl | Adapter setzt NULL; WARN |
| API liefert Tokenwert > 10 Mio | Adapter setzt NULL; WARN |
| Whole-run failure | Keine `processing_attempt`-Einträge |
| Per-document failure (technical) | Record mit allen neuen Feldern NULL |
| Modell ohne Preis-Eintrag | Tokens gespeichert, Snapshot NULL; WARN |
| Preis-Lookup wirft Exception | Tokens trotzdem persistiert, Snapshot NULL, ERROR; Attempt geht NICHT verloren |
| Identischer Modellname zwei Provider | Lookup pro Provider liefert passenden Eintrag |

### AP-A: Modell-Preise-Tab

| Testfall | Erwartung |
|---|---|
| Tab öffnen mit Default-Preisen (10 Einträge) | Tabelle in `$/1M` |
| Hinzufügen mit gültigen Werten | Speichern erfolgreich |
| Hinzufügen mit negativem Preis | Validierungsfehler |
| Hinzufügen mit > 6 Nachkommastellen | Validierungsfehler |
| Hinzufügen mit Preis > Maximum | Validierungsfehler |
| Hinzufügen mit nicht-numerischem Preis | Validierungsfehler |
| Hinzufügen mit (Provider, Modell)-Duplikat | Validierungsfehler |
| Hinzufügen mit identischem Modellnamen, anderem Provider | Erlaubt |
| Drei Modelle ändern, Speichern | Atomar persistiert |
| Drei Modelle ändern, simulierter Fehler beim zweiten | Rollback aller; deutsche Meldung; DB unverändert |
| Editieren, Speichern | Wert in DB; `updated_at` aus Clock |
| Löschen, Bestätigung OK / Abbrechen | Eintrag entfernt / bleibt |
| Eingabe `$0.123456 / 1M` | Konvertierung zu 123 nano-USD/Token (HALF_UP) |
| Anzeige nach Roundtrip | Wert wird wieder lesbar dargestellt |
| Unbekannter Provider in DB | Read-only Anzeige mit Tooltip; Löschen erlaubt |
| `updated_at` ungültig in DB | Spalte zeigt „ungültig" |
| Konfigurations-Tab: Modell ohne Preis ausgewählt, Speichern | Warnung; Speichern erfolgreich |
| Konfigurations-Tab: Modell mit Preis ausgewählt | Keine Warnung |

### AP-A: History-Tab

| Testfall | Erwartung |
|---|---|
| Tab öffnen | Drei neue Spalten sichtbar |
| Eintrag aus V3.2-Bestand | „keine Token-Daten" |
| Eintrag mit Tokens, kein Snapshot | Token-Spalten gefüllt, Kosten „Preis fehlt" |
| Eintrag mit Tokens und Snapshot | Alle Spalten korrekt |
| Modell-Preis nachträglich geändert | History-Eintrag stabil |
| Modell-Preis nachträglich gelöscht | History-Eintrag stabil |
| Cache-only Attempt | Status „nur Cache-Tokens, keine Standardkosten" |

### AP-A: Summary-Banner

| Testfall | Erwartung |
|---|---|
| Lauf mit 1 PDF, alles vorhanden | Tokens und exakte Kosten |
| Lauf mit 5 PDFs, alle Modelle bekannt | Σ je Attempt × Snapshot |
| Lauf mit Modell ohne Preis | Kosten `unvollständig` |
| Lauf nur fehlgeschlagen | Tokens 0/0; Kosten $0.0000 |
| Lauf mit Cache-Tokens | Suffix `(ohne Cache-Anteil)` |
| Lauf nur mit Cache-only | „nichts berechnet"-artige Anzeige plus Cache-only-Zeile |
| Preisänderung während Lauf | Pro-Attempt-Snapshot greift |

### AP-B: Kosten-Analyse-Tab

| Testfall | Erwartung |
|---|---|
| Default 7 Tage öffnen | Page 0 mit 100 Zeilen, Aggregation der letzten 7 Tage |
| 24h / 7T / 1M umschalten | Tabelle aktualisiert; Page 0 |
| Benutzerdefiniert, Von > Bis | Validierungsmeldung |
| Benutzerdefiniert, `Von=Bis=heute` | Heutige Versuche |
| Datei mit selbem (Provider, Modell) mehrfach, Preisänderung dazwischen | Eine Zeile, Σ je Attempt × eigener Snapshot |
| Datei mit zwei Modellen | Zwei Zeilen |
| Identischer Modellname zwei Provider | Zwei Zeilen |
| Modell-Spalte Tooltip | „Provider: {provider}" |
| 9 berechenbar + 1 ohne Preis | Zeile `~$X (unvollständig)`; Banner zählt |
| Versuch nur Input mit Preis | `~$X` |
| Versuch nur Input ohne Preis | `unvollständig` |
| Versuch mit Cache-Tokens | Suffix `(ohne Cache-Anteil)` |
| Cache-only Versuche im Zeitraum | Banner zählt; nicht in Aggregation |
| Bestandseintrag ohne Tokens | Nicht in Aggregation |
| Versuch ohne `document_record` | Sichtbar; Quellname „–" |
| Leerer Zeitraum | Tabelle leer; Gesamt-Tokens 0; Kosten $0.0000 |
| 10.000+ Versuche | Lade-Indikator; UI responsiv |
| 1 Mio Attempts | Pagination funktioniert; Performance akzeptabel (Erwartung: Page-Wechsel < 2s bei niedrigen OFFSETs) |
| Page-Navigation: Erste/Vorherige/Nächste/Letzte | Korrekte Ergebnisse |
| Page-Sprung manuell | Korrekte Ergebnisse |
| Sortierung über alle Spalten, beide Richtungen | Korrekt |
| NULL-Werte beim Sortieren | NULLS LAST für ASC und DESC |
| Sortierung nach Gesamtkosten, gemischte Status | Nicht-berechenbare am Ende, nicht als $0 einsortiert |
| Page-Größe-Wechsel | Approximative neue Page; User bleibt grob im Datenbereich |
| Zeitraum-Wechsel | Page 0 |
| Sortier-Wechsel | Page 0 |
| Page out-of-range nach Datenänderung | Letzte gültige Page; Banner „Page automatisch angepasst" |
| Ungültige Page-Parameter (Test-Hack) | Use-Case-Validierungsfehler |
| Schneller Wechsel von Page/Sort/Zeitraum | Stale Results verworfen |
| Gesamt-Kopfzeile bezieht sich auf vollen Zeitraum | Nicht nur aktuelle Page |
| Konsistenz Page/Totals/Count/Cache-only | Alle aus demselben Read-Snapshot (Test mit parallelem Insert) |
| Mikro-Kosten | `< $0.0001` |
| Tooltips bei kombiniertem Status | Alle Ursachen |
| Gleichzeitiger Scheduler-Schreibvorgang | Lese-Query funktioniert; partiell sichtbarer Lauf akzeptabel |
| `database is locked` simulieren | Deutsche Fehlermeldung; vorigen Zustand behalten |

### AP-C: Modell-Combobox-Filter (#98)

| Testfall | Erwartung |
|---|---|
| OpenAI-Endpunkt, „Modelle neu laden" | Combobox deutlich reduziert |
| Beispiele ausgeschlossen | `text-embedding-3-large`, `whisper-1`, `dall-e-3`, `tts-1`, `gpt-4o-2024-05-13`, `gpt-3.5-turbo-0125` |
| Beispiele erhalten | `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `o3`, `chat-latest` |
| Anthropic-Endpunkt | Alle erhalten, insbesondere `claude-haiku-4-5-20251001` |
| Hypothetisch `research-bot-v1` | NICHT entfernt |
| Hypothetisch `gpt-4o-search-preview` | DURCH `search` entfernt |
| Hypothetisch mit Doppelpunkt: `provider:whisper`, `text:embedding` | NICHT entfernt (dokumentierter Trade-off) |
| Hypothetisch mit Doppelpunkt: `openai:gpt-4o-20240513` | NICHT entfernt durch Datums-Regex (acht zusammenhängende Ziffern bei Anthropic-Schema; dokumentierter Trade-off) |
| Endpunkt nur Embeddings | Combobox leer; Hinweis; Button |
| Klick auf „Ungefilterte Liste anzeigen" | Originalliste; dauerhafter Warn-Hinweis |
| Vorher gewähltes Modell im Filterergebnis | Selektion bleibt |
| Vorher gewähltes Modell nicht im Filterergebnis | Bestehende Selektionslogik |

### AP-C: CLI (#99)

| Testfall | Erwartung |
|---|---|
| `--list-model-prices` mit gültiger Config | Tabelle ausgegeben; Exit 0 |
| `--list-model-prices` ohne `--config` | Exit 3 |
| `--list-model-prices` mit defekter Config | Exit 3 |
| `--upsert-model-price openai-compatible gpt-4o-mini 0.15 0.60` | INSERT/UPDATE; Exit 0 |
| `--upsert-model-price` mit unbekanntem Provider (z.B. `mistral`) | Validierungsfehler; Exit 1 |
| `--upsert-model-price` mit > 6 Nachkommastellen | Exit 1 |
| `--upsert-model-price` mit zu wenigen Argumenten | Exit 1 |
| `--upsert-model-price` mit negativem Preis | Exit 1 |
| `--delete-model-price openai-compatible gpt-4o-mini` | DELETE; Exit 0 |
| `--delete-model-price unknown-router foobar` | DELETE für unbekannten Provider erlaubt; Exit 0 |
| Kombination `--upsert-model-price ... --headless` | Exklusivitätsfehler; Exit 1 |
| Modellname mit Leerzeichen, korrekt gequotet | Funktioniert; Eintrag korrekt |
| Modellname mit Leerzeichen ohne Quoting | Shell teilt auf → Argumentanzahl falsch → Exit 1 |
| `--upsert-model-price` mit DB-Fehler (z.B. simuliert) | Exit 2 |
| Headless-Lauf mit fehlendem Preis | WARN-Log mit CLI-Hinweis |

### Fehlerpfade

| Testfall | Erwartung |
|---|---|
| `V2__token_tracking.sql` schlägt fehl | Flyway-Fehler; Reparatur-Banner |
| Datentyp-Fehler bei Snapshot-Preis (negativ) | CHECK-Constraint feuert |
| Wertüberschreitung in DB direkt eingefügt | CHECK-Constraint feuert |
| `database is locked` im Schreibpfad | `busy_timeout` 5s greift |
| Manuell gelöschte Tabelle `model_price` | Keine automatische Reparatur |
| `updated_at` in DB nicht parsebar | WARN; `updatedAt=null`; GUI „ungültig" |

---

## Empfohlene Unit- und Integrationstests

| Testklasse | Schwerpunkte |
|---|---|
| `AnthropicTokenExtractionTest` | Standard- und Cache-Tokens; ungültige Werte |
| `OpenAiTokenExtractionTest` | Mapping; Cache NULL; Edge Cases |
| `AiUsageMetadataTest` | Empty; `hasAnyTokenData`; `hasCacheTokens` |
| `ModelPriceEntryTest` | Konstruktor-Validierung |
| `ModelPriceViewMapperTest` | Parsing-Fehler `updated_at` → null + invalid-Flag |
| `ModelPriceEntryInvalidUpdatedAtRoundtripTest` | View darf nicht versehentlich als Entry gespeichert werden |
| `SqliteModelPriceRepositoryAdapterTest` | UPSERT, Composite-Key, identischer Modellname zwei Provider |
| `ModelPriceBatchSaveTransactionTest` | Atomarer Erfolg; Rollback bei Fehler |
| `ModelPriceChangeSetConflictTest` | Key in Upsert+Delete; Duplikate |
| `ModelPriceChangeSetEmptyNoOpTest` | Leeres ChangeSet → No-op |
| `SqliteTokenStatisticsReadModelAdapterPageTest` | Page-Query mit allen Sortierfeldern und Richtungen |
| `SqliteTokenStatisticsReadModelAdapterTotalsTest` | Totals-Query liefert korrekte Aggregate |
| `SqliteCacheOnlyCountTest` | Cache-only-Anzahl korrekt |
| `CostAnalysisProviderGroupingTest` | Gleicher `model_name`, andere Provider → zwei Zeilen |
| `RunSummaryProviderGroupingTest` | Gleiches im Run-Summary |
| `ProviderDisplayedInCostAnalysisTest` | GUI Tooltip |
| `CostAnalysisMixedSnapshotPriceTest` | Pro-Attempt-Snapshot in Aggregation |
| `CostAnalysisMixedStatusAggregationTest` | 9 berechenbar + 1 missing → `~$X (unvollständig)` |
| `CostAnalysisHeaderTotalMixedStatusTest` | Kopfzeile mit Mix-Status |
| `CostAnalysisHeaderTotalsFullTimeRangeTest` | Kopfzeile = voller Zeitraum, nicht nur Page |
| **`CostAnalysisReadConsistencyTest`** | Page, Count, Totals, Cache-only aus konsistentem Read-Snapshot trotz parallelem Insert |
| `CostAnalysisTotalsNullSemanticsTest` | Totals unterscheiden „keine Werte"/„0"/„missing price"/„Mikrobetrag" |
| `CostCalculatorTest` | Alle Flag-Kombinationen |
| `CostCalculatorBigIntegerTotalsTest` | `formatTotal` mit BigInteger und hasAny-Flags |
| `CostCalculatorTinyAmountFormattingTest` | `< $0.0001` |
| `CostResultMultipleStatusTest` | Mehrere Flags gleichzeitig |
| `ManageModelPricesUseCaseTest` | Validierung in Application |
| `QueryCostAnalysisFullUseCaseTest` | Mapping; Auto-Korrektur bei out-of-range Page; Validierung der Page-Parameter |
| `QueryCostAnalysisHeaderOnlyUseCaseTest` | Header-Only-Pfad |
| `QueryRunSummaryUseCaseTest` | Aggregation über (Provider, Modell)-Gruppen |
| `ModelListFilterTest` | Segment-Match; Anthropic 8-stelliges Datum bleibt; alle Blacklist-Tokens |
| `ModelListFilterNamespaceFalseNegativeTest` | `provider:whisper`, `text:embedding`, `openai:gpt-4o-20240513` Verhalten dokumentiert |
| `GuiModelCatalogCoordinatorFilterIntegrationTest` | Filter-Integration mit Fallback |
| `FlywayV2MigrationTest` | V2 auf produktivem V3.2-Schema-Zustand; Default-Preise; CHECK-Constraints |
| `BatchRunProcessingTokenPersistenceTest` | Atomarer Insert; Failure → NULL |
| `PriceLookupFailureDoesNotLoseAttemptTest` | Lookup-Exception verliert Attempt nicht |
| `PriceChangeHistoryRobustnessTest` | History stabil bei Preisänderung/-löschung |
| `ProviderModelKeyCollisionTest` | Identischer Modellname zwei Provider |
| `SqliteConcurrencyTest` | Paralleler Schreib/Lese-Pfad; `busy_timeout` |
| `GuiThreadingTest` | Worker-Erfolg, Stale-Verwerfung |
| `GuiPaginationStaleResultTest` | Schneller Page/Sort-Wechsel verwirft alte Ergebnisse |
| `GuiPaginationSortNullsLastTest` | NULL-Werte am Ende für beide Richtungen |
| `CostSortMissingPriceNullsLastTest` | „Preis fehlt" wird nicht wie 0-Kosten sortiert |
| `PageNumberOutOfRangeTest` | Datenänderung → ungültige Page sauber korrigiert |
| `PageSizeChangeTest` | Approximative neue Page nach Größenwechsel |
| `CostAnalysisPartialTokenTest` | NULL-Semantik der Aggregation |
| `CacheTokensIgnoredCostWarningTest` | `(ohne Cache-Anteil)`-Anzeige |
| `CacheOnlyAttemptHistoryVisibilityTest` | Cache-only in History sichtbar |
| `CacheOnlyAttemptCountTest` | `cacheOnlyAttemptCount` korrekt; Banner und Summary-Zeile |
| `CacheOnlyRunSummaryVisibilityTest` | Lauf nur mit Cache-only nicht „nichts passiert" |
| `DatePickerInclusiveDayRangeTest` | Folgetags-Konvertierung |
| `CostAnalysisWithoutDocumentRecordTest` | LEFT JOIN |
| `TokenOverflowAndNegativeParsingTest` | Negative/zu große Werte → NULL+WARN |
| `SqliteCostOverflowProtectionTest` | DB-CHECK gegen Maxima |
| `SqliteAggregateOverflowBoundaryTest` | Pro-Group-long bei realistischen 100 Attempts; Über-Group-BigInteger sicher |
| **`SqliteGroupAggregationOverflowGuardTest`** | Maximalwerte in einer realistischen Group ergeben kein still falsches Ergebnis |
| `ModelPriceRoundtripTest` | `$0.123456 / 1M` → 123 nano-USD/Token, HALF_UP |
| `ModelPriceMaxNachkommastellenTest` | > 6 Nachkommastellen → Validierungsfehler |
| `ConfigSaveWithoutPriceWarningTest` | Modell ohne Preis → Warnung |
| `ConfigSaveWithPriceNoWarningTest` | Modell mit Preis → keine Warnung |
| `BooleanResultsetMappingTest` | `getInt(...) != 0`-Mapping |
| `CliListModelPricesTest` | Tabellen-Ausgabe |
| `CliUpsertModelPriceValidationTest` | Validierungsfehler → Exit 1 |
| `CliUpsertModelPriceUnknownProviderRejectedTest` | Unbekannter Provider beim Upsert → Exit 1 |
| `CliDeleteModelPriceTest` | DELETE-Pfad (Whitelist und unbekannter Provider) |
| `CliCommandExclusivityTest` | CLI-Preisbefehle und `--headless` schließen sich aus |
| `CliConfigResolutionTest` | Gleicher DB-/Config-Kontext wie Headless |
| `CliExitCodeMatrixTest` | 0/1/2/3/4 entsprechend Vertrag |
| `HeadlessMissingPriceWarningTest` | WARN mit CLI-Hinweis |
| `UnknownProviderReadOnlyDisplayTest` | Unbekannter Provider in DB → GUI read-only |

---

## Code-Reads vor Implementierung

### Freigabe-blockierend

| # | Code-Read | Erwartetes Ergebnis | Konsequenz bei Abweichung | Status |
|---|---|---|---|---|
| 1 | `ai_provider`-Werte in `processing_attempt` | `openai-compatible`, `anthropic` | Default-Inserts und Lookup-Logik anpassen | abweichend – Provider-Wert ist `claude` (nicht `anthropic`); Spec angepasst |
| 2 | Primärschlüsselspalte `processing_attempt.id` | `id` (AUTOINCREMENT, monoton) | Pseudo-SQL und Tie-Breaker anpassen | bestätigt – `id INTEGER PRIMARY KEY AUTOINCREMENT`; einzige Migration: V1 |
| 3 | Insert-Pfad für `processing_attempt` | Eine zentrale Stelle | Hook für Tokens und Snapshot dort einbauen | bestätigt – `SqliteProcessingAttemptRepositoryAdapter.save()`, 20 Spalten |
| 4 | Stabilität von `last_known_source_file_name` | Wird nach Initial-Scan nicht überschrieben | Tooltip ggf. anpassen | abweichend – Feld mutabel, wird bei jedem Lauf überschrieben; Spec klargestellt |
| 5 | SQLite-JDBC-Window-Function-Unterstützung | `ROW_NUMBER() OVER (...)` ab SQLite 3.25 | Korrelierte Subquery als Fallback | bestätigt – sqlite-jdbc 3.45.1.0; SQLite 3.45.1; `ROW_NUMBER()` unterstützt |
| 6 | Connection-Setup: WAL und `busy_timeout` | Beide aktiv; pro Connection | Im Adapter ergänzen | abweichend – WAL und `busy_timeout` fehlen; `DriverManager`-Stellen ohne Config; Spec um Connection-Factory-Pflicht ergänzt |
| 7 | CLI-Adapter-Modul: Befehlsregistrierung | Vorhandenes Pattern nutzen | An bestehendes Pattern anschließen | bestätigt – Eigenbau-Switch-Case in `CliArgumentParser`; Einhänge-Punkt: vor `default` in switch (~Z.95), neuer `StartupMode` in `BootstrapRunner` |

### Nicht blockierend

| # | Code-Read | Zweck | Status |
|---|---|---|---|
| 8 | Spaltenmenü im History-Tab | Falls vorhanden: neue Spalten registrieren | offen |
| 9 | Lade-Indikator in der GUI | Wiederverwenden falls zentral | offen |
| 10 | JavaFX `Pagination`-Komponente in der Codebasis | Pattern übernehmen falls vorhanden | offen |

**Hinweis zum Status:** Alle freigabe-blockierenden Reads sind erledigt. Das Freigabe-Gate
ist offen. Die Implementierung kann mit AP-A beginnen.
