From 7972ca6a3a3b73c8640663b094ab60e3fdef7127 Mon Sep 17 00:00:00 2001 From: Meleki Date: Wed, 11 Mar 2026 16:15:09 +0100 Subject: [PATCH] feat: OpenAI-kompatibler Provider (Nvidia/g4f.space) mit Tool-Calling und SSE-Streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAiService.cs: Neuer Service für OpenAI-kompatible APIs - SSE-Streaming mit text_start/text_delta/text_end Events - Tool-Calling: list_files, read_file, write_file, edit_file, delete_file, execute - Agentic Loop (max 10 Iterationen) - Tool-Use + Tool-Result in DB gespeichert - History-Rebuild: Anthropic-Format → OpenAI-Format (tool_use/tool_result korrekt konvertiert) - ChatContext.cs: Provider-Feld hinzugefügt - ChatService.cs: OpenAI-Provider-Erkennung in ResolveModelId + PrepareContext - ChatEndpoint.cs: Provider-Dispatch (nvidia → OpenAiService, sonst → AnthropicService) - Program.cs: OpenAiService als Singleton registriert - wwwroot/index.html: nvidia:moonshotai/kimi-k2.5 in Modell-Auswahl --- Endpoints/ChatEndpoint.cs | 13 +- Models/ChatContext.cs | 1 + Program.cs | 4 + Services/ChatService.cs | 6 + Services/OpenAiService.cs | 640 ++++++++++++++++++++++++++++++++++++++ wwwroot/index.html | 3 + 6 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 Services/OpenAiService.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 5565ca1..03324ac 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -22,7 +22,7 @@ public static class ChatEndpoint app.MapPost("/api/chat/stop", HandleStop); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, OpenAiService openAi, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null) @@ -208,7 +208,16 @@ public static class ChatEndpoint try { - await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); + if (!string.IsNullOrEmpty(ctx.Provider)) + { + // OpenAI-kompatibler Provider (z.B. Nvidia) + await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, workCt: workCt, sseCt: sseCt); + } + else + { + // Standard: Anthropic + await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); + } } finally { diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs index 6852a40..2b4804b 100644 --- a/Models/ChatContext.cs +++ b/Models/ChatContext.cs @@ -8,6 +8,7 @@ public class ChatContext public string ChainId { get; set; } public string SessionId { get; set; } public string ModelId { get; set; } + public string Provider { get; set; } // "" = Anthropic, "nvidia" = OpenAI-kompatibler Provider public string Workspace { get; set; } public string AgentId { get; set; } public string SystemPromptText { get; set; } diff --git a/Program.cs b/Program.cs index ed2f39d..c70656d 100644 --- a/Program.cs +++ b/Program.cs @@ -68,6 +68,10 @@ builder.Services.AddSingleton(sp => return svc; }); +// OpenAI-kompatibler Service (Nvidia etc.) +builder.Services.AddSingleton(sp => + new OpenAiService(sp.GetRequiredService(), dbService, sp.GetRequiredService())); + // Telegram Bot (pro Session – Token aus Session-Config) var maxIterations = builder.Configuration.GetValue("MaxIterations", 100); builder.Services.AddSingleton(sp => diff --git a/Services/ChatService.cs b/Services/ChatService.cs index f5da266..1e1060b 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -30,6 +30,9 @@ public class ChatService public static string ResolveModelId(string model) { + // OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model") + if (OpenAiService.IsOpenAiProvider(model)) return model; + return model switch { "sonnet" => "claude-sonnet-4-6", @@ -160,11 +163,14 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3; + var (provider, _) = OpenAiService.ParseModelString(modelId); + return new ChatContext { ChainId = chainId, SessionId = sessionId, ModelId = modelId, + Provider = provider, Workspace = workspace, AgentId = agent.AgentId, SystemPromptText = systemPromptText, diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs new file mode 100644 index 0000000..4ab229b --- /dev/null +++ b/Services/OpenAiService.cs @@ -0,0 +1,640 @@ +using System; +using System.Collections.Generic; +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; + +/// +/// OpenAI-kompatibler Service – unterstützt Nvidia (g4f.space Proxy) und andere +/// OpenAI-API-kompatible Endpunkte. Unterstützt Tool-Calling (list_files, read_file, +/// write_file, edit_file, delete_file, execute) und Streaming via SSE. +/// +public class OpenAiService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly DatabaseService _db; + private readonly ToolService _toolService; + + // Provider-Definitionen + private static readonly Dictionary Providers = new() + { + ["nvidia"] = ("https://g4f.space/api/nvidia/v1", "") + }; + + // Tools die für OpenAI-kompatible Provider verfügbar sind + private static readonly List OpenAiTools = new() + { + new + { + type = "function", + function = new + { + name = "list_files", + description = "Listet Dateien und Ordner in einem Verzeichnis auf.", + parameters = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" } + }, + required = Array.Empty() + } + } + }, + new + { + type = "function", + function = new + { + name = "read_file", + description = "Liest den Inhalt einer Datei. Standardmäßig werden maximal 2000 Zeilen gelesen. Bei großen Dateien offset und limit verwenden.", + parameters = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Dateipfad" }, + ["offset"] = new { type = "integer", description = "Erste Zeile die gelesen werden soll (1-basiert, Default: 1)" }, + ["limit"] = new { type = "integer", description = "Maximale Anzahl Zeilen die gelesen werden sollen (Default: 2000)" } + }, + required = new[] { "path" } + } + } + }, + new + { + type = "function", + function = new + { + name = "write_file", + description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.", + parameters = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Dateipfad" }, + ["content"] = new { type = "string", description = "Dateiinhalt" } + }, + required = new[] { "path", "content" } + } + } + }, + new + { + type = "function", + function = new + { + name = "edit_file", + description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.", + parameters = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Dateipfad" }, + ["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" }, + ["new_text"] = new { type = "string", description = "Neuer Text" } + }, + required = new[] { "path", "old_text", "new_text" } + } + } + }, + new + { + type = "function", + function = new + { + name = "delete_file", + description = "Löscht eine Datei oder ein leeres Verzeichnis.", + parameters = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" } + }, + required = new[] { "path" } + } + } + }, + new + { + type = "function", + function = new + { + name = "execute", + description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück.", + parameters = new + { + type = "object", + properties = new Dictionary + { + ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } + }, + required = new[] { "command" } + } + } + } + }; + + public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService) + { + _httpFactory = httpFactory; + _db = db; + _toolService = toolService; + } + + /// + /// Gibt den Provider und das eigentliche Modell-ID aus einem kombinierten Model-String zurück. + /// Format: "nvidia:moonshotai/kimi-k2.5" → ("nvidia", "moonshotai/kimi-k2.5") + /// + public static (string provider, string modelId) ParseModelString(string model) + { + if (string.IsNullOrEmpty(model)) return ("", model ?? ""); + var idx = model.IndexOf(':'); + if (idx < 0) return ("", model); + return (model[..idx], model[(idx + 1)..]); + } + + /// + /// Prüft ob ein Model-String einen externen OpenAI-kompatiblen Provider referenziert. + /// + public static bool IsOpenAiProvider(string model) + { + var (provider, _) = ParseModelString(model); + return Providers.ContainsKey(provider); + } + + public async Task StreamChat( + HttpResponse response, + string modelString, + string systemPromptText, + List messages, + string chainId, + string workspace = null, + string sessionId = null, + string agentId = null, + int maxIterations = 10, + CancellationToken workCt = default, + CancellationToken sseCt = default) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var usage = new TokenUsage(); + var iteration = 0; + + var chain = new ChainRecord + { + ChainId = chainId, + SessionId = sessionId, + Model = modelString + }; + + var (provider, modelId) = ParseModelString(modelString); + if (!Providers.TryGetValue(provider, out var providerConfig)) + { + var errMsg = $"Unbekannter Provider: '{provider}'"; + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = 1, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await TryWriteSse(response, "done", new { chainId }, sseCt); + return; + } + + try + { + // OpenAI-kompatibles Messages-Format aufbauen + var apiMessages = new List(); + + // System-Prompt als erste System-Message + if (!string.IsNullOrEmpty(systemPromptText)) + apiMessages.Add(new { role = "system", content = systemPromptText }); + + // History-Messages konvertieren (Anthropic-Format → OpenAI-Format) + foreach (var msg in messages) + { + var converted = ConvertMessage(msg); + if (converted == null) continue; + + // Tool-Results kommen als List zurück → einzeln expandieren + if (converted is List toolResultList) + { + foreach (var tr in toolResultList) + apiMessages.Add(tr); + continue; + } + + apiMessages.Add(converted); + } + + // Agentic Loop + while (iteration < maxIterations) + { + workCt.ThrowIfCancellationRequested(); + iteration++; + + var client = CreateClient(providerConfig.ApiKey); + var payload = new Dictionary + { + ["model"] = modelId, + ["messages"] = apiMessages, + ["stream"] = true, + ["max_tokens"] = 16384, + ["tools"] = OpenAiTools, + ["tool_choice"] = "auto" + }; + + var jsonPayload = JsonSerializer.Serialize(payload); + var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + using var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent }, + HttpCompletionOption.ResponseHeadersRead, + workCt + ); + + if (!httpResp.IsSuccessStatusCode) + { + var errBody = await httpResp.Content.ReadAsStringAsync(workCt); + var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await FinalizeChain(chain, usage, iteration, "error", sw); + await TryWriteSse(response, "done", new { chainId }, sseCt); + return; + } + + // SSE-Stream lesen + using var stream = await httpResp.Content.ReadAsStreamAsync(workCt); + using var reader = new System.IO.StreamReader(stream); + + var fullText = new StringBuilder(); + // Tool-Call Akkumulatoren: id → (name, arguments) + var toolCallAccumulators = new Dictionary(); + var textStarted = false; + var stopReason = "end_turn"; + + string line; + while ((line = await reader.ReadLineAsync(workCt)) != null) + { + workCt.ThrowIfCancellationRequested(); + if (!line.StartsWith("data:")) continue; + + var data = line[5..].Trim(); + if (data == "[DONE]") break; + + JsonElement parsed; + try { parsed = JsonSerializer.Deserialize(data); } + catch { continue; } + + if (!parsed.TryGetProperty("choices", out var choices)) continue; + foreach (var choice in choices.EnumerateArray()) + { + // Finish reason + if (choice.TryGetProperty("finish_reason", out var finishProp) && finishProp.ValueKind != JsonValueKind.Null) + { + var fr = finishProp.GetString() ?? ""; + if (fr == "tool_calls") stopReason = "tool_use"; + else if (fr == "stop") stopReason = "end_turn"; + } + + if (!choice.TryGetProperty("delta", out var delta)) continue; + + // Text-Delta + if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) + { + var chunk = contentProp.GetString(); + if (!string.IsNullOrEmpty(chunk)) + { + if (!textStarted) + { + await TryWriteSse(response, "text_start", new { index = 0 }, sseCt); + textStarted = true; + } + fullText.Append(chunk); + await TryWriteSse(response, "text_delta", new { text = chunk }, sseCt); + } + } + + // Tool-Call Deltas akkumulieren + if (delta.TryGetProperty("tool_calls", out var toolCallsProp)) + { + foreach (var tc in toolCallsProp.EnumerateArray()) + { + var idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0; + if (!toolCallAccumulators.ContainsKey(idx)) + toolCallAccumulators[idx] = ("", "", new StringBuilder()); + + var (accId, accName, accArgs) = toolCallAccumulators[idx]; + + if (tc.TryGetProperty("id", out var idProp)) + accId = idProp.GetString() ?? accId; + + if (tc.TryGetProperty("function", out var funcProp)) + { + if (funcProp.TryGetProperty("name", out var nameProp2)) + accName = nameProp2.GetString() ?? accName; + if (funcProp.TryGetProperty("arguments", out var argsProp)) + accArgs.Append(argsProp.GetString() ?? ""); + } + + toolCallAccumulators[idx] = (accId, accName, accArgs); + } + } + } + + // Usage aus letztem Chunk + if (parsed.TryGetProperty("usage", out var usageProp)) + { + if (usageProp.TryGetProperty("prompt_tokens", out var pt)) + usage.InputTokens = pt.GetInt64(); + if (usageProp.TryGetProperty("completion_tokens", out var ct2)) + usage.OutputTokens = ct2.GetInt64(); + } + } + + // Text-End senden falls Text gestreamt wurde + if (textStarted) + await TryWriteSse(response, "text_end", new { }, sseCt); + + // Text in DB speichern + var finalText = fullText.ToString(); + if (!string.IsNullOrEmpty(finalText)) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "assistant", Type = "text", + Content = finalText + }); + } + + // Keine Tool-Calls → fertig + if (toolCallAccumulators.Count == 0 || stopReason != "tool_use") + { + await FinalizeChain(chain, usage, iteration, stopReason, sw); + await TryWriteSse(response, "done", new + { + model = modelString, + inputTokens = usage.InputTokens, + outputTokens = usage.OutputTokens, + iterations = iteration, + chainId, + durationMs = sw.ElapsedMilliseconds, + stopReason + }, sseCt); + return; + } + + // Tool-Calls ausführen + // Assistant-Message mit tool_calls in die History einfügen + var toolCallsForHistory = new List(); + foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators) + { + toolCallsForHistory.Add(new + { + id = tcId, + type = "function", + function = new { name = tcName, arguments = tcArgs.ToString() } + }); + + // Tool-Use in DB speichern + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "assistant", Type = "tool_use", + Content = tcArgs.ToString(), + ToolCallId = tcId, ToolName = tcName + }); + } + + apiMessages.Add(new + { + role = "assistant", + content = finalText, + tool_calls = toolCallsForHistory + }); + + // Tool-Results sammeln + var toolResults = new List(); + foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators) + { + // Tool-Start SSE + await TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt); + + // Tool ausführen + JsonElement inputEl; + try { inputEl = JsonSerializer.Deserialize(tcArgs.ToString()); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + + var toolResult = await _toolService.ExecuteTool(tcName, inputEl, workspace); + var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:"); + + // Tool-End SSE + await TryWriteSse(response, "tool_end", new + { + name = tcName, + id = tcId, + result = toolResult, + isError + }, sseCt); + + toolResults.Add(new + { + role = "tool", + tool_call_id = tcId, + content = toolResult + }); + + // Tool-Result in DB speichern + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "tool", Type = "tool_result", + Content = toolResult, + ToolCallId = tcId, ToolName = tcName, + IsError = isError + }); + } + + // Tool-Results in History einfügen + foreach (var tr in toolResults) + apiMessages.Add(tr); + } + + // Max Iterations erreicht + await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); + await FinalizeChain(chain, usage, iteration, "max_iterations", sw); + await TryWriteSse(response, "done", new { chainId }, sseCt); + } + catch (OperationCanceledException) + { + await FinalizeChain(chain, usage, iteration, "stopped", sw); + await TryWriteSse(response, "done", new + { + model = modelString, + iterations = iteration, + chainId, + durationMs = sw.ElapsedMilliseconds, + stopReason = "stopped" + }, CancellationToken.None); + } + catch (Exception ex) + { + var errMsg = $"Fehler: {ex.Message}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await FinalizeChain(chain, usage, iteration, "error", sw); + await TryWriteSse(response, "done", new { chainId }, sseCt); + } + } + + /// + /// Konvertiert eine Anthropic-format Message in das OpenAI-format. + /// + private static object ConvertMessage(object msg) + { + var json = JsonSerializer.Serialize(msg); + var el = JsonSerializer.Deserialize(json); + + if (!el.TryGetProperty("role", out var roleProp)) return null; + var role = roleProp.GetString() ?? ""; + + if (!el.TryGetProperty("content", out var contentProp)) return null; + + // Content als String + if (contentProp.ValueKind == JsonValueKind.String) + { + var text = contentProp.GetString() ?? ""; + if (string.IsNullOrEmpty(text)) return null; + return new { role, content = text }; + } + + // Content als Array (Anthropic-Format mit Blöcken) + if (contentProp.ValueKind == JsonValueKind.Array) + { + var textParts = new StringBuilder(); + var toolCalls = new List(); + var toolResults = new List(); + + foreach (var block in contentProp.EnumerateArray()) + { + if (!block.TryGetProperty("type", out var typeProp)) continue; + var blockType = typeProp.GetString() ?? ""; + + if (blockType == "text" && block.TryGetProperty("text", out var textProp)) + { + textParts.Append(textProp.GetString()); + } + else if (blockType == "tool_use") + { + // Anthropic tool_use → OpenAI tool_calls + var tcId = block.TryGetProperty("id", out var idProp) ? idProp.GetString() : ""; + var tcName = block.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : ""; + var tcInput = block.TryGetProperty("input", out var inputProp) + ? JsonSerializer.Serialize(inputProp) + : "{}"; + toolCalls.Add(new + { + id = tcId, + type = "function", + function = new { name = tcName, arguments = tcInput } + }); + } + else if (blockType == "tool_result") + { + // Anthropic tool_result → OpenAI role: "tool" + var trId = block.TryGetProperty("tool_use_id", out var idProp) ? idProp.GetString() : ""; + var trContent = ""; + if (block.TryGetProperty("content", out var cProp)) + { + if (cProp.ValueKind == JsonValueKind.String) + trContent = cProp.GetString() ?? ""; + else if (cProp.ValueKind == JsonValueKind.Array) + { + foreach (var item in cProp.EnumerateArray()) + if (item.TryGetProperty("text", out var t)) trContent += t.GetString(); + } + } + toolResults.Add(new { role = "tool", tool_call_id = trId, content = trContent }); + } + } + + // Assistant-Message mit tool_calls + if (toolCalls.Count > 0) + { + var text = textParts.ToString().Trim(); + return new + { + role = "assistant", + content = string.IsNullOrEmpty(text) ? (object)null : text, + tool_calls = toolCalls + }; + } + + // Tool-Results → als separate Objekte zurückgeben (werden im Caller verarbeitet) + if (toolResults.Count > 0) + return toolResults; // List wird im Aufrufer per OfType> erkannt + + var combined = textParts.ToString().Trim(); + if (string.IsNullOrEmpty(combined)) return null; + return new { role, content = combined }; + } + + return null; + } + + 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 HttpClient CreateClient(string apiKey) + { + var client = _httpFactory.CreateClient(); + if (!string.IsNullOrEmpty(apiKey)) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.Timeout = TimeSpan.FromMinutes(5); + return client; + } + + private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct) + { + try + { + if (!ct.IsCancellationRequested) + await AnthropicService.WriteSseEvent(response, eventType, data, ct); + } + catch { } + } +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 5af79c2..d74bc00 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -191,6 +191,9 @@ + + +