feat: API Key pro Agent statt pro User

- Neue Tabelle agent_api_keys (user_id, agent_id, key_id)
- Migration: bestehende selected_api_key_id auf alle Agents verteilt
- Backend: GetAgentApiKeyRecord(userId, agentId) in Chat, Telegram, Cronjobs
- Endpoints: select/selected mit ?agentId Query-Parameter
- Frontend: currentAgentId aus Session, alle Key-Calls agent-aware
- DeleteApiKey bereinigt agent_api_keys statt users.selected_api_key_id
This commit is contained in:
Meleki committed 2026-03-14 21:41:27 +01:00
1 parent b04b1a2cf9
commit 1effc6d00f
10 files changed
+128 -43

No files matched your search

+18 -3
View File
@@ -540,8 +540,16 @@ public static class ApiKeysEndpoint
return;
}
await db.SetUserSelectedApiKey(userId, keyId);
await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, name = key.Name });
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 ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, agentId, name = key.Name });
}
private static async Task HandleGetSelected(HttpContext ctx, DatabaseService db)
@@ -549,7 +557,14 @@ public static class ApiKeysEndpoint
var userId = ctx.GetUserId();
if (userId == null) { ctx.Response.StatusCode = 401; return; }
var key = await db.GetUserSelectedApiKey(userId);
var agentId = ctx.Request.Query["agentId"].FirstOrDefault();
if (string.IsNullOrEmpty(agentId))
{
await ctx.Response.WriteAsJsonAsync(new { selected = false });
return;
}
var key = await db.GetAgentApiKeyRecord(userId, agentId);
if (key == null)
{
await ctx.Response.WriteAsJsonAsync(new { selected = false });
+9 -9
View File
@@ -51,15 +51,6 @@ 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 = settings.GetInt("MaxIterations", 100);
var hasRemote = remoteMgr.GetBySessionId(sessionId) != null;
@@ -71,6 +62,15 @@ public static class ChatEndpoint
return;
}
// API-Key des Users für diesen Agent auflösen
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)
{
// Choice-Antwort: tool_result in die VORHERIGE Chain speichern (wo der tool_use liegt)
+6 -6
View File
@@ -86,10 +86,10 @@ 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);
// API-Key des Cronjob-Owners für diesen Agent auflösen
var userApiKey = await _db.GetAgentApiKeyRecord(job.UserId, ctx.AgentId);
if (userApiKey == null)
throw new Exception($"Kein API-Key für User {job.UserId} ausgewählt");
throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen (User {job.UserId})");
ctx.ApiToken = userApiKey.ApiKey;
// History aus DB laden
@@ -140,11 +140,11 @@ public class CronjobService : BackgroundService
return;
}
// API-Key des Cronjob-Owners auflösen
var notifyApiKey = await _db.GetUserSelectedApiKey(job.UserId);
// API-Key des Cronjob-Owners für Notify-Agent auflösen
var notifyApiKey = await _db.GetAgentApiKeyRecord(job.UserId, notifyCtx.AgentId);
if (notifyApiKey == null)
{
_logger.LogWarning("Kein API-Key für User {UserId} (Cronjob '{Name}' Notification)", job.UserId, job.Name);
_logger.LogWarning("Kein API-Key für Agent {AgentId} (Cronjob '{Name}' Notification)", notifyCtx.AgentId, job.Name);
return;
}
notifyCtx.ApiToken = notifyApiKey.ApiKey;
+2 -2
View File
@@ -427,9 +427,9 @@ public partial class DatabaseService
await conn.ExecuteAsync(
"UPDATE scanned_keys SET adopted_api_key_id = NULL WHERE adopted_api_key_id = @keyId",
new { keyId });
// User die diesen Key ausgewählt haben zurücksetzen
// Agent-Key-Zuordnungen die auf diesen Key verweisen löschen
await conn.ExecuteAsync(
"UPDATE users SET selected_api_key_id = NULL WHERE selected_api_key_id = @keyId",
"DELETE FROM agent_api_keys WHERE key_id = @keyId",
new { keyId });
await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId });
}
+23 -12
View File
@@ -187,32 +187,43 @@ public partial class DatabaseService
await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId });
}
// ── User API-Key Auswahl ──
// ── Agent API-Key Zuordnung ──
public async Task SetUserSelectedApiKey(string userId, string keyId)
public async Task SetAgentApiKey(string userId, string agentId, 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") });
if (string.IsNullOrEmpty(keyId))
{
await conn.ExecuteAsync(
"DELETE FROM agent_api_keys WHERE user_id = @userId AND agent_id = @agentId",
new { userId, agentId });
}
else
{
await conn.ExecuteAsync(@"
INSERT INTO agent_api_keys (user_id, agent_id, key_id)
VALUES (@userId, @agentId, @keyId)
ON CONFLICT(user_id, agent_id) DO UPDATE SET key_id = @keyId",
new { userId, agentId, keyId });
}
}
public async Task<string> GetUserSelectedApiKeyId(string userId)
public async Task<string> GetAgentApiKeyId(string userId, string agentId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<string>(
"SELECT selected_api_key_id FROM users WHERE user_id = @userId",
new { userId });
"SELECT key_id FROM agent_api_keys WHERE user_id = @userId AND agent_id = @agentId",
new { userId, agentId });
}
public async Task<ApiKeyRecord> GetUserSelectedApiKey(string userId)
public async Task<ApiKeyRecord> GetAgentApiKeyRecord(string userId, string agentId)
{
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 });
INNER JOIN agent_api_keys a ON a.key_id = k.key_id
WHERE a.user_id = @userId AND a.agent_id = @agentId",
new { userId, agentId });
}
/// <summary>
+52 -1
View File
@@ -357,9 +357,23 @@ public partial class DatabaseService
TryAddColumn(conn, "api_keys", "source_url", "TEXT");
TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0");
// users: ausgewählter API-Key
// users: ausgewählter API-Key (legacy, wird durch agent_api_keys ersetzt)
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
key_id TEXT NOT NULL,
UNIQUE(user_id, agent_id)
);
CREATE INDEX IF NOT EXISTS idx_agent_api_keys_user ON agent_api_keys(user_id);
");
// Migration: selected_api_key_id → agent_api_keys (einmalig)
MigrateSelectedApiKeysToAgentKeys(conn);
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
MigrateImagesToWebP(conn);
@@ -367,6 +381,43 @@ public partial class DatabaseService
MigrateImageThumbnails(conn);
}
private void MigrateSelectedApiKeysToAgentKeys(SqliteConnection conn)
{
// Nur migrieren wenn agent_api_keys leer ist und users mit selected_api_key_id existieren
var count = conn.ExecuteScalar<int>("SELECT COUNT(*) FROM agent_api_keys");
if (count > 0) return;
var usersWithKeys = conn.Query<(string UserId, string SelectedApiKeyId)>(
"SELECT user_id, selected_api_key_id FROM users WHERE selected_api_key_id IS NOT NULL AND selected_api_key_id != ''")
.ToList();
foreach (var (userId, keyId) in usersWithKeys)
{
// Alle Agents des Users + öffentliche Agents
var agentIds = conn.Query<string>(
"SELECT agent_id FROM agents WHERE user_id = @userId OR is_public = 1",
new { userId }).ToList();
// Alle Sessions des Users mit Agent → deren Agents auch berücksichtigen
var sessionAgentIds = conn.Query<string>(
"SELECT DISTINCT agent_id FROM sessions WHERE user_id = @userId AND agent_id IS NOT NULL AND agent_id != ''",
new { userId }).ToList();
var allAgentIds = agentIds.Union(sessionAgentIds).Distinct().ToList();
foreach (var agentId in allAgentIds)
{
try
{
conn.Execute(
"INSERT OR IGNORE INTO agent_api_keys (user_id, agent_id, key_id) VALUES (@userId, @agentId, @keyId)",
new { userId, agentId, keyId });
}
catch { /* UNIQUE constraint – already exists */ }
}
}
}
private void MigrateImagesToWebP(SqliteConnection conn)
{
const int webPQuality = 80;
+3 -3
View File
@@ -168,11 +168,11 @@ public class TelegramService
return;
}
// API-Key des Bot-Owners auflösen
var userApiKey = await _db.GetUserSelectedApiKey(botUserId);
// API-Key des Bot-Owners für diesen Agent auflösen
var userApiKey = await _db.GetAgentApiKeyRecord(botUserId, ctx.AgentId);
if (userApiKey == null)
{
await SendMessage(chatId, botToken, "❌ Kein API-Key ausgewählt. Der Bot-Besitzer muss im Web-UI einen Key auswählen.");
await SendMessage(chatId, botToken, "❌ Kein API-Key für diesen Agent zugewiesen. Der Bot-Besitzer muss im Web-UI einen Key zuweisen.");
return;
}
ctx.ApiToken = userApiKey.ApiKey;
+2 -2
View File
@@ -224,8 +224,8 @@ function apiToggleApiKeyPublic(id) { return _put('/api/admin/api-keys/' + id + '
// ── 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 apiSelectApiKey(keyId, agentId) { return _put('/api/api-keys/select/' + keyId + '?agentId=' + encodeURIComponent(agentId), undefined, 'Fehler beim Auswählen'); }
function apiGetSelectedKey(agentId) { return _get('/api/api-keys/selected' + (agentId ? '?agentId=' + encodeURIComponent(agentId) : ''), 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'); }
+8 -5
View File
@@ -49,7 +49,7 @@ async function _initChatUsageBadge() {
if (!container) return;
try {
var selectedKey = await apiGetSelectedKey();
var selectedKey = await apiGetSelectedKey(getCurrentAgentId());
if (!selectedKey || !selectedKey.selected) {
container.innerHTML = '';
var noKeyBtn = document.createElement('button');
@@ -85,7 +85,7 @@ async function _refreshChatUsageBadge() {
var container = document.getElementById('chatUsageBadge');
if (!container || !_chatUsageKeyId) return;
try {
var selectedKey = await apiGetSelectedKey();
var selectedKey = await apiGetSelectedKey(getCurrentAgentId());
if (selectedKey) _renderChatUsageBadge(container, selectedKey);
} catch(e) { /* ignore */ }
}
@@ -119,7 +119,7 @@ function _renderChatUsageBadge(container, key) {
checkBtn.querySelector('i').className = 'fa fa-spinner fa-spin';
try {
await apiCheckApiKey(_chatUsageKeyId);
var selectedKey = await apiGetSelectedKey();
var selectedKey = await apiGetSelectedKey(getCurrentAgentId());
if (selectedKey) _renderChatUsageBadge(container, selectedKey);
} catch(e) {
console.error('Key check failed:', e);
@@ -136,7 +136,7 @@ async function _showKeySwitchModal() {
try { keys = await apiGetAvailableKeys(); } catch(e) { return; }
var selectedKey = null;
try { selectedKey = await apiGetSelectedKey(); } catch(e) { /* ignore */ }
try { selectedKey = await apiGetSelectedKey(getCurrentAgentId()); } catch(e) { /* ignore */ }
var selectedKeyId = selectedKey ? selectedKey.keyId : null;
var { body, close } = _createModal('API Key wechseln', { modal: 'key-switch-modal' });
@@ -187,7 +187,7 @@ async function _showKeySwitchModal() {
row.addEventListener('click', async function() {
row.classList.add('loading-state');
try {
await apiSelectApiKey(key.keyId);
await apiSelectApiKey(key.keyId, getCurrentAgentId());
close();
await _initChatUsageBadge();
} catch(e) {
@@ -352,6 +352,8 @@ registerPage('chat', {
return;
}
currentAgentId = session.agentId;
const titleEl = document.getElementById('sessionTitle');
if (titleEl) {
titleEl.textContent = session.name || 'Unbenannt';
@@ -605,6 +607,7 @@ registerPage('chat', {
_chatAbandoned = true;
stopTypingIndicator();
setCurrentAgentName(null);
currentAgentId = null;
closeEmojiPicker();
// Usage Badge cleanup
if (_chatUsageScrollHandler) {
+5
View File
@@ -1,7 +1,12 @@
// ── Session State ──
let currentSessionId = null;
let currentAgentId = null;
function getCurrentSessionId() {
return currentSessionId;
}
function getCurrentAgentId() {
return currentAgentId;
}