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

+42 -89
View File
@@ -182,72 +182,56 @@ public static class ApiKeysEndpoint
return;
}
switch (existing.Provider)
var check = await KeyCheckDispatcher.CheckByProvider(httpFactory, existing.Provider, existing.ApiKey);
if (check != null)
{
case "anthropic":
await db.UpdateApiKeyMeta(keyId, check.DbStatus, check.Plan, check.Usage7d, check.Usage5h);
// Provider-spezifische Response-Details
if (check.ProviderResult is KeyCheckResult anthropic)
{
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
await ctx.Response.WriteAsJsonAsync(new
{
status = dbStatus,
message = result.Message,
plan = result.Plan,
result.IsSubscription,
result.Usage7d,
result.Usage5h,
result.Overage,
status7d = result.Status7d,
status5h = result.Status5h,
statusOverage = result.StatusOverage
status = check.DbStatus,
message = check.Message,
plan = check.Plan,
anthropic.IsSubscription,
anthropic.Usage7d,
anthropic.Usage5h,
anthropic.Overage,
status7d = anthropic.Status7d,
status5h = anthropic.Status5h,
statusOverage = anthropic.StatusOverage
});
break;
}
case "openai":
else if (check.ProviderResult is StripeCheckResult stripe)
{
var result = await OpenAiKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Message, null, null);
await ctx.Response.WriteAsJsonAsync(new { status = dbStatus, message = result.Message, plan = result.Message });
break;
}
case "google":
{
var result = await GoogleKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Message, null, null);
await ctx.Response.WriteAsJsonAsync(new { status = dbStatus, message = result.Message, plan = result.Message });
break;
}
case "stripe":
{
var result = await StripeKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Message, null, null);
await ctx.Response.WriteAsJsonAsync(new {
status = dbStatus,
message = result.Message,
plan = result.IsLiveKey ? "Live" : "Test",
result.AccountId,
result.AccountName,
result.AccountEmail,
result.AccountCountry,
result.Currency,
result.BalanceAvailable,
result.BalancePending,
result.ChargesEnabled,
result.PayoutsEnabled
await ctx.Response.WriteAsJsonAsync(new
{
status = check.DbStatus,
message = check.Message,
plan = check.Plan,
stripe.AccountId,
stripe.AccountName,
stripe.AccountEmail,
stripe.AccountCountry,
stripe.Currency,
stripe.BalanceAvailable,
stripe.BalancePending,
stripe.ChargesEnabled,
stripe.PayoutsEnabled
});
break;
}
default:
else
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = $"Check für Provider '{existing.Provider}' nicht unterstützt" });
break;
await ctx.Response.WriteAsJsonAsync(new { status = check.DbStatus, message = check.Message, plan = check.Plan });
}
}
else
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = $"Check für Provider '{existing.Provider}' nicht unterstützt" });
}
}
private static async Task HandleCheckAll(HttpContext ctx, IHttpClientFactory httpFactory, DatabaseService db)
@@ -259,41 +243,10 @@ public static class ApiKeysEndpoint
foreach (var key in allKeys)
{
switch (key.Provider)
{
case "anthropic":
{
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
results.Add(new { key.KeyId, key.Name, status = dbStatus, result.Plan, result.Usage5h, result.Usage7d });
break;
}
case "openai":
{
var result = await OpenAiKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Message, null, null);
results.Add(new { key.KeyId, key.Name, status = dbStatus, Plan = result.Message, Usage5h = (double?)null, Usage7d = (double?)null });
break;
}
case "google":
{
var result = await GoogleKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Message, null, null);
results.Add(new { key.KeyId, key.Name, status = dbStatus, Plan = result.Message, Usage5h = (double?)null, Usage7d = (double?)null });
break;
}
case "stripe":
{
var result = await StripeKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Message, null, null);
results.Add(new { key.KeyId, key.Name, status = dbStatus, Plan = result.IsLiveKey ? "Live" : "Test", Usage5h = (double?)null, Usage7d = (double?)null });
break;
}
}
var check = await KeyCheckDispatcher.CheckByProvider(httpFactory, key.Provider, key.ApiKey);
if (check == null) continue;
await db.UpdateApiKeyMeta(key.KeyId, check.DbStatus, check.Plan, check.Usage7d, check.Usage5h);
results.Add(new { key.KeyId, key.Name, status = check.DbStatus, check.Plan, check.Usage5h, check.Usage7d });
}
await ctx.Response.WriteAsJsonAsync(new { checked_ = results.Count, results });
+6 -6
View File
@@ -298,12 +298,12 @@ public static class SessionEndpoint
var tools = new List<object>();
if (hasTools)
{
var allTools = toolService.GetToolDefinitions(
includeFileTools: hasWorkspace,
includeMemory: includeMemory,
includeUserChoice: agent.AllowUserChoice,
includeGitHubSearch: includeGitHubSearch,
includeSerperSearch: includeSerperSearch);
var allTools = toolService.GetToolDefinitions(new ToolDefinitionOptions
{
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
IncludeUserChoice = agent.AllowUserChoice,
IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch
});
if (planMode)
allTools = toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
+12 -4
View File
@@ -538,7 +538,12 @@ public partial class AnthropicService
{
toolResult = cb.ExecuteTool != null
? await cb.ExecuteTool(toolName, inputEl, ct)
: await _toolService.ExecuteTool(toolName, inputEl, toolOpts.Workspace, _memoryService, toolOpts.AgentId, ct, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg);
: await _toolService.ExecuteTool(toolName, inputEl, new ToolExecutionContext
{
Workspace = toolOpts.Workspace, MemoryService = _memoryService,
AgentId = toolOpts.AgentId, CancellationToken = ct,
VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg
});
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -656,9 +661,12 @@ public partial class AnthropicService
OnIteration = onIteration,
EnqueueEmbeddings = true,
ExecuteTool = (name, input, ct) =>
_toolService.ExecuteTool(name, input, ctx.Workspace, _memoryService, ctx.AgentId, ct,
vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db,
chatService: _chatService, anthropicService: this, openAiService: _openAiService)
_toolService.ExecuteTool(name, input, new ToolExecutionContext
{
Workspace = ctx.Workspace, MemoryService = _memoryService, AgentId = ctx.AgentId,
CancellationToken = ct, VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg,
Db = _db, ChatService = _chatService, AnthropicService = this, OpenAiService = _openAiService
})
});
var finalStopReason = text.Length > 0 ? "end_turn" : "error";
+8 -1
View File
@@ -589,7 +589,14 @@ public partial class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, userId: userId, telegramService: _telegramService, apiToken: apiToken); }
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, new ToolExecutionContext
{
Workspace = workspace, MemoryService = _memoryService, AgentId = agentId,
CancellationToken = workCt, RemoteMgr = remoteMgr, SessionId = sessionId,
VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg, Db = _db,
ChatService = _chatService, AnthropicService = this, OpenAiService = _openAiService,
UserId = userId, TelegramService = _telegramService, ApiToken = apiToken
}); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
+7 -1
View File
@@ -261,7 +261,13 @@ public partial class AnthropicService
var tools = new List<object>();
if (hasTools)
{
var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: opts.AllowUserChoice, includeRemote: opts.IncludeRemote, includeMemorySearch: includeMemorySearch, includeSendToSession: includeSendToSession, includeGitHubSearch: opts.IncludeGitHubSearch, includeSerperSearch: opts.IncludeSerperSearch);
var allTools = _toolService.GetToolDefinitions(new ToolDefinitionOptions
{
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
IncludeUserChoice = opts.AllowUserChoice, IncludeRemote = opts.IncludeRemote,
IncludeMemorySearch = includeMemorySearch, IncludeSendToSession = includeSendToSession,
IncludeGitHubSearch = opts.IncludeGitHubSearch, IncludeSerperSearch = opts.IncludeSerperSearch
});
if (opts.PlanMode)
allTools = _toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
+31 -79
View File
@@ -86,7 +86,12 @@ public class ChatService
var toolChoice = hasTools ? "any" : null;
var toolDefs = hasTools
? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection, includeGitHubSearch: includeGitHubSearch, includeSerperSearch: includeSerperSearch)
? _toolService.GetToolDefinitions(new ToolDefinitionOptions
{
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
IncludeUserChoice = agent.AllowUserChoice, IncludeRemote = hasRemoteConnection,
IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch
})
: Array.Empty<object>();
var toolNames = toolDefs.Length > 0
? JsonSerializer.Serialize(toolDefs.Select(td =>
@@ -370,21 +375,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
? (object)new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl, thought_signature = msg.ThoughtSignature }
: new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl };
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
if (lastAssistant is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(toolUseBlock);
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { toolUseBlock }
});
}
AppendToLastMessage(history, "assistant", toolUseBlock);
}
else if (msg.Role == "assistant" && msg.Type == "server_tool_use")
{
@@ -393,21 +384,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
try { useEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
catch { continue; }
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
if (lastAssistant is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(useEl);
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { useEl }
});
}
AppendToLastMessage(history, "assistant", useEl);
}
else if (msg.Role == "assistant" && msg.Type == "server_tool_result")
{
@@ -417,21 +394,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
try { resultEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
catch { continue; }
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
if (lastAssistant is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(resultEl);
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { resultEl }
});
}
AppendToLastMessage(history, "assistant", resultEl);
}
else if (msg.Role == "assistant" && msg.Type == "web_search_result")
{
@@ -445,42 +408,12 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
// Diese überspringen, da sie kein gültiger web_search_tool_result-Block sind
if (resultEl.ValueKind == JsonValueKind.Array) continue;
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
if (lastAssistant is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(resultEl);
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { resultEl }
});
}
AppendToLastMessage(history, "assistant", resultEl);
}
else if (msg.Role == "user" && msg.Type == "tool_result")
{
var lastUser = history.Count > 0 ? history[history.Count - 1] : null;
if (lastUser is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "user" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError });
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object>
{
new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }
}
});
}
var toolResultBlock = new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError };
AppendToLastMessage(history, "user", toolResultBlock);
}
}
}
@@ -630,6 +563,25 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
return history;
}
private static void AppendToLastMessage(List<object> history, string role, object block)
{
var last = history.Count > 0 ? history[history.Count - 1] : null;
if (last is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var r) && r.ToString() == role &&
dict.TryGetValue("content", out var existing) && existing is List<object> contentList)
{
contentList.Add(block);
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = role,
["content"] = new List<object> { block }
});
}
}
/// <summary>
/// Entfernt verwaiste web_search-Blöcke aus assistant-Messages:
/// - server_tool_use ohne zugehörigen web_search_tool_result (z.B. alte DB-Einträge)
+7 -22
View File
@@ -414,29 +414,14 @@ public class GitHubEventScannerService : BackgroundService
try
{
if (KeyPatterns.IsAnthropic(type))
var check = await KeyCheckDispatcher.CheckByKeyType(_httpFactory, type, sk.ApiKey);
if (check != null)
{
var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h);
var checkMsg = $"🔍 {typeName} geprüft: {status} · Plan: {result.Plan} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, plan = result.Plan, usage_7d = result.Usage7d, usage_5h = result.Usage5h, source_url = sk.SourceUrl }));
}
else if (type == "openai_project" || type == "openai_service" || type == "openai_legacy")
{
var result = await OpenAiKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else if (type == "google_api")
{
var result = await GoogleKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, check.DbStatus, check.Plan, check.Usage7d, check.Usage5h);
var checkMsg = check.Usage7d.HasValue
? $"🔍 {typeName} geprüft: {check.DbStatus} · Plan: {check.Plan} · {keyPrefix}"
: $"🔍 {typeName} geprüft: {check.DbStatus} · {check.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status = check.DbStatus, plan = check.Plan, usage_7d = check.Usage7d, usage_5h = check.Usage5h, message = check.Message, source_url = sk.SourceUrl }));
}
else
{
+96
View File
@@ -0,0 +1,96 @@
using System.Net.Http;
using System.Threading.Tasks;
namespace Gott.Services;
/// <summary>
/// Normalisiertes Key-Check-Ergebnis für alle Provider.
/// Provider-spezifische Details über ProviderResult (cast zum konkreten Typ).
/// </summary>
public class UnifiedKeyCheckResult
{
/// <summary>Normalisierter DB-Status: "valid", "invalid", "rate_limited", "quota_exceeded", "error"</summary>
public string DbStatus { get; set; }
public string Message { get; set; }
public string Plan { get; set; }
public double? Usage7d { get; set; }
public double? Usage5h { get; set; }
/// <summary>Original-Result des Providers (KeyCheckResult, OpenAiCheckResult, etc.)</summary>
public object ProviderResult { get; set; }
}
/// <summary>
/// Zentraler Dispatcher für Key-Checks aller Provider.
/// Normalisiert den Status und gibt ein einheitliches Ergebnis zurück.
/// Eliminiert die wiederholten switch/if-Ketten in ApiKeysEndpoint, KeyScannerService, GitHubEventScannerService.
/// </summary>
public static class KeyCheckDispatcher
{
/// <summary>
/// Prüft einen API-Key anhand des Providers.
/// Provider: "anthropic", "openai", "google", "stripe".
/// Gibt null zurück bei unbekanntem Provider.
/// </summary>
public static async Task<UnifiedKeyCheckResult> CheckByProvider(IHttpClientFactory httpFactory, string provider, string apiKey)
{
switch (provider)
{
case "anthropic":
{
var r = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, apiKey);
return new UnifiedKeyCheckResult
{
DbStatus = (r.Status == "ok" || r.Status == "usage_limited") ? "valid" : r.Status,
Message = r.Message, Plan = r.Plan,
Usage7d = r.Usage7d, Usage5h = r.Usage5h,
ProviderResult = r
};
}
case "openai":
{
var r = await OpenAiKeyChecker.CheckKeyAsync(httpFactory, apiKey);
return new UnifiedKeyCheckResult
{
DbStatus = r.Status == "ok" ? "valid" : r.Status,
Message = r.Message, Plan = r.Message,
ProviderResult = r
};
}
case "google":
{
var r = await GoogleKeyChecker.CheckKeyAsync(httpFactory, apiKey);
return new UnifiedKeyCheckResult
{
DbStatus = r.Status == "ok" ? "valid" : r.Status,
Message = r.Message, Plan = r.Message,
ProviderResult = r
};
}
case "stripe":
{
var r = await StripeKeyChecker.CheckKeyAsync(httpFactory, apiKey);
return new UnifiedKeyCheckResult
{
DbStatus = r.Status == "ok" ? "valid" : r.Status,
Message = r.Message, Plan = r.IsLiveKey ? "Live" : "Test",
ProviderResult = r
};
}
default:
return null;
}
}
/// <summary>
/// Prüft einen API-Key anhand des Key-Typs (z.B. "openai_project", "anthropic_oauth").
/// Nutzt KeyPatterns.GetCheckerProvider() für die Zuordnung.
/// Gibt null zurück wenn kein Checker für den Typ existiert.
/// </summary>
public static Task<UnifiedKeyCheckResult> CheckByKeyType(IHttpClientFactory httpFactory, string keyType, string apiKey)
{
var provider = KeyPatterns.GetCheckerProvider(keyType);
if (provider == null) return Task.FromResult<UnifiedKeyCheckResult>(null);
return CheckByProvider(httpFactory, provider, apiKey);
}
}
+15
View File
@@ -1,6 +1,8 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Gott.Services;
@@ -102,6 +104,19 @@ public static class KeyPatterns
return type == "anthropic_oauth" || type == "anthropic_api" || type == "anthropic_unknown";
}
/// <summary>
/// Mappt einen Key-Typ auf den Provider-Namen für den Checker.
/// Gibt null zurück wenn kein Checker verfügbar ist.
/// </summary>
public static string GetCheckerProvider(string type)
{
if (IsAnthropic(type)) return "anthropic";
if (type == "openai_project" || type == "openai_service" || type == "openai_legacy") return "openai";
if (type == "google_api") return "google";
if (type == "stripe_live" || type == "stripe_test") return "stripe";
return null;
}
/// <summary>Menschenlesbarer Name für den Key-Typ.</summary>
public static string DisplayName(string type)
{
+7 -30
View File
@@ -965,37 +965,14 @@ public class KeyScannerService : BackgroundService
try
{
if (KeyPatterns.IsAnthropic(type))
var check = await KeyCheckDispatcher.CheckByKeyType(_httpFactory, type, sk.ApiKey);
if (check != null)
{
var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h);
var checkMsg = $"🔍 {typeName} geprüft: {status} · Plan: {result.Plan} · Usage 7d: {result.Usage7d?.ToString("F2") ?? "n/a"} · Usage 5h: {result.Usage5h?.ToString("F2") ?? "n/a"} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, plan = result.Plan, usage_7d = result.Usage7d, usage_5h = result.Usage5h, source_url = sk.SourceUrl }));
}
else if (type == "openai_project" || type == "openai_service" || type == "openai_legacy")
{
var result = await OpenAiKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else if (type == "google_api")
{
var result = await GoogleKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else if (type == "stripe_live" || type == "stripe_test")
{
var result = await StripeKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, is_live = result.IsLiveKey, account_id = result.AccountId, account_name = result.AccountName, account_email = result.AccountEmail, country = result.AccountCountry, balance_available = result.BalanceAvailable, balance_pending = result.BalancePending, currency = result.Currency, charges_enabled = result.ChargesEnabled, payouts_enabled = result.PayoutsEnabled, source_url = sk.SourceUrl }));
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, check.DbStatus, check.Plan, check.Usage7d, check.Usage5h);
var checkMsg = check.Usage7d.HasValue
? $"🔍 {typeName} geprüft: {check.DbStatus} · Plan: {check.Plan} · Usage 7d: {check.Usage7d?.ToString("F2") ?? "n/a"} · Usage 5h: {check.Usage5h?.ToString("F2") ?? "n/a"} · {keyPrefix}"
: $"🔍 {typeName} geprüft: {check.DbStatus} · {check.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status = check.DbStatus, plan = check.Plan, usage_7d = check.Usage7d, usage_5h = check.Usage5h, message = check.Message, source_url = sk.SourceUrl }));
}
else
{
+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);
+111 -80
View File
@@ -15,6 +15,37 @@ using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
namespace Gott.Services;
public class ToolDefinitionOptions
{
public bool IncludeFileTools { get; set; }
public bool IncludeMemory { get; set; }
public bool IncludeUserChoice { get; set; } = true;
public bool IncludeRemote { get; set; }
public bool IncludeMemorySearch { get; set; }
public bool IncludeSendToSession { get; set; }
public bool IncludeGitHubSearch { get; set; }
public bool IncludeSerperSearch { get; set; }
}
public class ToolExecutionContext
{
public string Workspace { get; set; }
public MemoryService MemoryService { get; set; }
public string AgentId { get; set; }
public CancellationToken CancellationToken { get; set; }
public RemoteConnectionManager RemoteMgr { get; set; }
public string SessionId { get; set; }
public VectorSearchService VectorSearch { get; set; }
public EmbeddingBackgroundService EmbeddingBg { get; set; }
public DatabaseService Db { get; set; }
public ChatService ChatService { get; set; }
public AnthropicService AnthropicService { get; set; }
public OpenAiService OpenAiService { get; set; }
public string UserId { get; set; }
public TelegramService TelegramService { get; set; }
public string ApiToken { get; set; }
}
public class ToolService
{
private readonly ServerSettingsService _settings;
@@ -63,24 +94,24 @@ public class ToolService
return filtered.ToArray();
}
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false, bool includeSendToSession = false, bool includeGitHubSearch = false, bool includeSerperSearch = false)
public object[] GetToolDefinitions(ToolDefinitionOptions opts)
{
var allTools = new List<object>();
if (includeUserChoice)
if (opts.IncludeUserChoice)
allTools.Add(GetAskUserChoiceTool());
if (includeFileTools)
if (opts.IncludeFileTools)
allTools.AddRange(GetFileTools());
if (includeMemory)
if (opts.IncludeMemory)
allTools.AddRange(GetMemoryToolDefinitions());
if (includeMemorySearch)
if (opts.IncludeMemorySearch)
allTools.Add(GetMemorySearchTool());
if (includeRemote)
if (opts.IncludeRemote)
allTools.Add(GetRemoteExecuteTool());
if (includeSendToSession)
if (opts.IncludeSendToSession)
allTools.Add(GetSendToSessionTool());
if (includeGitHubSearch)
if (opts.IncludeGitHubSearch)
allTools.Add(GetGitHubCodeSearchTool());
if (includeSerperSearch)
if (opts.IncludeSerperSearch)
allTools.Add(GetSerperWebSearchTool());
return allTools.ToArray();
}
@@ -390,17 +421,17 @@ public class ToolService
};
}
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, OpenAiService openAiService = null, string userId = null, TelegramService telegramService = null, string apiToken = null)
public async Task<string> ExecuteTool(string name, JsonElement input, ToolExecutionContext ctx)
{
switch (name)
{
case "read_file":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
var allLines = File.ReadAllLines(path);
var totalLines = allLines.Length;
var offset = input.TryGetProperty("offset", out var offsetProp) ? Math.Max(1, offsetProp.GetInt32()) : 1;
@@ -417,22 +448,22 @@ public class ToolService
}
case "write_file":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace);
var dir = Path.GetDirectoryName(path);
if (dir != null) Directory.CreateDirectory(dir);
var content = input.GetProperty("content").GetString() ?? "";
File.WriteAllText(path, content);
return $"Datei geschrieben: {WorkspaceHelper.GetRelativePath(workspace, path)} ({content.Length} Zeichen)";
return $"Datei geschrieben: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)} ({content.Length} Zeichen)";
}
case "edit_file":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
var fileContent = File.ReadAllText(path);
var oldText = input.GetProperty("old_text").GetString() ?? "";
var newText = input.GetProperty("new_text").GetString() ?? "";
@@ -440,42 +471,42 @@ public class ToolService
return "Fehler: old_text nicht in der Datei gefunden. Exakter Match nötig.";
fileContent = fileContent.Replace(oldText, newText);
File.WriteAllText(path, fileContent);
return $"Datei bearbeitet: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Datei bearbeitet: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
}
case "list_files":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", workspace);
var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", ctx.Workspace);
if (!Directory.Exists(path))
return $"Fehler: Verzeichnis nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Fehler: Verzeichnis nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
var entries = new List<string>();
foreach (var dir in Directory.GetDirectories(path))
entries.Add(WorkspaceHelper.GetRelativePath(workspace, dir) + "/");
entries.Add(WorkspaceHelper.GetRelativePath(ctx.Workspace, dir) + "/");
foreach (var file in Directory.GetFiles(path))
entries.Add(WorkspaceHelper.GetRelativePath(workspace, file));
entries.Add(WorkspaceHelper.GetRelativePath(ctx.Workspace, file));
return entries.Count == 0 ? "(leer)" : string.Join("\n", entries);
}
case "delete_file":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace);
if (File.Exists(path))
{
File.Delete(path);
return $"Datei gelöscht: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Datei gelöscht: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: false);
return $"Verzeichnis gelöscht: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Verzeichnis gelöscht: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
}
return $"Fehler: Nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return $"Fehler: Nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}";
}
case "execute":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var command = input.GetProperty("command").GetString() ?? "";
// Unter Windows: Befehl in temp .cmd-Datei schreiben und mit /c ausführen.
@@ -484,7 +515,7 @@ public class ToolService
var psi = new ProcessStartInfo
{
FileName = WorkspaceHelper.ShellExecutable,
WorkingDirectory = workspace,
WorkingDirectory = ctx.Workspace,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -513,7 +544,7 @@ public class ToolService
}
// Timeout UND externen CancellationToken verknüpfen
var timeoutSeconds = ExecuteTimeoutSeconds;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
// stdout/stderr VOR WaitForExit lesen – verhindert Deadlock
// wenn Pipe-Buffer volllaufen (Windows: nur 4KB!)
@@ -528,7 +559,7 @@ public class ToolService
proc.Kill(entireProcessTree: true);
if (scriptPath != null) try { File.Delete(scriptPath); } catch { }
// Wenn der externe Token abgebrochen wurde, weiterwerfen
cancellationToken.ThrowIfCancellationRequested();
ctx.CancellationToken.ThrowIfCancellationRequested();
return $"Fehler: Timeout nach {timeoutSeconds} Sekunden - Prozess abgebrochen";
}
if (scriptPath != null) try { File.Delete(scriptPath); } catch { }
@@ -547,7 +578,7 @@ public class ToolService
}
case "background_execute":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var command = input.GetProperty("command").GetString() ?? "";
// Befehl in temporäre Script-Datei schreiben und losgelöst starten.
@@ -572,7 +603,7 @@ public class ToolService
{
FileName = WorkspaceHelper.IsWindows ? "cmd.exe" : scriptPath,
Arguments = WorkspaceHelper.IsWindows ? $"/c \"{scriptPath}\"" : "",
WorkingDirectory = workspace,
WorkingDirectory = ctx.Workspace,
UseShellExecute = false,
CreateNoWindow = true
};
@@ -582,13 +613,13 @@ public class ToolService
}
case "glob":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var pattern = input.GetProperty("pattern").GetString() ?? "*";
var basePath = input.TryGetProperty("path", out var gp) ? gp.GetString() ?? "" : "";
var searchRoot = string.IsNullOrEmpty(basePath)
? workspace
: ResolvePath(basePath, workspace);
? ctx.Workspace
: ResolvePath(basePath, ctx.Workspace);
if (!Directory.Exists(searchRoot))
return $"Fehler: Verzeichnis nicht gefunden: {basePath}";
var matcher = new Matcher();
@@ -596,14 +627,14 @@ public class ToolService
var dirInfo = new DirectoryInfoWrapper(new DirectoryInfo(searchRoot));
var result = matcher.Execute(dirInfo);
var matches = result.Files
.Select(f => WorkspaceHelper.GetRelativePath(workspace, Path.Combine(searchRoot, f.Path)))
.Select(f => WorkspaceHelper.GetRelativePath(ctx.Workspace, Path.Combine(searchRoot, f.Path)))
.OrderBy(f => f)
.ToList();
return matches.Count == 0 ? "(keine Treffer)" : string.Join("\n", matches);
}
case "grep":
{
if (string.IsNullOrEmpty(workspace))
if (string.IsNullOrEmpty(ctx.Workspace))
return "Fehler: Kein Workspace konfiguriert";
var pattern = input.GetProperty("pattern").GetString() ?? "";
var searchPath = input.TryGetProperty("path", out var sp) ? sp.GetString() ?? "" : "";
@@ -614,8 +645,8 @@ public class ToolService
try { regex = new Regex(pattern, regexOptions); }
catch { regex = new Regex(Regex.Escape(pattern), regexOptions); }
var searchRoot = string.IsNullOrEmpty(searchPath)
? workspace
: ResolvePath(searchPath, workspace);
? ctx.Workspace
: ResolvePath(searchPath, ctx.Workspace);
IEnumerable<string> files;
if (File.Exists(searchRoot))
files = new[] { searchRoot };
@@ -632,7 +663,7 @@ public class ToolService
for (var i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
results.Add($"{WorkspaceHelper.GetRelativePath(workspace, file)}:{i + 1}: {lines[i].Trim()}");
results.Add($"{WorkspaceHelper.GetRelativePath(ctx.Workspace, file)}:{i + 1}: {lines[i].Trim()}");
}
}
if (results.Count == 0) return "(keine Treffer)";
@@ -657,51 +688,51 @@ public class ToolService
}
case "memory_read":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
if (ctx.MemoryService == null || string.IsNullOrEmpty(ctx.AgentId))
return "Fehler: Memory-Service nicht verfügbar";
var path = input.GetProperty("path").GetString() ?? "";
var content = memoryService.ReadMemoryFile(agentId, path);
var content = ctx.MemoryService.ReadMemoryFile(ctx.AgentId, path);
if (content == null)
return $"Datei nicht gefunden: {path}";
return content;
}
case "memory_write":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
if (ctx.MemoryService == null || string.IsNullOrEmpty(ctx.AgentId))
return "Fehler: Memory-Service nicht verfügbar";
var path = input.GetProperty("path").GetString() ?? "";
var content = input.GetProperty("content").GetString() ?? "";
var append = input.TryGetProperty("append", out var a) && a.GetBoolean();
if (append)
memoryService.AppendMemoryFile(agentId, path, content);
ctx.MemoryService.AppendMemoryFile(ctx.AgentId, path, content);
else
memoryService.WriteMemoryFile(agentId, path, content);
ctx.MemoryService.WriteMemoryFile(ctx.AgentId, path, content);
// Memory-Datei nach Schreiben embedden
if (embeddingBg != null)
if (ctx.EmbeddingBg != null)
{
var fullText = memoryService.ReadMemoryFile(agentId, path);
var fullText = ctx.MemoryService.ReadMemoryFile(ctx.AgentId, path);
if (!string.IsNullOrEmpty(fullText))
embeddingBg.EnqueueMemoryFile(agentId, path, fullText);
ctx.EmbeddingBg.EnqueueMemoryFile(ctx.AgentId, path, fullText);
}
return $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)";
}
case "memory_list":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
if (ctx.MemoryService == null || string.IsNullOrEmpty(ctx.AgentId))
return "Fehler: Memory-Service nicht verfügbar";
var subPath = input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var files = memoryService.ListMemoryFiles(agentId, subPath);
var files = ctx.MemoryService.ListMemoryFiles(ctx.AgentId, subPath);
return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files);
}
case "memory_search":
{
if (vectorSearch == null)
if (ctx.VectorSearch == null)
return "Fehler: Semantische Suche nicht verfügbar";
var query = input.GetProperty("query").GetString() ?? "";
var topN = input.TryGetProperty("top_n", out var tn) ? tn.GetInt32() : 10;
if (topN > 30) topN = 30;
if (topN < 1) topN = 1;
var results = await vectorSearch.SearchAsync(query, agentId, topN);
var results = await ctx.VectorSearch.SearchAsync(query, ctx.AgentId, topN);
if (results.Count == 0)
return "Keine relevanten Ergebnisse gefunden.";
var sb = new StringBuilder();
@@ -718,13 +749,13 @@ public class ToolService
}
case "remote_execute":
{
if (remoteMgr == null || string.IsNullOrEmpty(sessionId))
if (ctx.RemoteMgr == null || string.IsNullOrEmpty(ctx.SessionId))
return "Fehler: Keine Remote-Verbindung verfügbar";
var conn = remoteMgr.GetBySessionId(sessionId);
var conn = ctx.RemoteMgr.GetBySessionId(ctx.SessionId);
if (conn == null)
return "Fehler: Keine Remote-Verbindung für diese Session zugeordnet";
var command = input.GetProperty("command").GetString() ?? "";
var result = await remoteMgr.ExecuteCommand(conn.Id, command, ExecuteTimeoutSeconds, cancellationToken);
var result = await ctx.RemoteMgr.ExecuteCommand(conn.Id, command, ExecuteTimeoutSeconds, ctx.CancellationToken);
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(result.Output))
sb.Append(result.Output);
@@ -742,7 +773,7 @@ public class ToolService
}
case "send_to_session":
{
if (db == null || chatService == null || (anthropicService == null && openAiService == null))
if (ctx.Db == null || ctx.ChatService == null || (ctx.AnthropicService == null && ctx.OpenAiService == null))
return "Fehler: send_to_session nicht verfügbar";
var sessionIdOrName = input.GetProperty("session_id_or_name").GetString() ?? "";
@@ -753,62 +784,62 @@ public class ToolService
return "Fehler: message fehlt";
// Ziel-Session per ID oder Name suchen (instanzweit, kein User-Check)
var targetSession = await db.GetSession(sessionIdOrName);
var targetSession = await ctx.Db.GetSession(sessionIdOrName);
if (targetSession == null)
targetSession = await db.GetSessionByName(sessionIdOrName);
targetSession = await ctx.Db.GetSessionByName(sessionIdOrName);
if (targetSession == null)
return $"Fehler: Session '{sessionIdOrName}' nicht gefunden";
var targetSessionId = targetSession.SessionId;
if (targetSessionId == sessionId)
if (targetSessionId == ctx.SessionId)
return "Fehler: Kann keine Nachricht an die eigene Session senden";
// Context für Ziel-Session vorbereiten (analog zu CronjobService)
var ctx = await chatService.PrepareContext(targetSessionId, userId: userId);
if (ctx == null)
var targetCtx = await ctx.ChatService.PrepareContext(targetSessionId, userId: ctx.UserId);
if (targetCtx == null)
return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)";
// API-Key des Ziel-Agents auflösen (nicht den Key des aufrufenden Agents nutzen)
var targetApiKey = await db.GetAgentApiKeyRecord(userId, ctx.AgentId);
ctx.ApiToken = targetApiKey?.ApiKey ?? apiToken;
var targetApiKey = await ctx.Db.GetAgentApiKeyRecord(ctx.UserId, targetCtx.AgentId);
targetCtx.ApiToken = targetApiKey?.ApiKey ?? ctx.ApiToken;
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren
var history = await chatService.BuildHistoryFromDb(targetSessionId, promptBlocks: ctx.PromptBlocks);
var senderAgent = agentId != null ? await db.GetAgent(agentId) : null;
var senderName = senderAgent?.Name ?? agentId ?? "Unbekannter Agent";
var history = await ctx.ChatService.BuildHistoryFromDb(targetSessionId, promptBlocks: targetCtx.PromptBlocks);
var senderAgent = ctx.AgentId != null ? await ctx.Db.GetAgent(ctx.AgentId) : null;
var senderName = senderAgent?.Name ?? ctx.AgentId ?? "Unbekannter Agent";
var senderInfo = $"[Nachricht von Agent \"{senderName}\"]: {message}";
var userContent = new List<object> { new { type = "text", text = senderInfo } };
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = userContent });
try
{
if (!string.IsNullOrEmpty(ctx.Provider) && openAiService != null)
await openAiService.ChatNonStreaming(ctx, history);
if (!string.IsNullOrEmpty(targetCtx.Provider) && ctx.OpenAiService != null)
await ctx.OpenAiService.ChatNonStreaming(targetCtx, history);
else
await anthropicService.ChatNonStreaming(ctx, history);
await ctx.AnthropicService.ChatNonStreaming(targetCtx, history);
}
catch (Exception ex)
{
Console.WriteLine($"[send_to_session] ChatNonStreaming Fehler: {ex}");
return $"Fehler beim Senden an Session \"{targetSession.Name}\": {ex.Message}";
}
await db.UpdateSessionTimestamp(targetSessionId);
await ctx.Db.UpdateSessionTimestamp(targetSessionId);
// Telegram-Benachrichtigung senden falls Session einen Bot hat
if (telegramService != null)
if (ctx.TelegramService != null)
{
try
{
var updatedSession = await db.GetSession(targetSessionId);
var updatedSession = await ctx.Db.GetSession(targetSessionId);
if (updatedSession != null && !string.IsNullOrEmpty(updatedSession.TelegramChatId))
{
var recentMsgs = await db.GetLatestActiveMessages(targetSessionId, 5);
var recentMsgs = await ctx.Db.GetLatestActiveMessages(targetSessionId, 5);
var lastMsg = recentMsgs
.Where(m => m.Role == "assistant" && m.Type == "text")
.LastOrDefault();
if (lastMsg != null)
await telegramService.SendNotification(targetSessionId, lastMsg.Content);
await ctx.TelegramService.SendNotification(targetSessionId, lastMsg.Content);
}
}
catch (Exception ex)
@@ -821,11 +852,11 @@ public class ToolService
}
case "github_code_search":
{
return await ExecuteGitHubCodeSearch(input, cancellationToken);
return await ExecuteGitHubCodeSearch(input, ctx.CancellationToken);
}
case "serper_web_search":
{
return await ExecuteSerperWebSearch(input, cancellationToken);
return await ExecuteSerperWebSearch(input, ctx.CancellationToken);
}
default:
return $"Fehler: Unbekanntes Tool: {name}";