using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
///
/// OpenAI-kompatibler Service – unterstützt Nvidia (g4f.space Proxy) und andere
/// OpenAI-API-kompatible Endpunkte. Unterstützt Tool-Calling (list_files, read_file,
/// write_file, edit_file, delete_file, execute) und Streaming via SSE.
///
public class OpenAiService
{
private readonly IHttpClientFactory _httpFactory;
private readonly DatabaseService _db;
private readonly ToolService _toolService;
// Provider-Keys (statisch für ChatService.ResolveModelId)
public static readonly HashSet ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai" };
// Provider-Definitionen (inkl. API-Keys, instanzgebunden)
private readonly Dictionary Providers;
// Tools die für OpenAI-kompatible Provider verfügbar sind (konvertiert aus ToolService Anthropic-Format)
private List _openAiToolDefs;
///
/// Konvertiert Anthropic-Format Tool-Definitionen (input_schema) in OpenAI-Format (function/parameters).
/// Single source of truth: ToolService.GetToolDefinitions()
///
private static List ConvertTo_openAiToolDefs(object[] anthropicTools)
{
var result = new List();
foreach (var tool in anthropicTools)
{
var json = JsonSerializer.Serialize(tool);
var el = JsonSerializer.Deserialize(json);
var name = el.TryGetProperty("name", out var n) ? n.GetString() : "";
var desc = el.TryGetProperty("description", out var d) ? d.GetString() : "";
var schema = el.TryGetProperty("input_schema", out var s) ? s : default;
result.Add(new
{
type = "function",
function = new
{
name,
description = desc,
parameters = schema.ValueKind != JsonValueKind.Undefined
? JsonSerializer.Deserialize(schema.GetRawText())
: new { type = "object", properties = new Dictionary(), required = Array.Empty() } as object
}
});
}
return result;
}
public record ProviderModel(string Id, string DisplayName);
private List _nvidiaModels = new List();
private List _pollinationsModels = new List();
private List _geminiModels = new List();
private List _openaiModels = new List();
private 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
{
["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
};
// Tool-Definitionen aus ToolService konvertieren (single source of truth)
_openAiToolDefs = ConvertTo_openAiToolDefs(toolService.GetToolDefinitions(new ToolDefinitionOptions { IncludeFileTools = true }));
}
///
/// Lädt verfügbare Modelle – aus lokalem Cache (models_cache.json) oder von den APIs.
///
public async Task LoadModelsAsync(bool forceRefresh = false)
{
if (!forceRefresh && File.Exists(_cacheFile))
{
try
{
var json = await File.ReadAllTextAsync(_cacheFile);
var cache = JsonSerializer.Deserialize>>(json);
if (cache != null)
{
_nvidiaModels = cache.ContainsKey("nvidia") ? cache["nvidia"] : new List();
_pollinationsModels = cache.ContainsKey("pollinations") ? cache["pollinations"] : new List();
_geminiModels = cache.ContainsKey("gemini") ? cache["gemini"] : new List();
_openaiModels = cache.ContainsKey("openai") ? cache["openai"] : new List();
Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini, {_openaiModels.Count} OpenAI");
return;
}
}
catch (Exception ex)
{
Console.WriteLine($"[OpenAiService] Cache-Datei fehlerhaft, lade von APIs: {ex.Message}");
}
}
await RefreshModelsFromApis();
}
///
/// Lädt Modelle von allen Provider-APIs und speichert sie im Cache.
///
public async Task RefreshModelsFromApis()
{
var nvidiaApiKey = _settings.Get("NvidiaApiKey");
if (!string.IsNullOrEmpty(nvidiaApiKey))
_nvidiaModels = await FetchModels("https://integrate.api.nvidia.com/v1/models", "Nvidia", nvidiaApiKey);
else
_nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia");
_pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations");
var geminiApiKey = _settings.Get("GeminiApiKey");
if (!string.IsNullOrEmpty(geminiApiKey))
_geminiModels = await FetchModels("https://generativelanguage.googleapis.com/v1beta/openai/models", "Gemini", geminiApiKey);
else
Console.WriteLine("[OpenAiService] GeminiApiKey nicht konfiguriert – Gemini-Modelle nicht geladen.");
// OpenAI: Key aus DB holen (erster aktiver OpenAI-Key)
var openaiKey = await _db.GetFirstActiveKeyByProvider("openai");
if (!string.IsNullOrEmpty(openaiKey))
{
var allOpenAi = await FetchModels("https://api.openai.com/v1/models", "OpenAI", openaiKey);
// Nur Chat-Modelle filtern (gpt-*, o1-*, o3-*, o4-*, chatgpt-*)
_openaiModels = allOpenAi
.Where(m => m.Id.StartsWith("gpt-") || m.Id.StartsWith("o1-") || m.Id.StartsWith("o3-") || m.Id.StartsWith("o4-") || m.Id.StartsWith("chatgpt-"))
.OrderBy(m => m.Id)
.ToList();
Console.WriteLine($"[OpenAiService] {_openaiModels.Count} OpenAI Chat-Modelle gefiltert (von {allOpenAi.Count} total).");
}
else
Console.WriteLine("[OpenAiService] Kein aktiver OpenAI API-Key in DB – OpenAI-Modelle nicht geladen.");
// Cache speichern
try
{
var cache = new Dictionary>
{
["nvidia"] = _nvidiaModels,
["pollinations"] = _pollinationsModels,
["gemini"] = _geminiModels,
["openai"] = _openaiModels
};
var json = JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(_cacheFile, json);
Console.WriteLine($"[OpenAiService] Modelle-Cache gespeichert: {_cacheFile}");
}
catch (Exception ex)
{
Console.WriteLine($"[OpenAiService] Fehler beim Speichern des Modelle-Cache: {ex.Message}");
}
}
private async Task> FetchModels(string url, string providerName, string apiKey = "")
{
try
{
var client = CreateClient(apiKey);
var resp = await client.GetAsync(url);
if (!resp.IsSuccessStatusCode) return new List();
var json = await resp.Content.ReadAsStringAsync();
var root = JsonSerializer.Deserialize(json);
// Pollinations: root is array [{ name, ... }]
// Nvidia/OpenAI: root is { data: [{ id, ... }] }
JsonElement arr;
if (root.ValueKind == JsonValueKind.Array)
arr = root;
else if (root.TryGetProperty("data", out var data))
arr = data;
else
return new List();
var models = new List();
foreach (var item in arr.EnumerateArray())
{
// try "id" first (Nvidia), fallback to "name" (Pollinations)
string id = null;
if (item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) id = idProp.GetString();
if (id == null && item.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) id = nameProp.GetString();
if (string.IsNullOrEmpty(id)) continue;
var display = id.Contains('/') ? id[(id.LastIndexOf('/') + 1)..] : id;
models.Add(new ProviderModel(id, display));
}
Console.WriteLine($"[OpenAiService] {models.Count} {providerName}-Modelle geladen.");
return models;
}
catch (Exception ex)
{
Console.WriteLine($"[OpenAiService] Fehler beim Laden der {providerName}-Modelle: {ex.Message}");
return new List();
}
}
public List GetNvidiaModels() => _nvidiaModels;
public List GetPollinationsModels() => _pollinationsModels;
public List GetGeminiModels() => _geminiModels;
public List GetOpenAiModels() => _openaiModels;
///
/// Gibt den Provider und das eigentliche Modell-ID aus einem kombinierten Model-String zurück.
/// Format: "nvidia:moonshotai/kimi-k2.5" → ("nvidia", "moonshotai/kimi-k2.5")
///
public static (string provider, string modelId) ParseModelString(string model)
{
if (string.IsNullOrEmpty(model)) return ("", model ?? "");
var idx = model.IndexOf(':');
if (idx < 0) return ("", model);
return (model[..idx], model[(idx + 1)..]);
}
///
/// Prüft ob ein Model-String einen externen OpenAI-kompatiblen Provider referenziert.
///
public static bool IsOpenAiProvider(string model)
{
var (provider, _) = ParseModelString(model);
return ProviderKeys.Contains(provider);
}
public async Task StreamChat(
HttpResponse response,
string modelString,
string systemPromptText,
List messages,
string chainId,
string workspace = null,
string sessionId = null,
string agentId = null,
int maxIterations = 10,
string apiKeyOverride = null,
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);
var payload = new Dictionary
{
["model"] = modelId,
["messages"] = apiMessages,
["stream"] = true,
["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 },
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);
// Explizit UTF-8 ohne BOM-Detection, größerer Buffer (16 KB statt default 1024).
// Verhindert intermittierende Replacement-Chars (�) für Multi-Byte-Sequenzen,
// die entstehen können, wenn UTF-8-Bytes über die Read-Boundary eines
// NetworkStreams gesplittet werden und der Decoder bei kleinem Buffer State verliert.
using var reader = new System.IO.StreamReader(
stream,
encoding: new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false),
detectEncodingFromByteOrderMarks: false,
bufferSize: 16384,
leaveOpen: false);
var fullText = new StringBuilder();
var thinkingText = new StringBuilder();
// Tool-Call Akkumulatoren: id → (name, arguments)
var toolCallAccumulators = new Dictionary();
string thoughtSignature = null;
var textStarted = false;
var thinkingStarted = false;
var stopReason = "end_turn";
string line;
while ((line = await reader.ReadLineAsync(workCt)) != null)
{
workCt.ThrowIfCancellationRequested();
if (!line.StartsWith("data:")) continue;
var data = line[5..].Trim();
if (data == "[DONE]") break;
JsonElement parsed;
try { parsed = JsonSerializer.Deserialize(data); }
catch { continue; }
if (!parsed.TryGetProperty("choices", out var choices)) continue;
foreach (var choice in choices.EnumerateArray())
{
// Finish reason
if (choice.TryGetProperty("finish_reason", out var finishProp) && finishProp.ValueKind != JsonValueKind.Null)
{
var fr = (finishProp.GetString() ?? "").ToLowerInvariant();
if (fr == "tool_calls" || fr == "function_call") stopReason = "tool_use";
else if (fr == "stop") stopReason = "end_turn";
}
if (!choice.TryGetProperty("delta", out var delta)) continue;
// Reasoning/Thinking-Delta (Nvidia Thinking-Modelle)
if (delta.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String)
{
var chunk = reasoningProp.GetString();
if (!string.IsNullOrEmpty(chunk))
{
if (!thinkingStarted)
{
await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt);
thinkingStarted = true;
}
thinkingText.Append(chunk);
await SseHelper.TryWriteSse(response, "thinking_delta", new { text = chunk }, sseCt);
}
}
// Text-Delta
if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String)
{
var chunk = contentProp.GetString();
if (!string.IsNullOrEmpty(chunk))
{
// Thinking-Block beenden falls noch offen
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(chunk);
await SseHelper.TryWriteSse(response, "text_delta", new { text = chunk }, sseCt);
}
}
// Tool-Call Deltas akkumulieren
if (delta.TryGetProperty("tool_calls", out var toolCallsProp))
{
foreach (var tc in toolCallsProp.EnumerateArray())
{
// thought_signature aus tc.extra_content.google.thought_signature (Gemini-spezifisch)
if (tc.TryGetProperty("extra_content", out var extraContent)
&& extraContent.TryGetProperty("google", out var googleContent)
&& googleContent.TryGetProperty("thought_signature", out var tcTsProp)
&& tcTsProp.ValueKind == JsonValueKind.String)
{
var ts = tcTsProp.GetString();
if (!string.IsNullOrEmpty(ts)) thoughtSignature = ts;
}
// ID aus diesem Chunk lesen (kann leer sein bei Folge-Deltas)
var chunkId = tc.TryGetProperty("id", out var idProp2) ? (idProp2.GetString() ?? "") : "";
// Schlüssel bestimmen: zuerst per id, dann per index
// → verhindert Kollisionen bei parallelen Tool-Calls ohne index (z.B. Gemini)
int idx;
if (!string.IsNullOrEmpty(chunkId))
{
// Prüfen ob diese ID schon einem Index zugeordnet ist
idx = -1;
foreach (var kv in toolCallAccumulators)
{
if (kv.Value.Id == chunkId) { idx = kv.Key; break; }
}
if (idx == -1)
{
// Neue ID → nächsten freien Index vergeben
idx = toolCallAccumulators.Count;
while (toolCallAccumulators.ContainsKey(idx)) idx++;
}
}
else
{
idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0;
}
if (!toolCallAccumulators.ContainsKey(idx))
toolCallAccumulators[idx] = ("", "", new StringBuilder(), null);
var (accId, accName, accArgs, accTs) = toolCallAccumulators[idx];
if (!string.IsNullOrEmpty(chunkId))
accId = chunkId;
if (tc.TryGetProperty("function", out var funcProp))
{
if (funcProp.TryGetProperty("name", out var nameProp2))
{
var n = nameProp2.GetString() ?? "";
if (!string.IsNullOrEmpty(n)) accName = n;
}
if (funcProp.TryGetProperty("arguments", out var argsProp))
accArgs.Append(argsProp.GetString() ?? "");
}
// Globalen thoughtSignature-Wert auch in accTs des ersten Slots übernehmen
if (!string.IsNullOrEmpty(thoughtSignature) && accTs == null && idx == 0)
accTs = thoughtSignature;
toolCallAccumulators[idx] = (accId, accName, accArgs, accTs);
}
}
}
// Usage aus letztem Chunk
if (parsed.TryGetProperty("usage", out var usageProp))
{
if (usageProp.TryGetProperty("prompt_tokens", out var pt))
usage.InputTokens = pt.GetInt64();
if (usageProp.TryGetProperty("completion_tokens", out var ct2))
usage.OutputTokens = ct2.GetInt64();
}
}
// 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);
// 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();
foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators)
{
// thought_signature pro Tool-Call mitschicken falls vorhanden
// Gemini erwartet sie in extra_content.google.thought_signature (nicht in function.thought_signature)
if (!string.IsNullOrEmpty(tcTs))
{
toolCallsForHistory.Add(new
{
id = tcId,
type = "function",
function = new { name = tcName, arguments = tcArgs.ToString() },
extra_content = new { google = new { thought_signature = tcTs } }
});
}
else
{
toolCallsForHistory.Add(new
{
id = tcId,
type = "function",
function = new { name = tcName, arguments = tcArgs.ToString() }
});
}
// In DB speichern – thought_signature als eigenes Feld
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "assistant", Type = "tool_use",
Content = tcArgs.ToString(),
ToolCallId = tcId, ToolName = tcName,
ThoughtSignature = tcTs
});
}
apiMessages.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(finalText) ? "" : finalText,
tool_calls = toolCallsForHistory
});
// Tool-Results sammeln
var toolResults = new List();
foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators)
{
// Tool-Start SSE
await SseHelper.TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt);
// Tool ausführen
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize(tcArgs.ToString()); }
catch { inputEl = JsonSerializer.Deserialize("{}"); }
var toolResult = await _toolService.ExecuteTool(tcName, inputEl, new ToolExecutionContext { Workspace = workspace });
var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:");
// Tool-End SSE
await SseHelper.TryWriteSse(response, "tool_end", new
{
name = tcName,
id = tcId,
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);
}
}
///
/// Non-Streaming Chat für Telegram, Cronjobs und send_to_session.
/// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop).
///
public async Task ChatNonStreaming(
ChatContext ctx,
List messages,
Func onIteration = null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
int iterations = 0;
var finalText = new StringBuilder();
var resolved = ResolveProviderConfig(ctx.ModelId, ctx.ApiToken);
if (resolved == null)
throw new Exception($"Unbekannter Provider: '{ParseModelString(ctx.ModelId).provider}'");
var (provider, modelId, providerConfig) = resolved.Value;
var chain = new ChainRecord
{
ChainId = ctx.ChainId,
SessionId = ctx.SessionId,
Model = ctx.ModelId
};
var apiMessages = BuildApiMessages(ctx.SystemPromptText, messages);
try
{
while (iterations < ctx.MaxIterations)
{
iterations++;
onIteration?.Invoke(iterations);
var client = CreateClient(providerConfig.ApiKey);
var payload = new Dictionary
{
["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(respBody);
// Usage tracken
if (resp.TryGetProperty("usage", out var usageProp))
{
if (usageProp.TryGetProperty("prompt_tokens", out var pt))
usage.InputTokens = pt.GetInt64();
if (usageProp.TryGetProperty("completion_tokens", out var ct2))
usage.OutputTokens = ct2.GetInt64();
}
// Erste Choice auswerten
var choices = resp.GetProperty("choices");
if (choices.GetArrayLength() == 0) break;
var choice = choices[0];
var message = choice.GetProperty("message");
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.GetArrayLength() == 0)
{
await FinalizeChain(chain, usage, iterations, "end_turn", sw);
break;
}
// Assistant-Message mit tool_calls in History
var assistantMsg = JsonSerializer.Deserialize(message.GetRawText());
apiMessages.Add(new
{
role = "assistant",
content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "",
tool_calls = JsonSerializer.Deserialize>(toolCallsProp.GetRawText())
});
// Tool-Calls ausführen
var toolResults = new List();
foreach (var tc in toolCallsProp.EnumerateArray())
{
var tcId = tc.GetProperty("id").GetString() ?? "";
var func = tc.GetProperty("function");
var tcName = func.GetProperty("name").GetString() ?? "";
var tcArgs = func.TryGetProperty("arguments", out var argsProp) ? argsProp.GetString() ?? "{}" : "{}";
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "assistant", Type = "tool_use",
Content = tcArgs,
ToolCallId = tcId, ToolName = tcName
});
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize(tcArgs); }
catch { inputEl = JsonSerializer.Deserialize("{}"); }
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(tcName, inputEl, new ToolExecutionContext { Workspace = ctx.Workspace }); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
toolResults.Add(new
{
role = "tool",
tool_call_id = tcId,
content = toolResult
});
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "tool", Type = "tool_result",
Content = toolResult,
ToolCallId = tcId, ToolName = tcName,
IsError = isError
});
}
foreach (var tr in toolResults)
apiMessages.Add(tr);
}
}
catch
{
if (iterations > 0)
await FinalizeChain(chain, usage, iterations, "error", sw);
throw;
}
return finalText.Length > 0 ? finalText.ToString() : "(Keine Textantwort)";
}
///
/// Konvertiert eine Anthropic-format Message in das OpenAI-format.
///
private static object ConvertMessage(object msg)
{
var json = JsonSerializer.Serialize(msg);
var el = JsonSerializer.Deserialize(json);
if (!el.TryGetProperty("role", out var roleProp)) return null;
var role = roleProp.GetString() ?? "";
if (!el.TryGetProperty("content", out var contentProp)) return null;
// Content als String
if (contentProp.ValueKind == JsonValueKind.String)
{
var text = contentProp.GetString() ?? "";
if (string.IsNullOrEmpty(text)) return null;
return new { role, content = text };
}
// Content als Array (Anthropic-Format mit Blöcken)
if (contentProp.ValueKind == JsonValueKind.Array)
{
var textParts = new StringBuilder();
var toolCalls = new List();
var toolResults = new List();
foreach (var block in contentProp.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeProp)) continue;
var blockType = typeProp.GetString() ?? "";
if (blockType == "text" && block.TryGetProperty("text", out var textProp))
{
textParts.Append(textProp.GetString());
}
else if (blockType == "tool_use")
{
// Anthropic tool_use → OpenAI tool_calls
var tcId = block.TryGetProperty("id", out var idProp) ? idProp.GetString() : "";
var tcName = block.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : "";
var tcInput = block.TryGetProperty("input", out var inputProp)
? JsonSerializer.Serialize(inputProp)
: "{}";
// thought_signature als eigenes Feld (vom DB-Record direkt in den Block übernommen)
var tcTs = block.TryGetProperty("thought_signature", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.String
? tsProp2.GetString() : null;
// Gemini erwartet thought_signature in extra_content.google.thought_signature
if (!string.IsNullOrEmpty(tcTs))
toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput }, extra_content = new { google = new { thought_signature = tcTs } } });
else
toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput } });
}
else if (blockType == "tool_result")
{
// Anthropic tool_result → OpenAI role: "tool"
var trId = block.TryGetProperty("tool_use_id", out var idProp) ? idProp.GetString() : "";
var trContent = "";
if (block.TryGetProperty("content", out var cProp))
{
if (cProp.ValueKind == JsonValueKind.String)
trContent = cProp.GetString() ?? "";
else if (cProp.ValueKind == JsonValueKind.Array)
{
foreach (var item in cProp.EnumerateArray())
if (item.TryGetProperty("text", out var t)) trContent += t.GetString();
}
}
toolResults.Add(new { role = "tool", tool_call_id = trId, content = trContent });
}
}
// Assistant-Message mit tool_calls
if (toolCalls.Count > 0)
{
var text = textParts.ToString().Trim();
return new
{
role = "assistant",
content = string.IsNullOrEmpty(text) ? "" : text,
tool_calls = toolCalls
};
}
// Tool-Results → als separate Objekte zurückgeben (werden im Caller verarbeitet)
if (toolResults.Count > 0)
return toolResults; // List wird im Aufrufer per OfType> erkannt
var combined = textParts.ToString().Trim();
if (string.IsNullOrEmpty(combined)) return null;
return new { role, content = combined };
}
return null;
}
///
/// Löst Provider-Config auf: Provider-Name, Model-ID, BaseUrl + ApiKey.
/// Berücksichtigt API-Key-Override und dynamische Gemini-Keys.
///
private (string provider, string modelId, (string BaseUrl, string ApiKey) config)? ResolveProviderConfig(string modelString, string apiKeyOverride = null)
{
var (provider, modelId) = ParseModelString(modelString);
if (!Providers.TryGetValue(provider, out var providerConfig))
return null;
if (!string.IsNullOrEmpty(apiKeyOverride))
providerConfig = (providerConfig.BaseUrl, apiKeyOverride);
if (provider == "nvidia" && string.IsNullOrEmpty(providerConfig.ApiKey))
{
var nvidiaKey = _settings.Get("NvidiaApiKey");
if (!string.IsNullOrEmpty(nvidiaKey))
providerConfig = ("https://integrate.api.nvidia.com/v1", nvidiaKey);
}
if (provider == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey))
providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey"));
return (provider, modelId, providerConfig);
}
///
/// Prüft ob ein Nvidia-Modell Thinking-Mode unterstützt (chat_template_kwargs).
///
private static bool IsNvidiaThinkingModel(string modelId)
{
// Modelle mit Extended-Thinking-Support bei Nvidia
return modelId.Contains("kimi-k2", StringComparison.OrdinalIgnoreCase)
|| modelId.Contains("qwq", StringComparison.OrdinalIgnoreCase)
|| modelId.Contains("deepseek-r1", StringComparison.OrdinalIgnoreCase);
}
///
/// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format.
///
private static List BuildApiMessages(string systemPromptText, List messages)
{
var apiMessages = new List();
if (!string.IsNullOrEmpty(systemPromptText))
apiMessages.Add(new { role = "system", content = systemPromptText });
foreach (var msg in messages)
{
var converted = ConvertMessage(msg);
if (converted == null) continue;
if (converted is List toolResultList)
{
foreach (var tr in toolResultList)
apiMessages.Add(tr);
continue;
}
apiMessages.Add(converted);
}
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
}