- Auto-Context (Embedding-Suche) für OpenAI-Provider aktiviert: BuildAutoContextAsync auf internal static refactored und in StreamChat + ChatNonStreaming integriert - Tool-Results mit Role="tool" (OpenAI-Konvention) werden in BuildHistoryFromDb jetzt korrekt erkannt (vorher nur Role="user"/Anthropic) - Payload-Preview zeigt für OpenAI-Provider echtes API-Format (function-Wrapper, tool_choice:"auto", max_tokens, stream_options, extra_body) - Tool-Input (Argumente) wird im SSE tool_end-Event mitgesendet (fehlte im OpenAI-Pfad) - Leere Bubbles durch Whitespace-only Text nach </think>-Extraktion behoben (Trim in DB-Save, BuildHistoryFromDb und Frontend-Guard) - History-Toggle-Bug: var→const in for-Loop (Block-Scoping für Closures), tool_result-Matching per data-tool-call-id statt blindes letztes Element - Non-Streaming max_tokens für MiniMax auf 32768 + extra_body.reasoning_split Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1462 lines
72 KiB
C#
1462 lines
72 KiB
C#
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>();
|
||
|
||
// 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>();
|
||
// 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,
|
||
bool useAutoContext = true,
|
||
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
|
||
{
|
||
// ── 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;
|
||
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);
|
||
|
||
// 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 → 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
|
||
});
|
||
}
|
||
|
||
// 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,
|
||
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>
|
||
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["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 });
|
||
|
||
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"));
|
||
|
||
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>
|
||
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);
|
||
}
|
||
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
|
||
}
|