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; private readonly VectorSearchService _vectorSearch; // Provider-Keys (statisch für ChatService.ResolveModelId) public static readonly HashSet ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai", "minimax" }; // 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() /// public 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 List _minimaxModels = new List(); // Fallback-Modell-Liste für MiniMax (falls /v1/models nichts liefert). // Quelle: https://platform.minimax.io/docs/api-reference/api-overview private static readonly List MinimaxFallbackModels = new List { new ProviderModel("MiniMax-M2.7", "MiniMax-M2.7"), new ProviderModel("MiniMax-M2.7-highspeed", "MiniMax-M2.7-highspeed"), new ProviderModel("MiniMax-M2.5", "MiniMax-M2.5"), new ProviderModel("MiniMax-M2.5-highspeed", "MiniMax-M2.5-highspeed"), new ProviderModel("MiniMax-M2.1", "MiniMax-M2.1"), new ProviderModel("MiniMax-M2", "MiniMax-M2") }; private readonly string _cacheFile; private readonly ServerSettingsService _settings; public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, ServerSettingsService settings, string dataDir, VectorSearchService vectorSearch = null) { _httpFactory = httpFactory; _db = db; _toolService = toolService; _vectorSearch = vectorSearch; _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 ["minimax"] = ("https://api.minimax.io/v1", "") // Key kommt dynamisch aus Settings (MinimaxApiKey) }; // 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(); _minimaxModels = cache.ContainsKey("minimax") ? cache["minimax"] : new List(); // Falls der Cache älter als die MiniMax-Integration ist oder leer ist: // statische Fallback-Liste verwenden, damit das Dropdown nicht leer bleibt. if (_minimaxModels.Count == 0) { _minimaxModels = MinimaxFallbackModels; Console.WriteLine($"[OpenAiService] MiniMax-Cache leer/fehlt – nutze Fallback-Liste ({_minimaxModels.Count} Modelle). Tipp: /api/models/refresh aufrufen."); } Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini, {_openaiModels.Count} OpenAI, {_minimaxModels.Count} MiniMax"); 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."); // MiniMax: Key aus Settings (MinimaxApiKey). Versuche /v1/models, sonst statische Fallback-Liste. var minimaxKey = _settings.Get("MinimaxApiKey"); if (!string.IsNullOrEmpty(minimaxKey)) { var fetched = await FetchModels("https://api.minimax.io/v1/models", "MiniMax", minimaxKey); _minimaxModels = fetched.Count > 0 ? fetched : MinimaxFallbackModels; if (fetched.Count == 0) Console.WriteLine($"[OpenAiService] MiniMax /v1/models leer/nicht erreichbar – nutze Fallback-Liste ({_minimaxModels.Count} Modelle)."); } else { // Ohne Key: statische Fallback-Liste anbieten, damit das Dropdown nicht leer ist. _minimaxModels = MinimaxFallbackModels; Console.WriteLine($"[OpenAiService] MinimaxApiKey nicht konfiguriert – nutze Fallback-Liste ({_minimaxModels.Count} Modelle)."); } // Cache speichern try { var cache = new Dictionary> { ["nvidia"] = _nvidiaModels, ["pollinations"] = _pollinationsModels, ["gemini"] = _geminiModels, ["openai"] = _openaiModels, ["minimax"] = _minimaxModels }; 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; public List GetMinimaxModels() => _minimaxModels; /// /// 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, bool useAutoContext = true, TokenUsage seedUsage = null, CancellationToken workCt = default, CancellationToken sseCt = default) { var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); // Panel-Member-Tokens (Fusion/MoA) vorab in die Chain-Usage falten → erscheinen im // done-Event (Live-Badge) UND in FinalizeChain (DB, überlebt F5). Null/leer = No-op. if (seedUsage != null) usage.Add(seedUsage); var iteration = 0; var autoContinueCount = 0; const int MaxAutoContinues = 3; 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 { // ── Automatische Kontext-Injektion via Embedding-Suche ── if (useAutoContext) { try { var autoCtx = await AnthropicService.BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId); if (autoCtx != null) { // Auto-Context an System-Prompt anhängen systemPromptText = (systemPromptText ?? "") + "\n" + autoCtx; await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt); await _db.SaveMessage(new MessageRecord { ChainId = chainId, Role = "system", Type = "auto_context", Content = autoCtx }); } } catch (Exception ex) { Console.WriteLine($"[AutoContext/OpenAI] Error: {ex.Message}"); } } var apiMessages = BuildApiMessages(systemPromptText, messages); // Agentic Loop while (iteration < maxIterations) { workCt.ThrowIfCancellationRequested(); iteration++; var client = CreateClient(providerConfig.ApiKey); // MiniMax-spezifisch: höheres Output-Budget (Thinking-Modelle brauchen viel) var maxTokens = provider == "minimax" ? 32768 : 16384; // Bei Auto-Continue: tool_choice = "required" erzwingt einen Tool-Call, // statt dass das Modell erneut nur eine Ankündigung als Text schreibt. var toolChoice = autoContinueCount > 0 ? "required" : "auto"; var payload = new Dictionary { ["model"] = modelId, ["messages"] = apiMessages, ["stream"] = true, ["max_tokens"] = maxTokens, ["stream_options"] = new { include_usage = true }, ["tools"] = _openAiToolDefs, ["tool_choice"] = toolChoice }; if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) payload["chat_template_kwargs"] = new { thinking = true }; // MiniMax-spezifisch: // - temperature=1.0, top_p=0.95: MiniMax-Empfehlung für agentic Performance // - parallel_tool_calls=false: Modell muss nur einen Tool-Call planen statt mehrere parallel, // was bei komplexen Multi-Step-Tasks die Zuverlässigkeit erhöht // Hinweis: reasoning_split=true wurde entfernt – wird von der gehosteten MiniMax-API ignoriert // (Thinking kommt trotzdem als inline ... in delta.content) if (provider == "minimax") { payload["temperature"] = 1.0; payload["top_p"] = 0.95; payload["parallel_tool_calls"] = false; } 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); // Diagnose-Logging für MiniMax: jede SSE-data-Line + Header + Summary in JSONL. // Verzeichnis: data/openai-debug/{provider}-{ts}-{chainId8}.jsonl System.IO.StreamWriter debugWriter = null; string debugLogPath = null; long debugLineCount = 0; string lastFinishReason = null; if (provider == "minimax") { try { var dataDir = System.IO.Path.Combine(AppContext.BaseDirectory, "data", "openai-debug"); System.IO.Directory.CreateDirectory(dataDir); var ts = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff"); var chainShort = chainId.Length >= 8 ? chainId.Substring(0, 8) : chainId; debugLogPath = System.IO.Path.Combine(dataDir, $"{provider}-{ts}-{chainShort}.jsonl"); debugWriter = new System.IO.StreamWriter( new System.IO.FileStream(debugLogPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.Read), new UTF8Encoding(false)) { AutoFlush = true }; var header = JsonSerializer.Serialize(new { meta = true, provider, model = modelId, chainId, iteration, promptMessages = apiMessages.Count, maxTokens, started = DateTime.UtcNow.ToString("o") }); await debugWriter.WriteLineAsync(header); } catch { debugWriter = null; debugLogPath = null; } } 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"; var gotLengthFinish = false; // Inline--Tag-Parser für MiniMax-Modelle die Thinking als ... // in delta.content streamen (statt in reasoning_details). // Buffer hält partielle Tags zurück (z.B. wenn ein Chunk auf "(data); } catch { continue; } try { if (!parsed.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array) goto parseUsage; 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(); lastFinishReason = fr; if (fr == "tool_calls" || fr == "function_call") stopReason = "tool_use"; else if (fr == "stop") stopReason = "end_turn"; else if (fr == "length") { stopReason = "max_tokens"; gotLengthFinish = true; } } if (!choice.TryGetProperty("delta", out var delta) || delta.ValueKind != JsonValueKind.Object) continue; // Reasoning/Thinking-Delta: // - "reasoning" → Nvidia Thinking-Modelle (String) // - "reasoning_content"→ DeepSeek/manche OpenAI-Compat-Anbieter (String) // - "reasoning_details"→ MiniMax mit extra_body.reasoning_split (Array von {text, ...}) string reasoningChunk = null; if (delta.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String) reasoningChunk = reasoningProp.GetString(); else if (delta.TryGetProperty("reasoning_content", out var rcProp) && rcProp.ValueKind == JsonValueKind.String) reasoningChunk = rcProp.GetString(); else if (delta.TryGetProperty("reasoning_details", out var rdProp) && rdProp.ValueKind == JsonValueKind.Array) { var sbRd = new StringBuilder(); foreach (var rd in rdProp.EnumerateArray()) { if (rd.ValueKind == JsonValueKind.Object && rd.TryGetProperty("text", out var rdText) && rdText.ValueKind == JsonValueKind.String) sbRd.Append(rdText.GetString()); } if (sbRd.Length > 0) reasoningChunk = sbRd.ToString(); } if (!string.IsNullOrEmpty(reasoningChunk)) { if (!thinkingStarted) { await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt); thinkingStarted = true; } thinkingText.Append(reasoningChunk); await SseHelper.TryWriteSse(response, "thinking_delta", new { text = reasoningChunk }, sseCt); } // Text-Delta – mit Inline--Tag-Parser (Fallback für MiniMax wenn reasoning_split nicht greift) if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) { var chunk = contentProp.GetString(); if (!string.IsNullOrEmpty(chunk)) { inlineThinkBuf.Append(chunk); while (true) { var buf = inlineThinkBuf.ToString(); if (inlineInThink) { var endIdx = buf.IndexOf("", StringComparison.Ordinal); if (endIdx >= 0) { // Thinking-Inhalt bis zum emittieren, dann state-flip var thinkPart = buf.Substring(0, endIdx); if (thinkPart.Length > 0) { if (!thinkingStarted) { await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt); thinkingStarted = true; } thinkingText.Append(thinkPart); await SseHelper.TryWriteSse(response, "thinking_delta", new { text = thinkPart }, sseCt); } inlineThinkBuf.Clear(); inlineThinkBuf.Append(buf.Substring(endIdx + 8)); inlineInThink = false; continue; } // Kein vollständiger in Sicht – partielles Tag-Ende zurückhalten (max 7 Zeichen) var safeLen = buf.Length; for (int k = 1; k <= Math.Min(7, buf.Length); k++) { if ("".StartsWith(buf.Substring(buf.Length - k))) { safeLen = buf.Length - k; break; } } if (safeLen > 0) { var flush = buf.Substring(0, safeLen); if (!thinkingStarted) { await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt); thinkingStarted = true; } thinkingText.Append(flush); await SseHelper.TryWriteSse(response, "thinking_delta", new { text = flush }, sseCt); inlineThinkBuf.Clear(); inlineThinkBuf.Append(buf.Substring(safeLen)); } break; } else { var startIdx = buf.IndexOf("", StringComparison.Ordinal); if (startIdx >= 0) { // Text vor als Text emittieren, dann state-flip var textPart = buf.Substring(0, startIdx); if (textPart.Length > 0) { 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(textPart); await SseHelper.TryWriteSse(response, "text_delta", new { text = textPart }, sseCt); } inlineThinkBuf.Clear(); inlineThinkBuf.Append(buf.Substring(startIdx + 7)); inlineInThink = true; continue; } // Kein vollständiger in Sicht – partielles Tag-Ende zurückhalten (max 6 Zeichen) var safeLen = buf.Length; for (int k = 1; k <= Math.Min(6, buf.Length); k++) { if ("".StartsWith(buf.Substring(buf.Length - k))) { safeLen = buf.Length - k; break; } } if (safeLen > 0) { var flush = buf.Substring(0, safeLen); 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(flush); await SseHelper.TryWriteSse(response, "text_delta", new { text = flush }, sseCt); inlineThinkBuf.Clear(); inlineThinkBuf.Append(buf.Substring(safeLen)); } break; } } } } // Tool-Call Deltas akkumulieren if (delta.TryGetProperty("tool_calls", out var toolCallsProp) && toolCallsProp.ValueKind == JsonValueKind.Array) { 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); } } } parseUsage: // Usage aus letztem Chunk if (parsed.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object) { if (usageProp.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number) usage.InputTokens = pt.GetInt64(); if (usageProp.TryGetProperty("completion_tokens", out var ct2) && ct2.ValueKind == JsonValueKind.Number) usage.OutputTokens = ct2.GetInt64(); } } catch (InvalidOperationException) { /* Unerwartetes JSON-Format von Provider – Zeile überspringen */ } } // Diagnose-Log: Summary am Ende des Streams if (debugWriter != null) { try { var toolCallSummaries = new List(); foreach (var (idx, (tcId, tcName, tcArgs, _)) in toolCallAccumulators) { toolCallSummaries.Add(new { idx, id = tcId, name = tcName, argsLen = tcArgs.Length }); } var summary = JsonSerializer.Serialize(new { summary = true, chainId, iteration, promptTokens = usage.InputTokens, completionTokens = usage.OutputTokens, finishReason = lastFinishReason, stopReason, toolCalls = toolCallSummaries, toolCallCount = toolCallAccumulators.Count, textLen = fullText.Length, thinkingLen = thinkingText.Length, sseLines = debugLineCount, durationMs = sw.ElapsedMilliseconds, ended = DateTime.UtcNow.ToString("o") }); await debugWriter.WriteLineAsync(summary); await debugWriter.FlushAsync(); debugWriter.Dispose(); } catch { try { debugWriter?.Dispose(); } catch { } } debugWriter = null; } // Inline-Think-Buffer-Rest flushen (falls Stream mit partiellem Tag-Fragment endete) if (inlineThinkBuf.Length > 0) { var tail = inlineThinkBuf.ToString(); if (inlineInThink) { if (!thinkingStarted) { await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt); thinkingStarted = true; } thinkingText.Append(tail); await SseHelper.TryWriteSse(response, "thinking_delta", new { text = tail }, sseCt); } else { 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(tail); await SseHelper.TryWriteSse(response, "text_delta", new { text = tail }, sseCt); } inlineThinkBuf.Clear(); } // 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); // Warnung wenn finish_reason="length" → User soll wissen dass Output gekürzt wurde if (gotLengthFinish) { var warnMsg = $"Antwort wurde wegen max_tokens-Limit ({maxTokens}) gekürzt. Erhöhe das Limit oder lass das Modell die Antwort fortsetzen."; await SseHelper.TryWriteSse(response, "warning", new { message = warnMsg }, 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 (Whitespace-only nach ignorieren) var finalText = fullText.ToString().Trim(); if (!string.IsNullOrEmpty(finalText)) { await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = iteration, Role = "assistant", Type = "text", Content = finalText }); } // Keine Tool-Calls → prüfen ob Auto-Continue sinnvoll ist if (toolCallAccumulators.Count == 0) { // Auto-Continue: Wenn das Modell mit "stop" beendet hat, kein Tool-Call kam, // aber der Text nach einer Action-Ankündigung aussieht, senden wir automatisch // eine Continuation-Message. Bekanntes MiniMax-M2.7-Verhalten bei langen Kontexten. // Auch bei anderen Modellen kann das vorkommen (DeepSeek-R1, QwQ). // Max 3 Auto-Continues pro Chain um Endlos-Loops zu verhindern. if (autoContinueCount < MaxAutoContinues && !gotLengthFinish && lastFinishReason == "stop" && LooksLikeIncompleteAction(finalText)) { autoContinueCount++; // Thinking + Text als Assistant-Message in History einfügen var continueContent = string.IsNullOrEmpty(finalText) ? "" : finalText; if (!string.IsNullOrEmpty(finalThinking)) continueContent = $"\n{finalThinking}\n\n\n{continueContent}"; if (!string.IsNullOrEmpty(continueContent)) apiMessages.Add(new { role = "assistant", content = continueContent }); // Synthetische User-Message injizieren – direktiv, nicht nur "Continue." // Damit das Modell den Tool-Call tatsächlich ausführt statt nur erneut anzukündigen. apiMessages.Add(new { role = "user", content = "You announced an action but didn't execute it. Call the appropriate tool now to carry out what you described. Do not repeat the announcement." }); // SSE-Event an Frontend senden await SseHelper.TryWriteSse(response, "auto_continue", new { reason = "Model stopped without tool call after action announcement", attempt = autoContinueCount, maxAttempts = MaxAutoContinues }, sseCt); continue; // Nächste Iteration der while-Loop } await FinalizeChain(chain, usage, iteration, stopReason, sw); await SseHelper.TryWriteSse(response, "done", new { model = modelString, inputTokens = usage.InputTokens, outputTokens = usage.OutputTokens, cacheCreationTokens = usage.CacheCreationTokens, cacheReadTokens = usage.CacheReadTokens, iterations = iteration, chainId, durationMs = sw.ElapsedMilliseconds, stopReason }, sseCt); return; } // Tool-Calls ausführen → autoContinueCount zurücksetzen, damit tool_choice // wieder "auto" wird (Modell darf wieder frei zwischen Text und Tool-Call wählen) autoContinueCount = 0; // 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 }); } // Thinking als ...-Prefix dem finalText voranstellen, damit // das Modell in nachfolgenden Iterationen seine eigene Chain-of-Thought sieht // (essential für MiniMax-M2, DeepSeek-R1, QwQ, Kimi K2 etc. – sonst bricht // Tool-Calling-Reliability ein, siehe MiniMax-Doku zu Interleaved Thinking). var historyContent = string.IsNullOrEmpty(finalText) ? "" : finalText; if (!string.IsNullOrEmpty(finalThinking)) historyContent = $"\n{finalThinking}\n\n\n{historyContent}"; apiMessages.Add(new { role = "assistant", content = historyContent, tool_calls = toolCallsForHistory }); // Tool-Results sammeln var toolResults = new List(); bool awaitingChoice = false; foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators) { // ask_user_choice: NICHT ausführen, sondern als SSE-Event an Client senden. // Analog zum Anthropic-Pfad (AnthropicService.Streaming.cs:546-576). // tool_result kommt asynchron vom Client via ChatEndpoint (ChoiceToolCallId+ChoiceResult). if (tcName == ToolService.AskUserChoiceToolName) { JsonElement choiceInput; try { choiceInput = JsonSerializer.Deserialize(tcArgs.ToString()); } catch { choiceInput = JsonSerializer.Deserialize("{}"); } var question = choiceInput.TryGetProperty("question", out var qProp) ? qProp.GetString() ?? "" : ""; var choices = new List(); if (choiceInput.TryGetProperty("choices", out var cProp)) { if (cProp.ValueKind == JsonValueKind.Array) { foreach (var ch in cProp.EnumerateArray()) choices.Add(ch.GetString() ?? ""); } else if (cProp.ValueKind == JsonValueKind.String) { // Fallback: Modell hat einen einzelnen String statt Array gesendet choices.Add(cProp.GetString() ?? ""); } } await SseHelper.TryWriteSse(response, "user_choice", new { toolCallId = tcId, question, choices }, sseCt); awaitingChoice = true; continue; // Kein Execute, kein tool_result Save } // 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, input = tcArgs.ToString(), 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 }); } // Wenn ask_user_choice aufgerufen wurde → finalize mit awaiting_user_choice und return. // Die Choice-Antwort kommt async vom Client und triggert eine neue Chain. if (awaitingChoice) { await FinalizeChain(chain, usage, iteration, "awaiting_user_choice", sw); await SseHelper.TryWriteSse(response, "done", new { model = modelString, inputTokens = usage.InputTokens, outputTokens = usage.OutputTokens, cacheCreationTokens = usage.CacheCreationTokens, cacheReadTokens = usage.CacheReadTokens, iterations = iteration, chainId, durationMs = sw.ElapsedMilliseconds, stopReason = "awaiting_user_choice" }, sseCt); return; } // 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). /// /// /// Minimaler Single-Shot-Completion-Call: KEINE Tools, KEINE Persistenz, KEINE Chain. /// Für Panel-Member (Fusion) – liefert nur den Antworttext zurück. So leckt nichts /// ungetaggt in die Worker-History und es entstehen keine Seiteneffekte (Schreiben/Chains). /// public async Task CompleteText(string modelId, string systemPromptText, List messages, string apiToken) { var resolved = ResolveProviderConfig(modelId, apiToken); if (resolved == null) throw new Exception($"Unbekannter Provider: '{ParseModelString(modelId).provider}'"); var (provider, resolvedModelId, providerConfig) = resolved.Value; var apiMessages = BuildApiMessages(systemPromptText, messages); var client = CreateClient(providerConfig.ApiKey); var maxTokens = provider == "minimax" ? 32768 : 16384; var payload = new Dictionary { ["model"] = resolvedModelId, ["messages"] = apiMessages, ["stream"] = false, ["max_tokens"] = maxTokens }; if (provider == "nvidia" && IsNvidiaThinkingModel(resolvedModelId)) payload["chat_template_kwargs"] = new { thinking = true }; if (provider == "minimax") { payload["temperature"] = 1.0; payload["top_p"] = 0.95; } 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) throw new Exception($"OpenAI API {(int)httpResp.StatusCode}: {respBody}"); var resp = JsonSerializer.Deserialize(respBody); if (!resp.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0) return ""; var choice = choices[0]; if (!choice.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.Object) return ""; if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) return contentProp.GetString() ?? ""; return ""; } 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 }; // ── Automatische Kontext-Injektion via Embedding-Suche ── var systemPromptText = ctx.SystemPromptText; if (ctx.Agent?.UseAutoContext ?? true) { try { var autoCtx = await AnthropicService.BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId); if (autoCtx != null) { systemPromptText = (systemPromptText ?? "") + "\n" + autoCtx; await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Role = "system", Type = "auto_context", Content = autoCtx }); } } catch (Exception ex) { Console.WriteLine($"[AutoContext/OpenAI-NonStreaming] Error: {ex.Message}"); } } var apiMessages = BuildApiMessages(systemPromptText, messages); try { while (iterations < ctx.MaxIterations) { iterations++; onIteration?.Invoke(iterations); var client = CreateClient(providerConfig.ApiKey); var maxTokens = provider == "minimax" ? 32768 : 16384; var payload = new Dictionary { ["model"] = modelId, ["messages"] = apiMessages, ["stream"] = false, ["max_tokens"] = maxTokens, ["tools"] = _openAiToolDefs, ["tool_choice"] = "auto" }; if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) payload["chat_template_kwargs"] = new { thinking = true }; if (provider == "minimax") { payload["temperature"] = 1.0; payload["top_p"] = 0.95; payload["parallel_tool_calls"] = false; } 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) && usageProp.ValueKind == JsonValueKind.Object) { if (usageProp.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number) usage.InputTokens = pt.GetInt64(); if (usageProp.TryGetProperty("completion_tokens", out var ct2) && ct2.ValueKind == JsonValueKind.Number) usage.OutputTokens = ct2.GetInt64(); } // Erste Choice auswerten if (!resp.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0) break; var choice = choices[0]; if (!choice.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.Object) break; var finishReason = choice.TryGetProperty("finish_reason", out var frProp) ? frProp.GetString() ?? "" : ""; // Reasoning/Thinking extrahieren (Nvidia Thinking-Modelle) string iterationThinking = null; if (message.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String) { var reasoning = reasoningProp.GetString() ?? ""; if (!string.IsNullOrEmpty(reasoning)) { iterationThinking = 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.ValueKind != JsonValueKind.Array || toolCallsProp.GetArrayLength() == 0) { await FinalizeChain(chain, usage, iterations, "end_turn", sw); break; } // Assistant-Message mit tool_calls in History. // Thinking als ...-Prefix dem content voranstellen, damit das Modell // in nachfolgenden Iterationen seine eigene Chain-of-Thought sieht. var assistantMsg = JsonSerializer.Deserialize(message.GetRawText()); var iterContent = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : ""; if (!string.IsNullOrEmpty(iterationThinking)) iterContent = $"\n{iterationThinking}\n\n\n{iterContent}"; apiMessages.Add(new { role = "assistant", content = iterContent, 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 thinkingParts = 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 == "thinking" && block.TryGetProperty("thinking", out var thinkingProp)) { // Thinking-Inhalt sammeln; wird unten als ...-Prefix dem // Assistant-Text vorangestellt. So bekommen Thinking-Modelle (MiniMax-M2, // DeepSeek-R1, QwQ, Kimi K2 etc.) ihre eigene Chain-of-Thought zurück, was // laut Provider-Docs entscheidend für Tool-Calling-Reliability ist. thinkingParts.Append(thinkingProp.GetString()); } else 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 }); } } // Thinking als ...-Prefix dem Text voranstellen (MiniMax-Konvention, // wird auch von DeepSeek-R1, QwQ, Kimi K2 etc. verstanden). var thinkingStr = thinkingParts.ToString(); string PrependThinking(string baseText) { if (string.IsNullOrEmpty(thinkingStr)) return baseText; var prefix = $"\n{thinkingStr}\n\n\n"; return string.IsNullOrEmpty(baseText) ? prefix : prefix + baseText; } // Assistant-Message mit tool_calls if (toolCalls.Count > 0) { var text = textParts.ToString().Trim(); var combinedTc = PrependThinking(text); return new { role = "assistant", content = string.IsNullOrEmpty(combinedTc) ? "" : combinedTc, 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 = PrependThinking(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")); if (provider == "minimax" && string.IsNullOrEmpty(providerConfig.ApiKey)) providerConfig = (providerConfig.BaseUrl, _settings.Get("MinimaxApiKey")); return (provider, modelId, providerConfig); } /// /// Prüft ob der Text eines Assistant-Turns wie eine unvollständige Action-Ankündigung aussieht. /// Typisches MiniMax-M2.7-Verhalten: Modell schreibt "Lass mich X machen:" und stoppt ohne Tool-Call. /// private static bool LooksLikeIncompleteAction(string text) { if (string.IsNullOrWhiteSpace(text)) return false; var trimmed = text.TrimEnd(); // Heuristik 1: Text endet mit Doppelpunkt → Action-Ankündigung if (trimmed.EndsWith(":")) return true; // Heuristik 2: Text endet mit typischen Action-Verben + unvollständiger Phrase // z.B. "Lass mich die Datei anschauen" ohne nachfolgenden Tool-Call var lower = trimmed.ToLowerInvariant(); var actionPhrases = new[] { "lass mich", "let me", "i'll ", "i will ", "ich werde ", "schauen wir", "let's ", "weiter mit", "jetzt ", "als nächstes", "now i'll", "now let me", "proceeding to", "going to" }; foreach (var phrase in actionPhrases) { if (lower.Contains(phrase)) return true; } return false; } /// /// 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. /// public 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); } // Thinking aus älteren Turns strippen (Kontext-Spar-Maßnahme): // MiniMax/DeepSeek-R1/QwQ brauchen ihr eigenes Thinking in der History, // aber nur die letzten paar Turns sind relevant – ältere Thinking-Blöcke // blähen den Kontext auf und verursachen Tool-Call-Degradation ab ~20k Tokens. StripOldThinking(apiMessages, keepRecentCount: 4); return apiMessages; } /// /// Entfernt ...-Blöcke aus älteren Assistant-Messages, /// behält sie aber in den letzten Turns. /// Reduziert Kontext-Inflation bei Thinking-Modellen (MiniMax-M2, DeepSeek-R1, etc.). /// private static void StripOldThinking(List apiMessages, int keepRecentCount) { // Finde alle Indizes von Assistant-Messages mit -Blöcken var assistantIndices = new List(); for (int i = 0; i < apiMessages.Count; i++) { var json = JsonSerializer.Serialize(apiMessages[i]); if (json.Contains("\"role\":\"assistant\"") && json.Contains("")) assistantIndices.Add(i); } // Nur die ältesten strippen, die neuesten keepRecentCount behalten var stripCount = assistantIndices.Count - keepRecentCount; if (stripCount <= 0) return; for (int j = 0; j < stripCount; j++) { var idx = assistantIndices[j]; var json = JsonSerializer.Serialize(apiMessages[idx]); var el = JsonSerializer.Deserialize(json); if (!el.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.String) continue; var content = contentProp.GetString() ?? ""; // ...\n\n\n entfernen (greedy innerhalb eines Blocks) var stripped = System.Text.RegularExpressions.Regex.Replace( content, @"[\s\S]*?\s*", "", System.Text.RegularExpressions.RegexOptions.None); stripped = stripped.TrimStart(); if (string.IsNullOrWhiteSpace(stripped)) { // Kein sichtbarer Text übrig nach Strip → Message trotzdem behalten falls tool_calls dran sind if (json.Contains("\"tool_calls\"")) stripped = ""; else continue; // Komplett leer → überspringen } // Neue Message mit gestripptem Content erstellen var dict = JsonSerializer.Deserialize>(json); var newDict = new Dictionary(); foreach (var kv in dict) { if (kv.Key == "content") newDict["content"] = stripped; else newDict[kv.Key] = kv.Value; } apiMessages[idx] = newDict; } } 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 }