using System; using System.Collections.Generic; using System.IO; using System.Linq; 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-Keys (statisch für ChatService.ResolveModelId) public static readonly HashSet ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai" }; // Provider-Definitionen (inkl. API-Keys, instanzgebunden) private readonly Dictionary Providers; // Tools die für OpenAI-kompatible Provider verfügbar sind (konvertiert aus ToolService Anthropic-Format) private List _openAiToolDefs; /// /// Konvertiert Anthropic-Format Tool-Definitionen (input_schema) in OpenAI-Format (function/parameters). /// Single source of truth: ToolService.GetToolDefinitions() /// private static List ConvertTo_openAiToolDefs(object[] anthropicTools) { var result = new List(); foreach (var tool in anthropicTools) { var json = JsonSerializer.Serialize(tool); var el = JsonSerializer.Deserialize(json); var name = el.TryGetProperty("name", out var n) ? n.GetString() : ""; var desc = el.TryGetProperty("description", out var d) ? d.GetString() : ""; var schema = el.TryGetProperty("input_schema", out var s) ? s : default; result.Add(new { type = "function", function = new { name, description = desc, parameters = schema.ValueKind != JsonValueKind.Undefined ? JsonSerializer.Deserialize(schema.GetRawText()) : new { type = "object", properties = new Dictionary(), required = Array.Empty() } as object } }); } return result; } public record ProviderModel(string Id, string DisplayName); private List _nvidiaModels = new List(); private List _pollinationsModels = new List(); private List _geminiModels = new List(); private List _openaiModels = new List(); private readonly string _cacheFile; private readonly ServerSettingsService _settings; public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, ServerSettingsService settings, string dataDir) { _httpFactory = httpFactory; _db = db; _toolService = toolService; _settings = settings; _cacheFile = Path.Combine(dataDir, "models_cache.json"); Providers = new Dictionary { ["nvidia"] = ("https://g4f.space/api/nvidia/v1", ""), ["pollinations"] = ("https://text.pollinations.ai/openai", ""), ["gemini"] = ("https://generativelanguage.googleapis.com/v1beta/openai", ""), // Key kommt dynamisch aus Settings ["openai"] = ("https://api.openai.com/v1", "") // Key kommt dynamisch aus DB }; // Tool-Definitionen aus ToolService konvertieren (single source of truth) _openAiToolDefs = ConvertTo_openAiToolDefs(toolService.GetToolDefinitions(new ToolDefinitionOptions { IncludeFileTools = true })); } /// /// Lädt verfügbare Modelle – aus lokalem Cache (models_cache.json) oder von den APIs. /// public async Task LoadModelsAsync(bool forceRefresh = false) { if (!forceRefresh && File.Exists(_cacheFile)) { try { var json = await File.ReadAllTextAsync(_cacheFile); var cache = JsonSerializer.Deserialize>>(json); if (cache != null) { _nvidiaModels = cache.ContainsKey("nvidia") ? cache["nvidia"] : new List(); _pollinationsModels = cache.ContainsKey("pollinations") ? cache["pollinations"] : new List(); _geminiModels = cache.ContainsKey("gemini") ? cache["gemini"] : new List(); _openaiModels = cache.ContainsKey("openai") ? cache["openai"] : new List(); Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini, {_openaiModels.Count} OpenAI"); return; } } catch (Exception ex) { Console.WriteLine($"[OpenAiService] Cache-Datei fehlerhaft, lade von APIs: {ex.Message}"); } } await RefreshModelsFromApis(); } /// /// Lädt Modelle von allen Provider-APIs und speichert sie im Cache. /// public async Task RefreshModelsFromApis() { var nvidiaApiKey = _settings.Get("NvidiaApiKey"); if (!string.IsNullOrEmpty(nvidiaApiKey)) _nvidiaModels = await FetchModels("https://integrate.api.nvidia.com/v1/models", "Nvidia", nvidiaApiKey); else _nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia"); _pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations"); var geminiApiKey = _settings.Get("GeminiApiKey"); if (!string.IsNullOrEmpty(geminiApiKey)) _geminiModels = await FetchModels("https://generativelanguage.googleapis.com/v1beta/openai/models", "Gemini", geminiApiKey); else Console.WriteLine("[OpenAiService] GeminiApiKey nicht konfiguriert – Gemini-Modelle nicht geladen."); // OpenAI: Key aus DB holen (erster aktiver OpenAI-Key) var openaiKey = await _db.GetFirstActiveKeyByProvider("openai"); if (!string.IsNullOrEmpty(openaiKey)) { var allOpenAi = await FetchModels("https://api.openai.com/v1/models", "OpenAI", openaiKey); // Nur Chat-Modelle filtern (gpt-*, o1-*, o3-*, o4-*, chatgpt-*) _openaiModels = allOpenAi .Where(m => m.Id.StartsWith("gpt-") || m.Id.StartsWith("o1-") || m.Id.StartsWith("o3-") || m.Id.StartsWith("o4-") || m.Id.StartsWith("chatgpt-")) .OrderBy(m => m.Id) .ToList(); Console.WriteLine($"[OpenAiService] {_openaiModels.Count} OpenAI Chat-Modelle gefiltert (von {allOpenAi.Count} total)."); } else Console.WriteLine("[OpenAiService] Kein aktiver OpenAI API-Key in DB – OpenAI-Modelle nicht geladen."); // Cache speichern try { var cache = new Dictionary> { ["nvidia"] = _nvidiaModels, ["pollinations"] = _pollinationsModels, ["gemini"] = _geminiModels, ["openai"] = _openaiModels }; var json = JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(_cacheFile, json); Console.WriteLine($"[OpenAiService] Modelle-Cache gespeichert: {_cacheFile}"); } catch (Exception ex) { Console.WriteLine($"[OpenAiService] Fehler beim Speichern des Modelle-Cache: {ex.Message}"); } } private async Task> FetchModels(string url, string providerName, string apiKey = "") { try { var client = CreateClient(apiKey); var resp = await client.GetAsync(url); if (!resp.IsSuccessStatusCode) return new List(); var json = await resp.Content.ReadAsStringAsync(); var root = JsonSerializer.Deserialize(json); // Pollinations: root is array [{ name, ... }] // Nvidia/OpenAI: root is { data: [{ id, ... }] } JsonElement arr; if (root.ValueKind == JsonValueKind.Array) arr = root; else if (root.TryGetProperty("data", out var data)) arr = data; else return new List(); var models = new List(); foreach (var item in arr.EnumerateArray()) { // try "id" first (Nvidia), fallback to "name" (Pollinations) string id = null; if (item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) id = idProp.GetString(); if (id == null && item.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) id = nameProp.GetString(); if (string.IsNullOrEmpty(id)) continue; var display = id.Contains('/') ? id[(id.LastIndexOf('/') + 1)..] : id; models.Add(new ProviderModel(id, display)); } Console.WriteLine($"[OpenAiService] {models.Count} {providerName}-Modelle geladen."); return models; } catch (Exception ex) { Console.WriteLine($"[OpenAiService] Fehler beim Laden der {providerName}-Modelle: {ex.Message}"); return new List(); } } public List GetNvidiaModels() => _nvidiaModels; public List GetPollinationsModels() => _pollinationsModels; public List GetGeminiModels() => _geminiModels; public List GetOpenAiModels() => _openaiModels; /// /// 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 ProviderKeys.Contains(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, string apiKeyOverride = null, 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 resolved = ResolveProviderConfig(modelString, apiKeyOverride); if (resolved == null) { var errMsg = $"Unbekannter Provider: '{ParseModelString(modelString).provider}'"; await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "system", Type = "error", Content = errMsg, IsError = true }); await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); return; } var (provider, modelId, providerConfig) = resolved.Value; try { var apiMessages = BuildApiMessages(systemPromptText, messages); // 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"] = _openAiToolDefs, ["tool_choice"] = "auto" }; if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) payload["chat_template_kwargs"] = new { thinking = true }; 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 SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); await FinalizeChain(chain, usage, iteration, "error", sw); await SseHelper.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(); var thinkingText = new StringBuilder(); // Tool-Call Akkumulatoren: id → (name, arguments) var toolCallAccumulators = new Dictionary(); string thoughtSignature = null; var textStarted = false; var thinkingStarted = 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() ?? "").ToLowerInvariant(); if (fr == "tool_calls" || fr == "function_call") stopReason = "tool_use"; else if (fr == "stop") stopReason = "end_turn"; } if (!choice.TryGetProperty("delta", out var delta)) continue; // Reasoning/Thinking-Delta (Nvidia Thinking-Modelle) if (delta.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String) { var chunk = reasoningProp.GetString(); if (!string.IsNullOrEmpty(chunk)) { if (!thinkingStarted) { await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt); thinkingStarted = true; } thinkingText.Append(chunk); await SseHelper.TryWriteSse(response, "thinking_delta", new { text = chunk }, sseCt); } } // Text-Delta if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) { var chunk = contentProp.GetString(); if (!string.IsNullOrEmpty(chunk)) { // Thinking-Block beenden falls noch offen if (thinkingStarted) { await SseHelper.TryWriteSse(response, "thinking_end", new { }, sseCt); thinkingStarted = false; } if (!textStarted) { await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt); textStarted = true; } fullText.Append(chunk); await SseHelper.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()) { // thought_signature aus tc.extra_content.google.thought_signature (Gemini-spezifisch) if (tc.TryGetProperty("extra_content", out var extraContent) && extraContent.TryGetProperty("google", out var googleContent) && googleContent.TryGetProperty("thought_signature", out var tcTsProp) && tcTsProp.ValueKind == JsonValueKind.String) { var ts = tcTsProp.GetString(); if (!string.IsNullOrEmpty(ts)) thoughtSignature = ts; } // ID aus diesem Chunk lesen (kann leer sein bei Folge-Deltas) var chunkId = tc.TryGetProperty("id", out var idProp2) ? (idProp2.GetString() ?? "") : ""; // Schlüssel bestimmen: zuerst per id, dann per index // → verhindert Kollisionen bei parallelen Tool-Calls ohne index (z.B. Gemini) int idx; if (!string.IsNullOrEmpty(chunkId)) { // Prüfen ob diese ID schon einem Index zugeordnet ist idx = -1; foreach (var kv in toolCallAccumulators) { if (kv.Value.Id == chunkId) { idx = kv.Key; break; } } if (idx == -1) { // Neue ID → nächsten freien Index vergeben idx = toolCallAccumulators.Count; while (toolCallAccumulators.ContainsKey(idx)) idx++; } } else { idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0; } if (!toolCallAccumulators.ContainsKey(idx)) toolCallAccumulators[idx] = ("", "", new StringBuilder(), null); var (accId, accName, accArgs, accTs) = toolCallAccumulators[idx]; if (!string.IsNullOrEmpty(chunkId)) accId = chunkId; if (tc.TryGetProperty("function", out var funcProp)) { if (funcProp.TryGetProperty("name", out var nameProp2)) { var n = nameProp2.GetString() ?? ""; if (!string.IsNullOrEmpty(n)) accName = n; } if (funcProp.TryGetProperty("arguments", out var argsProp)) accArgs.Append(argsProp.GetString() ?? ""); } // Globalen thoughtSignature-Wert auch in accTs des ersten Slots übernehmen if (!string.IsNullOrEmpty(thoughtSignature) && accTs == null && idx == 0) accTs = thoughtSignature; toolCallAccumulators[idx] = (accId, accName, accArgs, accTs); } } } // 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(); } } // Thinking-End senden falls noch offen if (thinkingStarted) await SseHelper.TryWriteSse(response, "thinking_end", new { }, sseCt); // Text-End senden falls Text gestreamt wurde if (textStarted) await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt); // Thinking-Text in DB speichern var finalThinking = thinkingText.ToString(); if (!string.IsNullOrEmpty(finalThinking)) { await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = iteration, Role = "assistant", Type = "thinking", Content = finalThinking }); } // 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 (stopReason als Fallback, primär Count prüfen) if (toolCallAccumulators.Count == 0) { await FinalizeChain(chain, usage, iteration, stopReason, sw); await SseHelper.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, tcTs)) in toolCallAccumulators) { // thought_signature pro Tool-Call mitschicken falls vorhanden // Gemini erwartet sie in extra_content.google.thought_signature (nicht in function.thought_signature) if (!string.IsNullOrEmpty(tcTs)) { toolCallsForHistory.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcArgs.ToString() }, extra_content = new { google = new { thought_signature = tcTs } } }); } else { toolCallsForHistory.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcArgs.ToString() } }); } // In DB speichern – thought_signature als eigenes Feld await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = iteration, Role = "assistant", Type = "tool_use", Content = tcArgs.ToString(), ToolCallId = tcId, ToolName = tcName, ThoughtSignature = tcTs }); } apiMessages.Add(new { role = "assistant", content = string.IsNullOrEmpty(finalText) ? "" : finalText, tool_calls = toolCallsForHistory }); // Tool-Results sammeln var toolResults = new List(); foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators) { // Tool-Start SSE await SseHelper.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, new ToolExecutionContext { Workspace = workspace }); var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:"); // Tool-End SSE await SseHelper.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 SseHelper.TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); await FinalizeChain(chain, usage, iteration, "max_iterations", sw); await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); } catch (OperationCanceledException) { await FinalizeChain(chain, usage, iteration, "stopped", sw); await SseHelper.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 SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); await FinalizeChain(chain, usage, iteration, "error", sw); await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); } } /// /// Non-Streaming Chat für Telegram, Cronjobs und send_to_session. /// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop). /// public async Task ChatNonStreaming( ChatContext ctx, List messages, Func onIteration = null) { var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); int iterations = 0; var finalText = new StringBuilder(); var resolved = ResolveProviderConfig(ctx.ModelId, ctx.ApiToken); if (resolved == null) throw new Exception($"Unbekannter Provider: '{ParseModelString(ctx.ModelId).provider}'"); var (provider, modelId, providerConfig) = resolved.Value; var chain = new ChainRecord { ChainId = ctx.ChainId, SessionId = ctx.SessionId, Model = ctx.ModelId }; var apiMessages = BuildApiMessages(ctx.SystemPromptText, messages); try { while (iterations < ctx.MaxIterations) { iterations++; onIteration?.Invoke(iterations); var client = CreateClient(providerConfig.ApiKey); var payload = new Dictionary { ["model"] = modelId, ["messages"] = apiMessages, ["stream"] = false, ["max_tokens"] = 16384, ["tools"] = _openAiToolDefs, ["tool_choice"] = "auto" }; if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) payload["chat_template_kwargs"] = new { thinking = true }; 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 }); var respBody = await httpResp.Content.ReadAsStringAsync(); if (!httpResp.IsSuccessStatusCode) { var errMsg = $"API {(int)httpResp.StatusCode}: {respBody}"; await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = iterations, Role = "system", Type = "error", Content = errMsg, IsError = true }); await FinalizeChain(chain, usage, iterations, "error", sw); throw new Exception($"OpenAI API Fehler: {(int)httpResp.StatusCode}"); } var resp = JsonSerializer.Deserialize(respBody); // Usage tracken if (resp.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(); } // Erste Choice auswerten var choices = resp.GetProperty("choices"); if (choices.GetArrayLength() == 0) break; var choice = choices[0]; var message = choice.GetProperty("message"); var finishReason = choice.TryGetProperty("finish_reason", out var frProp) ? frProp.GetString() ?? "" : ""; // Reasoning/Thinking extrahieren (Nvidia Thinking-Modelle) if (message.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String) { var reasoning = reasoningProp.GetString() ?? ""; if (!string.IsNullOrEmpty(reasoning)) { await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = iterations, Role = "assistant", Type = "thinking", Content = reasoning }); } } // Text extrahieren if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) { var text = contentProp.GetString() ?? ""; if (!string.IsNullOrEmpty(text)) { finalText.Append(text); await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = iterations, Role = "assistant", Type = "text", Content = text }); } } // Tool-Calls prüfen if (!message.TryGetProperty("tool_calls", out var toolCallsProp) || toolCallsProp.GetArrayLength() == 0) { await FinalizeChain(chain, usage, iterations, "end_turn", sw); break; } // Assistant-Message mit tool_calls in History var assistantMsg = JsonSerializer.Deserialize(message.GetRawText()); apiMessages.Add(new { role = "assistant", content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "", tool_calls = JsonSerializer.Deserialize>(toolCallsProp.GetRawText()) }); // Tool-Calls ausführen var toolResults = new List(); foreach (var tc in toolCallsProp.EnumerateArray()) { var tcId = tc.GetProperty("id").GetString() ?? ""; var func = tc.GetProperty("function"); var tcName = func.GetProperty("name").GetString() ?? ""; var tcArgs = func.TryGetProperty("arguments", out var argsProp) ? argsProp.GetString() ?? "{}" : "{}"; await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = iterations, Role = "assistant", Type = "tool_use", Content = tcArgs, ToolCallId = tcId, ToolName = tcName }); JsonElement inputEl; try { inputEl = JsonSerializer.Deserialize(tcArgs); } catch { inputEl = JsonSerializer.Deserialize("{}"); } string toolResult; bool isError = false; try { toolResult = await _toolService.ExecuteTool(tcName, inputEl, new ToolExecutionContext { Workspace = ctx.Workspace }); } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } toolResults.Add(new { role = "tool", tool_call_id = tcId, content = toolResult }); await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = iterations, Role = "tool", Type = "tool_result", Content = toolResult, ToolCallId = tcId, ToolName = tcName, IsError = isError }); } foreach (var tr in toolResults) apiMessages.Add(tr); } } catch { if (iterations > 0) await FinalizeChain(chain, usage, iterations, "error", sw); throw; } return finalText.Length > 0 ? finalText.ToString() : "(Keine Textantwort)"; } /// /// 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) : "{}"; // thought_signature als eigenes Feld (vom DB-Record direkt in den Block übernommen) var tcTs = block.TryGetProperty("thought_signature", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.String ? tsProp2.GetString() : null; // Gemini erwartet thought_signature in extra_content.google.thought_signature if (!string.IsNullOrEmpty(tcTs)) toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput }, extra_content = new { google = new { thought_signature = tcTs } } }); else 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) ? "" : 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; } /// /// Löst Provider-Config auf: Provider-Name, Model-ID, BaseUrl + ApiKey. /// Berücksichtigt API-Key-Override und dynamische Gemini-Keys. /// private (string provider, string modelId, (string BaseUrl, string ApiKey) config)? ResolveProviderConfig(string modelString, string apiKeyOverride = null) { var (provider, modelId) = ParseModelString(modelString); if (!Providers.TryGetValue(provider, out var providerConfig)) return null; if (!string.IsNullOrEmpty(apiKeyOverride)) providerConfig = (providerConfig.BaseUrl, apiKeyOverride); if (provider == "nvidia" && string.IsNullOrEmpty(providerConfig.ApiKey)) { var nvidiaKey = _settings.Get("NvidiaApiKey"); if (!string.IsNullOrEmpty(nvidiaKey)) providerConfig = ("https://integrate.api.nvidia.com/v1", nvidiaKey); } if (provider == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey)) providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey")); return (provider, modelId, providerConfig); } /// /// Prüft ob ein Nvidia-Modell Thinking-Mode unterstützt (chat_template_kwargs). /// private static bool IsNvidiaThinkingModel(string modelId) { // Modelle mit Extended-Thinking-Support bei Nvidia return modelId.Contains("kimi-k2", StringComparison.OrdinalIgnoreCase) || modelId.Contains("qwq", StringComparison.OrdinalIgnoreCase) || modelId.Contains("deepseek-r1", StringComparison.OrdinalIgnoreCase); } /// /// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format. /// private static List BuildApiMessages(string systemPromptText, List messages) { var apiMessages = new List(); if (!string.IsNullOrEmpty(systemPromptText)) apiMessages.Add(new { role = "system", content = systemPromptText }); foreach (var msg in messages) { var converted = ConvertMessage(msg); if (converted == null) continue; if (converted is List toolResultList) { foreach (var tr in toolResultList) apiMessages.Add(tr); continue; } apiMessages.Add(converted); } return apiMessages; } private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) => SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw); 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; } // TryWriteSse → SseHelper.cs }