Provider-Abstraktion: Agents unterstützen jetzt OpenAI + Anthropic

Backend:
- ChatEndpoint: Globaler OpenAI-Fallback entfernt, Key-Check pro Provider
- TelegramService/CronjobService: Provider-Weiche statt hardcoded Anthropic
- AnthropicService: Preparer/Review/Finalizer sind Provider-aware
- OpenAiService: Neue ChatNonStreaming()-Methode für agentic loop
- ToolService: send_to_session lädt Key des Ziel-Agents
- SubAgentContext: Provider-Feld hinzugefügt
- ChatService: BuildSubAgentContext setzt Provider

Frontend:
- Key-Switch-Modal: 'Kein Key'-Option zum Entfernen der Zuweisung
- ApiKeysEndpoint: keyId='none' löscht Key-Zuweisung
This commit is contained in:
Meleki committed 2026-03-14 22:59:33 +01:00
1 parent 1effc6d00f
commit 02f5fe2faf
11 files changed
+400 -47

No files matched your search

+16 -8
View File
@@ -524,6 +524,22 @@ public static class ApiKeysEndpoint
var userId = ctx.GetUserId(); var userId = ctx.GetUserId();
if (userId == null) { ctx.Response.StatusCode = 401; return; } if (userId == null) { ctx.Response.StatusCode = 401; return; }
var agentId = ctx.Request.Query["agentId"].FirstOrDefault();
if (string.IsNullOrEmpty(agentId))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "agentId required" });
return;
}
// "none" = Key-Zuweisung entfernen
if (keyId == "none")
{
await db.SetAgentApiKey(userId, agentId, null);
await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId = (string)null, agentId, name = "Kein Key" });
return;
}
var key = await db.GetApiKey(keyId); var key = await db.GetApiKey(keyId);
if (key == null) if (key == null)
{ {
@@ -540,14 +556,6 @@ public static class ApiKeysEndpoint
return; return;
} }
var agentId = ctx.Request.Query["agentId"].FirstOrDefault();
if (string.IsNullOrEmpty(agentId))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "agentId required" });
return;
}
await db.SetAgentApiKey(userId, agentId, keyId); await db.SetAgentApiKey(userId, agentId, keyId);
await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, agentId, name = key.Name }); await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, agentId, name = key.Name });
} }
+26 -25
View File
@@ -62,14 +62,8 @@ public static class ChatEndpoint
return; return;
} }
// API-Key des Users für diesen Agent auflösen // API-Key des Users für diesen Agent auflösen (kann null sein)
var userApiKey = await db.GetAgentApiKeyRecord(userId, ctx.AgentId); var userApiKey = await db.GetAgentApiKeyRecord(userId, ctx.AgentId);
if (userApiKey == null)
{
res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." });
return;
}
if (isChoiceResponse) if (isChoiceResponse)
{ {
@@ -146,12 +140,21 @@ public static class ChatEndpoint
// ── Preparer-Agent Phase (vor dem Worker) ── // ── Preparer-Agent Phase (vor dem Worker) ──
if (ctx.Preparer != null && !isChoiceResponse) if (ctx.Preparer != null && !isChoiceResponse)
{ {
var preparerApiToken = userApiKey?.ApiKey;
// Anthropic-Preparer braucht zwingend einen Key, OpenAI-Provider können ohne auskommen
if (string.IsNullOrEmpty(ctx.Preparer.Provider) && string.IsNullOrEmpty(preparerApiToken))
{
res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." });
return;
}
// Preparer bekommt History MIT seinen eigenen vorherigen Messages (Tag "preparer"), // Preparer bekommt History MIT seinen eigenen vorherigen Messages (Tag "preparer"),
// damit er den Kontext seiner Rückfragen kennt // damit er den Kontext seiner Rückfragen kennt
var preparerHistory = await chatService.BuildHistoryFromDb(sessionId, includeTag: "preparer", promptBlocks: ctx.PromptBlocks); var preparerHistory = await chatService.BuildHistoryFromDb(sessionId, includeTag: "preparer", promptBlocks: ctx.PromptBlocks);
var preparerResult = await anthropic.RunPreparer( var preparerResult = await anthropic.RunPreparer(
res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, userApiKey.ApiKey, cancellationToken); res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, preparerApiToken, cancellationToken);
if (!preparerResult.ShouldForward) if (!preparerResult.ShouldForward)
{ {
@@ -219,33 +222,31 @@ public static class ChatEndpoint
{ {
if (!string.IsNullOrEmpty(ctx.Provider)) if (!string.IsNullOrEmpty(ctx.Provider))
{ {
// OpenAI-kompatibler Provider – für "openai" den User-Key aus DB verwenden // OpenAI-kompatibler Provider – Key des Users für diesen Agent verwenden
string openAiKeyOverride = null; string openAiKeyOverride = userApiKey?.ApiKey;
if (ctx.Provider == "openai")
{
// Zuerst prüfen ob der ausgewählte Key ein OpenAI-Key ist
if (userApiKey.Provider == "openai")
openAiKeyOverride = userApiKey.ApiKey;
else
{
// Fallback: ersten aktiven OpenAI-Key aus DB holen
openAiKeyOverride = await db.GetFirstActiveKeyByProvider("openai");
}
if (string.IsNullOrEmpty(openAiKeyOverride)) // OpenAI-Provider braucht zwingend einen Key
if (ctx.Provider == "openai" && string.IsNullOrEmpty(openAiKeyOverride))
{ {
await SseHelper.TryWriteSse(res, "error", new { error = "Kein OpenAI API-Key vorhanden. Bitte einen OpenAI-Key in den API-Keys hinzufügen." }, sseCt); await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte einen OpenAI-Key zuweisen." }, sseCt);
await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt);
return; return;
} }
}
await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, workCt: workCt, sseCt: sseCt); await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, workCt: workCt, sseCt: sseCt);
} }
else else
{ {
// Standard: Anthropic // Standard: Anthropic – Key wird zwingend benötigt
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, apiToken: userApiKey.ApiKey, sessionId: sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); var anthropicToken = userApiKey?.ApiKey;
if (string.IsNullOrEmpty(anthropicToken))
{
await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." }, sseCt);
await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt);
return;
}
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, apiToken: anthropicToken, sessionId: sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr);
} }
} }
finally finally
+1
View File
@@ -36,6 +36,7 @@ public class ChatContext
public class SubAgentContext public class SubAgentContext
{ {
public string ModelId { get; set; } public string ModelId { get; set; }
public string Provider { get; set; } // "" = Anthropic, "nvidia"/"openai"/etc. = OpenAI-kompatibel
public List<object> SystemPrompt { get; set; } public List<object> SystemPrompt { get; set; }
public string AgentId { get; set; } public string AgentId { get; set; }
public string Workspace { get; set; } public string Workspace { get; set; }
+2
View File
@@ -96,6 +96,7 @@ builder.Services.AddSingleton<TelegramService>(sp =>
sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IHttpClientFactory>(),
dbService, chatService, dbService, chatService,
sp.GetRequiredService<AnthropicService>(), sp.GetRequiredService<AnthropicService>(),
sp.GetRequiredService<OpenAiService>(),
serverSettings)); serverSettings));
// Krea Image Generation // Krea Image Generation
@@ -132,6 +133,7 @@ app.Use(async (context, next) =>
toolService.SetHttpClientFactory(app.Services.GetRequiredService<IHttpClientFactory>()); toolService.SetHttpClientFactory(app.Services.GetRequiredService<IHttpClientFactory>());
app.Services.GetRequiredService<AnthropicService>().SetTelegramService(app.Services.GetRequiredService<TelegramService>()); app.Services.GetRequiredService<AnthropicService>().SetTelegramService(app.Services.GetRequiredService<TelegramService>());
app.Services.GetRequiredService<AnthropicService>().SetOpenAiService(app.Services.GetRequiredService<OpenAiService>());
await app.Services.GetRequiredService<OpenAiService>().LoadModelsAsync(); await app.Services.GetRequiredService<OpenAiService>().LoadModelsAsync();
app.UseWebSockets(); app.UseWebSockets();
+86 -7
View File
@@ -50,6 +50,12 @@ public class AnthropicService
_telegramService = telegramService; _telegramService = telegramService;
} }
private OpenAiService _openAiService;
public void SetOpenAiService(OpenAiService openAiService)
{
_openAiService = openAiService;
}
/// <summary> /// <summary>
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle. /// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
/// Fire-and-forget – Fehler werden stillschweigend ignoriert. /// Fire-and-forget – Fehler werden stillschweigend ignoriert.
@@ -474,12 +480,25 @@ public class AnthropicService
var preparerMessages = new List<object>(historyMessages); var preparerMessages = new List<object>(historyMessages);
var usage = new TokenUsage(); var usage = new TokenUsage();
var (text, iterations) = await RunAgentChat( string text;
int iterations;
if (!string.IsNullOrEmpty(preparer.Provider) && _openAiService != null)
{
// OpenAI-kompatibler Preparer
var subCtx = BuildSubAgentChatContext(preparer, chainId, sessionId, apiToken);
text = await _openAiService.ChatNonStreaming(subCtx, preparerMessages);
iterations = 1;
}
else
{
(text, iterations) = await RunAgentChat(
response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId, response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId,
preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion, preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion,
preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage, preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage,
0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent", 0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent",
cancellationToken, cancellationToken); cancellationToken, cancellationToken);
}
var result = new PreparerResult { IterationCount = iterations, ResponseText = text }; var result = new PreparerResult { IterationCount = iterations, ResponseText = text };
@@ -566,13 +585,24 @@ public class AnthropicService
// Review-Agent bekommt eigene Message-History // Review-Agent bekommt eigene Message-History
var reviewMessages = BuildReviewMessages(workerMessages); var reviewMessages = BuildReviewMessages(workerMessages);
// Review-Agent iteriert mit eigenen Tools string reviewText;
var (reviewText, reviewIterations) = await RunAgentChat( int reviewIterations;
if (!string.IsNullOrEmpty(review.Provider) && _openAiService != null)
{
var subCtx = BuildSubAgentChatContext(review, chainId, sessionId, apiToken);
reviewText = await _openAiService.ChatNonStreaming(subCtx, reviewMessages);
reviewIterations = 1;
}
else
{
(reviewText, reviewIterations) = await RunAgentChat(
response, review.ModelId, review.SystemPrompt, review.AgentId, response, review.ModelId, review.SystemPrompt, review.AgentId,
workspace, review.UseMemory, review.WebSearchVersion, workspace, review.UseMemory, review.WebSearchVersion,
review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage, review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage,
baseIterations, "review", review.Agent?.Name ?? "Review-Agent", baseIterations, "review", review.Agent?.Name ?? "Review-Agent",
workCt, sseCt); workCt, sseCt);
}
var result = new ReviewResult { ReviewIterationCount = reviewIterations }; var result = new ReviewResult { ReviewIterationCount = reviewIterations };
@@ -636,12 +666,24 @@ public class AnthropicService
// Finalizer bekommt die Konversation als Kontext (gleiche Methode wie Review) // Finalizer bekommt die Konversation als Kontext (gleiche Methode wie Review)
var finalizerMessages = BuildReviewMessages(workerMessages); var finalizerMessages = BuildReviewMessages(workerMessages);
var (text, iterations) = await RunAgentChat( string text;
int iterations;
if (!string.IsNullOrEmpty(finalizer.Provider) && _openAiService != null)
{
var subCtx = BuildSubAgentChatContext(finalizer, chainId, sessionId, apiToken);
text = await _openAiService.ChatNonStreaming(subCtx, finalizerMessages);
iterations = 1;
}
else
{
(text, iterations) = await RunAgentChat(
response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId, response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId,
finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion, finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage, finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage,
baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent", baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent",
workCt, sseCt); workCt, sseCt);
}
await SseHelper.TryWriteSse(response, "finalizer_done", new await SseHelper.TryWriteSse(response, "finalizer_done", new
{ {
@@ -769,6 +811,43 @@ public class AnthropicService
return messages; return messages;
} }
/// <summary>
/// Baut einen ChatContext für Sub-Agents (Preparer/Review/Finalizer) auf,
/// wenn diese einen OpenAI-kompatiblen Provider nutzen.
/// </summary>
private ChatContext BuildSubAgentChatContext(SubAgentContext sub, string chainId, string sessionId, string apiToken)
{
// SystemPrompt als Text extrahieren (SubAgent hat List<object> mit { type, text } Blöcken)
var systemText = "";
if (sub.SystemPrompt != null)
{
var parts = new List<string>();
foreach (var block in sub.SystemPrompt)
{
var json = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("text", out var t))
parts.Add(t.GetString() ?? "");
}
systemText = string.Join("\n\n", parts);
}
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = sub.ModelId,
Provider = sub.Provider,
Workspace = sub.Workspace,
AgentId = sub.AgentId,
SystemPromptText = systemText,
UseMemory = sub.UseMemory,
WebSearchVersion = sub.WebSearchVersion,
MaxIterations = 20,
ApiToken = apiToken
};
}
/// <summary> /// <summary>
/// Generischer Agent-Chat (non-streaming für den Agent, aber SSE-Events für den Client). /// Generischer Agent-Chat (non-streaming für den Agent, aber SSE-Events für den Client).
/// Wird für Review-Agent und Finalizer-Agent verwendet. /// Wird für Review-Agent und Finalizer-Agent verwendet.
@@ -918,7 +997,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, telegramService: _telegramService, apiToken: apiToken); } try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, telegramService: _telegramService, apiToken: apiToken); }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1126,7 +1205,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); } try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1419,7 +1498,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; 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, userId: userId, telegramService: _telegramService, apiToken: apiToken); } 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); }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
+2
View File
@@ -806,10 +806,12 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
if (!string.IsNullOrWhiteSpace(systemText)) if (!string.IsNullOrWhiteSpace(systemText))
prompt.Add(new { type = "text", text = systemText }); prompt.Add(new { type = "text", text = systemText });
var (provider, _) = OpenAiService.ParseModelString(modelId);
var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory; var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory;
return new SubAgentContext return new SubAgentContext
{ {
ModelId = modelId, ModelId = modelId,
Provider = provider,
SystemPrompt = prompt, SystemPrompt = prompt,
AgentId = agent.AgentId, AgentId = agent.AgentId,
Workspace = workspace, Workspace = workspace,
+14 -3
View File
@@ -15,6 +15,7 @@ public class CronjobService : BackgroundService
private readonly DatabaseService _db; private readonly DatabaseService _db;
private readonly ChatService _chatService; private readonly ChatService _chatService;
private readonly AnthropicService _anthropicService; private readonly AnthropicService _anthropicService;
private readonly OpenAiService _openAiService;
private readonly TelegramService _telegramService; private readonly TelegramService _telegramService;
private readonly ILogger<CronjobService> _logger; private readonly ILogger<CronjobService> _logger;
@@ -22,12 +23,14 @@ public class CronjobService : BackgroundService
DatabaseService db, DatabaseService db,
ChatService chatService, ChatService chatService,
AnthropicService anthropicService, AnthropicService anthropicService,
OpenAiService openAiService,
TelegramService telegramService, TelegramService telegramService,
ILogger<CronjobService> logger) ILogger<CronjobService> logger)
{ {
_db = db; _db = db;
_chatService = chatService; _chatService = chatService;
_anthropicService = anthropicService; _anthropicService = anthropicService;
_openAiService = openAiService;
_telegramService = telegramService; _telegramService = telegramService;
_logger = logger; _logger = logger;
} }
@@ -109,8 +112,12 @@ public class CronjobService : BackgroundService
Content = job.Prompt Content = job.Prompt
}); });
// Non-Streaming Chat ausführen // Non-Streaming Chat ausführen – Provider-Weiche
var responseText = await _anthropicService.ChatNonStreaming(ctx, history); string responseText;
if (!string.IsNullOrEmpty(ctx.Provider))
responseText = await _openAiService.ChatNonStreaming(ctx, history);
else
responseText = await _anthropicService.ChatNonStreaming(ctx, history);
// Session-Timestamp aktualisieren // Session-Timestamp aktualisieren
await _db.UpdateSessionTimestamp(job.SessionId); await _db.UpdateSessionTimestamp(job.SessionId);
@@ -172,7 +179,11 @@ public class CronjobService : BackgroundService
["content"] = new List<object> { new { type = "text", text = summary } } ["content"] = new List<object> { new { type = "text", text = summary } }
} }
}; };
var notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory); string notifyResponse;
if (!string.IsNullOrEmpty(notifyCtx.Provider))
notifyResponse = await _openAiService.ChatNonStreaming(notifyCtx, notifyHistory);
else
notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory);
await _db.UpdateSessionTimestamp(job.NotifySessionId); await _db.UpdateSessionTimestamp(job.NotifySessionId);
+198
View File
@@ -732,6 +732,204 @@ public class OpenAiService
} }
} }
/// <summary>
/// Non-Streaming Chat für Telegram, Cronjobs und send_to_session.
/// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop).
/// </summary>
public async Task<string> ChatNonStreaming(
ChatContext ctx,
List<object> messages,
Func<int, Task> onIteration = null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
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}'");
// 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 chain = new ChainRecord
{
ChainId = ctx.ChainId,
SessionId = ctx.SessionId,
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);
}
try
{
while (iterations < ctx.MaxIterations)
{
iterations++;
onIteration?.Invoke(iterations);
var client = CreateClient(providerConfig.ApiKey);
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["messages"] = apiMessages,
["stream"] = false,
["max_tokens"] = 16384,
["tools"] = OpenAiTools,
["tool_choice"] = "auto"
};
var jsonPayload = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent });
var respBody = await httpResp.Content.ReadAsStringAsync();
if (!httpResp.IsSuccessStatusCode)
{
var errMsg = $"API {(int)httpResp.StatusCode}: {respBody}";
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await FinalizeChain(chain, usage, iterations, "error", sw);
throw new Exception($"OpenAI API Fehler: {(int)httpResp.StatusCode}");
}
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
// Usage tracken
if (resp.TryGetProperty("usage", out var usageProp))
{
if (usageProp.TryGetProperty("prompt_tokens", out var pt))
usage.InputTokens = pt.GetInt64();
if (usageProp.TryGetProperty("completion_tokens", out var ct2))
usage.OutputTokens = ct2.GetInt64();
}
// Erste Choice auswerten
var choices = resp.GetProperty("choices");
if (choices.GetArrayLength() == 0) break;
var choice = choices[0];
var message = choice.GetProperty("message");
var finishReason = choice.TryGetProperty("finish_reason", out var frProp) ? frProp.GetString() ?? "" : "";
// Text extrahieren
if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String)
{
var text = contentProp.GetString() ?? "";
if (!string.IsNullOrEmpty(text))
{
finalText.Append(text);
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = text
});
}
}
// Tool-Calls prüfen
if (!message.TryGetProperty("tool_calls", out var toolCallsProp) || toolCallsProp.GetArrayLength() == 0)
{
await FinalizeChain(chain, usage, iterations, "end_turn", sw);
break;
}
// Assistant-Message mit tool_calls in History
var assistantMsg = JsonSerializer.Deserialize<JsonElement>(message.GetRawText());
apiMessages.Add(new
{
role = "assistant",
content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "",
tool_calls = JsonSerializer.Deserialize<List<object>>(toolCallsProp.GetRawText())
});
// Tool-Calls ausführen
var toolResults = new List<object>();
foreach (var tc in toolCallsProp.EnumerateArray())
{
var tcId = tc.GetProperty("id").GetString() ?? "";
var func = tc.GetProperty("function");
var tcName = func.GetProperty("name").GetString() ?? "";
var tcArgs = func.TryGetProperty("arguments", out var argsProp) ? argsProp.GetString() ?? "{}" : "{}";
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "assistant", Type = "tool_use",
Content = tcArgs,
ToolCallId = tcId, ToolName = tcName
});
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize<JsonElement>(tcArgs); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(tcName, inputEl, ctx.Workspace); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
toolResults.Add(new
{
role = "tool",
tool_call_id = tcId,
content = toolResult
});
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
Role = "tool", Type = "tool_result",
Content = toolResult,
ToolCallId = tcId, ToolName = tcName,
IsError = isError
});
}
foreach (var tr in toolResults)
apiMessages.Add(tr);
}
}
catch
{
if (iterations > 0)
await FinalizeChain(chain, usage, iterations, "error", sw);
throw;
}
return finalText.Length > 0 ? finalText.ToString() : "(Keine Textantwort)";
}
/// <summary> /// <summary>
/// Konvertiert eine Anthropic-format Message in das OpenAI-format. /// Konvertiert eine Anthropic-format Message in das OpenAI-format.
/// </summary> /// </summary>
+16 -1
View File
@@ -15,6 +15,7 @@ public class TelegramService
private readonly DatabaseService _db; private readonly DatabaseService _db;
private readonly ChatService _chatService; private readonly ChatService _chatService;
private readonly AnthropicService _anthropic; private readonly AnthropicService _anthropic;
private readonly OpenAiService _openAi;
private readonly ServerSettingsService _settings; private readonly ServerSettingsService _settings;
public TelegramService( public TelegramService(
@@ -22,12 +23,14 @@ public class TelegramService
DatabaseService db, DatabaseService db,
ChatService chatService, ChatService chatService,
AnthropicService anthropic, AnthropicService anthropic,
OpenAiService openAi,
ServerSettingsService settings) ServerSettingsService settings)
{ {
_httpFactory = httpFactory; _httpFactory = httpFactory;
_db = db; _db = db;
_chatService = chatService; _chatService = chatService;
_anthropic = anthropic; _anthropic = anthropic;
_openAi = openAi;
_settings = settings; _settings = settings;
} }
@@ -197,11 +200,23 @@ public class TelegramService
Content = userText Content = userText
}); });
var reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) => string reply;
if (!string.IsNullOrEmpty(ctx.Provider))
{
reply = await _openAi.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{ {
if (iteration % 3 == 0) if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing"); await SendChatAction(chatId, botToken, "typing");
}); });
}
else
{
reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{
if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing");
});
}
await SendLongMessage(chatId, botToken, reply); await SendLongMessage(chatId, botToken, reply);
} }
+9 -3
View File
@@ -390,7 +390,7 @@ 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, string userId = null, TelegramService telegramService = null, string apiToken = null) 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)
{ {
switch (name) switch (name)
{ {
@@ -742,7 +742,7 @@ public class ToolService
} }
case "send_to_session": case "send_to_session":
{ {
if (db == null || chatService == null || anthropicService == null) if (db == null || chatService == null || (anthropicService == null && openAiService == null))
return "Fehler: send_to_session nicht verfügbar"; return "Fehler: send_to_session nicht verfügbar";
var sessionIdOrName = input.GetProperty("session_id_or_name").GetString() ?? ""; var sessionIdOrName = input.GetProperty("session_id_or_name").GetString() ?? "";
@@ -767,7 +767,10 @@ public class ToolService
var ctx = await chatService.PrepareContext(targetSessionId, userId: userId); var ctx = await chatService.PrepareContext(targetSessionId, userId: userId);
if (ctx == null) if (ctx == null)
return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)"; return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)";
ctx.ApiToken = apiToken;
// 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;
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob) // History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren // Stattdessen als kontextualisierte Nachricht in die History injizieren
@@ -780,6 +783,9 @@ public class ToolService
try try
{ {
if (!string.IsNullOrEmpty(ctx.Provider) && openAiService != null)
await openAiService.ChatNonStreaming(ctx, history);
else
await anthropicService.ChatNonStreaming(ctx, history); await anthropicService.ChatNonStreaming(ctx, history);
} }
catch (Exception ex) catch (Exception ex)
+30
View File
@@ -152,6 +152,36 @@ async function _showKeySwitchModal() {
var list = document.createElement('div'); var list = document.createElement('div');
list.className = 'key-switch-list'; list.className = 'key-switch-list';
// "Kein Key"-Option als erste Zeile
var noneRow = document.createElement('div');
noneRow.className = 'key-switch-row';
if (!selectedKeyId) noneRow.classList.add('active');
var noneInfo = document.createElement('div');
noneInfo.className = 'key-switch-info';
var noneName = document.createElement('div');
noneName.className = 'key-switch-name';
noneName.textContent = 'Kein Key';
noneInfo.appendChild(noneName);
var noneMeta = document.createElement('div');
noneMeta.className = 'key-switch-meta';
noneMeta.textContent = 'Agent ohne API-Key nutzen';
noneInfo.appendChild(noneMeta);
noneRow.appendChild(noneInfo);
if (selectedKeyId) {
noneRow.classList.add('clickable');
noneRow.addEventListener('click', async function() {
noneRow.classList.add('loading-state');
try {
await apiSelectApiKey('none', getCurrentAgentId());
close();
await _initChatUsageBadge();
} catch(e) {
noneRow.classList.remove('loading-state');
}
});
}
list.appendChild(noneRow);
keys.forEach(function(key) { keys.forEach(function(key) {
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'key-switch-row'; row.className = 'key-switch-row';