Files
pdf-umbenenner/docs/specs/V3_3_-_Spezifikation.md

75 KiB
Raw Permalink Blame History

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

-- 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

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

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

DTOs ModelPriceEntry (Schreibpfad) und ModelPriceView (Lesepfad)

/**
 * 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

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

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:

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

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

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:

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 DateTimeParseExceptionModelPriceView 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:

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):

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.