Files
gott/Services/AnthropicService.Streaming.cs
T
MelekiandClaude Opus 4.7 3f913f14b5 Add per-agent reasoning effort
Opus 4.5+ and Sonnet 4.6+ support a new "effort" knob that controls how
much budget the model spends on thinking and tool calls. Plumb it through
end to end so each agent can pick its own level.

Storage & shape:
- New TEXT column agents.effort (default '') added via TryAddColumn.
- AgentRecord.Effort + INSERT/UPDATE in DatabaseService.Agents.cs.
- ChatContext.Effort + SubAgentContext.Effort so the value travels with the
  request alongside model and tool settings.

Agent editor:
- Effort dropdown above git-commit behavior in the agent form (low/medium/
  high/xhigh/max plus empty = "use default"). Always visible; on OpenAI
  providers the value is sent but silently ignored.
- agent-page.js load/save and AgentEndpoint create/update wired up.

Anthropic API path:
- StreamChat and RunNonStreamingToolLoop add output_config: { effort } when
  effort is set and not "high" – empty/null/"high" means "omit and let the
  API pick its default".
- Sub-agents (preparer/review/finalizer) get the parameter through
  RunAgentChat so each step uses its own agent's effort.

Anthropic CLI path:
- ClaudeCliService.ChatStreamingAsync takes an effort parameter and exports
  CLAUDE_CODE_EFFORT_LEVEL on the spawned process. Env-var beats the
  --effort flag because it also covers "max" and "ultracode".
- StreamChatViaCli, ChatNonStreamingViaCli, RunSubAgentViaCli, and the
  ChatEndpoint anthropic branches all hand the value through.

ARCHITECTURE.md / ARCHITECTURE_QUICK.md updated with the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:33:32 +02:00

755 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
public async Task StreamChat(
HttpResponse response,
string modelId,
List<object> systemPrompt,
List<object> messages,
string chainId,
string workspace,
string apiToken,
string sessionId = null,
string agentId = null,
string userId = null,
bool useMemory = true,
string webSearchVersion = null,
int maxIterations = 100,
string toolChoice = "any",
SubAgentContext review = null,
SubAgentContext finalizer = null,
bool allowUserChoice = true,
bool planMode = false,
bool useAutoContext = true,
bool useGitHubSearch = false,
bool useSerperSearch = false,
string effort = null,
CancellationToken workCt = default,
CancellationToken hardCt = default,
CancellationToken sseCt = default,
RemoteConnectionManager remoteMgr = null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
int iterations = 0;
int reviewRound = 0;
string lastStopReason = "";
int promptTrimRetries = 0;
var chain = new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
Model = modelId
};
try
{
// ── Automatische Kontext-Injektion via Embedding-Suche ──
if (useAutoContext)
{
try
{
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId);
if (autoCtx != null)
{
systemPrompt.Add(new { type = "text", text = autoCtx });
await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt);
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Role = "system",
Type = "auto_context",
Content = autoCtx
});
}
}
catch (Exception ex)
{
Console.WriteLine($"[AutoContext] Error: {ex.Message}");
}
}
while (iterations < maxIterations)
{
// Hard-Stop: Sofortiger Abbruch aus while-Loop (Doppel-Klick Stop)
hardCt.ThrowIfCancellationRequested();
workCt.ThrowIfCancellationRequested();
iterations++;
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["max_tokens"] = 16384,
["messages"] = messages,
["stream"] = true
};
// Reasoning-Effort (Opus 4.5+, Sonnet 4.6+). "" / "high" / null = Default weglassen.
// Werte: low / medium / high / xhigh (Opus 4.7+) / max (Opus 4.6+, Sonnet 4.6).
if (!string.IsNullOrEmpty(effort) && effort != "high")
payload["output_config"] = new { effort };
if (systemPrompt != null && systemPrompt.Count > 0)
{
// Cache-Breakpoint auf den letzten System-Prompt-Block (1h TTL).
// Idempotent über Iterationen.
ApplyCacheControlToLast(systemPrompt);
payload["system"] = systemPrompt;
}
var remoteConn = remoteMgr?.GetBySessionId(sessionId);
var hasRemote = remoteConn != null;
AddToolsToPayload(payload, new ToolPayloadOptions
{
Workspace = workspace,
AgentId = agentId,
UseMemory = useMemory,
WebSearchVersion = webSearchVersion,
ToolChoice = toolChoice,
Iteration = iterations,
AllowUserChoice = allowUserChoice,
PlanMode = planMode,
IncludeRemote = hasRemote,
RemoteOs = remoteConn?.OsVersion,
IncludeGitHubSearch = useGitHubSearch && _toolService.HasGitHubToken,
IncludeSerperSearch = useSerperSearch && _toolService.HasSerperKey
});
var client = CreateClient(apiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, sessionId, chainId, iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead,
workCt
);
TryUpdateKeyMeta(httpResp, apiToken);
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync(workCt);
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
// Auto-Retry bei "prompt too long" / "exceed context limit": History kürzen und erneut versuchen
if ((int)httpResp.StatusCode == 400 &&
(errBody.Contains("prompt is too long") || errBody.Contains("exceed context limit")) &&
messages.Count > 4 && promptTrimRetries < 5)
{
promptTrimRetries++;
int trimCount = Math.Max(2, messages.Count / 5); // 20% der Messages entfernen
trimCount = TrimMessagesCleanly(messages, trimCount);
if (trimCount > 0)
{
await SseHelper.TryWriteSse(response, "info", new { message = $"History zu lang – {trimCount} ältere Messages entfernt, Retry ({promptTrimRetries}/5)..." }, sseCt);
iterations--; // Iteration nicht zählen, war ja kein echter Versuch
continue; // → nächste while-Iteration mit kürzerer History
}
}
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await FinalizeChain(chain, usage, iterations, "error", sw);
await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
return;
}
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId, apiToken);
lastStopReason = stopReason;
promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten
messages.Add(new { role = "assistant", content = assistantContent });
// Assistant-Blöcke persistieren
foreach (var block in assistantContent)
{
var blockJson = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(blockJson);
var blockType = el.GetProperty("type").GetString() ?? "";
if (blockType == "text")
{
var textContent = el.GetProperty("text").GetString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = textContent
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textContent ?? "");
}
else if (blockType == "tool_use")
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "tool_use",
ToolName = el.GetProperty("name").GetString(),
ToolCallId = el.GetProperty("id").GetString(),
Content = el.GetProperty("input").ToString()
});
}
else if (blockType == "server_tool_use")
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "server_tool_use",
Content = el.GetRawText()
});
}
else if (blockType == "web_search_tool_result")
{
// Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "web_search_result",
Content = el.GetRawText()
});
}
else if (blockType.EndsWith("_tool_result"))
{
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "server_tool_result",
Content = el.GetRawText()
});
}
}
// Tool Results persistieren
foreach (var tr in executedTools)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "user", Type = "tool_result",
ToolCallId = tr.Id,
Content = tr.Result,
IsError = tr.IsError
});
}
if (stopReason != "tool_use")
{
// ── Review-Agent Phase ──
if (review != null && stopReason == "end_turn" && reviewRound < review.MaxReviewIterations)
{
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, apiToken, usage, iterations, reviewRound, workCt, sseCt);
iterations += reviewResult.ReviewIterationCount;
reviewRound++;
if (reviewResult.HasFeedback)
{
// Feedback als User-Message an Worker zurückgeben → nächste Iteration
messages.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = reviewResult.Feedback } }
});
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "user", Type = "text",
Content = reviewResult.Feedback
});
continue; // → nächste Worker-Iteration
}
}
// ── Finalizer-Agent Phase ──
if (finalizer != null && stopReason == "end_turn")
{
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, apiToken, usage, iterations, workCt, sseCt);
iterations += finalizerIterations;
}
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = finalStopReason,
streamErrorInfo,
planMode
}, sseCt);
return;
}
var toolResults = new List<object>();
foreach (var tr in executedTools)
{
toolResults.Add(new
{
type = "tool_result",
tool_use_id = tr.Id,
content = TruncateToolResult(tr.Result),
is_error = tr.IsError
});
}
messages.Add(new { role = "user", content = toolResults });
// Hard-Stop Check nach Tool-Ausführung: Sofort raus statt nächste Iteration
hardCt.ThrowIfCancellationRequested();
}
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
await SseHelper.TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
}
catch (OperationCanceledException) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
{
// Stop-Button gedrückt (workCt oder hardCt wurde gecancelled)
await FinalizeChain(chain, usage, iterations, "stopped", sw);
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
// Der SSE-Stream zum Client ist noch offen – nur der Stop-Button hat ausgelöst
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "stopped",
planMode
}, CancellationToken.None);
}
catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
{
// Stream-Dispose durch CancellationToken-Registration löst
// ObjectDisposedException/IOException aus statt OperationCanceledException.
// Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop.
await FinalizeChain(chain, usage, iterations, "stopped", sw);
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "stopped",
planMode
}, CancellationToken.None);
}
catch (OperationCanceledException)
{
// Client-Disconnect (cancellationToken gecancelled, aber NICHT workCt/hardCt)
// Agent-Arbeit ist leider schon abgebrochen – Chain als "client_disconnected" finalisieren
// (sollte durch TryWriteSse eigentlich nicht mehr passieren)
await FinalizeChain(chain, usage, iterations, "client_disconnected", sw);
}
catch (Exception ex)
{
// Unerwarteter Fehler – in DB speichern und versuchen an Client zu senden
var errMsg = $"Stream-Fehler: {ex.Message}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await FinalizeChain(chain, usage, iterations, "error", sw);
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
}
}
private async Task<(string StopReason, List<object> Content, List<ToolResult> Tools, string StreamErrorInfo)> ProcessStream(
HttpResponse response,
HttpResponseMessage httpResp,
TokenUsage usage,
string workspace,
string agentId = null,
CancellationToken workCt = default,
CancellationToken sseCt = default,
RemoteConnectionManager remoteMgr = null,
string sessionId = null,
string userId = null,
string apiToken = null)
{
var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
// Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht.
// StreamReader.ReadLineAsync propagiert den CancellationToken nicht zuverlässig
// an den underlying NetworkStream – der Read blockiert sonst bis Anthropic
// das nächste Chunk sendet (kann bei extended thinking Minuten dauern).
using var ctReg = workCt.Register(() => { try { stream.Dispose(); } catch { } });
using var reader = new StreamReader(stream);
var stopReason = "";
var assistantContent = new List<object>();
var currentToolId = "";
var currentToolName = "";
var currentToolInputJson = new StringBuilder();
var currentTextBlock = new StringBuilder();
var blockTypes = new Dictionary<int, string>();
var webSearchResultBlocks = new Dictionary<int, object>();
var webSearchSources = new Dictionary<int, List<object>>();
var serverToolResultBlocks = new Dictionary<int, object>(); // bash_code_execution_tool_result, code_execution_tool_result etc.
var executedTools = new List<ToolResult>();
var streamErrors = new List<object>();
string line;
while ((line = await reader.ReadLineAsync(workCt)) != null)
{
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (string.IsNullOrWhiteSpace(data)) continue;
JsonElement evt;
try { evt = JsonSerializer.Deserialize<JsonElement>(data); }
catch { continue; }
var evtType = evt.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
switch (evtType)
{
case "message_start":
{
if (evt.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("usage", out var u))
TrackUsage(usage, u);
break;
}
case "content_block_start":
{
var idx = evt.GetProperty("index").GetInt32();
var cb = evt.GetProperty("content_block");
var cbType = cb.GetProperty("type").GetString() ?? "";
blockTypes[idx] = cbType;
if (cbType == "text")
{
currentTextBlock.Clear();
await SseHelper.TryWriteSse(response, "text_start", new { index = idx }, sseCt);
}
else if (cbType == "tool_use")
{
if (currentTextBlock.Length > 0)
{
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
currentTextBlock.Clear();
}
currentToolId = cb.GetProperty("id").GetString() ?? "";
currentToolName = cb.GetProperty("name").GetString() ?? "";
currentToolInputJson.Clear();
await SseHelper.TryWriteSse(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt);
}
else if (cbType == "server_tool_use")
{
if (currentTextBlock.Length > 0)
{
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
currentTextBlock.Clear();
}
currentToolId = cb.GetProperty("id").GetString() ?? "";
currentToolName = cb.GetProperty("name").GetString() ?? "";
currentToolInputJson.Clear();
await SseHelper.TryWriteSse(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt);
}
else if (cbType == "web_search_tool_result")
{
var rawBlock = JsonSerializer.Deserialize<JsonElement>(cb.GetRawText());
webSearchResultBlocks[idx] = rawBlock;
webSearchSources[idx] = ExtractWebSearchSources(cb);
}
else if (cbType.EndsWith("_tool_result"))
{
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
serverToolResultBlocks[idx] = JsonSerializer.Deserialize<JsonElement>(cb.GetRawText());
}
break;
}
case "content_block_delta":
{
var delta = evt.GetProperty("delta");
var deltaType = delta.GetProperty("type").GetString() ?? "";
if (deltaType == "text_delta")
{
var text = delta.GetProperty("text").GetString() ?? "";
currentTextBlock.Append(text);
await SseHelper.TryWriteSse(response, "text_delta", new { text }, sseCt);
}
else if (deltaType == "input_json_delta")
{
var partial = delta.GetProperty("partial_json").GetString() ?? "";
currentToolInputJson.Append(partial);
}
break;
}
case "content_block_stop":
{
var idx = evt.GetProperty("index").GetInt32();
if (blockTypes.TryGetValue(idx, out var bt))
{
if (bt == "text")
{
var text = currentTextBlock.ToString();
if (text.Length > 0)
{
assistantContent.Add(new { type = "text", text });
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
}
currentTextBlock.Clear();
}
else if (bt == "tool_use")
{
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize<JsonElement>(currentToolInputJson.ToString()); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
assistantContent.Add(new
{
type = "tool_use",
id = currentToolId,
name = currentToolName,
input = inputEl
});
// ask_user_choice: NICHT ausführen, sondern als SSE-Event an Client senden
if (currentToolName == ToolService.AskUserChoiceToolName)
{
var question = inputEl.TryGetProperty("question", out var q) ? q.GetString() ?? "" : "";
var choices = new List<string>();
if (inputEl.TryGetProperty("choices", out var c))
{
if (c.ValueKind == JsonValueKind.Array)
{
foreach (var ch in c.EnumerateArray())
choices.Add(ch.GetString() ?? "");
}
else if (c.ValueKind == JsonValueKind.String)
{
// Fallback: Claude hat einen einzelnen String statt Array gesendet
choices.Add(c.GetString() ?? "");
}
}
await SseHelper.TryWriteSse(response, "user_choice", new
{
toolCallId = currentToolId,
question,
choices
}, sseCt);
// Stream restlich konsumieren ohne weitere Verarbeitung
// (Claude sendet nach dem tool_use noch message_delta mit stop_reason)
stopReason = "awaiting_user_choice";
continue;
}
// Cancellation prüfen bevor Tool ausgeführt wird
workCt.ThrowIfCancellationRequested();
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, new ToolExecutionContext
{
Workspace = workspace, MemoryService = _memoryService, AgentId = agentId,
CancellationToken = workCt, RemoteMgr = remoteMgr, SessionId = sessionId,
VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg, Db = _db,
ChatService = _chatService, AnthropicService = this, OpenAiService = _openAiService,
UserId = userId, TelegramService = _telegramService, ApiToken = apiToken
}); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
workCt.ThrowIfCancellationRequested();
executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
await SseHelper.TryWriteSse(response, "tool_end", new
{
name = currentToolName,
input = currentToolInputJson.ToString(),
result = toolResult,
isError
}, sseCt);
}
else if (bt == "server_tool_use")
{
// Server-side tool (Web Search) – Anthropic führt es aus, wir speichern nur die Anfrage
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize<JsonElement>(currentToolInputJson.ToString()); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
assistantContent.Add(new
{
type = "server_tool_use",
id = currentToolId,
name = currentToolName,
input = inputEl
});
}
else if (bt == "web_search_tool_result")
{
// Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen
assistantContent.Add(webSearchResultBlocks[idx]);
var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List<object>();
await SseHelper.TryWriteSse(response, "web_search_end", new { id = currentToolId, sources }, sseCt);
}
else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock))
{
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
assistantContent.Add(srvResultBlock);
}
}
break;
}
case "message_delta":
{
if (evt.TryGetProperty("delta", out var d))
{
if (d.TryGetProperty("stop_reason", out var sr))
{
// Nicht überschreiben wenn bereits auf awaiting_user_choice gesetzt
if (stopReason != "awaiting_user_choice")
stopReason = sr.GetString() ?? "";
// Bei problematischen stop_reasons Debug-Infos sammeln
if (stopReason is "unexpected_end" or "max_tokens")
{
var debugInfo = new Dictionary<string, object>
{
["event"] = "message_delta",
["stop_reason"] = stopReason,
["output_tokens"] = usage.OutputTokens,
["text_length"] = currentTextBlock.Length,
["timestamp"] = DateTime.UtcNow.ToString("o")
};
if (d.TryGetProperty("stop_sequence", out var ss) && ss.ValueKind != JsonValueKind.Null)
debugInfo["stop_sequence"] = ss.GetString();
streamErrors.Add(debugInfo);
}
}
}
if (evt.TryGetProperty("usage", out var u2) &&
u2.TryGetProperty("output_tokens", out var ot))
usage.OutputTokens += ot.GetInt64();
break;
}
case "error":
{
// API Error-Event (z.B. overloaded_error)
var errorInfo = new Dictionary<string, object>
{
["event"] = "error",
["timestamp"] = DateTime.UtcNow.ToString("o"),
["raw"] = data
};
if (evt.TryGetProperty("error", out var err))
{
if (err.TryGetProperty("type", out var errType))
errorInfo["error_type"] = errType.GetString();
if (err.TryGetProperty("message", out var errMsg))
errorInfo["error_message"] = errMsg.GetString();
}
streamErrors.Add(errorInfo);
break;
}
}
}
if (currentTextBlock.Length > 0)
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
// Stream-Fehler als JSON serialisieren (null wenn keine Fehler)
string streamErrorInfo = streamErrors.Count > 0
? JsonSerializer.Serialize(streamErrors)
: null;
return (stopReason, assistantContent, executedTools, streamErrorInfo);
}
/// <summary>
/// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block.
/// </summary>
private static List<object> ExtractWebSearchSources(JsonElement block)
{
var sources = new List<object>();
if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array)
{
foreach (var item in searchContent.EnumerateArray())
{
var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : "";
if (itemType == "web_search_result")
{
var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "";
var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : "";
if (!string.IsNullOrEmpty(url))
sources.Add(new { url, title });
}
}
}
return sources;
}
/// <summary>
/// Parst einen server_tool_use-Block und gibt (id, name, input) zurück.
/// </summary>
private static (string id, string name, JsonElement input) ParseServerToolUse(JsonElement block)
{
var toolId = block.GetProperty("id").GetString() ?? "";
var toolName = block.GetProperty("name").GetString() ?? "";
JsonElement inputEl;
try { inputEl = block.GetProperty("input"); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
return (toolId, toolName, inputEl);
}
/// <summary>
/// Aktualisiert den TokenUsage-Tracker aus einem usage-JSON-Element.
/// </summary>
private static void TrackUsage(TokenUsage usage, JsonElement u)
{
if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64();
if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64();
if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64();
if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64();
}
}