diff --git a/Services/AnthropicService.Panel.cs b/Services/AnthropicService.Panel.cs index b9864fa..12f6733 100644 --- a/Services/AnthropicService.Panel.cs +++ b/Services/AnthropicService.Panel.cs @@ -35,6 +35,7 @@ public partial class AnthropicService public string Name { get; set; } public string Model { get; set; } public string Text { get; set; } + public TokenUsage Usage { get; set; } // Token-Verbrauch dieses Members (für die Chain-Kosten) } /// @@ -67,6 +68,23 @@ public partial class AnthropicService var mode = layers > 1 ? "moa" : "fusion"; await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode, layers }, sseCt, sseGate); + // Gesamt-Token aller Member über ALLE Runden. Jeder Member sammelt seine eigene Usage + // (result.Usage); summiert wird erst NACH der Task.WhenAll-Barrier → kein Lock nötig. + // Vor jedem panel_done als panel_usage-Event ans Frontend → addiert auf die Worker-Tokens. + var panelUsage = new TokenUsage(); + async Task SendPanelUsage() + { + if (panelUsage.InputTokens == 0 && panelUsage.OutputTokens == 0 + && panelUsage.CacheReadTokens == 0 && panelUsage.CacheCreationTokens == 0) return; + await SseHelper.TryWriteSse(response, "panel_usage", new + { + inputTokens = panelUsage.InputTokens, + outputTokens = panelUsage.OutputTokens, + cacheCreationTokens = panelUsage.CacheCreationTokens, + cacheReadTokens = panelUsage.CacheReadTokens + }, sseCt, sseGate); + } + // MoA: mehrere Proposer-Runden. Runde k>1 bekommt und verfeinert. // Fusion = layers==1 (keine Vorrunde). prev füttert die nächste Runde bzw. die finale Synthese. List prev = null; @@ -93,6 +111,12 @@ public partial class AnthropicService } var results = await Task.WhenAll(tasks); + + // Nach der Barrier (single-threaded): Token ALLER Member dieser Runde aufsummieren – + // auch von Membern ohne verwertbaren Text (sie haben trotzdem Tokens verbraucht). + foreach (var r in results) + if (r?.Usage != null) panelUsage.Add(r.Usage); + var ordered = results .Where(r => r != null && !string.IsNullOrWhiteSpace(r.Text)) .OrderBy(r => r.Index) @@ -103,6 +127,8 @@ public partial class AnthropicService if (ordered.Count == 0) { // Keine verwertbaren Antworten in dieser Runde → ohne Synthese weiter. + // Trotzdem die bisher verbrauchten Member-Tokens melden (ehrliche Kosten). + await SendPanelUsage(); await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt, sseGate); return 0; } @@ -130,6 +156,7 @@ public partial class AnthropicService MergePanelIntoLastUserText(workerMessages, sb.ToString()); + await SendPanelUsage(); await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = prev.Count }, sseCt, sseGate); return prev.Count; } @@ -163,6 +190,7 @@ public partial class AnthropicService var memberMessages = CloneForMember(workerMessages, previousLayerBlock); string text; List segments = null; // interleavtes Transkript (Text + Tools) für die Reload-Persistenz + TokenUsage memberUsage = null; // Token-Verbrauch dieses Members (null = nicht verfügbar, z.B. OpenAI) try { if (!string.IsNullOrEmpty(member.Provider) && _openAiService != null) @@ -176,15 +204,15 @@ public partial class AnthropicService { // Kein API-Key → CLI-Member, read-only (Plan-Modus: nur Read/Glob/Grep). // Streamt LIVE (Token + Tool-Uses) über panel_member_*-Events und liefert das Transkript. - var (t, segs) = await RunPanelMemberViaCli(response, member, memberMessages, index, sseGate, workCt, sseCt); - text = t; segments = segs; + var (t, segs, u) = await RunPanelMemberViaCli(response, member, memberMessages, index, sseGate, workCt, sseCt); + text = t; segments = segs; memberUsage = u; } else { // Anthropic-API-Member: read-only (PlanMode) + NICHT persistieren (Caller persistiert einmal). // Live-Streaming über den Loop: pro Tool-Runde panel_member_text / panel_member_tool. // TranscriptSink sammelt Text-/Tool-Segmente für die Reload-Persistenz. - var memberUsage = new TokenUsage(); + memberUsage = new TokenUsage(); segments = new List(); var (t, _) = await RunNonStreamingToolLoop( member.ModelId, member.SystemPrompt, @@ -239,7 +267,7 @@ public partial class AnthropicService await SseHelper.TryWriteSse(response, "panel_member_done", new { index, name, model = member.ModelId, text }, sseCt, sseGate); - return new PanelMemberResult { Index = index, Name = name, Model = member.ModelId, Text = text }; + return new PanelMemberResult { Index = index, Name = name, Model = member.ModelId, Text = text, Usage = memberUsage }; } /// @@ -250,7 +278,7 @@ public partial class AnthropicService /// laufen durch den geteilten (parallele Member → sonst /// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message. /// - private async Task<(string text, List segments)> RunPanelMemberViaCli( + private async Task<(string text, List segments, TokenUsage usage)> RunPanelMemberViaCli( HttpResponse response, SubAgentContext member, List memberMessages, @@ -259,7 +287,7 @@ public partial class AnthropicService CancellationToken workCt, CancellationToken sseCt) { - if (_cliService == null || !_cliService.IsAvailable) return ("", new List()); + if (_cliService == null || !_cliService.IsAvailable) return ("", new List(), null); var systemText = ExtractSystemPromptText(member.SystemPrompt); var prompt = ExtractLastUserText(memberMessages) ?? ""; @@ -292,9 +320,10 @@ public partial class AnthropicService // (content_block_stop) vollständig – ToolStart/ToolResult tragen ihn nicht. var toolNames = new Dictionary(); // toolId -> CLI-Name var toolInputs = new Dictionary(); // toolId -> Input-JSON + CliChatResult cliResult = null; // vor dem try, damit die Usage nach dem finally lesbar ist try { - await _cliService.ChatStreamingAsync( + cliResult = await _cliService.ChatStreamingAsync( prompt, async evt => { @@ -344,7 +373,14 @@ public partial class AnthropicService CleanupTempImages(tempImagePaths); } FlushText(); // restliches Text-Segment am Ende sichern - return (fullText.ToString(), segments); + var usage = cliResult == null ? null : new TokenUsage + { + InputTokens = cliResult.InputTokens, + OutputTokens = cliResult.OutputTokens, + CacheReadTokens = cliResult.CacheReadTokens, + CacheCreationTokens = cliResult.CacheCreationTokens + }; + return (fullText.ToString(), segments, usage); } /// Extrahiert den System-Prompt-Text aus den List<object>-Blöcken eines SubAgentContext. diff --git a/Services/SseHelper.cs b/Services/SseHelper.cs index e8831b4..8b2634a 100644 --- a/Services/SseHelper.cs +++ b/Services/SseHelper.cs @@ -90,6 +90,21 @@ public class TokenUsage public long OutputTokens { get; set; } public long CacheCreationTokens { get; set; } public long CacheReadTokens { get; set; } + + /// + /// Addiert die Werte von auf. NICHT thread-safe – der einzige + /// Aufrufer (Panel-Engine) summiert die Member-Usage erst NACH der Task.WhenAll-Barrier, + /// also single-threaded. Jeder Member befüllt seine EIGENE TokenUsage parallel; geteilt + /// wird nichts → kein Lock/Interlocked nötig. + /// + public void Add(TokenUsage other) + { + if (other == null) return; + InputTokens += other.InputTokens; + OutputTokens += other.OutputTokens; + CacheCreationTokens += other.CacheCreationTokens; + CacheReadTokens += other.CacheReadTokens; + } } /// diff --git a/TODO.md b/TODO.md index 29288f7..ca6619b 100644 --- a/TODO.md +++ b/TODO.md @@ -56,4 +56,4 @@ ## Multi-Modell-Panel (Fusion/MoA) - [x] 🔄 MoA-Zwischenrunden persistieren (F5 überleben) - `RunPanelMember` (`Services/AnthropicService.Panel.cs`) persistiert jetzt JEDE Proposer-Runde (statt nur der finalen) mit `layer`/`totalLayers` im JSON-Transkript; `wwwroot/js/history.js` gruppiert die Panel-Bubbles nach Layer und rendert die "Runde k/N"-Divider beim Reload. -- [ ] 💰 Member-Token in die Chain-Kosten - Panel-Member-Tokens fließen nicht ins `TokenUsage` → Badge zeigt nur Worker-Kosten (bei MoA jetzt ≈ Runden × Agenten, also spürbar). Ziel: Usage aus allen drei Member-Pfaden (Anthropic-Loop, CLI-Events, OpenAI-Response) thread-safe (lock/Interlocked) ins geteilte `usage`-Objekt in `Services/AnthropicService.Panel.cs` akkumulieren. +- [x] 💰 Member-Token in die Chain-Kosten - Jeder Panel-Member sammelt seine eigene `TokenUsage` (Anthropic-Loop via `memberUsage`, CLI via `CliChatResult`; OpenAI=null/nachrüstbar); `RunPanel` summiert sie NACH der `Task.WhenAll`-Barrier (single-threaded → kein Lock) über alle Runden und sendet ein `panel_usage`-SSE-Event. Das Frontend (`wwwroot/js/chat.js`) addiert die Panel-Tokens im `done`-Event auf die Worker-Tokens → Badge = Gesamtkosten. v1-Grenze: nur Live-Badge (DB/Reload zeigt weiter nur Worker-Tokens, da `FinalizeChain` unverändert). diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 5fdbcef..cae2986 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -170,6 +170,11 @@ function handleStreamEvent(eventType, data) { endPanel(data); break; + case 'panel_usage': + // Panel-Member-Tokens merken → im done-Event auf die Worker-Tokens addieren. + _panelUsage = data; + break; + case 'auto_context': addAutoContextBubble(data.context); break; @@ -199,14 +204,18 @@ function handleStreamEvent(eventType, data) { if (data.stopReason === 'max_tokens') { addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true); } + // Panel-Member-Tokens (live gesammelt) auf die Worker-Tokens addieren → Badge = Gesamtkosten. + var _pu = _panelUsage; + _panelUsage = null; _lastDoneChainData = null; if (data.model) { + _addPanelUsage(data, _pu); _lastDoneChainData = data; } else if (data.chainId) { // Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern // (async – _lastDoneChainData wäre im finally schon gelesen) apiGetChain(data.chainId).then(chain => { - if (chain) addChainMetaBubble(chain, false); + if (chain) { _addPanelUsage(chain, _pu); addChainMetaBubble(chain, false); } }).catch(() => {}); } setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit'); @@ -235,6 +244,17 @@ function togglePlanMode() { let isStreaming = false; let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event +let _panelUsage = null; // Panel-Member-Tokens (panel_usage-Event) → werden auf die Worker-Tokens des done-Events addiert + +// Addiert die live gesammelten Panel-Member-Tokens auf ein Chain-/done-Objekt, sodass das +// Token-Badge die Gesamtkosten (Worker + alle Panel-Member aller Runden) zeigt. +function _addPanelUsage(target, pu) { + if (!target || !pu) return; + target.inputTokens = (target.inputTokens || 0) + (pu.inputTokens || 0); + target.outputTokens = (target.outputTokens || 0) + (pu.outputTokens || 0); + target.cacheCreationTokens = (target.cacheCreationTokens || 0) + (pu.cacheCreationTokens || 0); + target.cacheReadTokens = (target.cacheReadTokens || 0) + (pu.cacheReadTokens || 0); +} let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events) let _lastFinishedToolBox = null; // Letzte fertige Tool-Box (für tool_result Zuordnung) let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft @@ -320,6 +340,7 @@ async function _runStream(fetchPromise) { sendBtn.hidden = true; stopBtn.hidden = false; isStreaming = true; + _panelUsage = null; // frische Chain → alte Panel-Tokens verwerfen _abortController = new AbortController(); // Eine Live-SSE-Antwort rendert ALLEIN. Einen evtl. noch laufenden Recovery-Poll // (_streamingPollInterval – gestartet beim Betreten des Chats WAEHREND eine Chain