User-spezifische API-Key-Auswahl, globales is_active entfernt

Backend:
- is_active komplett entfernt, durch is_public (Admin-Toggle) ersetzt
- Neues selected_api_key_id auf users-Tabelle
- AnthropicService: globaler _token entfernt, apiToken pro Request
- Token-Durchreichung: StreamChat → ProcessStream → RunAgentChat →
  RunPreparer/RunReview/RunFinalizer, ExecuteTool, send_to_session
- ChatEndpoint: User-Key aus DB laden, an StreamChat übergeben
- TelegramService: Key per bot.UserId auflösen
- CronjobService: Key per job.UserId auflösen
- Neue Endpoints: GET /api/api-keys/available, PUT /api/api-keys/select/{keyId},
  GET /api/api-keys/selected, PUT /api/admin/api-keys/{keyId}/public,
  POST /api/admin/api-keys/check-all
- Program.cs: AnthropicService ohne Token-Parameter

Frontend:
- API-Keys-Page: Public-Toggle (Globe/Lock) statt Activate/Deactivate
- Alle prüfen Button für Batch-Key-Check
- Chat: Key-Auswahl für alle User (nicht nur Admin)
- Dummy-Badge wenn kein Key ausgewählt
- Fallback-Badge bei Key ohne Usage-Daten
- Key-Switch-Modal: apiGetAvailableKeys + apiSelectApiKey
This commit is contained in:
Meleki committed 2026-03-13 12:27:03 +01:00
1 parent cc46cf2bbc
commit dde0fede7d
18 files changed
+340 -193

No files matched your search

+116 -46
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
@@ -15,13 +16,14 @@ public static class ApiKeysEndpoint
{
public static void MapApiKeysEndpoints(this WebApplication app)
{
// Admin-Endpoints
app.MapGet("/api/admin/api-keys", HandleGetAll);
app.MapPost("/api/admin/api-keys", HandleCreate);
app.MapPut("/api/admin/api-keys/{keyId}", HandleUpdate);
app.MapPut("/api/admin/api-keys/{keyId}/activate", HandleActivate);
app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate);
app.MapPut("/api/admin/api-keys/{keyId}/public", HandleTogglePublic);
app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete);
app.MapPost("/api/admin/api-keys/{keyId}/check", HandleCheck);
app.MapPost("/api/admin/api-keys/check-all", HandleCheckAll);
// Scan-Endpoints
app.MapPost("/api/admin/api-keys/scan", HandleScanTrigger);
@@ -30,6 +32,11 @@ public static class ApiKeysEndpoint
app.MapPost("/api/admin/api-keys/scanned/{id}/recheck", HandleRecheck);
app.MapPost("/api/admin/api-keys/scanned/{id}/adopt", HandleAdopt);
app.MapDelete("/api/admin/api-keys/scanned/{id}", HandleDeleteScanned);
// User-Endpoints (für alle authentifizierten User)
app.MapGet("/api/api-keys/available", HandleGetAvailable);
app.MapPut("/api/api-keys/select/{keyId}", HandleSelectKey);
app.MapGet("/api/api-keys/selected", HandleGetSelected);
}
private static async Task HandleGetAll(HttpContext ctx, DatabaseService db)
@@ -43,7 +50,7 @@ public static class ApiKeysEndpoint
k.Provider,
k.Name,
k.ApiKey,
k.IsActive,
k.IsPublic,
k.Status,
k.Plan,
k.Usage7d,
@@ -73,13 +80,15 @@ public static class ApiKeysEndpoint
}
var now = DateTime.UtcNow.ToString("o");
var isPublic = body.TryGetProperty("isPublic", out var pub) && pub.GetBoolean();
var record = new ApiKeyRecord
{
KeyId = Guid.NewGuid().ToString("N"),
Provider = provider,
Name = name,
ApiKey = apiKey,
IsActive = false,
IsPublic = isPublic,
CreatedAt = now,
UpdatedAt = now
};
@@ -91,12 +100,12 @@ public static class ApiKeysEndpoint
record.Provider,
record.Name,
record.ApiKey,
record.IsActive,
record.IsPublic,
record.CreatedAt
});
}
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, string keyId)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
@@ -116,15 +125,10 @@ public static class ApiKeysEndpoint
name = existing.Name;
await db.UpdateApiKey(keyId, name, apiKey);
// Wenn aktiver Key geändert wird → Token im Service aktualisieren
if (existing.IsActive && existing.Provider == "anthropic" && !string.IsNullOrWhiteSpace(apiKey))
anthropic.UpdateToken(apiKey);
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
private static async Task HandleActivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
private static async Task HandleTogglePublic(HttpContext ctx, DatabaseService db, string keyId)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
@@ -136,16 +140,12 @@ public static class ApiKeysEndpoint
return;
}
await db.SetActiveApiKey(keyId, existing.Provider);
// Token im laufenden Service aktualisieren
if (existing.Provider == "anthropic")
anthropic.UpdateToken(existing.ApiKey);
await ctx.Response.WriteAsJsonAsync(new { ok = true });
var newPublic = !existing.IsPublic;
await db.SetApiKeyPublic(keyId, newPublic);
await ctx.Response.WriteAsJsonAsync(new { ok = true, isPublic = newPublic });
}
private static async Task HandleDeactivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
private static async Task HandleDelete(HttpContext ctx, DatabaseService db, string keyId)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
@@ -157,31 +157,6 @@ public static class ApiKeysEndpoint
return;
}
await db.DeactivateApiKey(keyId);
// Token im laufenden Service leeren
if (existing.Provider == "anthropic")
anthropic.UpdateToken("");
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
private static async Task HandleDelete(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
var existing = await db.GetApiKey(keyId);
if (existing == null)
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
return;
}
// Wenn aktiver Key gelöscht wird → Token leeren
if (existing.IsActive && existing.Provider == "anthropic")
anthropic.UpdateToken("");
await db.DeleteApiKey(keyId);
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
@@ -226,6 +201,25 @@ public static class ApiKeysEndpoint
});
}
private static async Task HandleCheckAll(HttpContext ctx, IHttpClientFactory httpFactory, DatabaseService db)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
var allKeys = await db.GetApiKeys();
var anthropicKeys = allKeys.Where(k => k.Provider == "anthropic").ToList();
var results = new List<object>();
foreach (var key in anthropicKeys)
{
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 });
}
await ctx.Response.WriteAsJsonAsync(new { checked_ = results.Count, results });
}
// ── Scan-Endpoints ──
private static Task HandleScanTrigger(HttpContext ctx, KeyScannerService scanner)
@@ -350,7 +344,7 @@ public static class ApiKeysEndpoint
Provider = "anthropic",
Name = title,
ApiKey = sk.ApiKey,
IsActive = false,
IsPublic = false,
Status = sk.Status,
Plan = sk.Plan,
Usage7d = sk.Usage7d,
@@ -374,4 +368,80 @@ public static class ApiKeysEndpoint
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
// ── User-Endpoints ──
private static async Task HandleGetAvailable(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
if (userId == null) { ctx.Response.StatusCode = 401; return; }
var isAdmin = ctx.IsAdmin();
var keys = isAdmin ? await db.GetApiKeys() : await db.GetPublicApiKeys();
var result = keys.Select(k => new
{
k.KeyId,
k.Provider,
k.Name,
k.IsPublic,
k.Status,
k.Plan,
k.Usage7d,
k.Usage5h,
k.CheckedAt
});
await ctx.Response.WriteAsJsonAsync(result);
}
private static async Task HandleSelectKey(HttpContext ctx, DatabaseService db, string keyId)
{
var userId = ctx.GetUserId();
if (userId == null) { ctx.Response.StatusCode = 401; return; }
var key = await db.GetApiKey(keyId);
if (key == null)
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
return;
}
// Nicht-Admins dürfen nur public Keys auswählen
if (!ctx.IsAdmin() && !key.IsPublic)
{
ctx.Response.StatusCode = 403;
await ctx.Response.WriteAsJsonAsync(new { error = "Key ist nicht öffentlich" });
return;
}
await db.SetUserSelectedApiKey(userId, keyId);
await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, name = key.Name });
}
private static async Task HandleGetSelected(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
if (userId == null) { ctx.Response.StatusCode = 401; return; }
var key = await db.GetUserSelectedApiKey(userId);
if (key == null)
{
await ctx.Response.WriteAsJsonAsync(new { selected = false });
return;
}
await ctx.Response.WriteAsJsonAsync(new
{
selected = true,
keyId = key.KeyId,
provider = key.Provider,
name = key.Name,
isPublic = key.IsPublic,
status = key.Status,
plan = key.Plan,
usage5h = key.Usage5h,
usage7d = key.Usage7d
});
}
}
+12 -2
View File
@@ -51,6 +51,16 @@ public static class ChatEndpoint
}
var userId = req.HttpContext.GetUserId();
// API-Key des Users auflösen
var userApiKey = await db.GetUserSelectedApiKey(userId);
if (userApiKey == null)
{
res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "Kein API-Key ausgewählt. Bitte wähle einen Key aus." });
return;
}
var maxIterations = config.GetValue<int>("MaxIterations", 100);
var hasRemote = remoteMgr.GetBySessionId(sessionId) != null;
@@ -142,7 +152,7 @@ public static class ChatEndpoint
var preparerHistory = await chatService.BuildHistoryFromDb(sessionId, includeTag: "preparer", promptBlocks: ctx.PromptBlocks);
var preparerResult = await anthropic.RunPreparer(
res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, cancellationToken);
res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, userApiKey.ApiKey, cancellationToken);
if (!preparerResult.ShouldForward)
{
@@ -216,7 +226,7 @@ public static class ChatEndpoint
else
{
// Standard: Anthropic
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, 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);
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);
}
}
finally
+1 -1
View File
@@ -7,7 +7,7 @@ public class ApiKeyRecord
public string Provider { get; set; } // anthropic, gemini, openai, ...
public string Name { get; set; } // Anzeigename z.B. "Tausi Pro Key"
public string ApiKey { get; set; } // Der eigentliche Key
public bool IsActive { get; set; } // Nur einer pro Provider aktiv
public bool IsPublic { get; set; } // Für alle User sichtbar/auswählbar
public string Status { get; set; } // ok, invalid, rate_limited, quota_exceeded, error
public string Plan { get; set; } // Pro, Max, Pro/Max, API Console, Unknown
public double? Usage7d { get; set; }
+1
View File
@@ -21,6 +21,7 @@ public class ChatContext
public AgentRecord Agent { get; set; }
public SessionRecord Session { get; set; }
public bool PlanMode { get; set; }
public string ApiToken { get; set; }
// Preparer-Agent (vor dem Worker)
public SubAgentContext Preparer { get; set; }
+1
View File
@@ -13,6 +13,7 @@ public class UserRecord
public string Persona { get; set; }
public string Settings { get; set; }
public string DefaultPath { get; set; }
public string SelectedApiKeyId { get; set; }
public string GetEffectivePersona()
{
+2 -9
View File
@@ -66,21 +66,14 @@ var embeddingBgService = new Gott.Services.EmbeddingBackgroundService(vectorSear
builder.Services.AddSingleton(embeddingBgService);
builder.Services.AddHostedService(sp => sp.GetRequiredService<Gott.Services.EmbeddingBackgroundService>());
// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService)
// Token wird aus DB geladen (api_keys Tabelle), Fallback auf Config
var anthropicToken = "";
var activeKey = dbService.GetActiveApiKey("anthropic").GetAwaiter().GetResult();
if (activeKey != null)
anthropicToken = activeKey.ApiKey;
else if (!string.IsNullOrWhiteSpace(builder.Configuration["AnthropicToken"]))
anthropicToken = builder.Configuration["AnthropicToken"];
// AnthropicService (Singleton – Token wird pro Request vom User aufgelöst)
var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false);
var payloadLoggingGzip = builder.Configuration.GetValue<bool>("PayloadLoggingGzip", false);
builder.Services.AddSingleton<AnthropicService>(sp =>
{
var svc = new AnthropicService(
sp.GetRequiredService<IHttpClientFactory>(),
anthropicToken, toolService, dbService, memoryService,
toolService, dbService, memoryService,
payloadLogging, payloadLoggingGzip,
vectorSearch: vectorSearchService,
embeddingBg: embeddingBgService);
+28 -32
View File
@@ -16,7 +16,6 @@ namespace Gott.Services;
public class AnthropicService
{
private readonly IHttpClientFactory _httpFactory;
private string _token;
private readonly ToolService _toolService;
private readonly DatabaseService _db;
private readonly MemoryService _memoryService;
@@ -27,10 +26,9 @@ public class AnthropicService
private ChatService _chatService;
private TelegramService _telegramService;
public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
{
_httpFactory = httpFactory;
_token = token;
_toolService = toolService;
_db = db;
_memoryService = memoryService;
@@ -54,25 +52,17 @@ public class AnthropicService
_telegramService = telegramService;
}
/// <summary>
/// Aktualisiert den API-Token zur Laufzeit (z.B. wenn ein anderer Key aktiviert wird).
/// </summary>
public void UpdateToken(string token)
{
_token = token ?? "";
}
/// <summary>
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
/// </summary>
private void TryUpdateKeyMeta(HttpResponseMessage httpResp)
private void TryUpdateKeyMeta(HttpResponseMessage httpResp, string apiToken)
{
try
{
var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers);
if (meta == null || string.IsNullOrEmpty(_token)) return;
_ = _db.UpdateApiKeyMetaByToken(_token, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
if (meta == null || string.IsNullOrEmpty(apiToken)) return;
_ = _db.UpdateApiKeyMetaByToken(apiToken, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
}
catch { /* silent */ }
}
@@ -84,6 +74,7 @@ public class AnthropicService
List<object> messages,
string chainId,
string workspace,
string apiToken,
string sessionId = null,
string agentId = null,
string userId = null,
@@ -181,7 +172,7 @@ public class AnthropicService
var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null;
AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote, includeGitHubSearch: useGitHubSearch && _toolService.HasGitHubToken, includeSerperSearch: useSerperSearch && _toolService.HasSerperKey);
var client = CreateClient();
var client = CreateClient(apiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, sessionId, chainId, iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
@@ -192,7 +183,7 @@ public class AnthropicService
workCt
);
TryUpdateKeyMeta(httpResp);
TryUpdateKeyMeta(httpResp, apiToken);
if (!httpResp.IsSuccessStatusCode)
{
@@ -227,7 +218,7 @@ public class AnthropicService
return;
}
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId);
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId, apiToken);
lastStopReason = stopReason;
promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten
@@ -311,7 +302,7 @@ public class AnthropicService
// ── Review-Agent Phase ──
if (review != null && stopReason == "end_turn" && reviewRound < review.MaxReviewIterations)
{
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, usage, iterations, reviewRound, workCt, sseCt);
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, apiToken, usage, iterations, reviewRound, workCt, sseCt);
iterations += reviewResult.ReviewIterationCount;
reviewRound++;
@@ -336,7 +327,7 @@ public class AnthropicService
// ── Finalizer-Agent Phase ──
if (finalizer != null && stopReason == "end_turn")
{
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, usage, iterations, workCt, sseCt);
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, apiToken, usage, iterations, workCt, sseCt);
iterations += finalizerIterations;
}
@@ -467,6 +458,7 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId,
string apiToken,
CancellationToken cancellationToken)
{
// Preparer läuft VOR dem Worker und nutzt den Request-CancellationToken direkt.
@@ -487,7 +479,7 @@ public class AnthropicService
var (text, iterations) = await RunAgentChat(
response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId,
preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion,
preparer.ToolChoice, preparerMessages, chainId, sessionId, usage,
preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage,
0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent",
cancellationToken, cancellationToken);
@@ -555,6 +547,7 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
int reviewRound,
@@ -579,7 +572,7 @@ public class AnthropicService
var (reviewText, reviewIterations) = await RunAgentChat(
response, review.ModelId, review.SystemPrompt, review.AgentId,
workspace, review.UseMemory, review.WebSearchVersion,
review.ToolChoice, reviewMessages, chainId, sessionId, usage,
review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage,
baseIterations, "review", review.Agent?.Name ?? "Review-Agent",
workCt, sseCt);
@@ -630,6 +623,7 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
CancellationToken workCt,
@@ -647,7 +641,7 @@ public class AnthropicService
var (text, iterations) = await RunAgentChat(
response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId,
finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, usage,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage,
baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent",
workCt, sseCt);
@@ -794,6 +788,7 @@ public class AnthropicService
List<object> messages,
string chainId,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
string agentType,
@@ -825,7 +820,7 @@ public class AnthropicService
AddToolsToPayload(payload, agentWorkspace, agentId, useMemory, webSearchVersion, agentToolChoice, iterations);
var client = CreateClient();
var client = CreateClient(apiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, sessionId, chainId, baseIterations + iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
@@ -834,7 +829,7 @@ public class AnthropicService
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
cancellationToken: workCt);
TryUpdateKeyMeta(httpResp);
TryUpdateKeyMeta(httpResp, apiToken);
var respBody = await httpResp.Content.ReadAsStringAsync(workCt);
@@ -925,7 +920,7 @@ public class AnthropicService
string toolResult;
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); }
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); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1047,7 +1042,7 @@ public class AnthropicService
AddToolsToPayload(payload, ctx.Workspace, ctx.AgentId, ctx.UseMemory, ctx.WebSearchVersion, ctx.ToolChoice, iterations);
var client = CreateClient();
var client = CreateClient(ctx.ApiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, ctx.SessionId, ctx.ChainId, iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
@@ -1055,7 +1050,7 @@ public class AnthropicService
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent });
TryUpdateKeyMeta(httpResp);
TryUpdateKeyMeta(httpResp, ctx.ApiToken);
var respBody = await httpResp.Content.ReadAsStringAsync();
@@ -1133,7 +1128,7 @@ public class AnthropicService
string toolResult;
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); }
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); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1243,7 +1238,8 @@ public class AnthropicService
CancellationToken sseCt = default,
RemoteConnectionManager remoteMgr = null,
string sessionId = null,
string userId = null)
string userId = null,
string apiToken = null)
{
var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
// Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht.
@@ -1425,7 +1421,7 @@ public 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, userId: userId, telegramService: _telegramService); }
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); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1694,10 +1690,10 @@ public class AnthropicService
return 0;
}
private HttpClient CreateClient()
private HttpClient CreateClient(string apiToken)
{
var client = _httpFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,code-execution-2025-08-25");
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
+15
View File
@@ -86,6 +86,12 @@ public class CronjobService : BackgroundService
if (ctx == null)
throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert");
// API-Key des Cronjob-Owners auflösen
var userApiKey = await _db.GetUserSelectedApiKey(job.UserId);
if (userApiKey == null)
throw new Exception($"Kein API-Key für User {job.UserId} ausgewählt");
ctx.ApiToken = userApiKey.ApiKey;
// History aus DB laden
var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks);
@@ -134,6 +140,15 @@ public class CronjobService : BackgroundService
return;
}
// API-Key des Cronjob-Owners auflösen
var notifyApiKey = await _db.GetUserSelectedApiKey(job.UserId);
if (notifyApiKey == null)
{
_logger.LogWarning("Kein API-Key für User {UserId} (Cronjob '{Name}' Notification)", job.UserId, job.Name);
return;
}
notifyCtx.ApiToken = notifyApiKey.ApiKey;
// Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
+10 -24
View File
@@ -324,7 +324,7 @@ public partial class DatabaseService
{
var now = DateTime.UtcNow.ToString("o");
conn.Execute(@"
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at)
INSERT INTO api_keys (key_id, provider, name, api_key, is_public, created_at, updated_at)
VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)",
new { keyId = Guid.NewGuid().ToString("N"), token, now });
Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert");
@@ -351,12 +351,11 @@ public partial class DatabaseService
new { provider });
}
public async Task<ApiKeyRecord> GetActiveApiKey(string provider)
public async Task<IEnumerable<ApiKeyRecord>> GetPublicApiKeys()
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
"SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1",
new { provider });
return await conn.QueryAsync<ApiKeyRecord>(
"SELECT * FROM api_keys WHERE is_public = 1 ORDER BY provider, name");
}
public async Task<ApiKeyRecord> GetApiKey(string keyId)
@@ -372,8 +371,8 @@ public partial class DatabaseService
{
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at)
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)",
INSERT INTO api_keys (key_id, provider, name, api_key, is_public, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at)
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsPublic, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)",
record);
return await conn.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
}
@@ -409,29 +408,16 @@ public partial class DatabaseService
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
UPDATE api_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now, updated_at = @now
WHERE api_key = @apiKey AND is_active = 1",
WHERE api_key = @apiKey",
new { apiKey, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") });
}
public async Task SetActiveApiKey(string keyId, string provider)
{
using var conn = CreateConnection();
// Erst alle deaktivieren
await conn.ExecuteAsync(
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider",
new { provider, now = DateTime.UtcNow.ToString("o") });
// Dann den gewählten aktivieren
await conn.ExecuteAsync(
"UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId",
new { keyId, now = DateTime.UtcNow.ToString("o") });
}
public async Task DeactivateApiKey(string keyId)
public async Task SetApiKeyPublic(string keyId, bool isPublic)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId",
new { keyId, now = DateTime.UtcNow.ToString("o") });
"UPDATE api_keys SET is_public = @isPublic, updated_at = @now WHERE key_id = @keyId",
new { keyId, isPublic, now = DateTime.UtcNow.ToString("o") });
}
public async Task DeleteApiKey(string keyId)
+28
View File
@@ -187,4 +187,32 @@ public partial class DatabaseService
await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId });
}
// ── User API-Key Auswahl ──
public async Task SetUserSelectedApiKey(string userId, string keyId)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"UPDATE users SET selected_api_key_id = @keyId, updated_at = @now WHERE user_id = @userId",
new { userId, keyId = (object)keyId ?? DBNull.Value, now = DateTime.UtcNow.ToString("o") });
}
public async Task<string> GetUserSelectedApiKeyId(string userId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<string>(
"SELECT selected_api_key_id FROM users WHERE user_id = @userId",
new { userId });
}
public async Task<ApiKeyRecord> GetUserSelectedApiKey(string userId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(@"
SELECT k.* FROM api_keys k
INNER JOIN users u ON u.selected_api_key_id = k.key_id
WHERE u.user_id = @userId",
new { userId });
}
}
+4
View File
@@ -341,6 +341,10 @@ public partial class DatabaseService
TryAddColumn(conn, "api_keys", "usage_5h", "REAL");
TryAddColumn(conn, "api_keys", "checked_at", "TEXT");
TryAddColumn(conn, "api_keys", "source_url", "TEXT");
TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0");
// users: ausgewählter API-Key
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
MigrateImagesToWebP(conn);
+12 -3
View File
@@ -80,7 +80,7 @@ public class TelegramService
return;
}
await HandleMessage(chatId, text, bot.BotToken, session);
await HandleMessage(chatId, text, bot.BotToken, bot.UserId, session);
}
private async Task HandleStart(long chatId, TelegramBotRecord bot, SessionRecord session)
@@ -159,15 +159,24 @@ public class TelegramService
// ── Nachricht an Claude weiterleiten ──
private async Task HandleMessage(long chatId, string userText, string botToken, SessionRecord session)
private async Task HandleMessage(long chatId, string userText, string botToken, string botUserId, SessionRecord session)
{
var ctx = await _chatService.PrepareContext(session.SessionId, null, _maxIterations);
var ctx = await _chatService.PrepareContext(session.SessionId, botUserId, _maxIterations);
if (ctx == null)
{
await SendMessage(chatId, botToken, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu.");
return;
}
// API-Key des Bot-Owners auflösen
var userApiKey = await _db.GetUserSelectedApiKey(botUserId);
if (userApiKey == null)
{
await SendMessage(chatId, botToken, "❌ Kein API-Key ausgewählt. Der Bot-Besitzer muss im Web-UI einen Key auswählen.");
return;
}
ctx.ApiToken = userApiKey.ApiKey;
await SendChatAction(chatId, botToken, "typing");
try
+3 -2
View File
@@ -391,7 +391,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)
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)
{
switch (name)
{
@@ -765,9 +765,10 @@ public class ToolService
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: null);
var ctx = await chatService.PrepareContext(targetSessionId, userId: userId);
if (ctx == null)
return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)";
ctx.ApiToken = apiToken;
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren
+8
View File
@@ -45,6 +45,14 @@
border-color: var(--f3-border);
}
.chat-usage-overlay .usage-badge-nodata {
flex-direction: row;
align-items: center;
width: auto;
gap: var(--badge-spacing);
padding: var(--box-padding);
}
#chat {
flex: 1;
overflow-y: auto;
+1
View File
@@ -559,6 +559,7 @@
<div class="form">
<div class="section-header">
<h2>API-Key</h2>
<button id="apiKeysCheckAllBtn" class="section-add-btn" style="margin-right: var(--badge-spacing); margin-left: auto;"><i class="fa fa-refresh"></i> Alle prüfen</button>
<button id="apiKeysNewBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Key</button>
</div>
<div id="apiKeysList" class="admin-user-list"></div>
+8 -2
View File
@@ -219,10 +219,16 @@ function apiSaveServerSettings(data) { return _put('/api/admin/server-settings',
function apiGetApiKeys() { return _get('/api/admin/api-keys'); }
function apiCreateApiKey(data) { return _post('/api/admin/api-keys', data, 'Fehler beim Erstellen'); }
function apiUpdateApiKey(id, data) { return _put('/api/admin/api-keys/' + id, data, 'Fehler beim Aktualisieren'); }
function apiActivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/activate', undefined, 'Fehler beim Aktivieren'); }
function apiDeactivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/deactivate', undefined, 'Fehler beim Deaktivieren'); }
function apiToggleApiKeyPublic(id) { return _put('/api/admin/api-keys/' + id + '/public', undefined, 'Fehler beim Umschalten'); }
// ── User Key Selection API ──
function apiGetAvailableKeys() { return _get('/api/api-keys/available', []); }
function apiSelectApiKey(keyId) { return _put('/api/api-keys/select/' + keyId, undefined, 'Fehler beim Auswählen'); }
function apiGetSelectedKey() { return _get('/api/api-keys/selected', null); }
function apiDeleteApiKey(id) { return _del('/api/admin/api-keys/' + id, 'Fehler beim Löschen'); }
function apiCheckApiKey(id) { return _post('/api/admin/api-keys/' + id + '/check', undefined, 'Fehler beim Prüfen'); }
function apiCheckAllApiKeys() { return _post('/api/admin/api-keys/check-all', undefined, 'Fehler beim Prüfen'); }
// ── Key Scanner API (Admin) ──
+25 -28
View File
@@ -63,6 +63,7 @@ registerPage('api-keys', {
var list = document.getElementById('apiKeysList');
var newBtn = document.getElementById('apiKeysNewBtn');
var checkAllBtn = document.getElementById('apiKeysCheckAllBtn');
var form = document.getElementById('apiKeysForm');
var formTitle = document.getElementById('apiKeysFormTitle');
var providerSelect = document.getElementById('apiKeysProvider');
@@ -119,21 +120,14 @@ registerPage('api-keys', {
var badgeRow = document.createElement('div');
badgeRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:2px';
if (key.isActive) {
var deactivateBtn = document.createElement('button');
deactivateBtn.className = 'icon-btn';
deactivateBtn.title = 'Deaktivieren';
deactivateBtn.innerHTML = '<i class="fa fa-toggle-on" style="color:var(--accent)"></i>';
deactivateBtn.addEventListener('click', function() { deactivateKey(key); });
badgeRow.appendChild(deactivateBtn);
} else {
var activateBtn = document.createElement('button');
activateBtn.className = 'icon-btn';
activateBtn.title = 'Aktivieren';
activateBtn.innerHTML = '<i class="fa fa-toggle-off"></i>';
activateBtn.addEventListener('click', function() { activateKey(key); });
badgeRow.appendChild(activateBtn);
}
var publicBtn = document.createElement('button');
publicBtn.className = 'icon-btn';
publicBtn.title = key.isPublic ? 'Öffentlich (klicken zum Privatschalten)' : 'Privat (klicken zum Veröffentlichen)';
publicBtn.innerHTML = key.isPublic
? '<i class="fa fa-globe" style="color:var(--accent)"></i>'
: '<i class="fa fa-lock"></i>';
publicBtn.addEventListener('click', function() { togglePublic(key); });
badgeRow.appendChild(publicBtn);
var usageBadge = createUsageBadge(key.usage5h, key.usage7d);
if (usageBadge) badgeRow.appendChild(usageBadge);
@@ -234,20 +228,10 @@ registerPage('api-keys', {
nameInput.focus();
}
async function activateKey(key) {
async function togglePublic(key) {
try {
await apiActivateApiKey(key.keyId);
showToast(key.name + ' aktiviert');
await loadKeys();
} catch (err) {
showToast(err.message, 'error');
}
}
async function deactivateKey(key) {
try {
await apiDeactivateApiKey(key.keyId);
showToast(key.name + ' deaktiviert');
await apiToggleApiKeyPublic(key.keyId);
showToast(key.name + (key.isPublic ? ' privat geschaltet' : ' veröffentlicht'));
await loadKeys();
} catch (err) {
showToast(err.message, 'error');
@@ -265,6 +249,19 @@ registerPage('api-keys', {
}
}
// Alle Keys prüfen
checkAllBtn.addEventListener('click', function() {
withButtonLoading(checkAllBtn, 'Alle prüfen', async function() {
try {
var result = await apiCheckAllApiKeys();
showToast(result.checked_ + ' Keys geprüft');
await loadKeys();
} catch (err) {
showToast(err.message, 'error');
}
});
});
// Neuer Key
newBtn.addEventListener('click', function() {
editingKeyId = null;
+65 -44
View File
@@ -44,17 +44,25 @@ function _createChatUsageBadge(usage5h, usage7d) {
}
async function _initChatUsageBadge() {
if (!currentUser || !currentUser.isAdmin) return;
if (!currentUser) return;
var container = document.getElementById('chatUsageBadge');
if (!container) return;
try {
var keys = await apiGetApiKeys();
var activeKey = keys.find(function(k) { return k.isActive; });
if (!activeKey) { container.hidden = true; return; }
var selectedKey = await apiGetSelectedKey();
if (!selectedKey || !selectedKey.selected) {
container.innerHTML = '';
var noKeyBtn = document.createElement('button');
noKeyBtn.className = 'chat-usage-nokey';
noKeyBtn.innerHTML = '<i class="fa fa-key"></i> Key auswählen';
noKeyBtn.addEventListener('click', function() { _showKeySwitchModal(); });
container.appendChild(noKeyBtn);
container.hidden = false;
return;
}
_chatUsageKeyId = activeKey.keyId;
_renderChatUsageBadge(container, activeKey);
_chatUsageKeyId = selectedKey.keyId;
_renderChatUsageBadge(container, selectedKey);
container.hidden = false;
// Scroll-Listener: Badge ausblenden wenn User nach oben scrollt
@@ -73,13 +81,12 @@ async function _initChatUsageBadge() {
}
async function _refreshChatUsageBadge() {
if (!currentUser || !currentUser.isAdmin) return;
if (!currentUser) return;
var container = document.getElementById('chatUsageBadge');
if (!container || !_chatUsageKeyId) return;
try {
var keys = await apiGetApiKeys();
var activeKey = keys.find(function(k) { return k.isActive; });
if (activeKey) _renderChatUsageBadge(container, activeKey);
var selectedKey = await apiGetSelectedKey();
if (selectedKey) _renderChatUsageBadge(container, selectedKey);
} catch(e) { /* ignore */ }
}
@@ -87,52 +94,68 @@ function _renderChatUsageBadge(container, key) {
container.innerHTML = '';
var badge = _createChatUsageBadge(key.usage5h, key.usage7d);
if (badge) {
badge.addEventListener('click', function(e) {
e.stopPropagation();
_showKeySwitchModal();
});
badge.title = (key.name || 'API Key') + ' · ' + (key.plan || '?');
container.appendChild(badge);
if (!badge) {
badge = document.createElement('div');
badge.className = 'usage-badge usage-badge-nodata';
badge.innerHTML = '<i class="fa fa-key"></i> ' + (key.name || 'API Key');
}
var checkBtn = document.createElement('button');
checkBtn.className = 'chat-usage-check icon-btn';
checkBtn.innerHTML = '<i class="fa fa-refresh"></i>';
checkBtn.title = 'Key prüfen';
checkBtn.addEventListener('click', async function(e) {
badge.addEventListener('click', function(e) {
e.stopPropagation();
checkBtn.disabled = true;
checkBtn.querySelector('i').className = 'fa fa-spinner fa-spin';
try {
var result = await apiCheckApiKey(_chatUsageKeyId);
if (result) {
var keys = await apiGetApiKeys();
var activeKey = keys.find(function(k) { return k.isActive; });
if (activeKey) _renderChatUsageBadge(container, activeKey);
}
} catch(e) {
console.error('Key check failed:', e);
}
checkBtn.disabled = false;
checkBtn.querySelector('i').className = 'fa fa-refresh';
_showKeySwitchModal();
});
container.appendChild(checkBtn);
badge.title = (key.name || 'API Key') + ' · ' + (key.plan || '?');
badge.style.cursor = 'pointer';
container.appendChild(badge);
// Check-Button nur für Admins
if (currentUser && currentUser.isAdmin) {
var checkBtn = document.createElement('button');
checkBtn.className = 'chat-usage-check icon-btn';
checkBtn.innerHTML = '<i class="fa fa-refresh"></i>';
checkBtn.title = 'Key prüfen';
checkBtn.addEventListener('click', async function(e) {
e.stopPropagation();
checkBtn.disabled = true;
checkBtn.querySelector('i').className = 'fa fa-spinner fa-spin';
try {
await apiCheckApiKey(_chatUsageKeyId);
var selectedKey = await apiGetSelectedKey();
if (selectedKey) _renderChatUsageBadge(container, selectedKey);
} catch(e) {
console.error('Key check failed:', e);
}
checkBtn.disabled = false;
checkBtn.querySelector('i').className = 'fa fa-refresh';
});
container.appendChild(checkBtn);
}
}
async function _showKeySwitchModal() {
var keys = [];
try { keys = await apiGetApiKeys(); } catch(e) { return; }
try { keys = await apiGetAvailableKeys(); } catch(e) { return; }
var selectedKey = null;
try { selectedKey = await apiGetSelectedKey(); } catch(e) { /* ignore */ }
var selectedKeyId = selectedKey ? selectedKey.keyId : null;
var { body, close } = _createModal('API Key wechseln', { modal: 'key-switch-modal' });
if (keys.length === 0) {
var empty = document.createElement('div');
empty.style.cssText = 'padding:1rem;text-align:center;opacity:0.7';
empty.textContent = 'Keine Keys verfügbar. Ein Admin muss Keys als öffentlich markieren.';
body.appendChild(empty);
return;
}
var list = document.createElement('div');
list.className = 'key-switch-list';
keys.forEach(function(key) {
var row = document.createElement('div');
row.className = 'key-switch-row';
if (key.isActive) row.classList.add('active');
if (key.keyId === selectedKeyId) row.classList.add('active');
var info = document.createElement('div');
info.className = 'key-switch-info';
@@ -157,15 +180,13 @@ async function _showKeySwitchModal() {
row.appendChild(badge);
}
if (!key.isActive) {
if (key.keyId !== selectedKeyId) {
row.style.cursor = 'pointer';
row.addEventListener('click', async function() {
row.style.opacity = '0.5';
row.style.pointerEvents = 'none';
try {
var activeKey = keys.find(function(k) { return k.isActive; });
if (activeKey) await apiDeactivateApiKey(activeKey.keyId);
await apiActivateApiKey(key.keyId);
await apiSelectApiKey(key.keyId);
close();
await _initChatUsageBadge();
} catch(e) {