refactor: Backend-Deduplizierung – Parameter-Objekte, Shared Helpers, KeyCheckDispatcher

- ToolDefinitionOptions + ToolExecutionContext ersetzen lange Parameterlisten in ToolService
- AppendToLastMessage-Helper in ChatService eliminiert 5x duplizierte Logik
- OpenAiService: ResolveProviderConfig + BuildApiMessages + ConvertToOpenAiTools statt doppeltem Code
- Tool-Definitionen aus einer Quelle (Anthropic→OpenAI Konvertierung statt hardcodierter Liste)
- KeyCheckDispatcher zentralisiert Key-Check-Dispatch (ApiKeysEndpoint, KeyScanner, GitHubEventScanner)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.6 committed 2026-03-19 08:01:49 +01:00
1 parent f3069cedf7
commit 3e19da4d48
12 files changed
+428 -487

No files matched your search

+86 -175
View File
@@ -30,123 +30,38 @@ public class OpenAiService
// 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
private static readonly List<object> OpenAiTools = new()
// Tools die für OpenAI-kompatible Provider verfügbar sind (konvertiert aus ToolService Anthropic-Format)
private List<object> _openAiToolDefs;
/// <summary>
/// Konvertiert Anthropic-Format Tool-Definitionen (input_schema) in OpenAI-Format (function/parameters).
/// Single source of truth: ToolService.GetToolDefinitions()
/// </summary>
private static List<object> ConvertTo_openAiToolDefs(object[] anthropicTools)
{
new
var result = new List<object>();
foreach (var tool in anthropicTools)
{
type = "function",
function = new
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
{
name = "list_files",
description = "Listet Dateien und Ordner in einem Verzeichnis auf.",
parameters = new
type = "function",
function = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" }
},
required = Array.Empty<string>()
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
}
}
},
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" }
}
}
});
}
};
return result;
}
public record ProviderModel(string Id, string DisplayName);
@@ -172,6 +87,9 @@ public class OpenAiService
["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 }));
}
/// <summary>
@@ -347,10 +265,10 @@ public class OpenAiService
Model = modelString
};
var (provider, modelId) = ParseModelString(modelString);
if (!Providers.TryGetValue(provider, out var providerConfig))
var resolved = ResolveProviderConfig(modelString, apiKeyOverride);
if (resolved == null)
{
var errMsg = $"Unbekannter Provider: '{provider}'";
var errMsg = $"Unbekannter Provider: '{ParseModelString(modelString).provider}'";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
@@ -362,39 +280,11 @@ public class OpenAiService
return;
}
// API-Key Override: User-Key aus DB hat Vorrang vor hardcoded Provider-Key
if (!string.IsNullOrEmpty(apiKeyOverride))
providerConfig = (providerConfig.BaseUrl, apiKeyOverride);
// Gemini: Key dynamisch aus ServerSettings holen (falls nicht per Override gesetzt)
if (provider == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey))
providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey"));
var (provider, modelId, providerConfig) = resolved.Value;
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);
}
var apiMessages = BuildApiMessages(systemPromptText, messages);
// Agentic Loop
while (iteration < maxIterations)
@@ -409,7 +299,7 @@ public class OpenAiService
["messages"] = apiMessages,
["stream"] = true,
["max_tokens"] = 16384,
["tools"] = OpenAiTools,
["tools"] = _openAiToolDefs,
["tool_choice"] = "auto"
};
@@ -665,7 +555,7 @@ public class OpenAiService
try { inputEl = JsonSerializer.Deserialize<JsonElement>(tcArgs.ToString()); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
var toolResult = await _toolService.ExecuteTool(tcName, inputEl, workspace);
var toolResult = await _toolService.ExecuteTool(tcName, inputEl, new ToolExecutionContext { Workspace = workspace });
var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:");
// Tool-End SSE
@@ -746,17 +636,11 @@ public class OpenAiService
int iterations = 0;
var finalText = new StringBuilder();
var (provider, modelId) = ParseModelString(ctx.ModelId);
if (!Providers.TryGetValue(provider, out var providerConfig))
throw new Exception($"Unbekannter Provider: '{provider}'");
var resolved = ResolveProviderConfig(ctx.ModelId, ctx.ApiToken);
if (resolved == null)
throw new Exception($"Unbekannter Provider: '{ParseModelString(ctx.ModelId).provider}'");
// API-Key Override: User-Key hat Vorrang
if (!string.IsNullOrEmpty(ctx.ApiToken))
providerConfig = (providerConfig.BaseUrl, ctx.ApiToken);
// Gemini: Key dynamisch aus ServerSettings
if (provider == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey))
providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey"));
var (provider, modelId, providerConfig) = resolved.Value;
var chain = new ChainRecord
{
@@ -765,23 +649,7 @@ public class OpenAiService
Model = ctx.ModelId
};
// Messages in OpenAI-Format konvertieren
var apiMessages = new List<object>();
if (!string.IsNullOrEmpty(ctx.SystemPromptText))
apiMessages.Add(new { role = "system", content = ctx.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);
}
var apiMessages = BuildApiMessages(ctx.SystemPromptText, messages);
try
{
@@ -797,7 +665,7 @@ public class OpenAiService
["messages"] = apiMessages,
["stream"] = false,
["max_tokens"] = 16384,
["tools"] = OpenAiTools,
["tools"] = _openAiToolDefs,
["tool_choice"] = "auto"
};
@@ -896,7 +764,7 @@ public class OpenAiService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(tcName, inputEl, ctx.Workspace); }
try { toolResult = await _toolService.ExecuteTool(tcName, inputEl, new ToolExecutionContext { Workspace = ctx.Workspace }); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
toolResults.Add(new
@@ -1028,6 +896,49 @@ public class OpenAiService
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 == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey))
providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey"));
return (provider, modelId, providerConfig);
}
/// <summary>
/// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format.
/// </summary>
private static List<object> BuildApiMessages(string systemPromptText, List<object> messages)
{
var apiMessages = new List<object>();
if (!string.IsNullOrEmpty(systemPromptText))
apiMessages.Add(new { role = "system", content = systemPromptText });
foreach (var msg in messages)
{
var converted = ConvertMessage(msg);
if (converted == null) continue;
if (converted is List<object> toolResultList)
{
foreach (var tr in toolResultList)
apiMessages.Add(tr);
continue;
}
apiMessages.Add(converted);
}
return apiMessages;
}
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw);