75 KiB
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_pricefü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 Subpaketadapter-out→ SQLite-Implementierungen, Token-Extraktion in beiden KI-Adapternadapter-in-gui→ zwei neue Tabs, History-Erweiterung, Summary-Banner-Erweiterung, Modell-Filteradapter-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)mitRoundingMode.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 alslongmit 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_tokensODERoutput_tokensnicht 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 Summary-Banner: zusätzliche Zeile
- 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,
--headlessund CLI-Befehle bleiben vollständig erhalten .propertiesbleibt die einzige Konfigurationswahrheit- Flyway ist die einzige Schema-Evolutionsquelle
- Kein JavaFX in
domainoderapplication - 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
BigDecimalmit voller Präzision; Persistierung alsINTEGERin 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_nameallein - 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_priceals Snapshot zusammen mit den Token-Counts inprocessing_attemptpersistiert. 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 zuBigDecimal-USD - Der
CostCalculatorinterpretiert 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
providerals 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 lockedgreift derbusy_timeoutvon 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.0000fü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 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:
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-compatibleundclaude - „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 lockedoder 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.sqlerstellt und auf V1-DB getestet- WAL +
busy_timeout5s 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,AiInvocationSuccesserweitertModelPriceEntry(non-null updatedAt),ModelPriceView(nullable)ModelPriceChangeSet,ModelPriceKeyModelPriceRepository(View für Lese, Entry für Schreib)ManageModelPricesUseCasemit ChangeSet-Konfliktvalidierung- Anthropic- und OpenAI-Adapter Token-Extraktion mit Validierung
processing_attempt-Schreibpfad mit allen sechs neuen SpaltenBatchRunProcessingUseCaselädt Snapshot, Lookup-Fehler verliert keinen AttemptSqliteModelPriceRepositoryAdaptermit 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
TokenStatisticsReadModelPortim Packageapplication.reporting.costanalysis- DTOs
CostAnalysisFullResult,CostAnalysisHeaderResult,CostAnalysisRow,CostAnalysisTotalsmit hasAny-Flags,RunSummaryResult,RunModelTokenSummary - Enums
CostAnalysisSortField,SortDirection CostResultmit Boolean-Flags inkl.hasAnyCalculatedCostCostCalculatormitformatRow,formatTotal(BigInteger-Eingabe, hasAny-Flags),calculateAttemptQueryCostAnalysisFullUseCasemit Page-Parameter-Validierung und Auto-Korrektur bei out-of-rangeQueryCostAnalysisHeaderOnlyUseCaseQueryRunSummaryUseCaseSqliteTokenStatisticsReadModelAdaptermit 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)
ModelListFiltersegmentbasiert- 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.mdmit CLI-Beispielengui-bedienanleitung.mdauf V3.3-Stand inkl. Pagination/Sortierungfreigabe-v3_3.mderstellt- 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.