SSE Streaming, Tool-Loop Refactor, Hilfsfunktionen extrahiert

This commit is contained in:
Meleki committed 2026-02-26 14:22:55 +01:00
1 parent d32b637760
commit 6ccd8cdb06
2 files changed
+374 -205

No files matched your search

+216 -123
View File
@@ -7,6 +7,7 @@ using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -20,11 +21,12 @@ var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
// Workspace-Root für Dateioperationen
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
var workspaceRoot = Path.Combine(dataDir, "workspace");
Directory.CreateDirectory(workspaceRoot);
// ── Tool Definitionen ──
var toolDefs = new[]
{
new
@@ -88,11 +90,13 @@ var toolDefs = new[]
}
};
// ── Hilfsfunktionen ──
string ResolvePath(string relativePath)
{
var full = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath ?? ""));
if (!full.StartsWith(workspaceRoot))
throw new System.Exception("Zugriff außerhalb des Workspace nicht erlaubt");
throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt");
return full;
}
@@ -147,15 +151,45 @@ string ExecuteTool(string name, JsonElement input)
}
}
app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory, IConfiguration config) =>
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();
}
HttpClient CreateAnthropicClient(IHttpClientFactory factory, string token)
{
var client = factory.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;
}
// ── SSE Streaming Endpoint ──
app.MapPost("/api/chat", async (HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config) =>
{
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Messages == null || body.Messages.Count == 0)
return Results.BadRequest(new { error = "messages required" });
{
res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "messages required" });
return;
}
var token = config["AnthropicToken"];
if (string.IsNullOrEmpty(token))
return Results.Problem("AnthropicToken not configured");
{
res.StatusCode = 500;
await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" });
return;
}
var modelId = body.Model switch
{
@@ -164,23 +198,22 @@ app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory,
_ => "claude-sonnet-4-6"
};
// Messages in API-Format konvertieren
// SSE Response starten
res.Headers["Content-Type"] = "text/event-stream";
res.Headers["Cache-Control"] = "no-cache";
res.Headers["Connection"] = "keep-alive";
var messages = new List<object>();
foreach (var msg in body.Messages)
{
messages.Add(new { role = msg.Role, content = msg.Content });
}
var systemPrompt = new List<object>
{
new { type = "text", text = "You are Claude Code, Anthropic's official CLI for Claude." }
};
if (!string.IsNullOrWhiteSpace(body.SystemPrompt))
{
systemPrompt.Add(new { type = "text", text = body.SystemPrompt });
}
var steps = new List<StepInfo>();
long totalInputTokens = 0;
long totalOutputTokens = 0;
int iterations = 0;
@@ -196,138 +229,219 @@ app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory,
["max_tokens"] = 4096,
["system"] = systemPrompt,
["messages"] = messages,
["tools"] = toolDefs
["tools"] = toolDefs,
["stream"] = true
};
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");
var client = CreateAnthropicClient(httpFactory, token);
var json = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
var resp = await client.PostAsync("https://api.anthropic.com/v1/messages", httpContent);
var respBody = await resp.Content.ReadAsStringAsync();
var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead
);
if (!resp.IsSuccessStatusCode)
return Results.Problem($"Anthropic API error: {(int)resp.StatusCode} - {respBody}");
var result = JsonSerializer.Deserialize<JsonElement>(respBody);
// Usage tracken
if (result.TryGetProperty("usage", out var usage))
if (!httpResp.IsSuccessStatusCode)
{
if (usage.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.GetInt64();
if (usage.TryGetProperty("output_tokens", out var ot)) totalOutputTokens += ot.GetInt64();
var errBody = await httpResp.Content.ReadAsStringAsync();
await WriteSseEvent(res, "error", new { error = $"API {(int)httpResp.StatusCode}: {errBody}" });
await WriteSseEvent(res, "done", new { });
return;
}
var stopReason = result.GetProperty("stop_reason").GetString();
var contentBlocks = result.GetProperty("content");
// Anthropic SSE Stream parsen
var stream = await httpResp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
// Assistant-Antwort zur History hinzufügen
var stopReason = "";
var assistantContent = new List<object>();
var textParts = new List<string>();
var currentToolId = "";
var currentToolName = "";
var currentToolInputJson = new StringBuilder();
var currentTextBlock = new StringBuilder();
var blockIndex = -1;
foreach (var block in contentBlocks.EnumerateArray())
var blockTypes = new Dictionary<int, string>();
var executedTools = new List<(string Id, string Result, bool IsError)>();
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
var blockType = block.GetProperty("type").GetString();
if (blockType == "text")
{
var text = block.GetProperty("text").GetString() ?? "";
textParts.Add(text);
assistantContent.Add(new { type = "text", text });
}
else if (blockType == "tool_use")
{
var toolId = block.GetProperty("id").GetString() ?? "";
var toolName = block.GetProperty("name").GetString() ?? "";
var toolInput = block.GetProperty("input");
if (!line.StartsWith("data: ")) continue;
assistantContent.Add(new
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":
{
type = "tool_use",
id = toolId,
name = toolName,
input = toolInput
});
if (evt.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("usage", out var u))
{
if (u.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.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")
{
blockIndex = idx;
currentTextBlock.Clear();
await WriteSseEvent(res, "text_start", new { index = idx });
}
else if (cbType == "tool_use")
{
// Vorherigen Text-Block abschließen
if (currentTextBlock.Length > 0)
{
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
currentTextBlock.Clear();
}
blockIndex = idx;
currentToolId = cb.GetProperty("id").GetString() ?? "";
currentToolName = cb.GetProperty("name").GetString() ?? "";
currentToolInputJson.Clear();
await WriteSseEvent(res, "tool_start", new { name = currentToolName, id = currentToolId });
}
break;
}
case "content_block_delta":
{
var idx = evt.GetProperty("index").GetInt32();
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(res, "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(res, "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
});
// Tool sofort ausführen und streamen
string toolResult;
bool isError = false;
try { toolResult = ExecuteTool(currentToolName, inputEl); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
executedTools.Add((currentToolId, toolResult, isError));
await WriteSseEvent(res, "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))
totalOutputTokens += ot.GetInt64();
break;
}
}
}
messages.Add(new { role = "assistant", content = assistantContent });
// Restlichen Text abschließen
if (currentTextBlock.Length > 0)
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
// Zwischen-Text als Step erfassen
if (textParts.Count > 0)
{
steps.Add(new StepInfo
{
Type = "text",
Text = string.Join("\n", textParts)
});
}
// Assistant-Antwort zur History
messages.Add(new { role = "assistant", content = assistantContent });
// Kein Tool Use? Fertig!
if (stopReason != "tool_use")
{
return Results.Ok(new
await WriteSseEvent(res, "done", new
{
text = string.Join("\n", textParts),
model = modelId,
inputTokens = totalInputTokens,
outputTokens = totalOutputTokens,
iterations,
steps
iterations
});
return;
}
// Tool Calls ausführen
// Tool Results zusammenbauen (wurden beim Streaming bereits ausgeführt)
var toolResults = new List<object>();
foreach (var block in contentBlocks.EnumerateArray())
foreach (var tr in executedTools)
{
if (block.GetProperty("type").GetString() != "tool_use") continue;
var toolId = block.GetProperty("id").GetString() ?? "";
var toolName = block.GetProperty("name").GetString() ?? "";
var toolInput = block.GetProperty("input");
string toolResult;
bool isError = false;
try
{
toolResult = ExecuteTool(toolName, toolInput);
}
catch (System.Exception ex)
{
toolResult = $"Fehler: {ex.Message}";
isError = true;
}
steps.Add(new StepInfo
{
Type = "tool",
ToolName = toolName,
ToolInput = toolInput.ToString(),
ToolResult = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult,
IsError = isError
});
toolResults.Add(new
{
type = "tool_result",
tool_use_id = toolId,
content = toolResult,
is_error = isError
tool_use_id = tr.Id,
content = tr.Result,
is_error = tr.IsError
});
}
messages.Add(new { role = "user", content = toolResults });
}
return Results.Problem("Max iterations erreicht");
await WriteSseEvent(res, "error", new { error = "Max iterations erreicht" });
await WriteSseEvent(res, "done", new { });
});
app.Run("http://0.0.0.0:19000");
@@ -352,24 +466,3 @@ class ChatRequest
[JsonPropertyName("systemPrompt")]
public string SystemPrompt { get; set; } = "";
}
class StepInfo
{
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("text")]
public string Text { get; set; } = "";
[JsonPropertyName("toolName")]
public string ToolName { get; set; } = "";
[JsonPropertyName("toolInput")]
public string ToolInput { get; set; } = "";
[JsonPropertyName("toolResult")]
public string ToolResult { get; set; } = "";
[JsonPropertyName("isError")]
public bool IsError { get; set; }
}