Files
gott/Services/AnthropicService.cs
T

430 lines
18 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.Net.Http;
using System.Net.Http.Headers;
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 class AnthropicService
{
private readonly IHttpClientFactory _httpFactory;
private readonly string _token;
private readonly ToolService _toolService;
private readonly DatabaseService _db;
public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db)
{
_httpFactory = httpFactory;
_token = token;
_toolService = toolService;
_db = db;
}
public async Task StreamChat(
HttpResponse response,
string modelId,
List<object> systemPrompt,
List<object> messages,
string chainId,
string workspace,
string sessionId = null,
int maxIterations = 25,
CancellationToken cancellationToken = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
int iterations = 0;
string lastStopReason = "";
var chain = new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
Model = modelId
};
try
{
while (iterations < maxIterations)
{
iterations++;
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["max_tokens"] = 16384,
["system"] = systemPrompt,
["messages"] = messages,
["stream"] = true
};
if (!string.IsNullOrEmpty(workspace))
payload["tools"] = _toolService.GetToolDefinitions(markLastAsCached: true);
var client = CreateClient();
var json = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
);
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync(cancellationToken);
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await TryWriteSse(response, "error", new { error = errMsg }, cancellationToken);
await FinalizeChain(chain, usage, iterations, "error", sw);
await TryWriteSse(response, "done", new { }, cancellationToken);
return;
}
var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace, cancellationToken);
lastStopReason = stopReason;
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")
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = el.GetProperty("text").GetString()
});
}
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()
});
}
}
// 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")
{
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw);
await 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
}, cancellationToken);
return;
}
var toolResults = new List<object>();
foreach (var tr in executedTools)
{
// Tool-Results in der History auf 20.000 Zeichen kürzen.
// Das vollständige Ergebnis wurde bereits in der DB gespeichert.
// 20k Zeichen ≈ ~5k Tokens – verhindert Token-Explosion bei großen
// read_file / execute-Outputs die sich über alle weiteren Iterationen schleppen.
const int MaxToolResultChars = 20_000;
var content = tr.Result.Length > MaxToolResultChars
? tr.Result[..MaxToolResultChars] + $"\n\n[... gekürzt, {tr.Result.Length - MaxToolResultChars} weitere Zeichen ...]"
: tr.Result;
toolResults.Add(new
{
type = "tool_result",
tool_use_id = tr.Id,
content,
is_error = tr.IsError
});
}
messages.Add(new { role = "user", content = toolResults });
}
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, cancellationToken);
await TryWriteSse(response, "done", new { }, cancellationToken);
}
catch (OperationCanceledException)
{
// Client hat die Verbindung getrennt (z.B. Browser-Refresh)
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 TryWriteSse(response, "error", new { error = errMsg }, cancellationToken);
await TryWriteSse(response, "done", new { }, cancellationToken);
}
}
private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
{
chain.InputTokens = usage.InputTokens;
chain.OutputTokens = usage.OutputTokens;
chain.CacheCreationTokens = usage.CacheCreationTokens;
chain.CacheReadTokens = usage.CacheReadTokens;
chain.Iterations = iterations;
chain.StopReason = stopReason;
chain.DurationMs = sw.ElapsedMilliseconds;
await _db.UpdateChain(chain);
}
private async Task<(string StopReason, List<object> Content, List<ToolResult> Tools)> ProcessStream(
HttpResponse response,
HttpResponseMessage httpResp,
TokenUsage usage,
string workspace,
CancellationToken cancellationToken = default)
{
var stream = await httpResp.Content.ReadAsStreamAsync(cancellationToken);
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 executedTools = new List<ToolResult>();
string line;
while ((line = await reader.ReadLineAsync(cancellationToken)) != 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))
{
if (u.TryGetProperty("input_tokens", out var it))
usage.InputTokens += it.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();
}
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 WriteSseEvent(response, "text_start", new { index = idx });
}
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 WriteSseEvent(response, "tool_start", new { name = currentToolName, id = currentToolId });
}
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 WriteSseEvent(response, "text_delta", new { text });
}
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 WriteSseEvent(response, "text_end", new { });
}
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
});
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
await WriteSseEvent(response, "tool_end", new
{
name = currentToolName,
input = currentToolInputJson.ToString(),
result = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult,
isError
});
}
}
break;
}
case "message_delta":
{
if (evt.TryGetProperty("delta", out var d) &&
d.TryGetProperty("stop_reason", out var sr))
stopReason = sr.GetString() ?? "";
if (evt.TryGetProperty("usage", out var u2) &&
u2.TryGetProperty("output_tokens", out var ot))
usage.OutputTokens += ot.GetInt64();
break;
}
}
}
if (currentTextBlock.Length > 0)
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
return (stopReason, assistantContent, executedTools);
}
private HttpClient CreateClient()
{
var client = _httpFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20");
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
client.DefaultRequestHeaders.Add("x-app", "cli");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true");
return client;
}
public static async Task WriteSseEvent(HttpResponse response, string eventType, object data)
{
var json = JsonSerializer.Serialize(data);
await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n");
await response.Body.FlushAsync();
}
private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct)
{
try
{
if (!ct.IsCancellationRequested)
await WriteSseEvent(response, eventType, data);
}
catch
{
// Client bereits disconnected – ignorieren
}
}
}
public record ToolResult(string Id, string Result, bool IsError);
public class TokenUsage
{
public long InputTokens { get; set; }
public long OutputTokens { get; set; }
public long CacheCreationTokens { get; set; }
public long CacheReadTokens { get; set; }
}