feat: OpenAI-kompatibler Provider (Nvidia/g4f.space) mit Tool-Calling und SSE-Streaming

- OpenAiService.cs: Neuer Service für OpenAI-kompatible APIs
  - SSE-Streaming mit text_start/text_delta/text_end Events
  - Tool-Calling: list_files, read_file, write_file, edit_file, delete_file, execute
  - Agentic Loop (max 10 Iterationen)
  - Tool-Use + Tool-Result in DB gespeichert
  - History-Rebuild: Anthropic-Format → OpenAI-Format (tool_use/tool_result korrekt konvertiert)
- ChatContext.cs: Provider-Feld hinzugefügt
- ChatService.cs: OpenAI-Provider-Erkennung in ResolveModelId + PrepareContext
- ChatEndpoint.cs: Provider-Dispatch (nvidia → OpenAiService, sonst → AnthropicService)
- Program.cs: OpenAiService als Singleton registriert
- wwwroot/index.html: nvidia:moonshotai/kimi-k2.5 in Modell-Auswahl
This commit is contained in:
Meleki committed 2026-03-11 16:15:09 +01:00
1 parent a646e695cd
commit 7972ca6a3a
6 files changed
+665 -2

No files matched your search

+11 -2
View File
@@ -22,7 +22,7 @@ public static class ChatEndpoint
app.MapPost("/api/chat/stop", HandleStop);
}
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken)
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, OpenAiService openAi, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken)
{
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null)
@@ -208,7 +208,16 @@ public static class ChatEndpoint
try
{
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr);
if (!string.IsNullOrEmpty(ctx.Provider))
{
// OpenAI-kompatibler Provider (z.B. Nvidia)
await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, workCt: workCt, sseCt: sseCt);
}
else
{
// Standard: Anthropic
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr);
}
}
finally
{
+1
View File
@@ -8,6 +8,7 @@ public class ChatContext
public string ChainId { get; set; }
public string SessionId { get; set; }
public string ModelId { get; set; }
public string Provider { get; set; } // "" = Anthropic, "nvidia" = OpenAI-kompatibler Provider
public string Workspace { get; set; }
public string AgentId { get; set; }
public string SystemPromptText { get; set; }
+4
View File
@@ -68,6 +68,10 @@ builder.Services.AddSingleton<AnthropicService>(sp =>
return svc;
});
// OpenAI-kompatibler Service (Nvidia etc.)
builder.Services.AddSingleton<OpenAiService>(sp =>
new OpenAiService(sp.GetRequiredService<IHttpClientFactory>(), dbService, sp.GetRequiredService<ToolService>()));
// Telegram Bot (pro Session – Token aus Session-Config)
var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100);
builder.Services.AddSingleton<TelegramService>(sp =>
+6
View File
@@ -30,6 +30,9 @@ public class ChatService
public static string ResolveModelId(string model)
{
// OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model")
if (OpenAiService.IsOpenAiProvider(model)) return model;
return model switch
{
"sonnet" => "claude-sonnet-4-6",
@@ -160,11 +163,14 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3;
var (provider, _) = OpenAiService.ParseModelString(modelId);
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = modelId,
Provider = provider,
Workspace = workspace,
AgentId = agent.AgentId,
SystemPromptText = systemPromptText,
+640
View File
@@ -0,0 +1,640 @@
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", "")
};
// 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 OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService)
{
_httpFactory = httpFactory;
_db = db;
_toolService = toolService;
}
/// <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 { }
}
}
+3
View File
@@ -191,6 +191,9 @@
<option value="opus-4-5">Opus 4.5</option>
<option value="opus-4-6" selected>Opus 4.6</option>
<option value="haiku-4-5">Haiku 4.5</option>
<optgroup label="Nvidia (via g4f.space)">
<option value="nvidia:moonshotai/kimi-k2.5">Kimi K2.5 (Moonshot)</option>
</optgroup>
</select>
<div class="more">
<a>Weitere Einstellungen</a>