Files
gott/Services/OpenAiService.cs
T
Meleki d66f8d2e73 feat(minimax): Thinking-Parsing, 1h-Cache-TTL, Null-Safety, Provider-Integration
- Thinking-Streaming: Parse 'reasoning_details' (MiniMax mit reasoning_split),
  'reasoning' (Nvidia), 'reasoning_content' (DeepSeek/OpenAI-Compat),
  und inline <think>...</think>-Tags aus delta.content (Fallback)
- MiniMax: max_tokens=32768, stream_options={include_usage}, reasoning_split=true
- 1h-Prompt-Cache-TTL für API (extended-cache-ttl-2025-04-11 Header,
  Cache-Breakpoints auf System-Prompt + letztes Tool) und CLI (ENABLE_PROMPT_CACHING_1H=1)
- Null-Safety: choices/delta/tool_calls/usage null-Checks in Streaming + Non-Streaming
- finish_reason='length' → warning-Event + stopReason='max_tokens'
- MiniMax-Provider: BaseUrl, MinimaxApiKey-Setting, /api/models/minimax-Endpoint,
  Model-Dropdown, statische Fallback-Modelle, Cache-Laden mit Fallback
- Safety-Net: InvalidOperationException-Catch um SSE-Zeilen-Block
2026-05-14 00:55:32 +02:00

1228 lines
60 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;
// 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>
private 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>();
// 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)
{
_httpFactory = httpFactory;
_db = db;
_toolService = toolService;
_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>();
// 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>
/// Lädt Modelle von allen Provider-APIs und speichert sie im Cache.
/// </summary>
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<string, List<ProviderModel>>
{
["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<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;
/// <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,
CancellationToken workCt = default,
CancellationToken sseCt = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var iteration = 0;
var chain = new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
Model = modelString
};
var resolved = ResolveProviderConfig(modelString, apiKeyOverride);
if (resolved == null)
{
var errMsg = $"Unbekannter Provider: '{ParseModelString(modelString).provider}'";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
return;
}
var (provider, modelId, providerConfig) = resolved.Value;
try
{
var apiMessages = BuildApiMessages(systemPromptText, messages);
// Agentic Loop
while (iteration < maxIterations)
{
workCt.ThrowIfCancellationRequested();
iteration++;
var client = CreateClient(providerConfig.ApiKey);
// MiniMax-spezifisch: höheres Output-Budget (Thinking-Modelle brauchen viel)
var maxTokens = provider == "minimax" ? 32768 : 16384;
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"] = "auto"
};
if (provider == "nvidia" && IsNvidiaThinkingModel(modelId))
payload["chat_template_kwargs"] = new { thinking = true };
// MiniMax-Thinking-Modelle: reasoning_split → Thinking landet in delta.reasoning_details
// statt als <think>...</think> inline in delta.content (siehe MiniMax-OpenAI-Compat-Docs)
if (provider == "minimax")
payload["extra_body"] = new { reasoning_split = true };
var jsonPayload = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead,
workCt
);
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync(workCt);
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await FinalizeChain(chain, usage, iteration, "error", sw);
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
return;
}
// SSE-Stream lesen
using var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
using var reader = new System.IO.StreamReader(stream);
var fullText = new StringBuilder();
var thinkingText = new StringBuilder();
// Tool-Call Akkumulatoren: id → (name, arguments)
var toolCallAccumulators = new Dictionary<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();
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();
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 */ }
}
// 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
var finalText = fullText.ToString();
if (!string.IsNullOrEmpty(finalText))
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "assistant", Type = "text",
Content = finalText
});
}
// Keine Tool-Calls → fertig (stopReason als Fallback, primär Count prüfen)
if (toolCallAccumulators.Count == 0)
{
await FinalizeChain(chain, usage, iteration, stopReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelString,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
iterations = iteration,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason
}, sseCt);
return;
}
// Tool-Calls ausführen
// Assistant-Message mit tool_calls in die History einfügen
var toolCallsForHistory = new List<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
});
}
apiMessages.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(finalText) ? "" : finalText,
tool_calls = toolCallsForHistory
});
// Tool-Results sammeln
var toolResults = new List<object>();
foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators)
{
// Tool-Start SSE
await SseHelper.TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt);
// Tool ausführen
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize<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,
result = toolResult,
isError
}, sseCt);
toolResults.Add(new
{
role = "tool",
tool_call_id = tcId,
content = toolResult
});
// Tool-Result in DB speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "tool", Type = "tool_result",
Content = toolResult,
ToolCallId = tcId, ToolName = tcName,
IsError = isError
});
}
// Tool-Results in History einfügen
foreach (var tr in toolResults)
apiMessages.Add(tr);
}
// Max Iterations erreicht
await SseHelper.TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
await FinalizeChain(chain, usage, iteration, "max_iterations", sw);
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
}
catch (OperationCanceledException)
{
await FinalizeChain(chain, usage, iteration, "stopped", sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelString,
iterations = iteration,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "stopped"
}, CancellationToken.None);
}
catch (Exception ex)
{
var errMsg = $"Fehler: {ex.Message}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await FinalizeChain(chain, usage, iteration, "error", sw);
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
}
}
/// <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>
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
};
var apiMessages = BuildApiMessages(ctx.SystemPromptText, messages);
try
{
while (iterations < ctx.MaxIterations)
{
iterations++;
onIteration?.Invoke(iterations);
var client = CreateClient(providerConfig.ApiKey);
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["messages"] = apiMessages,
["stream"] = false,
["max_tokens"] = 16384,
["tools"] = _openAiToolDefs,
["tool_choice"] = "auto"
};
if (provider == "nvidia" && IsNvidiaThinkingModel(modelId))
payload["chat_template_kwargs"] = new { thinking = true };
var jsonPayload = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent });
var respBody = await httpResp.Content.ReadAsStringAsync();
if (!httpResp.IsSuccessStatusCode)
{
var errMsg = $"API {(int)httpResp.StatusCode}: {respBody}";
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await FinalizeChain(chain, usage, iterations, "error", sw);
throw new Exception($"OpenAI API Fehler: {(int)httpResp.StatusCode}");
}
var resp = JsonSerializer.Deserialize<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)
if (message.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String)
{
var reasoning = reasoningProp.GetString() ?? "";
if (!string.IsNullOrEmpty(reasoning))
{
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "assistant", Type = "thinking",
Content = reasoning
});
}
}
// Text extrahieren
if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String)
{
var text = contentProp.GetString() ?? "";
if (!string.IsNullOrEmpty(text))
{
finalText.Append(text);
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = text
});
}
}
// Tool-Calls prüfen
if (!message.TryGetProperty("tool_calls", out var toolCallsProp) || toolCallsProp.ValueKind != JsonValueKind.Array || toolCallsProp.GetArrayLength() == 0)
{
await FinalizeChain(chain, usage, iterations, "end_turn", sw);
break;
}
// Assistant-Message mit tool_calls in History
var assistantMsg = JsonSerializer.Deserialize<JsonElement>(message.GetRawText());
apiMessages.Add(new
{
role = "assistant",
content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "",
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 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 == "text" && block.TryGetProperty("text", out var textProp))
{
textParts.Append(textProp.GetString());
}
else if (blockType == "tool_use")
{
// Anthropic tool_use → OpenAI tool_calls
var tcId = block.TryGetProperty("id", out var idProp) ? idProp.GetString() : "";
var tcName = block.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : "";
var tcInput = block.TryGetProperty("input", out var inputProp)
? JsonSerializer.Serialize(inputProp)
: "{}";
// thought_signature als eigenes Feld (vom DB-Record direkt in den Block übernommen)
var tcTs = block.TryGetProperty("thought_signature", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.String
? tsProp2.GetString() : null;
// Gemini erwartet thought_signature in extra_content.google.thought_signature
if (!string.IsNullOrEmpty(tcTs))
toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput }, extra_content = new { google = new { thought_signature = tcTs } } });
else
toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput } });
}
else if (blockType == "tool_result")
{
// Anthropic tool_result → OpenAI role: "tool"
var trId = block.TryGetProperty("tool_use_id", out var idProp) ? idProp.GetString() : "";
var trContent = "";
if (block.TryGetProperty("content", out var cProp))
{
if (cProp.ValueKind == JsonValueKind.String)
trContent = cProp.GetString() ?? "";
else if (cProp.ValueKind == JsonValueKind.Array)
{
foreach (var item in cProp.EnumerateArray())
if (item.TryGetProperty("text", out var t)) trContent += t.GetString();
}
}
toolResults.Add(new { role = "tool", tool_call_id = trId, content = trContent });
}
}
// Assistant-Message mit tool_calls
if (toolCalls.Count > 0)
{
var text = textParts.ToString().Trim();
return new
{
role = "assistant",
content = string.IsNullOrEmpty(text) ? "" : text,
tool_calls = toolCalls
};
}
// Tool-Results → als separate Objekte zurückgeben (werden im Caller verarbeitet)
if (toolResults.Count > 0)
return toolResults; // List<object> wird im Aufrufer per OfType<List<object>> erkannt
var combined = 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"));
return (provider, modelId, providerConfig);
}
/// <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>
private 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);
}
return apiMessages;
}
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw);
private HttpClient CreateClient(string apiKey)
{
var client = _httpFactory.CreateClient();
if (!string.IsNullOrEmpty(apiKey))
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromMinutes(5);
return client;
}
// TryWriteSse → SseHelper.cs
}