Files
gott/Services/OpenAiService.cs
T
Meleki 5ed910edcd feat: Dynamisches Modell-Laden für Nvidia & Pollinations Provider
- OpenAiService: FetchModels() generisch für beide Provider (OpenAI-Format & Pollinations-Array-Format)
- Pollinations Base-URL direkt auf text.pollinations.ai/openai (kein g4f.space Proxy → kein 402)
- Pollinations Modelle-URL: text.pollinations.ai/models
- ModelsEndpoint: /api/models/nvidia + /api/models/pollinations
- agent-page.js: loadProviderModels() generisch, Promise.all für beide Provider parallel
- index.html: statische Nvidia-Option entfernt, beide Optgroups mit 'Wird geladen...'
2026-03-11 16:43:08 +01:00

702 lines
28 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.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-Definitionen
private static readonly Dictionary<string, (string BaseUrl, string ApiKey)> Providers = new()
{
["nvidia"] = ("https://g4f.space/api/nvidia/v1", ""),
["pollinations"] = ("https://text.pollinations.ai/openai", "")
};
// Tools die für OpenAI-kompatible Provider verfügbar sind
private static readonly List<object> OpenAiTools = new()
{
new
{
type = "function",
function = new
{
name = "list_files",
description = "Listet Dateien und Ordner in einem Verzeichnis auf.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" }
},
required = Array.Empty<string>()
}
}
},
new
{
type = "function",
function = new
{
name = "read_file",
description = "Liest den Inhalt einer Datei. Standardmäßig werden maximal 2000 Zeilen gelesen. Bei großen Dateien offset und limit verwenden.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Dateipfad" },
["offset"] = new { type = "integer", description = "Erste Zeile die gelesen werden soll (1-basiert, Default: 1)" },
["limit"] = new { type = "integer", description = "Maximale Anzahl Zeilen die gelesen werden sollen (Default: 2000)" }
},
required = new[] { "path" }
}
}
},
new
{
type = "function",
function = new
{
name = "write_file",
description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Dateipfad" },
["content"] = new { type = "string", description = "Dateiinhalt" }
},
required = new[] { "path", "content" }
}
}
},
new
{
type = "function",
function = new
{
name = "edit_file",
description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Dateipfad" },
["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" },
["new_text"] = new { type = "string", description = "Neuer Text" }
},
required = new[] { "path", "old_text", "new_text" }
}
}
},
new
{
type = "function",
function = new
{
name = "delete_file",
description = "Löscht eine Datei oder ein leeres Verzeichnis.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" }
},
required = new[] { "path" }
}
}
},
new
{
type = "function",
function = new
{
name = "execute",
description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" }
},
required = new[] { "command" }
}
}
}
};
public record ProviderModel(string Id, string DisplayName);
private List<ProviderModel> _nvidiaModels = new List<ProviderModel>();
private List<ProviderModel> _pollinationsModels = new List<ProviderModel>();
public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService)
{
_httpFactory = httpFactory;
_db = db;
_toolService = toolService;
}
/// <summary>
/// Lädt verfügbare Modelle aller Provider beim Server-Start und cached sie.
/// </summary>
public async Task LoadModelsAsync()
{
_nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia");
_pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations");
}
private async Task<List<ProviderModel>> FetchModels(string url, string providerName)
{
try
{
var client = CreateClient("");
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;
/// <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 Providers.ContainsKey(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,
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 (provider, modelId) = ParseModelString(modelString);
if (!Providers.TryGetValue(provider, out var providerConfig))
{
var errMsg = $"Unbekannter Provider: '{provider}'";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await TryWriteSse(response, "done", new { chainId }, sseCt);
return;
}
try
{
// OpenAI-kompatibles Messages-Format aufbauen
var apiMessages = new List<object>();
// System-Prompt als erste System-Message
if (!string.IsNullOrEmpty(systemPromptText))
apiMessages.Add(new { role = "system", content = systemPromptText });
// History-Messages konvertieren (Anthropic-Format → OpenAI-Format)
foreach (var msg in messages)
{
var converted = ConvertMessage(msg);
if (converted == null) continue;
// Tool-Results kommen als List<object> zurück → einzeln expandieren
if (converted is List<object> toolResultList)
{
foreach (var tr in toolResultList)
apiMessages.Add(tr);
continue;
}
apiMessages.Add(converted);
}
// Agentic Loop
while (iteration < maxIterations)
{
workCt.ThrowIfCancellationRequested();
iteration++;
var client = CreateClient(providerConfig.ApiKey);
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["messages"] = apiMessages,
["stream"] = true,
["max_tokens"] = 16384,
["tools"] = OpenAiTools,
["tool_choice"] = "auto"
};
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 TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await FinalizeChain(chain, usage, iteration, "error", sw);
await 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();
// Tool-Call Akkumulatoren: id → (name, arguments)
var toolCallAccumulators = new Dictionary<int, (string Id, string Name, StringBuilder Args)>();
var textStarted = 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<JsonElement>(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() ?? "";
if (fr == "tool_calls") stopReason = "tool_use";
else if (fr == "stop") stopReason = "end_turn";
}
if (!choice.TryGetProperty("delta", out var delta)) continue;
// Text-Delta
if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String)
{
var chunk = contentProp.GetString();
if (!string.IsNullOrEmpty(chunk))
{
if (!textStarted)
{
await TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
textStarted = true;
}
fullText.Append(chunk);
await 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())
{
var idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0;
if (!toolCallAccumulators.ContainsKey(idx))
toolCallAccumulators[idx] = ("", "", new StringBuilder());
var (accId, accName, accArgs) = toolCallAccumulators[idx];
if (tc.TryGetProperty("id", out var idProp))
accId = idProp.GetString() ?? accId;
if (tc.TryGetProperty("function", out var funcProp))
{
if (funcProp.TryGetProperty("name", out var nameProp2))
accName = nameProp2.GetString() ?? accName;
if (funcProp.TryGetProperty("arguments", out var argsProp))
accArgs.Append(argsProp.GetString() ?? "");
}
toolCallAccumulators[idx] = (accId, accName, accArgs);
}
}
}
// 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();
}
}
// Text-End senden falls Text gestreamt wurde
if (textStarted)
await TryWriteSse(response, "text_end", new { }, sseCt);
// 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
if (toolCallAccumulators.Count == 0 || stopReason != "tool_use")
{
await FinalizeChain(chain, usage, iteration, stopReason, sw);
await 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)) in toolCallAccumulators)
{
toolCallsForHistory.Add(new
{
id = tcId,
type = "function",
function = new { name = tcName, arguments = tcArgs.ToString() }
});
// Tool-Use in DB speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "assistant", Type = "tool_use",
Content = tcArgs.ToString(),
ToolCallId = tcId, ToolName = tcName
});
}
apiMessages.Add(new
{
role = "assistant",
content = finalText,
tool_calls = toolCallsForHistory
});
// Tool-Results sammeln
var toolResults = new List<object>();
foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators)
{
// Tool-Start SSE
await 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, workspace);
var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:");
// Tool-End SSE
await 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 TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
await FinalizeChain(chain, usage, iteration, "max_iterations", sw);
await TryWriteSse(response, "done", new { chainId }, sseCt);
}
catch (OperationCanceledException)
{
await FinalizeChain(chain, usage, iteration, "stopped", sw);
await 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 TryWriteSse(response, "error", new { error = errMsg }, sseCt);
await FinalizeChain(chain, usage, iteration, "error", sw);
await TryWriteSse(response, "done", new { chainId }, sseCt);
}
}
/// <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)
: "{}";
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) ? (object)null : 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;
}
private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
{
chain.InputTokens = usage.InputTokens;
chain.OutputTokens = usage.OutputTokens;
chain.CacheCreationTokens = usage.CacheCreationTokens;
chain.CacheReadTokens = usage.CacheReadTokens;
chain.Iterations = iterations;
chain.StopReason = stopReason;
chain.DurationMs = sw.ElapsedMilliseconds;
await _db.UpdateChain(chain);
}
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;
}
private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct)
{
try
{
if (!ct.IsCancellationRequested)
await AnthropicService.WriteSseEvent(response, eventType, data, ct);
}
catch { }
}
}