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; /// /// 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" }; // Provider-Definitionen (inkl. API-Keys, instanzgebunden) private readonly Dictionary Providers; // 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 record ProviderModel(string Id, string DisplayName); private List _nvidiaModels = new List(); private List _pollinationsModels = new List(); private List _geminiModels = new List(); private readonly string _cacheFile; public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, Microsoft.Extensions.Configuration.IConfiguration configuration, string dataDir) { _httpFactory = httpFactory; _db = db; _toolService = toolService; _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", configuration["GeminiApiKey"] ?? "") }; } /// /// 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(); Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini"); 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() { _nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia"); _pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations"); var geminiApiKey = Providers["gemini"].ApiKey; 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."); // Cache speichern try { var cache = new Dictionary> { ["nvidia"] = _nvidiaModels, ["pollinations"] = _pollinationsModels, ["gemini"] = _geminiModels }; 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; /// /// 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, 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(); string thoughtSignature = null; 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() ?? "").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; // 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()) { // 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(); } } // 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 (stopReason als Fallback, primär Count prüfen) if (toolCallAccumulators.Count == 0) { 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, 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 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) : "{}"; // 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; } 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 { } } }