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>
114 lines
3.9 KiB
C#
114 lines
3.9 KiB
C#
using System.Diagnostics;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Gott.Models;
|
||
using Microsoft.AspNetCore.Http;
|
||
|
||
namespace Gott.Services;
|
||
|
||
/// <summary>
|
||
/// Shared SSE-Streaming-Utilities für AnthropicService und OpenAiService.
|
||
/// </summary>
|
||
public static class SseHelper
|
||
{
|
||
/// <summary>
|
||
/// Schreibt ein SSE-Event (wirft bei CancellationToken).
|
||
/// </summary>
|
||
public static async Task WriteSseEvent(HttpResponse response, string eventType, object data, CancellationToken ct = default)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
var json = JsonSerializer.Serialize(data);
|
||
await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n", ct);
|
||
await response.Body.FlushAsync(ct);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Versucht ein SSE-Event zu schreiben, ignoriert Fehler (Client disconnected).
|
||
/// Optionaler <paramref name="gate"/>: serialisiert die Byte-Writes, wenn mehrere
|
||
/// Producer (z.B. parallele Panel-Member) auf DIESELBE HttpResponse schreiben –
|
||
/// sonst vermischen sich die SSE-Frames auf Byte-Ebene.
|
||
/// </summary>
|
||
public static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct, SemaphoreSlim gate = null)
|
||
{
|
||
if (ct.IsCancellationRequested) return;
|
||
bool acquired = false;
|
||
try
|
||
{
|
||
if (gate != null)
|
||
{
|
||
await gate.WaitAsync(ct);
|
||
acquired = true;
|
||
}
|
||
await WriteSseEvent(response, eventType, data, ct);
|
||
}
|
||
catch
|
||
{
|
||
// Client bereits disconnected / abgebrochen – ignorieren
|
||
}
|
||
finally
|
||
{
|
||
if (acquired) gate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Finalisiert eine Chain in der Datenbank (Token-Counts, Stopgrund, Dauer).
|
||
/// </summary>
|
||
public static async Task FinalizeChain(DatabaseService db, ChainRecord chain, TokenUsage usage, int iterations, string stopReason, Stopwatch sw, string streamErrorInfo = null)
|
||
{
|
||
chain.InputTokens = usage.InputTokens;
|
||
chain.OutputTokens = usage.OutputTokens;
|
||
chain.CacheCreationTokens = usage.CacheCreationTokens;
|
||
chain.CacheReadTokens = usage.CacheReadTokens;
|
||
chain.Iterations = iterations;
|
||
chain.StopReason = stopReason;
|
||
chain.StreamErrorInfo = streamErrorInfo;
|
||
chain.DurationMs = sw.ElapsedMilliseconds;
|
||
await db.UpdateChain(chain);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Kürzt Tool-Results auf max. Zeichenzahl.
|
||
/// </summary>
|
||
public const int MaxToolResultChars = 20_000;
|
||
|
||
public static string TruncateToolResult(string result)
|
||
{
|
||
return result.Length > MaxToolResultChars
|
||
? result[..MaxToolResultChars] + $"\n\n[... gekürzt, {result.Length - MaxToolResultChars} weitere Zeichen ...]"
|
||
: result;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Token-Zähler für eine Chat-Session (Input, Output, Cache).
|
||
/// </summary>
|
||
public class TokenUsage
|
||
{
|
||
public long InputTokens { get; set; }
|
||
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>
|
||
/// Ergebnis einer Tool-Ausführung.
|
||
/// </summary>
|
||
public record ToolResult(string Id, string Result, bool IsError);
|