diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index d431c2f..d73082b 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -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 }); diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index d2d75de..c6d13af 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -298,12 +298,12 @@ public static class SessionEndpoint var tools = new List(); 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); diff --git a/Services/AnthropicService.Agents.cs b/Services/AnthropicService.Agents.cs index 2cb2be3..20ba09e 100644 --- a/Services/AnthropicService.Agents.cs +++ b/Services/AnthropicService.Agents.cs @@ -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"; diff --git a/Services/AnthropicService.Streaming.cs b/Services/AnthropicService.Streaming.cs index 3064bdb..bb95e21 100644 --- a/Services/AnthropicService.Streaming.cs +++ b/Services/AnthropicService.Streaming.cs @@ -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; } diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 61f1c62..443d527 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -261,7 +261,13 @@ public partial class AnthropicService var tools = new List(); 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); diff --git a/Services/ChatService.cs b/Services/ChatService.cs index ac4894e..8a17f1f 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -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(); 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 dict && - dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && - dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) - { - contentList.Add(toolUseBlock); - } - else - { - history.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = new List { 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(msg.Content ?? "{}"); } catch { continue; } - var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; - if (lastAssistant is Dictionary dict && - dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && - dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) - { - contentList.Add(useEl); - } - else - { - history.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = new List { 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(msg.Content ?? "{}"); } catch { continue; } - var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; - if (lastAssistant is Dictionary dict && - dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && - dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) - { - contentList.Add(resultEl); - } - else - { - history.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = new List { 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 dict && - dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && - dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) - { - contentList.Add(resultEl); - } - else - { - history.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = new List { 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 dict && - dict.TryGetValue("role", out var role) && role.ToString() == "user" && - dict.TryGetValue("content", out var existingContent) && existingContent is List 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 - { - ["role"] = "user", - ["content"] = new List - { - 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 history, string role, object block) + { + var last = history.Count > 0 ? history[history.Count - 1] : null; + if (last is Dictionary dict && + dict.TryGetValue("role", out var r) && r.ToString() == role && + dict.TryGetValue("content", out var existing) && existing is List contentList) + { + contentList.Add(block); + } + else + { + history.Add(new Dictionary + { + ["role"] = role, + ["content"] = new List { block } + }); + } + } + /// /// 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) diff --git a/Services/GitHubEventScannerService.cs b/Services/GitHubEventScannerService.cs index dfaa6fb..8722429 100644 --- a/Services/GitHubEventScannerService.cs +++ b/Services/GitHubEventScannerService.cs @@ -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 { diff --git a/Services/KeyCheckDispatcher.cs b/Services/KeyCheckDispatcher.cs new file mode 100644 index 0000000..8cdde0e --- /dev/null +++ b/Services/KeyCheckDispatcher.cs @@ -0,0 +1,96 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace Gott.Services; + +/// +/// Normalisiertes Key-Check-Ergebnis für alle Provider. +/// Provider-spezifische Details über ProviderResult (cast zum konkreten Typ). +/// +public class UnifiedKeyCheckResult +{ + /// Normalisierter DB-Status: "valid", "invalid", "rate_limited", "quota_exceeded", "error" + 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; } + + /// Original-Result des Providers (KeyCheckResult, OpenAiCheckResult, etc.) + public object ProviderResult { get; set; } +} + +/// +/// 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. +/// +public static class KeyCheckDispatcher +{ + /// + /// Prüft einen API-Key anhand des Providers. + /// Provider: "anthropic", "openai", "google", "stripe". + /// Gibt null zurück bei unbekanntem Provider. + /// + public static async Task 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; + } + } + + /// + /// 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. + /// + public static Task CheckByKeyType(IHttpClientFactory httpFactory, string keyType, string apiKey) + { + var provider = KeyPatterns.GetCheckerProvider(keyType); + if (provider == null) return Task.FromResult(null); + return CheckByProvider(httpFactory, provider, apiKey); + } +} diff --git a/Services/KeyPatterns.cs b/Services/KeyPatterns.cs index a210d93..8c435b9 100644 --- a/Services/KeyPatterns.cs +++ b/Services/KeyPatterns.cs @@ -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"; } + /// + /// Mappt einen Key-Typ auf den Provider-Namen für den Checker. + /// Gibt null zurück wenn kein Checker verfügbar ist. + /// + 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; + } + /// Menschenlesbarer Name für den Key-Typ. public static string DisplayName(string type) { diff --git a/Services/KeyScannerService.cs b/Services/KeyScannerService.cs index 2ab7b9c..09f748a 100644 --- a/Services/KeyScannerService.cs +++ b/Services/KeyScannerService.cs @@ -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 { diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 4908b6d..f7e01c6 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -30,123 +30,38 @@ public class OpenAiService // Provider-Definitionen (inkl. API-Keys, instanzgebunden) private readonly Dictionary Providers; - // Tools die für OpenAI-kompatible Provider verfügbar sind - private static readonly List OpenAiTools = new() + // Tools die für OpenAI-kompatible Provider verfügbar sind (konvertiert aus ToolService Anthropic-Format) + private List _openAiToolDefs; + /// + /// Konvertiert Anthropic-Format Tool-Definitionen (input_schema) in OpenAI-Format (function/parameters). + /// Single source of truth: ToolService.GetToolDefinitions() + /// + private static List ConvertTo_openAiToolDefs(object[] anthropicTools) { - new + var result = new List(); + foreach (var tool in anthropicTools) { - type = "function", - function = new + var json = JsonSerializer.Serialize(tool); + var el = JsonSerializer.Deserialize(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 - { - ["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" } - }, - required = Array.Empty() + name, + description = desc, + parameters = schema.ValueKind != JsonValueKind.Undefined + ? JsonSerializer.Deserialize(schema.GetRawText()) + : new { type = "object", properties = new Dictionary(), required = Array.Empty() } 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 - { - ["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 - { - ["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 - { - ["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 - { - ["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 - { - ["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 })); } /// @@ -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(); - - // 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 zurück → einzeln expandieren - if (converted is List 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(tcArgs.ToString()); } catch { inputEl = JsonSerializer.Deserialize("{}"); } - 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(); - 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 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; } + /// + /// Löst Provider-Config auf: Provider-Name, Model-ID, BaseUrl + ApiKey. + /// Berücksichtigt API-Key-Override und dynamische Gemini-Keys. + /// + 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); + } + + /// + /// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format. + /// + private static List BuildApiMessages(string systemPromptText, List messages) + { + var apiMessages = new List(); + 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 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); diff --git a/Services/ToolService.cs b/Services/ToolService.cs index 4a04fc5..e393d50 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -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(); - 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 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 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(); 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 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 { new { type = "text", text = senderInfo } }; history.Add(new Dictionary { ["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}";