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:
MelekiandClaude Opus 4.8 committed 2026-06-18 18:08:55 +02:00
1 parent 0f87ccdcab
commit 47557c9724
4 files changed
+82 -10

No files matched your search

+44 -8
View File
@@ -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)
}
/// <summary>
@@ -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 <previous_layer_responses> und verfeinert.
// Fusion = layers==1 (keine Vorrunde). prev füttert die nächste Runde bzw. die finale Synthese.
List<PanelMemberResult> 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<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
{
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<object>();
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 };
}
/// <summary>
@@ -250,7 +278,7 @@ public partial class AnthropicService
/// laufen durch den geteilten <paramref name="sseGate"/> (parallele Member → sonst
/// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message.
/// </summary>
private async Task<(string text, List<object> segments)> RunPanelMemberViaCli(
private async Task<(string text, List<object> segments, TokenUsage usage)> RunPanelMemberViaCli(
HttpResponse response,
SubAgentContext member,
List<object> memberMessages,
@@ -259,7 +287,7 @@ public partial class AnthropicService
CancellationToken workCt,
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 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<string, string>(); // toolId -> CLI-Name
var toolInputs = new Dictionary<string, string>(); // 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);
}
/// <summary>Extrahiert den System-Prompt-Text aus den List&lt;object&gt;-Blöcken eines SubAgentContext.</summary>
+15
View File
@@ -90,6 +90,21 @@ public class TokenUsage
public long OutputTokens { get; set; }
public long CacheCreationTokens { 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>