From 5d1d03fe5a24659680dbe6ec7687fcb6c6eb71bf Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sun, 19 Jul 2026 20:44:48 +0200 Subject: [PATCH] feat(server): splits API keys into user keys and client keys --- README.md | 9 +- docs/Screenshots.md | 4 +- src/ReverseLlama.Server/AdminEndpoints.cs | 42 +- src/ReverseLlama.Server/ManagementStore.cs | 361 +++++++++++------- src/ReverseLlama.Server/Program.cs | 8 +- .../ReverseProxyEndpoint.cs | 32 +- src/ReverseLlama.Server/ServerSettings.cs | 3 + .../TokenAuthentication.cs | 42 +- src/ReverseLlama.Server/wwwroot/admin/app.js | 100 ++--- .../wwwroot/admin/index.html | 2 +- 10 files changed, 350 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index be297ff..6967c2c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The client opens and maintains a WebSocket connection to the server. The server The server provides - An API with - - Authentication via API keys + - Authentication via user keys - Authorization (planned) - Load balancing (Scale your AI strategy horizontally!) - (Ollama-only) Model management (install, remove, load, unload models) @@ -27,7 +27,7 @@ The server provides - What clients are mapped to which groups - Billing (planned) - (planned) Price per model per thousand tokens - - (planned) Usage per API user + - (planned) Usage per user key - (planned) Rate limiting The client provides a persistent outbound connection to the server and forwards requests to the local Ollama (or vLLM, etc.) instance. Responses stream back through the tunnel with minimal overhead. @@ -84,13 +84,14 @@ Server options: - `--status-path `: defaults to `/_reverse-llama/status`. - `--chunk-size ` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`. - `--embedding-cache-path ` or `REVERSE_LLAMA_EMBEDDING_CACHE_PATH`: SQLite cache file for embedding vectors. Defaults to `App_Data\embedding-cache.sqlite` under the server app directory. -- `--management-database-path ` or `REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH`: SQLite database for admin API keys, client disable state, and request/model metrics. Defaults to `App_Data\management.sqlite` under the server app directory. +- `--management-database-path ` or `REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH`: SQLite database for admin user keys, client keys, client disable state, and request/model metrics. Defaults to `App_Data\management.sqlite` under the server app directory. +- `--secure-cookies` or `REVERSE_LLAMA_SECURE_COOKIES`: set to `false` to allow admin auth cookies over plain HTTP (for local development). Defaults to `true`. Admin UI: - `GET /admin` opens the Keycloak-protected management UI. - The temporary Keycloak settings live under `Authentication:Keycloak` in `appsettings.json`. -- API keys created in the UI are accepted anywhere the shared token is accepted: `X-Reverse-Llama-Token`, `Authorization: Bearer `, `?token=...`, and `/token//...`. +- User keys created in the UI are accepted anywhere the shared token is accepted: `X-Reverse-Llama-Token`, `Authorization: Bearer `, `?token=...`, and `/token//...`. - Model add/remove/load/unload commands are sent through the connected tunnel client to Ollama (`/api/pull`, `/api/delete`, `/api/generate`, and `/api/show`). Client options: diff --git a/docs/Screenshots.md b/docs/Screenshots.md index c09a4ec..22cc2d7 100644 --- a/docs/Screenshots.md +++ b/docs/Screenshots.md @@ -2,5 +2,5 @@ ![Screenshot of the backend - clients view](images/Screenshots_website_clients.png) # Models view ![Screenshot of the backend - models view](images/Screenshots_website_models.png) -# API Keys view -![Screenshot of the backend - api keys view](images/Screenshots_website_apikeys.png) \ No newline at end of file +# User Keys view +![Screenshot of the backend - user keys view](images/Screenshots_website_apikeys.png) \ No newline at end of file diff --git a/src/ReverseLlama.Server/AdminEndpoints.cs b/src/ReverseLlama.Server/AdminEndpoints.cs index 4e40c57..2db8756 100644 --- a/src/ReverseLlama.Server/AdminEndpoints.cs +++ b/src/ReverseLlama.Server/AdminEndpoints.cs @@ -268,14 +268,14 @@ internal static class AdminEndpoints return Results.Json(result); }); - api.MapGet("/api-keys", (ManagementStore store) => - Results.Json(store.ListApiKeys())); + api.MapGet("/user-keys", (ManagementStore store) => + Results.Json(store.ListUserKeys())); - api.MapPost("/api-keys", (CreateApiKeyRequest request, ManagementStore store) => + api.MapPost("/user-keys", (CreateUserKeyRequest request, ManagementStore store) => { try { - return Results.Json(store.CreateApiKey(request.Name)); + return Results.Json(store.CreateUserKey(request.Name)); } catch (Exception exception) { @@ -283,15 +283,15 @@ internal static class AdminEndpoints } }); - api.MapDelete("/api-keys/{id}", (string id, ManagementStore store) => - store.DeleteApiKey(id) + api.MapDelete("/user-keys/{id}", (string id, ManagementStore store) => + store.DeleteUserKey(id) ? Results.NoContent() - : Results.NotFound(new { error = $"API key '{id}' was not found." })); + : Results.NotFound(new { error = $"User key '{id}' was not found." })); api.MapGet("/client-keys", (ManagementStore store) => Results.Json(store.ListClientKeys())); - api.MapPost("/client-keys", (CreateApiKeyRequest request, ManagementStore store) => + api.MapPost("/client-keys", (CreateUserKeyRequest request, ManagementStore store) => { try { @@ -395,21 +395,21 @@ internal static class AdminEndpoints : Results.NotFound(new { error = $"Client '{clientId}' was not found." }); }); - api.MapGet("/api-keys/groups", (ManagementStore store) => - Results.Json(store.ListApiKeyGroups())); + api.MapGet("/user-keys/groups", (ManagementStore store) => + Results.Json(store.ListUserKeyGroups())); - api.MapPut("/api-keys/{id}/groups", (string id, SetApiKeyGroupsRequest request, ManagementStore store) => + api.MapPut("/user-keys/{id}/groups", (string id, SetUserKeyGroupsRequest request, ManagementStore store) => { - var keys = store.ListApiKeys(); + var keys = store.ListUserKeys(); if (!keys.Any(k => k.Id == id)) { - return Results.NotFound(new { error = $"API key '{id}' was not found." }); + return Results.NotFound(new { error = $"User key '{id}' was not found." }); } try { - store.SetApiKeyGroups(id, request.GroupIds ?? []); - return Results.Ok(new { apiKeyId = id, groupIds = store.GetApiKeyGroupIds(id) }); + store.SetUserKeyGroups(id, request.GroupIds ?? []); + return Results.Ok(new { userKeyId = id, groupIds = store.GetUserKeyGroupIds(id) }); } catch (Exception exception) { @@ -556,7 +556,7 @@ internal static class AdminEndpoints { byModel = store.GetTokenStatsByModel(), byClient = store.GetTokenStatsByClient(), - byApiKey = store.GetTokenStatsByApiKey(), + byUserKey = store.GetTokenStatsByUserKey(), byGroup = store.GetTokenStatsByGroup() })); @@ -620,7 +620,7 @@ internal static class AdminEndpoints keycloakConfigured = settings.Keycloak.IsConfigured, sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token), clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken), - apiKeysConfigured = store.HasApiKeys, + userKeysConfigured = store.HasUserKeys, clientKeysConfigured = store.HasClientKeys }, management = new @@ -631,10 +631,10 @@ internal static class AdminEndpoints }, clients = BuildClientSummaries(hub, store), models = BuildModelSummaries(hub, store), - apiKeys = store.ListApiKeys(), + userKeys = store.ListUserKeys(), clientKeys = store.ListClientKeys(), groups = store.ListGroups(), - apiKeyGroups = store.ListApiKeyGroups(), + userKeyGroups = store.ListUserKeyGroups(), clientGroups = store.ResolveClientGroups( hub.ClientSnapshots.Select(c => c.Id).ToList()) }; @@ -880,7 +880,7 @@ internal sealed record ModelActionRequest( string Model, string Action); -internal sealed record CreateApiKeyRequest(string? Name); +internal sealed record CreateUserKeyRequest(string? Name); internal sealed record CreateGroupRequest(string? Name); @@ -891,7 +891,7 @@ internal sealed record AddGroupClientRequest( string? Model, string? ClientPattern); -internal sealed record SetApiKeyGroupsRequest(IReadOnlyList? GroupIds); +internal sealed record SetUserKeyGroupsRequest(IReadOnlyList? GroupIds); internal sealed record UpdateBillingRequest( string? Currency, diff --git a/src/ReverseLlama.Server/ManagementStore.cs b/src/ReverseLlama.Server/ManagementStore.cs index 16867d2..ae12b32 100644 --- a/src/ReverseLlama.Server/ManagementStore.cs +++ b/src/ReverseLlama.Server/ManagementStore.cs @@ -7,10 +7,10 @@ namespace ReverseLlama.Server; internal sealed class ManagementStore { - private static readonly TimeSpan ApiKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1); + private static readonly TimeSpan UserKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1); - private readonly Dictionary _apiKeysByHash = new(StringComparer.Ordinal); - private readonly Dictionary _clientKeysByHash = new(StringComparer.Ordinal); + private readonly Dictionary _userKeysByHash = new(StringComparer.Ordinal); + private readonly Dictionary _clientKeysByHash = new(StringComparer.Ordinal); private readonly string _connectionString = ""; private readonly string _databasePath = ""; private readonly object _lock = new(); @@ -51,7 +51,7 @@ internal sealed class ManagementStore public string? LastError => _lastError; - public bool HasApiKeys + public bool HasUserKeys { get { @@ -62,7 +62,7 @@ internal sealed class ManagementStore lock (_lock) { - return _apiKeysByHash.Count > 0; + return _userKeysByHash.Count > 0; } } } @@ -83,26 +83,26 @@ internal sealed class ManagementStore } } - public bool IsApiKeyValid(string apiKey, bool updateLastUsed) + public bool IsUserKeyValid(string userKey, bool updateLastUsed) { - if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey)) + if (!_isAvailable || string.IsNullOrWhiteSpace(userKey)) { return false; } - var hash = HashApiKey(apiKey); + var hash = HashKey(userKey); var now = DateTimeOffset.UtcNow; lock (_lock) { - if (!_apiKeysByHash.TryGetValue(hash, out var key)) + if (!_userKeysByHash.TryGetValue(hash, out var key)) { return false; } if (!updateLastUsed || key.LastUsedUtc is not null - && now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval) + && now - key.LastUsedUtc.Value < UserKeyLastUsedWriteInterval) { return true; } @@ -113,21 +113,21 @@ internal sealed class ManagementStore { using var connection = OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "UPDATE api_keys SET last_used_at_utc = $last_used_at_utc WHERE id = $id"; + command.CommandText = "UPDATE user_keys SET last_used_at_utc = $last_used_at_utc WHERE id = $id"; command.Parameters.AddWithValue("$last_used_at_utc", now.ToString("O")); command.Parameters.AddWithValue("$id", key.Id); command.ExecuteNonQuery(); } catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException) { - _logger.LogWarning(exception, "Failed to update API key last-used timestamp."); + _logger.LogWarning(exception, "Failed to update user key last-used timestamp."); } return true; } } - public IReadOnlyList ListApiKeys() + public IReadOnlyList ListUserKeys() { if (!_isAvailable) { @@ -136,10 +136,10 @@ internal sealed class ManagementStore lock (_lock) { - return _apiKeysByHash.Values + return _userKeysByHash.Values .OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(key => key.CreatedAtUtc) - .Select(key => new ApiKeyInfo( + .Select(key => new UserKeyInfo( key.Id, key.Name, key.KeyPrefix, @@ -149,18 +149,18 @@ internal sealed class ManagementStore } } - public CreatedApiKey CreateApiKey(string? name) + public CreatedUserKey CreateUserKey(string? name) { EnsureAvailable(); - var apiKey = GenerateApiKey(); + var key = GenerateKey(); var now = DateTimeOffset.UtcNow; - var state = new ApiKeyState + var state = new KeyState { Id = Guid.NewGuid().ToString("n"), - Name = string.IsNullOrWhiteSpace(name) ? "API key" : name.Trim(), - KeyHash = HashApiKey(apiKey), - KeyPrefix = GetKeyPrefix(apiKey), + Name = string.IsNullOrWhiteSpace(name) ? "User key" : name.Trim(), + KeyHash = HashKey(key), + KeyPrefix = GetKeyPrefix(key), CreatedAtUtc = now }; @@ -169,7 +169,7 @@ internal sealed class ManagementStore using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ - INSERT INTO api_keys (id, name, key_hash, key_prefix, created_at_utc) + INSERT INTO user_keys (id, name, key_hash, key_prefix, created_at_utc) VALUES ($id, $name, $key_hash, $key_prefix, $created_at_utc) """; command.Parameters.AddWithValue("$id", state.Id); @@ -179,18 +179,18 @@ internal sealed class ManagementStore command.Parameters.AddWithValue("$created_at_utc", state.CreatedAtUtc.ToString("O")); command.ExecuteNonQuery(); - _apiKeysByHash[state.KeyHash] = state; + _userKeysByHash[state.KeyHash] = state; } - return new CreatedApiKey( + return new CreatedUserKey( state.Id, state.Name, state.KeyPrefix, state.CreatedAtUtc, - apiKey); + key); } - public bool DeleteApiKey(string id) + public bool DeleteUserKey(string id) { if (!_isAvailable || string.IsNullOrWhiteSpace(id)) { @@ -201,15 +201,15 @@ internal sealed class ManagementStore { using var connection = OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM api_keys WHERE id = $id"; + command.CommandText = "DELETE FROM user_keys WHERE id = $id"; command.Parameters.AddWithValue("$id", id); var deleted = command.ExecuteNonQuery() > 0; if (deleted) { - foreach (var pair in _apiKeysByHash.Where(pair => pair.Value.Id == id).ToArray()) + foreach (var pair in _userKeysByHash.Where(pair => pair.Value.Id == id).ToArray()) { - _apiKeysByHash.Remove(pair.Key); + _userKeysByHash.Remove(pair.Key); } } @@ -224,7 +224,7 @@ internal sealed class ManagementStore return false; } - var hash = HashApiKey(clientKey); + var hash = HashKey(clientKey); var now = DateTimeOffset.UtcNow; lock (_lock) @@ -236,7 +236,7 @@ internal sealed class ManagementStore if (!updateLastUsed || key.LastUsedUtc is not null - && now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval) + && now - key.LastUsedUtc.Value < UserKeyLastUsedWriteInterval) { return true; } @@ -261,7 +261,7 @@ internal sealed class ManagementStore } } - public IReadOnlyList ListClientKeys() + public IReadOnlyList ListClientKeys() { if (!_isAvailable) { @@ -273,7 +273,7 @@ internal sealed class ManagementStore return _clientKeysByHash.Values .OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(key => key.CreatedAtUtc) - .Select(key => new ApiKeyInfo( + .Select(key => new UserKeyInfo( key.Id, key.Name, key.KeyPrefix, @@ -283,18 +283,18 @@ internal sealed class ManagementStore } } - public CreatedApiKey CreateClientKey(string? name) + public CreatedUserKey CreateClientKey(string? name) { EnsureAvailable(); - var apiKey = GenerateApiKey(); + var key = GenerateKey(); var now = DateTimeOffset.UtcNow; - var state = new ApiKeyState + var state = new KeyState { Id = Guid.NewGuid().ToString("n"), Name = string.IsNullOrWhiteSpace(name) ? "Client key" : name.Trim(), - KeyHash = HashApiKey(apiKey), - KeyPrefix = GetKeyPrefix(apiKey), + KeyHash = HashKey(key), + KeyPrefix = GetKeyPrefix(key), CreatedAtUtc = now }; @@ -316,12 +316,12 @@ internal sealed class ManagementStore _clientKeysByHash[state.KeyHash] = state; } - return new CreatedApiKey( + return new CreatedUserKey( state.Id, state.Name, state.KeyPrefix, state.CreatedAtUtc, - apiKey); + key); } public bool DeleteClientKey(string id) @@ -358,7 +358,7 @@ internal sealed class ManagementStore return null; } - var hash = HashApiKey(clientKey); + var hash = HashKey(clientKey); lock (_lock) { @@ -547,7 +547,7 @@ internal sealed class ManagementStore prompt_tokens, completion_tokens, token_count, - api_key_id, + user_key_id, cost, started_at_utc, completed_at_utc, @@ -561,7 +561,7 @@ internal sealed class ManagementStore $prompt_tokens, $completion_tokens, $token_count, - $api_key_id, + $user_key_id, $cost, $started_at_utc, $completed_at_utc, @@ -575,7 +575,7 @@ internal sealed class ManagementStore command.Parameters.AddWithValue("$prompt_tokens", metric.PromptTokens); command.Parameters.AddWithValue("$completion_tokens", metric.CompletionTokens); command.Parameters.AddWithValue("$token_count", metric.TokenCount); - command.Parameters.AddWithValue("$api_key_id", string.IsNullOrWhiteSpace(metric.ApiKeyId) ? DBNull.Value : metric.ApiKeyId); + command.Parameters.AddWithValue("$user_key_id", string.IsNullOrWhiteSpace(metric.UserKeyId) ? DBNull.Value : metric.UserKeyId); command.Parameters.AddWithValue("$cost", metric.Cost); command.Parameters.AddWithValue("$started_at_utc", metric.StartedAtUtc.ToString("O")); command.Parameters.AddWithValue("$completed_at_utc", metric.CompletedAtUtc.ToString("O")); @@ -676,18 +676,18 @@ internal sealed class ManagementStore return result; } - public string? GetApiKeyId(string apiKey) + public string? GetUserKeyId(string userKey) { - if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey)) + if (!_isAvailable || string.IsNullOrWhiteSpace(userKey)) { return null; } - var hash = HashApiKey(apiKey); + var hash = HashKey(userKey); lock (_lock) { - return _apiKeysByHash.TryGetValue(hash, out var key) ? key.Id : null; + return _userKeysByHash.TryGetValue(hash, out var key) ? key.Id : null; } } @@ -911,9 +911,9 @@ internal sealed class ManagementStore } } - public IReadOnlyList GetApiKeyGroupIds(string apiKeyId) + public IReadOnlyList GetUserKeyGroupIds(string userKeyId) { - if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId)) + if (!_isAvailable || string.IsNullOrWhiteSpace(userKeyId)) { return []; } @@ -924,11 +924,11 @@ internal sealed class ManagementStore using var command = connection.CreateCommand(); command.CommandText = """ SELECT g.id FROM groups g - INNER JOIN api_key_groups akg ON g.id = akg.group_id - WHERE akg.api_key_id = $api_key_id + INNER JOIN user_key_groups ukg ON g.id = ukg.group_id + WHERE ukg.user_key_id = $user_key_id ORDER BY g.name """; - command.Parameters.AddWithValue("$api_key_id", apiKeyId); + command.Parameters.AddWithValue("$user_key_id", userKeyId); var result = new List(); using var reader = command.ExecuteReader(); @@ -941,13 +941,13 @@ internal sealed class ManagementStore } } - public void SetApiKeyGroups(string apiKeyId, IReadOnlyList groupIds) + public void SetUserKeyGroups(string userKeyId, IReadOnlyList groupIds) { EnsureAvailable(); - if (string.IsNullOrWhiteSpace(apiKeyId)) + if (string.IsNullOrWhiteSpace(userKeyId)) { - throw new ArgumentException("API key id is required.", nameof(apiKeyId)); + throw new ArgumentException("User key id is required.", nameof(userKeyId)); } lock (_lock) @@ -960,8 +960,8 @@ internal sealed class ManagementStore using (var deleteCommand = connection.CreateCommand()) { deleteCommand.Transaction = transaction; - deleteCommand.CommandText = "DELETE FROM api_key_groups WHERE api_key_id = $api_key_id"; - deleteCommand.Parameters.AddWithValue("$api_key_id", apiKeyId); + deleteCommand.CommandText = "DELETE FROM user_key_groups WHERE user_key_id = $user_key_id"; + deleteCommand.Parameters.AddWithValue("$user_key_id", userKeyId); deleteCommand.ExecuteNonQuery(); } @@ -969,13 +969,13 @@ internal sealed class ManagementStore { insertCommand.Transaction = transaction; insertCommand.CommandText = """ - INSERT INTO api_key_groups (api_key_id, group_id) - VALUES ($api_key_id, $group_id) + INSERT INTO user_key_groups (user_key_id, group_id) + VALUES ($user_key_id, $group_id) """; - var apiKeyParam = insertCommand.Parameters.Add("$api_key_id", SqliteType.Text); + var userKeyParam = insertCommand.Parameters.Add("$user_key_id", SqliteType.Text); var groupParam = insertCommand.Parameters.Add("$group_id", SqliteType.Text); - apiKeyParam.Value = apiKeyId; + userKeyParam.Value = userKeyId; foreach (var groupId in groupIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase)) { @@ -994,7 +994,7 @@ internal sealed class ManagementStore } } - public IReadOnlyList ListApiKeyGroups() + public IReadOnlyList ListUserKeyGroups() { if (!_isAvailable) { @@ -1006,17 +1006,17 @@ internal sealed class ManagementStore using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ - SELECT ak.id, ak.name, ak.key_prefix, + SELECT uk.id, uk.name, uk.key_prefix, GROUP_CONCAT(g.id) as group_ids, GROUP_CONCAT(g.name) as group_names - FROM api_keys ak - LEFT JOIN api_key_groups akg ON ak.id = akg.api_key_id - LEFT JOIN groups g ON akg.group_id = g.id - GROUP BY ak.id - ORDER BY ak.name + FROM user_keys uk + LEFT JOIN user_key_groups ukg ON uk.id = ukg.user_key_id + LEFT JOIN groups g ON ukg.group_id = g.id + GROUP BY uk.id + ORDER BY uk.name """; - var result = new List(); + var result = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { @@ -1030,26 +1030,26 @@ internal sealed class ManagementStore ? [] : reader.GetString(4).Split(',', StringSplitOptions.RemoveEmptyEntries); - result.Add(new ApiKeyGroupInfo(keyId, keyName, keyPrefix, groupIds, groupNames)); + result.Add(new UserKeyGroupInfo(keyId, keyName, keyPrefix, groupIds, groupNames)); } return result; } } - public GroupAccess ResolveGroupAccess(string? apiKeyId) + public GroupAccess ResolveGroupAccess(string? userKeyId) { - if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId)) + if (!_isAvailable || string.IsNullOrWhiteSpace(userKeyId)) { return GroupAccess.Unrestricted; } lock (_lock) { - var groupIds = GetApiKeyGroupIdsLocked(apiKeyId); + var groupIds = GetUserKeyGroupIdsLocked(userKeyId); if (groupIds.Count == 0) { - return GroupAccess.Empty; + return GroupAccess.Unrestricted; } var clientModels = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -1060,10 +1060,10 @@ internal sealed class ManagementStore command.CommandText = """ SELECT gm.client_id, gm.model, gm.client_pattern FROM group_members gm - INNER JOIN api_key_groups akg ON gm.group_id = akg.group_id - WHERE akg.api_key_id = $api_key_id + INNER JOIN user_key_groups ukg ON gm.group_id = ukg.group_id + WHERE ukg.user_key_id = $user_key_id """; - command.Parameters.AddWithValue("$api_key_id", apiKeyId); + command.Parameters.AddWithValue("$user_key_id", userKeyId); using var reader = command.ExecuteReader(); while (reader.Read()) @@ -1505,8 +1505,8 @@ internal sealed class ManagementStore costCmd.CommandText = """ SELECT COALESCE(SUM(rm.cost), 0) FROM request_metrics rm - INNER JOIN api_key_groups akg ON rm.api_key_id = akg.api_key_id - WHERE akg.group_id = $group_id AND rm.api_key_id IS NOT NULL + INNER JOIN user_key_groups ukg ON rm.user_key_id = ukg.user_key_id + WHERE ukg.group_id = $group_id AND rm.user_key_id IS NOT NULL """; costCmd.Parameters.AddWithValue("$group_id", groupId); totalCosts = Convert.ToDouble(costCmd.ExecuteScalar()); @@ -1516,9 +1516,9 @@ internal sealed class ManagementStore } } - public GroupBillingInfo? ResolveBillingForApiKey(string? apiKeyId) + public GroupBillingInfo? ResolveBillingForUserKey(string? userKeyId) { - if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId)) + if (!_isAvailable || string.IsNullOrWhiteSpace(userKeyId)) { return null; } @@ -1530,12 +1530,12 @@ internal sealed class ManagementStore command.CommandText = """ SELECT gb.group_id, gb.currency, gb.default_rate_per_1k, gb.refuse_below_balance, gb.enabled, gb.created_at_utc, gb.updated_at_utc FROM group_billing gb - INNER JOIN api_key_groups akg ON gb.group_id = akg.group_id - WHERE akg.api_key_id = $api_key_id AND gb.enabled = 1 + INNER JOIN user_key_groups ukg ON gb.group_id = ukg.group_id + WHERE ukg.user_key_id = $user_key_id AND gb.enabled = 1 ORDER BY gb.created_at_utc ASC LIMIT 1 """; - command.Parameters.AddWithValue("$api_key_id", apiKeyId); + command.Parameters.AddWithValue("$user_key_id", userKeyId); using var reader = command.ExecuteReader(); if (!reader.Read()) @@ -1596,9 +1596,9 @@ internal sealed class ManagementStore } } - public (bool Allowed, double Balance, string Currency, double Threshold) CheckBalanceForApiKey(string? apiKeyId) + public (bool Allowed, double Balance, string Currency, double Threshold) CheckBalanceForUserKey(string? userKeyId) { - var billing = ResolveBillingForApiKey(apiKeyId); + var billing = ResolveBillingForUserKey(userKeyId); if (billing is null) { return (true, 0, "", 0); @@ -1686,7 +1686,7 @@ internal sealed class ManagementStore } } - public IReadOnlyList GetTokenStatsByApiKey() + public IReadOnlyList GetTokenStatsByUserKey() { if (!_isAvailable) { @@ -1698,23 +1698,23 @@ internal sealed class ManagementStore using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ - SELECT rm.api_key_id, COALESCE(ak.name, 'Unknown'), COALESCE(ak.key_prefix, ''), + SELECT rm.user_key_id, COALESCE(uk.name, 'Unknown'), COALESCE(uk.key_prefix, ''), COALESCE(SUM(rm.prompt_tokens), 0), COALESCE(SUM(rm.completion_tokens), 0), COALESCE(SUM(rm.token_count), 0), COUNT(*) FROM request_metrics rm - LEFT JOIN api_keys ak ON rm.api_key_id = ak.id - WHERE rm.api_key_id IS NOT NULL - GROUP BY rm.api_key_id - ORDER BY ak.name + LEFT JOIN user_keys uk ON rm.user_key_id = uk.id + WHERE rm.user_key_id IS NOT NULL + GROUP BY rm.user_key_id + ORDER BY uk.name """; - var result = new List(); + var result = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { - result.Add(new TokenStatsByApiKey( + result.Add(new TokenStatsByUserKey( reader.GetString(0), reader.GetString(1), reader.GetString(2), @@ -1746,9 +1746,9 @@ internal sealed class ManagementStore COALESCE(SUM(rm.token_count), 0), COUNT(*) FROM request_metrics rm - INNER JOIN api_key_groups akg ON rm.api_key_id = akg.api_key_id + INNER JOIN user_key_groups akg ON rm.user_key_id = akg.user_key_id INNER JOIN groups g ON akg.group_id = g.id - WHERE rm.api_key_id IS NOT NULL + WHERE rm.user_key_id IS NOT NULL GROUP BY akg.group_id ORDER BY g.name """; @@ -1785,7 +1785,7 @@ internal sealed class ManagementStore SELECT rm.client_id, COALESCE(SUM(rm.cost), 0) FROM request_metrics rm - WHERE rm.api_key_id IS NOT NULL AND rm.cost > 0 + WHERE rm.user_key_id IS NOT NULL AND rm.cost > 0 GROUP BY rm.client_id ORDER BY rm.client_id """; @@ -1797,7 +1797,7 @@ internal sealed class ManagementStore var clientId = reader.GetString(0); var revenue = reader.GetDouble(1); - var billing = ResolveBillingForApiKeyForClientLocked(clientId); + var billing = ResolveBillingForUserKeyForClientLocked(clientId); var currency = billing?.Currency ?? "EUR"; result.Add(new ClientRevenue(clientId, revenue, currency)); @@ -1858,15 +1858,15 @@ internal sealed class ManagementStore ReadDateTimeOffset(reader.GetString(6))); } - private GroupBillingInfo? ResolveBillingForApiKeyForClientLocked(string clientId) + private GroupBillingInfo? ResolveBillingForUserKeyForClientLocked(string clientId) { using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ SELECT gb.group_id, gb.currency, gb.default_rate_per_1k, gb.refuse_below_balance, gb.enabled, gb.created_at_utc, gb.updated_at_utc FROM group_billing gb - INNER JOIN api_key_groups akg ON gb.group_id = akg.group_id - INNER JOIN request_metrics rm ON rm.api_key_id = akg.api_key_id + INNER JOIN user_key_groups ukg ON gb.group_id = ukg.group_id + INNER JOIN request_metrics rm ON rm.user_key_id = ukg.user_key_id WHERE rm.client_id = $client_id AND gb.enabled = 1 ORDER BY gb.created_at_utc ASC LIMIT 1 @@ -1889,12 +1889,12 @@ internal sealed class ManagementStore ReadDateTimeOffset(reader.GetString(6))); } - private IReadOnlyList GetApiKeyGroupIdsLocked(string apiKeyId) + private IReadOnlyList GetUserKeyGroupIdsLocked(string userKeyId) { using var connection = OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "SELECT group_id FROM api_key_groups WHERE api_key_id = $api_key_id"; - command.Parameters.AddWithValue("$api_key_id", apiKeyId); + command.CommandText = "SELECT group_id FROM user_key_groups WHERE user_key_id = $user_key_id"; + command.Parameters.AddWithValue("$user_key_id", userKeyId); var result = new List(); using var reader = command.ExecuteReader(); @@ -1940,7 +1940,7 @@ internal sealed class ManagementStore updated_at_utc TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS api_keys ( + CREATE TABLE IF NOT EXISTS user_keys ( id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, @@ -1957,6 +1957,10 @@ internal sealed class ManagementStore path TEXT NOT NULL, status_code INTEGER NULL, token_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + user_key_id TEXT NULL, + cost REAL NOT NULL DEFAULT 0, started_at_utc TEXT NOT NULL, completed_at_utc TEXT NOT NULL, duration_ms REAL NOT NULL @@ -1968,6 +1972,9 @@ internal sealed class ManagementStore CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started ON request_metrics (model, started_at_utc); + CREATE INDEX IF NOT EXISTS idx_request_metrics_user_key + ON request_metrics (user_key_id, started_at_utc); + CREATE TABLE IF NOT EXISTS groups ( id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, @@ -1986,10 +1993,10 @@ internal sealed class ManagementStore CREATE INDEX IF NOT EXISTS idx_group_members_group_id ON group_members (group_id); - CREATE TABLE IF NOT EXISTS api_key_groups ( - api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + CREATE TABLE IF NOT EXISTS user_key_groups ( + user_key_id TEXT NOT NULL REFERENCES user_keys(id) ON DELETE CASCADE, group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, - PRIMARY KEY (api_key_id, group_id) + PRIMARY KEY (user_key_id, group_id) ); CREATE TABLE IF NOT EXISTS client_keys ( @@ -2021,7 +2028,7 @@ internal sealed class ManagementStore alter2.ExecuteNonQuery(); using var alter3 = connection.CreateCommand(); - alter3.CommandText = "ALTER TABLE request_metrics ADD COLUMN api_key_id TEXT NULL"; + alter3.CommandText = "ALTER TABLE request_metrics ADD COLUMN user_key_id TEXT NULL"; alter3.ExecuteNonQuery(); using var alter4 = connection.CreateCommand(); @@ -2029,9 +2036,43 @@ internal sealed class ManagementStore alter4.ExecuteNonQuery(); using var idx = connection.CreateCommand(); - idx.CommandText = "CREATE INDEX IF NOT EXISTS idx_request_metrics_api_key ON request_metrics (api_key_id, started_at_utc)"; + idx.CommandText = "CREATE INDEX IF NOT EXISTS idx_request_metrics_user_key ON request_metrics (user_key_id, started_at_utc)"; idx.ExecuteNonQuery(); } + else + { + using var checkCol = connection.CreateCommand(); + checkCol.CommandText = "SELECT COUNT(*) FROM pragma_table_info('request_metrics') WHERE name = 'api_key_id'"; + var hasOldColumn = (long)checkCol.ExecuteScalar()! > 0; + + if (hasOldColumn) + { + using var rename = connection.CreateCommand(); + rename.CommandText = "ALTER TABLE request_metrics RENAME COLUMN api_key_id TO user_key_id"; + rename.ExecuteNonQuery(); + + using var idx = connection.CreateCommand(); + idx.CommandText = "CREATE INDEX IF NOT EXISTS idx_request_metrics_user_key ON request_metrics (user_key_id, started_at_utc)"; + idx.ExecuteNonQuery(); + } + else + { + using var checkNew = connection.CreateCommand(); + checkNew.CommandText = "SELECT COUNT(*) FROM pragma_table_info('request_metrics') WHERE name = 'user_key_id'"; + var hasNewColumn = (long)checkNew.ExecuteScalar()! > 0; + + if (!hasNewColumn) + { + using var addCol = connection.CreateCommand(); + addCol.CommandText = "ALTER TABLE request_metrics ADD COLUMN user_key_id TEXT NULL"; + addCol.ExecuteNonQuery(); + + using var idx = connection.CreateCommand(); + idx.CommandText = "CREATE INDEX IF NOT EXISTS idx_request_metrics_user_key ON request_metrics (user_key_id, started_at_utc)"; + idx.ExecuteNonQuery(); + } + } + } } using (var command2 = connection.CreateCommand()) @@ -2070,19 +2111,71 @@ internal sealed class ManagementStore """; command2.ExecuteNonQuery(); } + + using (var migrate = connection.CreateCommand()) + { + migrate.CommandText = """ + SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='api_key_groups' + """; + var hasOldTable = (long)migrate.ExecuteScalar()! > 0; + + if (hasOldTable) + { + using var rename = connection.CreateCommand(); + rename.CommandText = "ALTER TABLE api_key_groups RENAME TO user_key_groups"; + rename.ExecuteNonQuery(); + + using var renameCol = connection.CreateCommand(); + renameCol.CommandText = "ALTER TABLE user_key_groups RENAME COLUMN api_key_id TO user_key_id"; + renameCol.ExecuteNonQuery(); + } + else + { + migrate.CommandText = """ + SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='user_key_groups' + """; + var hasNewTable = (long)migrate.ExecuteScalar()! > 0; + + if (hasNewTable) + { + using var checkCol = connection.CreateCommand(); + checkCol.CommandText = "SELECT COUNT(*) FROM pragma_table_info('user_key_groups') WHERE name = 'api_key_id'"; + var hasOldCol = (long)checkCol.ExecuteScalar()! > 0; + + if (hasOldCol) + { + using var renameCol = connection.CreateCommand(); + renameCol.CommandText = "ALTER TABLE user_key_groups RENAME COLUMN api_key_id TO user_key_id"; + renameCol.ExecuteNonQuery(); + } + } + } + + migrate.CommandText = """ + SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='api_keys' + """; + var hasOldApiKeys = (long)migrate.ExecuteScalar()! > 0; + + if (hasOldApiKeys) + { + using var rename = connection.CreateCommand(); + rename.CommandText = "ALTER TABLE api_keys RENAME TO user_keys"; + rename.ExecuteNonQuery(); + } + } } using (var command = connection.CreateCommand()) { command.CommandText = """ SELECT id, name, key_hash, key_prefix, created_at_utc, last_used_at_utc - FROM api_keys + FROM user_keys """; using var reader = command.ExecuteReader(); while (reader.Read()) { - var state = new ApiKeyState + var state = new KeyState { Id = reader.GetString(0), Name = reader.GetString(1), @@ -2092,13 +2185,13 @@ internal sealed class ManagementStore LastUsedUtc = ReadNullableDateTimeOffset(reader, 5) }; - _apiKeysByHash[state.KeyHash] = state; + _userKeysByHash[state.KeyHash] = state; } } _logger.LogInformation( - "Loaded {ApiKeyCount} API key(s) from {DatabasePath}.", - _apiKeysByHash.Count, + "Loaded {UserKeyCount} user key(s) from {DatabasePath}.", + _userKeysByHash.Count, _databasePath); using (var command = connection.CreateCommand()) @@ -2111,7 +2204,7 @@ internal sealed class ManagementStore using var reader = command.ExecuteReader(); while (reader.Read()) { - var state = new ApiKeyState + var state = new KeyState { Id = reader.GetString(0), Name = reader.GetString(1), @@ -2152,17 +2245,17 @@ internal sealed class ManagementStore private static DateTimeOffset ReadDateTimeOffset(string value) => DateTimeOffset.TryParse(value, out var parsed) ? parsed : DateTimeOffset.MinValue; - private static string GenerateApiKey() + private static string GenerateKey() { var bytes = RandomNumberGenerator.GetBytes(32); return $"rl_{Base64UrlEncode(bytes)}"; } - private static string HashApiKey(string apiKey) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(apiKey))).ToLowerInvariant(); + private static string HashKey(string key) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))).ToLowerInvariant(); - private static string GetKeyPrefix(string apiKey) => - apiKey.Length <= 12 ? apiKey : apiKey[..12]; + private static string GetKeyPrefix(string key) => + key.Length <= 12 ? key : key[..12]; private static string Base64UrlEncode(byte[] bytes) => Convert.ToBase64String(bytes) @@ -2183,7 +2276,7 @@ internal sealed class ManagementStore return Path.Combine(AppContext.BaseDirectory, "App_Data", "management.sqlite"); } - private sealed class ApiKeyState + private sealed class KeyState { public string Id { get; init; } = ""; @@ -2208,14 +2301,14 @@ internal sealed record ClientAccess( public static ClientAccess Enabled { get; } = new(false, null, false, null); } -internal sealed record ApiKeyInfo( +internal sealed record UserKeyInfo( string Id, string Name, string KeyPrefix, DateTimeOffset CreatedAtUtc, DateTimeOffset? LastUsedUtc); -internal sealed record CreatedApiKey( +internal sealed record CreatedUserKey( string Id, string Name, string KeyPrefix, @@ -2231,7 +2324,7 @@ internal sealed record RequestMetric( int PromptTokens, int CompletionTokens, int TokenCount, - string? ApiKeyId, + string? UserKeyId, double Cost, DateTimeOffset StartedAtUtc, DateTimeOffset CompletedAtUtc, @@ -2294,10 +2387,10 @@ internal sealed record TokenStatsByClient( long TotalTokens, long Requests); -internal sealed record TokenStatsByApiKey( - string ApiKeyId, - string ApiKeyName, - string ApiKeyPrefix, +internal sealed record TokenStatsByUserKey( + string UserKeyId, + string UserKeyName, + string UserKeyPrefix, long PromptTokens, long CompletionTokens, long TotalTokens, @@ -2329,10 +2422,10 @@ internal sealed record GroupClientInfo( string? Model, string? ClientPattern); -internal sealed record ApiKeyGroupInfo( - string ApiKeyId, - string ApiKeyName, - string ApiKeyPrefix, +internal sealed record UserKeyGroupInfo( + string UserKeyId, + string UserKeyName, + string UserKeyPrefix, IReadOnlyList GroupIds, IReadOnlyList GroupNames); diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 198591f..c326a26 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -35,7 +35,7 @@ if (settings.Keycloak.IsConfigured) { options.Cookie.Name = "ReverseLlama.Admin"; options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.LoginPath = "/admin/login"; options.LogoutPath = "/admin/logout"; }) @@ -50,9 +50,9 @@ if (settings.Keycloak.IsConfigured) options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.CorrelationCookie.SameSite = SameSiteMode.Lax; - options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.CorrelationCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.NonceCookie.SameSite = SameSiteMode.Lax; - options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; + options.NonceCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); @@ -105,7 +105,7 @@ if (!settings.Keycloak.IsConfigured) { options.Cookie.Name = "ReverseLlama.Admin"; options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.LoginPath = "/admin/login"; options.LogoutPath = "/admin/logout"; options.AccessDeniedPath = "/admin/login"; diff --git a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs index 1a44f53..be50ffa 100644 --- a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs +++ b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs @@ -44,7 +44,7 @@ internal static class ReverseProxyEndpoint return; } - var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId); + var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId); if (!billingCheck.Allowed) { context.Response.StatusCode = StatusCodes.Status402PaymentRequired; @@ -58,7 +58,7 @@ internal static class ReverseProxyEndpoint return; } - var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); + var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore); var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath); if (!pathTokenRemoved) @@ -92,7 +92,7 @@ internal static class ReverseProxyEndpoint embeddingCache, managementStore, groupAccess, - auth.ApiKeyId); + auth.UserKeyId); return; } @@ -141,7 +141,7 @@ internal static class ReverseProxyEndpoint embeddingCache, embeddingRequest, managementStore, - auth.ApiKeyId); + auth.UserKeyId); } public static async Task HandleClientAsync( @@ -162,7 +162,7 @@ internal static class ReverseProxyEndpoint return; } - var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId); + var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId); if (!billingCheck.Allowed) { context.Response.StatusCode = StatusCodes.Status402PaymentRequired; @@ -176,7 +176,7 @@ internal static class ReverseProxyEndpoint return; } - var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); + var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore); if (!groupAccess.IsClientAllowed(clientId)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -197,7 +197,7 @@ internal static class ReverseProxyEndpoint embeddingCache, managementStore, groupAccess, - auth.ApiKeyId); + auth.UserKeyId); } private static async Task ForwardToClientAsync( @@ -211,7 +211,7 @@ internal static class ReverseProxyEndpoint EmbeddingCache embeddingCache, ManagementStore managementStore, GroupAccess? groupAccess = null, - string? apiKeyId = null) + string? userKeyId = null) { var clientAccess = managementStore.GetClientAccess(clientId); if (clientAccess.IsDisabled) @@ -254,7 +254,7 @@ internal static class ReverseProxyEndpoint embeddingCache, embeddingRequest, managementStore, - apiKeyId); + userKeyId); } private static bool IsRootPath(PathString path) => @@ -303,14 +303,14 @@ internal static class ReverseProxyEndpoint return true; } - private static GroupAccess ResolveGroupAccess(string? apiKeyId, ManagementStore managementStore) + private static GroupAccess ResolveGroupAccess(string? userKeyId, ManagementStore managementStore) { - if (string.IsNullOrWhiteSpace(apiKeyId)) + if (string.IsNullOrWhiteSpace(userKeyId)) { return GroupAccess.Unrestricted; } - return managementStore.ResolveGroupAccess(apiKeyId); + return managementStore.ResolveGroupAccess(userKeyId); } private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) => @@ -499,7 +499,7 @@ internal static class ReverseProxyEndpoint EmbeddingCache embeddingCache, EmbeddingCacheRequest? embeddingRequest, ManagementStore managementStore, - string? apiKeyId = null) + string? userKeyId = null) { var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy"); var requestId = Guid.NewGuid().ToString("n"); @@ -593,9 +593,9 @@ internal static class ReverseProxyEndpoint var tokenCounts = tokenCounter.CountTokens(); var cost = 0.0; - if (!string.IsNullOrWhiteSpace(apiKeyId) && tokenCounts.TotalTokens > 0) + if (!string.IsNullOrWhiteSpace(userKeyId) && tokenCounts.TotalTokens > 0) { - var billing = managementStore.ResolveBillingForApiKey(apiKeyId); + var billing = managementStore.ResolveBillingForUserKey(userKeyId); if (billing is not null) { cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens); @@ -611,7 +611,7 @@ internal static class ReverseProxyEndpoint tokenCounts.PromptTokens, tokenCounts.CompletionTokens, tokenCounts.TotalTokens, - apiKeyId, + userKeyId, cost, startedAt, completedAt, diff --git a/src/ReverseLlama.Server/ServerSettings.cs b/src/ReverseLlama.Server/ServerSettings.cs index 84431a4..3925193 100644 --- a/src/ReverseLlama.Server/ServerSettings.cs +++ b/src/ReverseLlama.Server/ServerSettings.cs @@ -19,6 +19,8 @@ internal sealed class ServerSettings public string? ManagementDatabasePath { get; init; } + public bool SecureCookies { get; init; } = true; + public KeycloakSettings Keycloak { get; init; } = new(); public CorsSettings Cors { get; init; } = new(); @@ -42,6 +44,7 @@ internal sealed class ServerSettings "ReverseLlama:ManagementDatabasePath", "management-database-path", "REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH"), + SecureCookies = ReadBool(configuration, true, "ReverseLlama:SecureCookies", "secure-cookies", "REVERSE_LLAMA_SECURE_COOKIES"), Keycloak = new KeycloakSettings { Authority = Read(configuration, "Authentication:Keycloak:Authority", "REVERSE_LLAMA_KEYCLOAK_AUTHORITY"), diff --git a/src/ReverseLlama.Server/TokenAuthentication.cs b/src/ReverseLlama.Server/TokenAuthentication.cs index 4a5b228..ad08cae 100644 --- a/src/ReverseLlama.Server/TokenAuthentication.cs +++ b/src/ReverseLlama.Server/TokenAuthentication.cs @@ -19,7 +19,7 @@ internal static class TokenAuthentication { foreach (var value in headerValues) { - var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -33,7 +33,7 @@ internal static class TokenAuthentication { if (TryGetBearerToken(value, out var bearerToken)) { - var result = AuthorizeApiToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeUserToken(bearerToken, settings, managementStore, updateUserKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -48,7 +48,7 @@ internal static class TokenAuthentication if (allowPathToken && TryGetPathToken(request.Path, out var pathToken, out _)) { - var result = AuthorizeApiToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeUserToken(pathToken, settings, managementStore, updateUserKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -63,7 +63,7 @@ internal static class TokenAuthentication { foreach (var value in queryValues) { - var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -159,7 +159,7 @@ internal static class TokenAuthentication remainingPath = path; if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath) - || !IsApiTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false)) + || !IsUserTokenAuthorized(pathToken, settings, managementStore, updateUserKeyLastUsed: false)) { return false; } @@ -172,13 +172,13 @@ internal static class TokenAuthentication public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) => TryGetBearerToken(value, out var token) - && IsApiTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false); + && IsUserTokenAuthorized(token, settings, managementStore, updateUserKeyLastUsed: false); - private static AuthResult AuthorizeApiToken( + private static AuthResult AuthorizeUserToken( string? token, ServerSettings settings, ManagementStore managementStore, - bool updateApiKeyLastUsed) + bool updateUserKeyLastUsed) { if (string.IsNullOrWhiteSpace(token)) { @@ -193,11 +193,11 @@ internal static class TokenAuthentication return AuthResult.Success(null); } - var apiKeyId = managementStore.GetApiKeyId(token); - if (apiKeyId is not null) + var userKeyId = managementStore.GetUserKeyId(token); + if (userKeyId is not null) { - managementStore.IsApiKeyValid(token, updateApiKeyLastUsed); - return AuthResult.Success(apiKeyId); + managementStore.IsUserKeyValid(token, updateUserKeyLastUsed); + return AuthResult.Success(userKeyId); } return AuthResult.Failure; @@ -236,15 +236,15 @@ internal static class TokenAuthentication string? token, ServerSettings settings, ManagementStore managementStore, - bool updateApiKeyLastUsed) => - AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; + bool updateUserKeyLastUsed) => + AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized; - private static bool IsApiTokenAuthorized( + private static bool IsUserTokenAuthorized( string? token, ServerSettings settings, ManagementStore managementStore, - bool updateApiKeyLastUsed) => - AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; + bool updateUserKeyLastUsed) => + AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized; private static bool TryGetBearerToken(string? authorization, out string token) { @@ -297,15 +297,15 @@ internal sealed class AuthResult { public static AuthResult Failure { get; } = new(false, null); - public static AuthResult Success(string? apiKeyId) => new(true, apiKeyId); + public static AuthResult Success(string? userKeyId) => new(true, userKeyId); public bool IsAuthorized { get; } - public string? ApiKeyId { get; } + public string? UserKeyId { get; } - private AuthResult(bool isAuthorized, string? apiKeyId) + private AuthResult(bool isAuthorized, string? userKeyId) { IsAuthorized = isAuthorized; - ApiKeyId = apiKeyId; + UserKeyId = userKeyId; } } diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.js b/src/ReverseLlama.Server/wwwroot/admin/app.js index 01f7451..6b20d63 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.js +++ b/src/ReverseLlama.Server/wwwroot/admin/app.js @@ -77,12 +77,12 @@ content.addEventListener("click", async (event) => { } if (action === "delete-key") { - if (!confirm("Delete this API key?")) { + if (!confirm("Delete this user key?")) { return; } - await api(`/api-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" }); - setNotice("API key deleted."); + await api(`/user-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" }); + setNotice("User key deleted."); await refresh(); } @@ -98,7 +98,7 @@ content.addEventListener("click", async (event) => { if (action === "copy-key") { await navigator.clipboard.writeText(button.dataset.key); - setNotice("API key copied."); + setNotice("User key copied."); } if (action === "delete-group") { @@ -123,7 +123,7 @@ content.addEventListener("click", async (event) => { } if (action === "toggle-key-assignment") { - await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true"); + await toggleUserKeyAssignment(groupId, keyId, button.dataset.assigned === "true"); } if (action === "delete-billing-rule") { @@ -176,12 +176,12 @@ content.addEventListener("submit", async (event) => { await loadModelDetail(data.model, data.clientId); } - if (form.dataset.form === "api-key") { - state.newKey = await api("/api-keys", { + if (form.dataset.form === "user-key") { + state.newKey = await api("/user-keys", { method: "POST", body: { name: data.name } }); - setNotice("API key created."); + setNotice("User key created."); await refresh(); } @@ -374,8 +374,8 @@ async function renderRoute(showLoading = true) { return; } - if (view === "api-keys") { - renderApiKeys(); + if (view === "user-keys") { + renderUserKeys(); return; } @@ -408,7 +408,7 @@ function updateShell() {
${summary.clients.length} clients
${summary.models.length} models
${(summary.clientKeys || []).length} client keys
-
${(summary.apiKeys || []).length} API keys
+
${(summary.userKeys || []).length} user keys
${(summary.groups || []).length} groups
${formatDate(summary.generatedAtUtc)}
`); @@ -708,22 +708,22 @@ async function runModelCommand(clientId, model, action) { await refreshAfterModelCommand(model, clientId); } -function renderApiKeys() { - const keys = state.summary?.apiKeys || []; - pageTitle.textContent = "API Keys"; +function renderUserKeys() { + const keys = state.summary?.userKeys || []; + pageTitle.textContent = "User Keys"; pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path."; patchContent(` ${state.newKey ? newKeyPanel(state.newKey) : ""}
-

Create API key

+

Create user key

-
+
- - + +
@@ -731,11 +731,11 @@ function renderApiKeys() {
-

API keys

+

User keys

${keys.length} total
- ${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")} + ${keys.length ? userKeysTable(keys) : emptyState("No user keys have been created.")}
`); @@ -777,7 +777,7 @@ function renderClientKeys() { function newKeyPanel(key) { return `
- New API key + New user key ${escapeHtml(key.key)}
@@ -786,7 +786,7 @@ function newKeyPanel(key) { `; } -function apiKeysTable(keys) { +function userKeysTable(keys) { const rows = keys.map((key) => ` @@ -849,7 +849,7 @@ function clientKeysTable(keys) { function renderGroups() { const groups = state.summary?.groups || []; pageTitle.textContent = "Groups"; - pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach."; + pageSubtitle.textContent = "Manage access groups that control which clients and models user keys can reach."; patchContent(`
@@ -916,17 +916,17 @@ async function loadGroupDetail(groupId, showLoading = true) { } try { - const [group, clients, apiKeyGroups, billing, rules, payments, balance] = await Promise.all([ + const [group, clients, userKeyGroups, billing, rules, payments, balance] = await Promise.all([ api(`/groups/${encodeURIComponent(groupId)}`), api(`/groups/${encodeURIComponent(groupId)}/clients`), - api("/api-keys/groups"), + api("/user-keys/groups"), api(`/groups/${encodeURIComponent(groupId)}/billing`), api(`/groups/${encodeURIComponent(groupId)}/billing/rules`), api(`/groups/${encodeURIComponent(groupId)}/billing/payments`), api(`/groups/${encodeURIComponent(groupId)}/billing/balance`) ]); - state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance }; + state.groupDetail = { group, clients, userKeyGroups, billing, rules, payments, balance }; renderGroupDetail(); } catch (error) { patchContent(`
${escapeHtml(error.message)}
`); @@ -934,12 +934,12 @@ async function loadGroupDetail(groupId, showLoading = true) { } function renderGroupDetail() { - const { group, clients, apiKeyGroups, billing, rules, payments, balance } = state.groupDetail; - const allApiKeys = state.summary?.apiKeys || []; + const { group, clients, userKeyGroups, billing, rules, payments, balance } = state.groupDetail; + const allUserKeys = state.summary?.userKeys || []; const assignedKeyIds = new Set( - apiKeyGroups - .filter((akg) => akg.apiKeyId && (akg.groupIds || []).includes(group.id)) - .map((akg) => akg.apiKeyId) + userKeyGroups + .filter((akg) => akg.userKeyId && (akg.groupIds || []).includes(group.id)) + .map((akg) => akg.userKeyId) ); pageTitle.textContent = "Group Detail"; @@ -997,10 +997,10 @@ function renderGroupDetail() {
-

API key assignments

+

User key assignments

- ${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")} + ${allUserKeys.length ? userKeyAssignmentTable(allUserKeys, group.id, assignedKeyIds) : emptyState("No user keys have been created.")}
@@ -1138,8 +1138,8 @@ function groupClientsTable(clients, groupId) { `; } -function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) { - const rows = apiKeys.map((key) => { +function userKeyAssignmentTable(userKeys, groupId, assignedKeyIds) { + const rows = userKeys.map((key) => { const isAssigned = assignedKeyIds.has(key.id); return ` @@ -1162,7 +1162,7 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) { - + @@ -1171,11 +1171,11 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) { `; } -async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) { - const apiKeyGroups = state.groupDetail?.apiKeyGroups || []; - const allApiKeys = state.summary?.apiKeys || []; +async function toggleUserKeyAssignment(groupId, keyId, currentlyAssigned) { + const userKeyGroups = state.groupDetail?.userKeyGroups || []; + const allUserKeys = state.summary?.userKeys || []; - const keyGroups = apiKeyGroups.find((akg) => akg.apiKeyId === keyId); + const keyGroups = userKeyGroups.find((ukg) => ukg.userKeyId === keyId); const currentGroupIds = keyGroups ? [...keyGroups.groupIds] : []; let newGroupIds; @@ -1185,12 +1185,12 @@ async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) { newGroupIds = [...currentGroupIds, groupId]; } - await api(`/api-keys/${encodeURIComponent(keyId)}/groups`, { + await api(`/user-keys/${encodeURIComponent(keyId)}/groups`, { method: "PUT", body: { groupIds: newGroupIds } }); - setNotice(currentlyAssigned ? "API key unassigned from group." : "API key assigned to group."); + setNotice(currentlyAssigned ? "User key unassigned from group." : "User key assigned to group."); await loadGroupDetail(groupId); } @@ -1286,7 +1286,7 @@ function renderUsage() { const byModel = usage.byModel || []; const byClient = usage.byClient || []; - const byApiKey = usage.byApiKey || []; + const byUserKey = usage.byUserKey || []; const byGroup = usage.byGroup || []; const clientRevenue = revenue || []; @@ -1316,11 +1316,11 @@ function renderUsage() {
-

Tokens by API key

- ${byApiKey.length} keys +

Tokens by user key

+ ${byUserKey.length} keys
- ${byApiKey.length ? tokenStatsApiKeyTable(byApiKey) : emptyState("No token data yet.")} + ${byUserKey.length ? tokenStatsUserKeyTable(byUserKey) : emptyState("No token data yet.")}
@@ -1393,12 +1393,12 @@ function tokenStatsClientTable(stats, revenueMap) { `; } -function tokenStatsApiKeyTable(stats) { +function tokenStatsUserKeyTable(stats) { const rows = stats.map((s) => `
@@ -1411,7 +1411,7 @@ function tokenStatsApiKeyTable(stats) {
API KeyUser key
-
${escapeHtml(s.apiKeyName)}
-
${escapeHtml(s.apiKeyPrefix)}...
+
${escapeHtml(s.userKeyName)}
+
${escapeHtml(s.userKeyPrefix)}...
${number(s.promptTokens)} ${number(s.completionTokens)}
- + diff --git a/src/ReverseLlama.Server/wwwroot/admin/index.html b/src/ReverseLlama.Server/wwwroot/admin/index.html index 3fb2f12..9ce345b 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/index.html +++ b/src/ReverseLlama.Server/wwwroot/admin/index.html @@ -20,7 +20,7 @@ ClientsModelsClient keys - API keys + User keysGroupsUsage
API keyUser key Prompt tokens Completion tokens Total tokens