Include panel member tokens in the chain cost badge
Panel member token usage was tracked but discarded, so the cost badge only ever showed the worker's tokens — misleading for MoA where the real cost is roughly rounds x agents. Collect each member's usage and add it to the badge. Each member fills its own TokenUsage in parallel (Anthropic loop via the existing memberUsage, CLI via the now-captured CliChatResult return value; OpenAI members stay null, nachruestbar). RunPanel sums them after the Task.WhenAll barrier — single-threaded, so no lock or Interlocked needed — across all MoA rounds and emits a panel_usage SSE event before panel_done. The frontend adds those tokens onto the worker totals in the done handler so the badge reflects the full chain cost. - SseHelper: add TokenUsage.Add() - AnthropicService.Panel.cs: PanelMemberResult.Usage, accumulate per round after the barrier, emit panel_usage; RunPanelMemberViaCli returns usage - chat.js: _panelUsage state, panel_usage case, add to worker tokens on done, reset on stream start - TODO.md: check off the member-token item v1 limit: live badge only — FinalizeChain still persists worker tokens, so a reload shows worker-only cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f87ccdcab
commit
47557c9724
4 files changed
+82
-10
No files matched your search
@@ -35,6 +35,7 @@ public partial class AnthropicService
|
|||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string Model { get; set; }
|
public string Model { get; set; }
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
|
public TokenUsage Usage { get; set; } // Token-Verbrauch dieses Members (für die Chain-Kosten)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,6 +68,23 @@ public partial class AnthropicService
|
|||||||
var mode = layers > 1 ? "moa" : "fusion";
|
var mode = layers > 1 ? "moa" : "fusion";
|
||||||
await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode, layers }, sseCt, sseGate);
|
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 <previous_layer_responses> und verfeinert.
|
// MoA: mehrere Proposer-Runden. Runde k>1 bekommt <previous_layer_responses> und verfeinert.
|
||||||
// Fusion = layers==1 (keine Vorrunde). prev füttert die nächste Runde bzw. die finale Synthese.
|
// Fusion = layers==1 (keine Vorrunde). prev füttert die nächste Runde bzw. die finale Synthese.
|
||||||
List<PanelMemberResult> prev = null;
|
List<PanelMemberResult> prev = null;
|
||||||
@@ -93,6 +111,12 @@ public partial class AnthropicService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var results = await Task.WhenAll(tasks);
|
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
|
var ordered = results
|
||||||
.Where(r => r != null && !string.IsNullOrWhiteSpace(r.Text))
|
.Where(r => r != null && !string.IsNullOrWhiteSpace(r.Text))
|
||||||
.OrderBy(r => r.Index)
|
.OrderBy(r => r.Index)
|
||||||
@@ -103,6 +127,8 @@ public partial class AnthropicService
|
|||||||
if (ordered.Count == 0)
|
if (ordered.Count == 0)
|
||||||
{
|
{
|
||||||
// Keine verwertbaren Antworten in dieser Runde → ohne Synthese weiter.
|
// 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);
|
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt, sseGate);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -130,6 +156,7 @@ public partial class AnthropicService
|
|||||||
|
|
||||||
MergePanelIntoLastUserText(workerMessages, sb.ToString());
|
MergePanelIntoLastUserText(workerMessages, sb.ToString());
|
||||||
|
|
||||||
|
await SendPanelUsage();
|
||||||
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = prev.Count }, sseCt, sseGate);
|
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = prev.Count }, sseCt, sseGate);
|
||||||
return prev.Count;
|
return prev.Count;
|
||||||
}
|
}
|
||||||
@@ -163,6 +190,7 @@ public partial class AnthropicService
|
|||||||
var memberMessages = CloneForMember(workerMessages, previousLayerBlock);
|
var memberMessages = CloneForMember(workerMessages, previousLayerBlock);
|
||||||
string text;
|
string text;
|
||||||
List<object> segments = null; // interleavtes Transkript (Text + Tools) für die Reload-Persistenz
|
List<object> 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
|
try
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(member.Provider) && _openAiService != null)
|
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).
|
// 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.
|
// 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);
|
var (t, segs, u) = await RunPanelMemberViaCli(response, member, memberMessages, index, sseGate, workCt, sseCt);
|
||||||
text = t; segments = segs;
|
text = t; segments = segs; memberUsage = u;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Anthropic-API-Member: read-only (PlanMode) + NICHT persistieren (Caller persistiert einmal).
|
// 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.
|
// Live-Streaming über den Loop: pro Tool-Runde panel_member_text / panel_member_tool.
|
||||||
// TranscriptSink sammelt Text-/Tool-Segmente für die Reload-Persistenz.
|
// TranscriptSink sammelt Text-/Tool-Segmente für die Reload-Persistenz.
|
||||||
var memberUsage = new TokenUsage();
|
memberUsage = new TokenUsage();
|
||||||
segments = new List<object>();
|
segments = new List<object>();
|
||||||
var (t, _) = await RunNonStreamingToolLoop(
|
var (t, _) = await RunNonStreamingToolLoop(
|
||||||
member.ModelId, member.SystemPrompt,
|
member.ModelId, member.SystemPrompt,
|
||||||
@@ -239,7 +267,7 @@ public partial class AnthropicService
|
|||||||
await SseHelper.TryWriteSse(response, "panel_member_done",
|
await SseHelper.TryWriteSse(response, "panel_member_done",
|
||||||
new { index, name, model = member.ModelId, text }, sseCt, sseGate);
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -250,7 +278,7 @@ public partial class AnthropicService
|
|||||||
/// laufen durch den geteilten <paramref name="sseGate"/> (parallele Member → sonst
|
/// laufen durch den geteilten <paramref name="sseGate"/> (parallele Member → sonst
|
||||||
/// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message.
|
/// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(string text, List<object> segments)> RunPanelMemberViaCli(
|
private async Task<(string text, List<object> segments, TokenUsage usage)> RunPanelMemberViaCli(
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
SubAgentContext member,
|
SubAgentContext member,
|
||||||
List<object> memberMessages,
|
List<object> memberMessages,
|
||||||
@@ -259,7 +287,7 @@ public partial class AnthropicService
|
|||||||
CancellationToken workCt,
|
CancellationToken workCt,
|
||||||
CancellationToken sseCt)
|
CancellationToken sseCt)
|
||||||
{
|
{
|
||||||
if (_cliService == null || !_cliService.IsAvailable) return ("", new List<object>());
|
if (_cliService == null || !_cliService.IsAvailable) return ("", new List<object>(), null);
|
||||||
|
|
||||||
var systemText = ExtractSystemPromptText(member.SystemPrompt);
|
var systemText = ExtractSystemPromptText(member.SystemPrompt);
|
||||||
var prompt = ExtractLastUserText(memberMessages) ?? "";
|
var prompt = ExtractLastUserText(memberMessages) ?? "";
|
||||||
@@ -292,9 +320,10 @@ public partial class AnthropicService
|
|||||||
// (content_block_stop) vollständig – ToolStart/ToolResult tragen ihn nicht.
|
// (content_block_stop) vollständig – ToolStart/ToolResult tragen ihn nicht.
|
||||||
var toolNames = new Dictionary<string, string>(); // toolId -> CLI-Name
|
var toolNames = new Dictionary<string, string>(); // toolId -> CLI-Name
|
||||||
var toolInputs = new Dictionary<string, string>(); // toolId -> Input-JSON
|
var toolInputs = new Dictionary<string, string>(); // toolId -> Input-JSON
|
||||||
|
CliChatResult cliResult = null; // vor dem try, damit die Usage nach dem finally lesbar ist
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _cliService.ChatStreamingAsync(
|
cliResult = await _cliService.ChatStreamingAsync(
|
||||||
prompt,
|
prompt,
|
||||||
async evt =>
|
async evt =>
|
||||||
{
|
{
|
||||||
@@ -344,7 +373,14 @@ public partial class AnthropicService
|
|||||||
CleanupTempImages(tempImagePaths);
|
CleanupTempImages(tempImagePaths);
|
||||||
}
|
}
|
||||||
FlushText(); // restliches Text-Segment am Ende sichern
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Extrahiert den System-Prompt-Text aus den List<object>-Blöcken eines SubAgentContext.</summary>
|
/// <summary>Extrahiert den System-Prompt-Text aus den List<object>-Blöcken eines SubAgentContext.</summary>
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ public class TokenUsage
|
|||||||
public long OutputTokens { get; set; }
|
public long OutputTokens { get; set; }
|
||||||
public long CacheCreationTokens { get; set; }
|
public long CacheCreationTokens { get; set; }
|
||||||
public long CacheReadTokens { get; set; }
|
public long CacheReadTokens { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Addiert die Werte von <paramref name="other"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public void Add(TokenUsage other)
|
||||||
|
{
|
||||||
|
if (other == null) return;
|
||||||
|
InputTokens += other.InputTokens;
|
||||||
|
OutputTokens += other.OutputTokens;
|
||||||
|
CacheCreationTokens += other.CacheCreationTokens;
|
||||||
|
CacheReadTokens += other.CacheReadTokens;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -56,4 +56,4 @@
|
|||||||
|
|
||||||
## Multi-Modell-Panel (Fusion/MoA)
|
## 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.
|
- [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).
|
||||||
+22
-1
@@ -170,6 +170,11 @@ function handleStreamEvent(eventType, data) {
|
|||||||
endPanel(data);
|
endPanel(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'panel_usage':
|
||||||
|
// Panel-Member-Tokens merken → im done-Event auf die Worker-Tokens addieren.
|
||||||
|
_panelUsage = data;
|
||||||
|
break;
|
||||||
|
|
||||||
case 'auto_context':
|
case 'auto_context':
|
||||||
addAutoContextBubble(data.context);
|
addAutoContextBubble(data.context);
|
||||||
break;
|
break;
|
||||||
@@ -199,14 +204,18 @@ function handleStreamEvent(eventType, data) {
|
|||||||
if (data.stopReason === 'max_tokens') {
|
if (data.stopReason === 'max_tokens') {
|
||||||
addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true);
|
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;
|
_lastDoneChainData = null;
|
||||||
if (data.model) {
|
if (data.model) {
|
||||||
|
_addPanelUsage(data, _pu);
|
||||||
_lastDoneChainData = data;
|
_lastDoneChainData = data;
|
||||||
} else if (data.chainId) {
|
} else if (data.chainId) {
|
||||||
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
|
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
|
||||||
// (async – _lastDoneChainData wäre im finally schon gelesen)
|
// (async – _lastDoneChainData wäre im finally schon gelesen)
|
||||||
apiGetChain(data.chainId).then(chain => {
|
apiGetChain(data.chainId).then(chain => {
|
||||||
if (chain) addChainMetaBubble(chain, false);
|
if (chain) { _addPanelUsage(chain, _pu); addChainMetaBubble(chain, false); }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit');
|
setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit');
|
||||||
@@ -235,6 +244,17 @@ function togglePlanMode() {
|
|||||||
|
|
||||||
let isStreaming = false;
|
let isStreaming = false;
|
||||||
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
|
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 _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events)
|
||||||
let _lastFinishedToolBox = null; // Letzte fertige Tool-Box (für tool_result Zuordnung)
|
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
|
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
|
||||||
@@ -320,6 +340,7 @@ async function _runStream(fetchPromise) {
|
|||||||
sendBtn.hidden = true;
|
sendBtn.hidden = true;
|
||||||
stopBtn.hidden = false;
|
stopBtn.hidden = false;
|
||||||
isStreaming = true;
|
isStreaming = true;
|
||||||
|
_panelUsage = null; // frische Chain → alte Panel-Tokens verwerfen
|
||||||
_abortController = new AbortController();
|
_abortController = new AbortController();
|
||||||
// Eine Live-SSE-Antwort rendert ALLEIN. Einen evtl. noch laufenden Recovery-Poll
|
// Eine Live-SSE-Antwort rendert ALLEIN. Einen evtl. noch laufenden Recovery-Poll
|
||||||
// (_streamingPollInterval – gestartet beim Betreten des Chats WAEHREND eine Chain
|
// (_streamingPollInterval – gestartet beim Betreten des Chats WAEHREND eine Chain
|
||||||
|
|||||||
Reference in new issue
Block a user