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:
1 parent
f3069cedf7
commit
3e19da4d48
12 files changed
+428
-487
No files matched your search
+86
-175
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user