Files
gott/Services/OpenAiService.cs
T
MelekiandClaude Opus 5 7305a5a50f OpenAI: Server-Key-Setting, Filter-Fix fuer o-Reihe, ehrlicher Modell-Refresh
Der Modell-Refresh war fuer OpenAI faktisch ein No-Op: der Key wurde nur aus
der api_keys-Tabelle geholt, und ohne Key blieb die alte Liste im Speicher
stehen und wurde direkt wieder in models_cache.json geschrieben - waehrend der
Endpoint pauschal Erfolg meldete.

- ServerSettingsService: neues Setting OpenAiApiKey (OpenAI war der einzige
  Provider ohne Server-Key, analog Gemini/MiniMax/z.ai)
- OpenAiService: ResolveOpenAiKey() (Setting -> api_keys), HasServerOpenAiKey(),
  ResolveProviderConfig zieht den Setting-Key auch im Chat
- OpenAiService: IsOpenAiChatModel() erfasst die o-Reihe inkl. bare IDs
  (o1, o3, kuenftig o5) - StartsWith("o3-") hat die verschluckt
- RefreshModelsFromApis() liefert ProviderRefreshResult[] pro Provider;
  ModelsEndpoint gibt providers[] zurueck, agent-page.js meldet uebersprungene
  Provider als Fehler-Toast statt pauschalem Erfolg
- ChatEndpoint/TelegramService/CronjobService: OpenAI ohne Agent-Key faellt auf
  den Server-Key zurueck statt hart abzubrechen
- ARCHITECTURE.md nachgezogen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:55:22 +02:00

1753 lines
86 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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.
/// </summary>
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<string> ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai", "minimax" };
// Provider-Definitionen (inkl. API-Keys, instanzgebunden)
private readonly Dictionary<string, (string BaseUrl, string ApiKey)> Providers;
// Tools die für OpenAI-kompatible Provider verfügbar sind (konvertiert aus ToolService Anthropic-Format)
private List<object> _openAiToolDefs;
/// <summary>
/// Konvertiert Anthropic-Format Tool-Definitionen (input_schema) in OpenAI-Format (function/parameters).
/// Single source of truth: ToolService.GetToolDefinitions()
/// </summary>
public static List<object> ConvertTo_openAiToolDefs(object[] anthropicTools)
{
var result = new List<object>();
foreach (var tool in anthropicTools)
{
var json = JsonSerializer.Serialize(tool);
var el = JsonSerializer.Deserialize<JsonElement>(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<object>(schema.GetRawText())
: new { type = "object", properties = new Dictionary<string, object>(), required = Array.Empty<string>() } as object
}
});
}
return result;
}
public record ProviderModel(string Id, string DisplayName);
private List<ProviderModel> _nvidiaModels = new List<ProviderModel>();
private List<ProviderModel> _pollinationsModels = new List<ProviderModel>();
private List<ProviderModel> _geminiModels = new List<ProviderModel>();
private List<ProviderModel> _openaiModels = new List<ProviderModel>();
private List<ProviderModel> _minimaxModels = new List<ProviderModel>();
private List<ProviderModel> _glmModels = new List<ProviderModel>();
// 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<ProviderModel> MinimaxFallbackModels = new List<ProviderModel>
{
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<string, (string BaseUrl, string ApiKey)>
{
["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 }));
}
/// <summary>
/// Lädt verfügbare Modelle – aus lokalem Cache (models_cache.json) oder von den APIs.
/// </summary>
public async Task LoadModelsAsync(bool forceRefresh = false)
{
if (!forceRefresh && File.Exists(_cacheFile))
{
try
{
var json = await File.ReadAllTextAsync(_cacheFile);
var cache = JsonSerializer.Deserialize<Dictionary<string, List<ProviderModel>>>(json);
if (cache != null)
{
_nvidiaModels = cache.ContainsKey("nvidia") ? cache["nvidia"] : new List<ProviderModel>();
_pollinationsModels = cache.ContainsKey("pollinations") ? cache["pollinations"] : new List<ProviderModel>();
_geminiModels = cache.ContainsKey("gemini") ? cache["gemini"] : new List<ProviderModel>();
_openaiModels = cache.ContainsKey("openai") ? cache["openai"] : new List<ProviderModel>();
_minimaxModels = cache.ContainsKey("minimax") ? cache["minimax"] : new List<ProviderModel>();
_glmModels = cache.ContainsKey("glm") ? cache["glm"] : new List<ProviderModel>();
// 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();
}
/// <summary>
/// Ergebnis eines Modell-Refreshs pro Provider – damit der Aufrufer (und die UI)
/// sieht, was tatsächlich geladen und was übersprungen wurde.
/// </summary>
public class ProviderRefreshResult
{
public string Provider { get; set; }
public bool Loaded { get; set; } // true = frisch von der API geladen
public int Count { get; set; } // Anzahl Modelle nach dem Refresh
public string Note { get; set; } // Grund bei Skip/Fehler, sonst ""
}
/// <summary>
/// Löst den OpenAI-Key auf: bevorzugt das Server-Setting (OpenAiApiKey, analog
/// Gemini/MiniMax), sonst der erste aktive 'openai'-Key aus der api_keys-Tabelle.
/// </summary>
public bool HasServerOpenAiKey() => !string.IsNullOrEmpty(_settings.Get("OpenAiApiKey"));
public async Task<string> ResolveOpenAiKey()
{
var fromSettings = _settings.Get("OpenAiApiKey");
if (!string.IsNullOrEmpty(fromSettings)) return fromSettings;
return await _db.GetFirstActiveKeyByProvider("openai");
}
/// <summary>
/// Chat-Modelle von OpenAI filtern: gpt-*, chatgpt-* und die o-Reihe.
/// Wichtig: auch bare IDs ohne Bindestrich (o1, o3, künftig o5) – der alte
/// Filter (StartsWith("o3-")) hat die verschluckt.
/// </summary>
private static bool IsOpenAiChatModel(string id)
{
if (string.IsNullOrEmpty(id)) return false;
if (id.StartsWith("gpt-", StringComparison.OrdinalIgnoreCase)) return true;
if (id.StartsWith("chatgpt-", StringComparison.OrdinalIgnoreCase)) return true;
// o-Reihe: 'o' + Ziffer, danach Ende oder '-'/'.' (o1, o3, o3-mini, o4-mini, o5.1)
if (id.Length >= 2 && (id[0] == 'o' || id[0] == 'O') && char.IsDigit(id[1]))
return true;
return false;
}
/// <summary>
/// Lädt Modelle von allen Provider-APIs und speichert sie im Cache.
/// Gibt pro Provider zurück, ob geladen oder übersprungen wurde.
/// </summary>
public async Task<List<ProviderRefreshResult>> RefreshModelsFromApis()
{
var report = new List<ProviderRefreshResult>();
void Report(string provider, bool loaded, int count, string note = "")
{
report.Add(new ProviderRefreshResult { Provider = provider, Loaded = loaded, Count = count, Note = note });
Console.WriteLine($"[OpenAiService] {provider}: {(loaded ? "geladen" : "übersprungen")} – {count} Modelle{(string.IsNullOrEmpty(note) ? "" : $" ({note})")}");
}
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");
Report("nvidia", _nvidiaModels.Count > 0, _nvidiaModels.Count,
_nvidiaModels.Count == 0 ? "API lieferte keine Modelle" : "");
_pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations");
Report("pollinations", _pollinationsModels.Count > 0, _pollinationsModels.Count,
_pollinationsModels.Count == 0 ? "API lieferte keine Modelle" : "");
var geminiApiKey = _settings.Get("GeminiApiKey");
if (!string.IsNullOrEmpty(geminiApiKey))
{
_geminiModels = await FetchModels("https://generativelanguage.googleapis.com/v1beta/openai/models", "Gemini", geminiApiKey);
Report("gemini", _geminiModels.Count > 0, _geminiModels.Count,
_geminiModels.Count == 0 ? "Key gesetzt, aber API lieferte nichts" : "");
}
else
Report("gemini", false, _geminiModels.Count, "GeminiApiKey nicht konfiguriert – alte Liste bleibt");
// OpenAI: Key aus Settings (OpenAiApiKey), sonst aus der api_keys-Tabelle.
var openaiKey = await ResolveOpenAiKey();
if (!string.IsNullOrEmpty(openaiKey))
{
var allOpenAi = await FetchModels("https://api.openai.com/v1/models", "OpenAI", openaiKey);
if (allOpenAi.Count > 0)
{
_openaiModels = allOpenAi
.Where(m => IsOpenAiChatModel(m.Id))
.OrderBy(m => m.Id)
.ToList();
Report("openai", true, _openaiModels.Count, $"gefiltert aus {allOpenAi.Count} total");
}
else
Report("openai", false, _openaiModels.Count, "Key gesetzt, aber /v1/models lieferte nichts (Key ungültig?) – alte Liste bleibt");
}
else
Report("openai", false, _openaiModels.Count, "kein OpenAI-Key (Setting 'OpenAiApiKey' oder api_keys-Tabelle) – alte Liste bleibt");
// 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;
Report("minimax", fetched.Count > 0, _minimaxModels.Count,
fetched.Count == 0 ? "/v1/models leer – Fallback-Liste" : "");
}
else
{
// Ohne Key: statische Fallback-Liste anbieten, damit das Dropdown nicht leer ist.
_minimaxModels = MinimaxFallbackModels;
Report("minimax", false, _minimaxModels.Count, "MinimaxApiKey nicht konfiguriert – Fallback-Liste");
}
// z.ai / GLM: Key aus Settings (ZaiApiKey). OpenAI-kompatibler /models-Endpoint von z.ai.
var zaiKey = _settings.Get("ZaiApiKey");
if (!string.IsNullOrEmpty(zaiKey))
{
_glmModels = await FetchModels("https://api.z.ai/api/paas/v4/models", "GLM (z.ai)", zaiKey);
Report("glm", _glmModels.Count > 0, _glmModels.Count,
_glmModels.Count == 0 ? "Key gesetzt, aber API lieferte nichts" : "");
}
else
Report("glm", false, _glmModels.Count, "ZaiApiKey nicht konfiguriert – alte Liste bleibt");
// Cache speichern
try
{
var cache = new Dictionary<string, List<ProviderModel>>
{
["nvidia"] = _nvidiaModels,
["pollinations"] = _pollinationsModels,
["gemini"] = _geminiModels,
["openai"] = _openaiModels,
["minimax"] = _minimaxModels,
["glm"] = _glmModels
};
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}");
}
return report;
}
private async Task<List<ProviderModel>> FetchModels(string url, string providerName, string apiKey = "")
{
try
{
var client = CreateClient(apiKey);
var resp = await client.GetAsync(url);
if (!resp.IsSuccessStatusCode) return new List<ProviderModel>();
var json = await resp.Content.ReadAsStringAsync();
var root = JsonSerializer.Deserialize<JsonElement>(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<ProviderModel>();
var models = new List<ProviderModel>();
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<ProviderModel>();
}
}
public List<ProviderModel> GetNvidiaModels() => _nvidiaModels;
public List<ProviderModel> GetPollinationsModels() => _pollinationsModels;
public List<ProviderModel> GetGeminiModels() => _geminiModels;
public List<ProviderModel> GetOpenAiModels() => _openaiModels;
public List<ProviderModel> GetMinimaxModels() => _minimaxModels;
public List<ProviderModel> GetGlmModels() => _glmModels;
/// <summary>
/// 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")
/// </summary>
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)..]);
}
/// <summary>
/// Prüft ob ein Model-String einen externen OpenAI-kompatiblen Provider referenziert.
/// </summary>
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<object> 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<string, object>
{
["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 <think>...</think> 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<int, (string Id, string Name, StringBuilder Args, string ThoughtSignature)>();
string thoughtSignature = null;
var textStarted = false;
var thinkingStarted = false;
var stopReason = "end_turn";
var gotLengthFinish = false;
// Inline-<think>-Tag-Parser für MiniMax-Modelle die Thinking als <think>...</think>
// in delta.content streamen (statt in reasoning_details).
// Buffer hält partielle Tags zurück (z.B. wenn ein Chunk auf "<thi" endet).
var inlineThinkBuf = new StringBuilder();
var inlineInThink = false;
string line;
while ((line = await reader.ReadLineAsync(workCt)) != null)
{
workCt.ThrowIfCancellationRequested();
if (!line.StartsWith("data:")) continue;
var data = line[5..].Trim();
// Diagnose-Log: rohe data-Zeile als JSONL-Record
if (debugWriter != null)
{
try
{
var rec = JsonSerializer.Serialize(new { t = DateTime.UtcNow.ToString("o"), raw = data });
await debugWriter.WriteLineAsync(rec);
debugLineCount++;
}
catch { /* Debug-Log darf den Stream nicht killen */ }
}
if (data == "[DONE]") break;
JsonElement parsed;
try { parsed = JsonSerializer.Deserialize<JsonElement>(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-<think>-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("</think>", StringComparison.Ordinal);
if (endIdx >= 0)
{
// Thinking-Inhalt bis zum </think> 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 </think> 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 ("</think>".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("<think>", StringComparison.Ordinal);
if (startIdx >= 0)
{
// Text vor <think> 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 <think> 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 ("<think>".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<object>();
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 </think> 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 = $"<think>\n{finalThinking}\n</think>\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<object>();
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 <think>...</think>-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 = $"<think>\n{finalThinking}\n</think>\n\n{historyContent}";
apiMessages.Add(new
{
role = "assistant",
content = historyContent,
tool_calls = toolCallsForHistory
});
// Tool-Results sammeln
var toolResults = new List<object>();
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<JsonElement>(tcArgs.ToString()); }
catch { choiceInput = JsonSerializer.Deserialize<JsonElement>("{}"); }
var question = choiceInput.TryGetProperty("question", out var qProp) ? qProp.GetString() ?? "" : "";
var choices = new List<string>();
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<JsonElement>(tcArgs.ToString()); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
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);
}
}
/// <summary>
/// Non-Streaming Chat für Telegram, Cronjobs und send_to_session.
/// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop).
/// </summary>
/// <summary>
/// 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).
/// </summary>
public async Task<string> CompleteText(string modelId, string systemPromptText, List<object> 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<string, object>
{
["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<JsonElement>(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<string> ChatNonStreaming(
ChatContext ctx,
List<object> messages,
Func<int, Task> 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<string, object>
{
["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<JsonElement>(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 <think>...</think>-Prefix dem content voranstellen, damit das Modell
// in nachfolgenden Iterationen seine eigene Chain-of-Thought sieht.
var assistantMsg = JsonSerializer.Deserialize<JsonElement>(message.GetRawText());
var iterContent = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "";
if (!string.IsNullOrEmpty(iterationThinking))
iterContent = $"<think>\n{iterationThinking}\n</think>\n\n{iterContent}";
apiMessages.Add(new
{
role = "assistant",
content = iterContent,
tool_calls = JsonSerializer.Deserialize<List<object>>(toolCallsProp.GetRawText())
});
// Tool-Calls ausführen
var toolResults = new List<object>();
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<JsonElement>(tcArgs); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
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)";
}
/// <summary>
/// Konvertiert eine Anthropic-format Message in das OpenAI-format.
/// </summary>
private static object ConvertMessage(object msg)
{
var json = JsonSerializer.Serialize(msg);
var el = JsonSerializer.Deserialize<JsonElement>(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<object>();
var toolResults = new List<object>();
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 <think>...</think>-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 <think>...</think>-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 = $"<think>\n{thinkingStr}\n</think>\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<object> wird im Aufrufer per OfType<List<object>> erkannt
var combined = PrependThinking(textParts.ToString().Trim());
if (string.IsNullOrEmpty(combined)) return null;
return new { role, content = combined };
}
return null;
}
/// <summary>
/// Löst Provider-Config auf: Provider-Name, Model-ID, BaseUrl + ApiKey.
/// Berücksichtigt API-Key-Override und dynamische Gemini-Keys.
/// </summary>
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"));
if (provider == "openai" && string.IsNullOrEmpty(providerConfig.ApiKey))
providerConfig = (providerConfig.BaseUrl, _settings.Get("OpenAiApiKey"));
return (provider, modelId, providerConfig);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Prüft ob ein Nvidia-Modell Thinking-Mode unterstützt (chat_template_kwargs).
/// </summary>
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);
}
/// <summary>
/// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format.
/// </summary>
public static List<object> BuildApiMessages(string systemPromptText, List<object> messages)
{
var apiMessages = new List<object>();
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<object> 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;
}
/// <summary>
/// Entfernt <think>...</think>-Blöcke aus älteren Assistant-Messages,
/// behält sie aber in den letzten <paramref name="keepRecentCount"/> Turns.
/// Reduziert Kontext-Inflation bei Thinking-Modellen (MiniMax-M2, DeepSeek-R1, etc.).
/// </summary>
private static void StripOldThinking(List<object> apiMessages, int keepRecentCount)
{
// Finde alle Indizes von Assistant-Messages mit <think>-Blöcken
var assistantIndices = new List<int>();
for (int i = 0; i < apiMessages.Count; i++)
{
var json = JsonSerializer.Serialize(apiMessages[i]);
if (json.Contains("\"role\":\"assistant\"") && json.Contains("<think>"))
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<JsonElement>(json);
if (!el.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.String)
continue;
var content = contentProp.GetString() ?? "";
// <think>...\n</think>\n\n entfernen (greedy innerhalb eines Blocks)
var stripped = System.Text.RegularExpressions.Regex.Replace(
content, @"<think>[\s\S]*?</think>\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<Dictionary<string, JsonElement>>(json);
var newDict = new Dictionary<string, object>();
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
}