feat(server): splits API keys into user keys and client keys

This commit is contained in:
2026-07-19 20:44:48 +02:00
parent 6c43744745
commit 5d1d03fe5a
10 changed files with 350 additions and 253 deletions
+5 -4
View File
@@ -13,7 +13,7 @@ The client opens and maintains a WebSocket connection to the server. The server
The server provides The server provides
- An API with - An API with
- Authentication via API keys - Authentication via user keys
- Authorization (planned) - Authorization (planned)
- Load balancing (Scale your AI strategy horizontally!) - Load balancing (Scale your AI strategy horizontally!)
- (Ollama-only) Model management (install, remove, load, unload models) - (Ollama-only) Model management (install, remove, load, unload models)
@@ -27,7 +27,7 @@ The server provides
- What clients are mapped to which groups - What clients are mapped to which groups
- Billing (planned) - Billing (planned)
- (planned) Price per model per thousand tokens - (planned) Price per model per thousand tokens
- (planned) Usage per API user - (planned) Usage per user key
- (planned) Rate limiting - (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. 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 <path>`: defaults to `/_reverse-llama/status`. - `--status-path <path>`: defaults to `/_reverse-llama/status`.
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`. - `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
- `--embedding-cache-path <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. - `--embedding-cache-path <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 <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 <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: Admin UI:
- `GET /admin` opens the Keycloak-protected management UI. - `GET /admin` opens the Keycloak-protected management UI.
- The temporary Keycloak settings live under `Authentication:Keycloak` in `appsettings.json`. - 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 <key>`, `?token=...`, and `/token/<key>/...`. - User keys created in the UI are accepted anywhere the shared token is accepted: `X-Reverse-Llama-Token`, `Authorization: Bearer <key>`, `?token=...`, and `/token/<key>/...`.
- Model add/remove/load/unload commands are sent through the connected tunnel client to Ollama (`/api/pull`, `/api/delete`, `/api/generate`, and `/api/show`). - 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: Client options:
+2 -2
View File
@@ -2,5 +2,5 @@
![Screenshot of the backend - clients view](images/Screenshots_website_clients.png) ![Screenshot of the backend - clients view](images/Screenshots_website_clients.png)
# Models view # Models view
![Screenshot of the backend - models view](images/Screenshots_website_models.png) ![Screenshot of the backend - models view](images/Screenshots_website_models.png)
# API Keys view # User Keys view
![Screenshot of the backend - api keys view](images/Screenshots_website_apikeys.png) ![Screenshot of the backend - user keys view](images/Screenshots_website_apikeys.png)
+21 -21
View File
@@ -268,14 +268,14 @@ internal static class AdminEndpoints
return Results.Json(result); return Results.Json(result);
}); });
api.MapGet("/api-keys", (ManagementStore store) => api.MapGet("/user-keys", (ManagementStore store) =>
Results.Json(store.ListApiKeys())); Results.Json(store.ListUserKeys()));
api.MapPost("/api-keys", (CreateApiKeyRequest request, ManagementStore store) => api.MapPost("/user-keys", (CreateUserKeyRequest request, ManagementStore store) =>
{ {
try try
{ {
return Results.Json(store.CreateApiKey(request.Name)); return Results.Json(store.CreateUserKey(request.Name));
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -283,15 +283,15 @@ internal static class AdminEndpoints
} }
}); });
api.MapDelete("/api-keys/{id}", (string id, ManagementStore store) => api.MapDelete("/user-keys/{id}", (string id, ManagementStore store) =>
store.DeleteApiKey(id) store.DeleteUserKey(id)
? Results.NoContent() ? 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) => api.MapGet("/client-keys", (ManagementStore store) =>
Results.Json(store.ListClientKeys())); Results.Json(store.ListClientKeys()));
api.MapPost("/client-keys", (CreateApiKeyRequest request, ManagementStore store) => api.MapPost("/client-keys", (CreateUserKeyRequest request, ManagementStore store) =>
{ {
try try
{ {
@@ -395,21 +395,21 @@ internal static class AdminEndpoints
: Results.NotFound(new { error = $"Client '{clientId}' was not found." }); : Results.NotFound(new { error = $"Client '{clientId}' was not found." });
}); });
api.MapGet("/api-keys/groups", (ManagementStore store) => api.MapGet("/user-keys/groups", (ManagementStore store) =>
Results.Json(store.ListApiKeyGroups())); 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)) 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 try
{ {
store.SetApiKeyGroups(id, request.GroupIds ?? []); store.SetUserKeyGroups(id, request.GroupIds ?? []);
return Results.Ok(new { apiKeyId = id, groupIds = store.GetApiKeyGroupIds(id) }); return Results.Ok(new { userKeyId = id, groupIds = store.GetUserKeyGroupIds(id) });
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -556,7 +556,7 @@ internal static class AdminEndpoints
{ {
byModel = store.GetTokenStatsByModel(), byModel = store.GetTokenStatsByModel(),
byClient = store.GetTokenStatsByClient(), byClient = store.GetTokenStatsByClient(),
byApiKey = store.GetTokenStatsByApiKey(), byUserKey = store.GetTokenStatsByUserKey(),
byGroup = store.GetTokenStatsByGroup() byGroup = store.GetTokenStatsByGroup()
})); }));
@@ -620,7 +620,7 @@ internal static class AdminEndpoints
keycloakConfigured = settings.Keycloak.IsConfigured, keycloakConfigured = settings.Keycloak.IsConfigured,
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token), sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken), clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken),
apiKeysConfigured = store.HasApiKeys, userKeysConfigured = store.HasUserKeys,
clientKeysConfigured = store.HasClientKeys clientKeysConfigured = store.HasClientKeys
}, },
management = new management = new
@@ -631,10 +631,10 @@ internal static class AdminEndpoints
}, },
clients = BuildClientSummaries(hub, store), clients = BuildClientSummaries(hub, store),
models = BuildModelSummaries(hub, store), models = BuildModelSummaries(hub, store),
apiKeys = store.ListApiKeys(), userKeys = store.ListUserKeys(),
clientKeys = store.ListClientKeys(), clientKeys = store.ListClientKeys(),
groups = store.ListGroups(), groups = store.ListGroups(),
apiKeyGroups = store.ListApiKeyGroups(), userKeyGroups = store.ListUserKeyGroups(),
clientGroups = store.ResolveClientGroups( clientGroups = store.ResolveClientGroups(
hub.ClientSnapshots.Select(c => c.Id).ToList()) hub.ClientSnapshots.Select(c => c.Id).ToList())
}; };
@@ -880,7 +880,7 @@ internal sealed record ModelActionRequest(
string Model, string Model,
string Action); string Action);
internal sealed record CreateApiKeyRequest(string? Name); internal sealed record CreateUserKeyRequest(string? Name);
internal sealed record CreateGroupRequest(string? Name); internal sealed record CreateGroupRequest(string? Name);
@@ -891,7 +891,7 @@ internal sealed record AddGroupClientRequest(
string? Model, string? Model,
string? ClientPattern); string? ClientPattern);
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds); internal sealed record SetUserKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
internal sealed record UpdateBillingRequest( internal sealed record UpdateBillingRequest(
string? Currency, string? Currency,
+227 -134
View File
@@ -7,10 +7,10 @@ namespace ReverseLlama.Server;
internal sealed class ManagementStore internal sealed class ManagementStore
{ {
private static readonly TimeSpan ApiKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1); private static readonly TimeSpan UserKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1);
private readonly Dictionary<string, ApiKeyState> _apiKeysByHash = new(StringComparer.Ordinal); private readonly Dictionary<string, KeyState> _userKeysByHash = new(StringComparer.Ordinal);
private readonly Dictionary<string, ApiKeyState> _clientKeysByHash = new(StringComparer.Ordinal); private readonly Dictionary<string, KeyState> _clientKeysByHash = new(StringComparer.Ordinal);
private readonly string _connectionString = ""; private readonly string _connectionString = "";
private readonly string _databasePath = ""; private readonly string _databasePath = "";
private readonly object _lock = new(); private readonly object _lock = new();
@@ -51,7 +51,7 @@ internal sealed class ManagementStore
public string? LastError => _lastError; public string? LastError => _lastError;
public bool HasApiKeys public bool HasUserKeys
{ {
get get
{ {
@@ -62,7 +62,7 @@ internal sealed class ManagementStore
lock (_lock) 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; return false;
} }
var hash = HashApiKey(apiKey); var hash = HashKey(userKey);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
lock (_lock) lock (_lock)
{ {
if (!_apiKeysByHash.TryGetValue(hash, out var key)) if (!_userKeysByHash.TryGetValue(hash, out var key))
{ {
return false; return false;
} }
if (!updateLastUsed if (!updateLastUsed
|| key.LastUsedUtc is not null || key.LastUsedUtc is not null
&& now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval) && now - key.LastUsedUtc.Value < UserKeyLastUsedWriteInterval)
{ {
return true; return true;
} }
@@ -113,21 +113,21 @@ internal sealed class ManagementStore
{ {
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); 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("$last_used_at_utc", now.ToString("O"));
command.Parameters.AddWithValue("$id", key.Id); command.Parameters.AddWithValue("$id", key.Id);
command.ExecuteNonQuery(); command.ExecuteNonQuery();
} }
catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException) 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; return true;
} }
} }
public IReadOnlyList<ApiKeyInfo> ListApiKeys() public IReadOnlyList<UserKeyInfo> ListUserKeys()
{ {
if (!_isAvailable) if (!_isAvailable)
{ {
@@ -136,10 +136,10 @@ internal sealed class ManagementStore
lock (_lock) lock (_lock)
{ {
return _apiKeysByHash.Values return _userKeysByHash.Values
.OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(key => key.CreatedAtUtc) .ThenBy(key => key.CreatedAtUtc)
.Select(key => new ApiKeyInfo( .Select(key => new UserKeyInfo(
key.Id, key.Id,
key.Name, key.Name,
key.KeyPrefix, key.KeyPrefix,
@@ -149,18 +149,18 @@ internal sealed class ManagementStore
} }
} }
public CreatedApiKey CreateApiKey(string? name) public CreatedUserKey CreateUserKey(string? name)
{ {
EnsureAvailable(); EnsureAvailable();
var apiKey = GenerateApiKey(); var key = GenerateKey();
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var state = new ApiKeyState var state = new KeyState
{ {
Id = Guid.NewGuid().ToString("n"), Id = Guid.NewGuid().ToString("n"),
Name = string.IsNullOrWhiteSpace(name) ? "API key" : name.Trim(), Name = string.IsNullOrWhiteSpace(name) ? "User key" : name.Trim(),
KeyHash = HashApiKey(apiKey), KeyHash = HashKey(key),
KeyPrefix = GetKeyPrefix(apiKey), KeyPrefix = GetKeyPrefix(key),
CreatedAtUtc = now CreatedAtUtc = now
}; };
@@ -169,7 +169,7 @@ internal sealed class ManagementStore
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = """ 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) VALUES ($id, $name, $key_hash, $key_prefix, $created_at_utc)
"""; """;
command.Parameters.AddWithValue("$id", state.Id); 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.Parameters.AddWithValue("$created_at_utc", state.CreatedAtUtc.ToString("O"));
command.ExecuteNonQuery(); command.ExecuteNonQuery();
_apiKeysByHash[state.KeyHash] = state; _userKeysByHash[state.KeyHash] = state;
} }
return new CreatedApiKey( return new CreatedUserKey(
state.Id, state.Id,
state.Name, state.Name,
state.KeyPrefix, state.KeyPrefix,
state.CreatedAtUtc, state.CreatedAtUtc,
apiKey); key);
} }
public bool DeleteApiKey(string id) public bool DeleteUserKey(string id)
{ {
if (!_isAvailable || string.IsNullOrWhiteSpace(id)) if (!_isAvailable || string.IsNullOrWhiteSpace(id))
{ {
@@ -201,15 +201,15 @@ internal sealed class ManagementStore
{ {
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); 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); command.Parameters.AddWithValue("$id", id);
var deleted = command.ExecuteNonQuery() > 0; var deleted = command.ExecuteNonQuery() > 0;
if (deleted) 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; return false;
} }
var hash = HashApiKey(clientKey); var hash = HashKey(clientKey);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
lock (_lock) lock (_lock)
@@ -236,7 +236,7 @@ internal sealed class ManagementStore
if (!updateLastUsed if (!updateLastUsed
|| key.LastUsedUtc is not null || key.LastUsedUtc is not null
&& now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval) && now - key.LastUsedUtc.Value < UserKeyLastUsedWriteInterval)
{ {
return true; return true;
} }
@@ -261,7 +261,7 @@ internal sealed class ManagementStore
} }
} }
public IReadOnlyList<ApiKeyInfo> ListClientKeys() public IReadOnlyList<UserKeyInfo> ListClientKeys()
{ {
if (!_isAvailable) if (!_isAvailable)
{ {
@@ -273,7 +273,7 @@ internal sealed class ManagementStore
return _clientKeysByHash.Values return _clientKeysByHash.Values
.OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(key => key.CreatedAtUtc) .ThenBy(key => key.CreatedAtUtc)
.Select(key => new ApiKeyInfo( .Select(key => new UserKeyInfo(
key.Id, key.Id,
key.Name, key.Name,
key.KeyPrefix, key.KeyPrefix,
@@ -283,18 +283,18 @@ internal sealed class ManagementStore
} }
} }
public CreatedApiKey CreateClientKey(string? name) public CreatedUserKey CreateClientKey(string? name)
{ {
EnsureAvailable(); EnsureAvailable();
var apiKey = GenerateApiKey(); var key = GenerateKey();
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var state = new ApiKeyState var state = new KeyState
{ {
Id = Guid.NewGuid().ToString("n"), Id = Guid.NewGuid().ToString("n"),
Name = string.IsNullOrWhiteSpace(name) ? "Client key" : name.Trim(), Name = string.IsNullOrWhiteSpace(name) ? "Client key" : name.Trim(),
KeyHash = HashApiKey(apiKey), KeyHash = HashKey(key),
KeyPrefix = GetKeyPrefix(apiKey), KeyPrefix = GetKeyPrefix(key),
CreatedAtUtc = now CreatedAtUtc = now
}; };
@@ -316,12 +316,12 @@ internal sealed class ManagementStore
_clientKeysByHash[state.KeyHash] = state; _clientKeysByHash[state.KeyHash] = state;
} }
return new CreatedApiKey( return new CreatedUserKey(
state.Id, state.Id,
state.Name, state.Name,
state.KeyPrefix, state.KeyPrefix,
state.CreatedAtUtc, state.CreatedAtUtc,
apiKey); key);
} }
public bool DeleteClientKey(string id) public bool DeleteClientKey(string id)
@@ -358,7 +358,7 @@ internal sealed class ManagementStore
return null; return null;
} }
var hash = HashApiKey(clientKey); var hash = HashKey(clientKey);
lock (_lock) lock (_lock)
{ {
@@ -547,7 +547,7 @@ internal sealed class ManagementStore
prompt_tokens, prompt_tokens,
completion_tokens, completion_tokens,
token_count, token_count,
api_key_id, user_key_id,
cost, cost,
started_at_utc, started_at_utc,
completed_at_utc, completed_at_utc,
@@ -561,7 +561,7 @@ internal sealed class ManagementStore
$prompt_tokens, $prompt_tokens,
$completion_tokens, $completion_tokens,
$token_count, $token_count,
$api_key_id, $user_key_id,
$cost, $cost,
$started_at_utc, $started_at_utc,
$completed_at_utc, $completed_at_utc,
@@ -575,7 +575,7 @@ internal sealed class ManagementStore
command.Parameters.AddWithValue("$prompt_tokens", metric.PromptTokens); command.Parameters.AddWithValue("$prompt_tokens", metric.PromptTokens);
command.Parameters.AddWithValue("$completion_tokens", metric.CompletionTokens); command.Parameters.AddWithValue("$completion_tokens", metric.CompletionTokens);
command.Parameters.AddWithValue("$token_count", metric.TokenCount); 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("$cost", metric.Cost);
command.Parameters.AddWithValue("$started_at_utc", metric.StartedAtUtc.ToString("O")); command.Parameters.AddWithValue("$started_at_utc", metric.StartedAtUtc.ToString("O"));
command.Parameters.AddWithValue("$completed_at_utc", metric.CompletedAtUtc.ToString("O")); command.Parameters.AddWithValue("$completed_at_utc", metric.CompletedAtUtc.ToString("O"));
@@ -676,18 +676,18 @@ internal sealed class ManagementStore
return result; return result;
} }
public string? GetApiKeyId(string apiKey) public string? GetUserKeyId(string userKey)
{ {
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey)) if (!_isAvailable || string.IsNullOrWhiteSpace(userKey))
{ {
return null; return null;
} }
var hash = HashApiKey(apiKey); var hash = HashKey(userKey);
lock (_lock) 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<string> GetApiKeyGroupIds(string apiKeyId) public IReadOnlyList<string> GetUserKeyGroupIds(string userKeyId)
{ {
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId)) if (!_isAvailable || string.IsNullOrWhiteSpace(userKeyId))
{ {
return []; return [];
} }
@@ -924,11 +924,11 @@ internal sealed class ManagementStore
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = """ command.CommandText = """
SELECT g.id FROM groups g SELECT g.id FROM groups g
INNER JOIN api_key_groups akg ON g.id = akg.group_id INNER JOIN user_key_groups ukg ON g.id = ukg.group_id
WHERE akg.api_key_id = $api_key_id WHERE ukg.user_key_id = $user_key_id
ORDER BY g.name ORDER BY g.name
"""; """;
command.Parameters.AddWithValue("$api_key_id", apiKeyId); command.Parameters.AddWithValue("$user_key_id", userKeyId);
var result = new List<string>(); var result = new List<string>();
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
@@ -941,13 +941,13 @@ internal sealed class ManagementStore
} }
} }
public void SetApiKeyGroups(string apiKeyId, IReadOnlyList<string> groupIds) public void SetUserKeyGroups(string userKeyId, IReadOnlyList<string> groupIds)
{ {
EnsureAvailable(); 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) lock (_lock)
@@ -960,8 +960,8 @@ internal sealed class ManagementStore
using (var deleteCommand = connection.CreateCommand()) using (var deleteCommand = connection.CreateCommand())
{ {
deleteCommand.Transaction = transaction; deleteCommand.Transaction = transaction;
deleteCommand.CommandText = "DELETE FROM api_key_groups WHERE api_key_id = $api_key_id"; deleteCommand.CommandText = "DELETE FROM user_key_groups WHERE user_key_id = $user_key_id";
deleteCommand.Parameters.AddWithValue("$api_key_id", apiKeyId); deleteCommand.Parameters.AddWithValue("$user_key_id", userKeyId);
deleteCommand.ExecuteNonQuery(); deleteCommand.ExecuteNonQuery();
} }
@@ -969,13 +969,13 @@ internal sealed class ManagementStore
{ {
insertCommand.Transaction = transaction; insertCommand.Transaction = transaction;
insertCommand.CommandText = """ insertCommand.CommandText = """
INSERT INTO api_key_groups (api_key_id, group_id) INSERT INTO user_key_groups (user_key_id, group_id)
VALUES ($api_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); 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)) foreach (var groupId in groupIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase))
{ {
@@ -994,7 +994,7 @@ internal sealed class ManagementStore
} }
} }
public IReadOnlyList<ApiKeyGroupInfo> ListApiKeyGroups() public IReadOnlyList<UserKeyGroupInfo> ListUserKeyGroups()
{ {
if (!_isAvailable) if (!_isAvailable)
{ {
@@ -1006,17 +1006,17 @@ internal sealed class ManagementStore
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = """ 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.id) as group_ids,
GROUP_CONCAT(g.name) as group_names GROUP_CONCAT(g.name) as group_names
FROM api_keys ak FROM user_keys uk
LEFT JOIN api_key_groups akg ON ak.id = akg.api_key_id LEFT JOIN user_key_groups ukg ON uk.id = ukg.user_key_id
LEFT JOIN groups g ON akg.group_id = g.id LEFT JOIN groups g ON ukg.group_id = g.id
GROUP BY ak.id GROUP BY uk.id
ORDER BY ak.name ORDER BY uk.name
"""; """;
var result = new List<ApiKeyGroupInfo>(); var result = new List<UserKeyGroupInfo>();
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
@@ -1030,26 +1030,26 @@ internal sealed class ManagementStore
? [] ? []
: reader.GetString(4).Split(',', StringSplitOptions.RemoveEmptyEntries); : 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; 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; return GroupAccess.Unrestricted;
} }
lock (_lock) lock (_lock)
{ {
var groupIds = GetApiKeyGroupIdsLocked(apiKeyId); var groupIds = GetUserKeyGroupIdsLocked(userKeyId);
if (groupIds.Count == 0) if (groupIds.Count == 0)
{ {
return GroupAccess.Empty; return GroupAccess.Unrestricted;
} }
var clientModels = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var clientModels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -1060,10 +1060,10 @@ internal sealed class ManagementStore
command.CommandText = """ command.CommandText = """
SELECT gm.client_id, gm.model, gm.client_pattern SELECT gm.client_id, gm.model, gm.client_pattern
FROM group_members gm FROM group_members gm
INNER JOIN api_key_groups akg ON gm.group_id = akg.group_id INNER JOIN user_key_groups ukg ON gm.group_id = ukg.group_id
WHERE akg.api_key_id = $api_key_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(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
@@ -1505,8 +1505,8 @@ internal sealed class ManagementStore
costCmd.CommandText = """ costCmd.CommandText = """
SELECT COALESCE(SUM(rm.cost), 0) SELECT COALESCE(SUM(rm.cost), 0)
FROM request_metrics rm FROM request_metrics rm
INNER JOIN api_key_groups akg ON rm.api_key_id = akg.api_key_id INNER JOIN user_key_groups ukg ON rm.user_key_id = ukg.user_key_id
WHERE akg.group_id = $group_id AND rm.api_key_id IS NOT NULL WHERE ukg.group_id = $group_id AND rm.user_key_id IS NOT NULL
"""; """;
costCmd.Parameters.AddWithValue("$group_id", groupId); costCmd.Parameters.AddWithValue("$group_id", groupId);
totalCosts = Convert.ToDouble(costCmd.ExecuteScalar()); 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; return null;
} }
@@ -1530,12 +1530,12 @@ internal sealed class ManagementStore
command.CommandText = """ 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 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 FROM group_billing gb
INNER JOIN api_key_groups akg ON gb.group_id = akg.group_id INNER JOIN user_key_groups ukg ON gb.group_id = ukg.group_id
WHERE akg.api_key_id = $api_key_id AND gb.enabled = 1 WHERE ukg.user_key_id = $user_key_id AND gb.enabled = 1
ORDER BY gb.created_at_utc ASC ORDER BY gb.created_at_utc ASC
LIMIT 1 LIMIT 1
"""; """;
command.Parameters.AddWithValue("$api_key_id", apiKeyId); command.Parameters.AddWithValue("$user_key_id", userKeyId);
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
if (!reader.Read()) 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) if (billing is null)
{ {
return (true, 0, "", 0); return (true, 0, "", 0);
@@ -1686,7 +1686,7 @@ internal sealed class ManagementStore
} }
} }
public IReadOnlyList<TokenStatsByApiKey> GetTokenStatsByApiKey() public IReadOnlyList<TokenStatsByUserKey> GetTokenStatsByUserKey()
{ {
if (!_isAvailable) if (!_isAvailable)
{ {
@@ -1698,23 +1698,23 @@ internal sealed class ManagementStore
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = """ 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.prompt_tokens), 0),
COALESCE(SUM(rm.completion_tokens), 0), COALESCE(SUM(rm.completion_tokens), 0),
COALESCE(SUM(rm.token_count), 0), COALESCE(SUM(rm.token_count), 0),
COUNT(*) COUNT(*)
FROM request_metrics rm FROM request_metrics rm
LEFT JOIN api_keys ak ON rm.api_key_id = ak.id LEFT JOIN user_keys uk ON rm.user_key_id = uk.id
WHERE rm.api_key_id IS NOT NULL WHERE rm.user_key_id IS NOT NULL
GROUP BY rm.api_key_id GROUP BY rm.user_key_id
ORDER BY ak.name ORDER BY uk.name
"""; """;
var result = new List<TokenStatsByApiKey>(); var result = new List<TokenStatsByUserKey>();
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
result.Add(new TokenStatsByApiKey( result.Add(new TokenStatsByUserKey(
reader.GetString(0), reader.GetString(0),
reader.GetString(1), reader.GetString(1),
reader.GetString(2), reader.GetString(2),
@@ -1746,9 +1746,9 @@ internal sealed class ManagementStore
COALESCE(SUM(rm.token_count), 0), COALESCE(SUM(rm.token_count), 0),
COUNT(*) COUNT(*)
FROM request_metrics rm 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 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 GROUP BY akg.group_id
ORDER BY g.name ORDER BY g.name
"""; """;
@@ -1785,7 +1785,7 @@ internal sealed class ManagementStore
SELECT rm.client_id, SELECT rm.client_id,
COALESCE(SUM(rm.cost), 0) COALESCE(SUM(rm.cost), 0)
FROM request_metrics rm 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 GROUP BY rm.client_id
ORDER BY rm.client_id ORDER BY rm.client_id
"""; """;
@@ -1797,7 +1797,7 @@ internal sealed class ManagementStore
var clientId = reader.GetString(0); var clientId = reader.GetString(0);
var revenue = reader.GetDouble(1); var revenue = reader.GetDouble(1);
var billing = ResolveBillingForApiKeyForClientLocked(clientId); var billing = ResolveBillingForUserKeyForClientLocked(clientId);
var currency = billing?.Currency ?? "EUR"; var currency = billing?.Currency ?? "EUR";
result.Add(new ClientRevenue(clientId, revenue, currency)); result.Add(new ClientRevenue(clientId, revenue, currency));
@@ -1858,15 +1858,15 @@ internal sealed class ManagementStore
ReadDateTimeOffset(reader.GetString(6))); ReadDateTimeOffset(reader.GetString(6)));
} }
private GroupBillingInfo? ResolveBillingForApiKeyForClientLocked(string clientId) private GroupBillingInfo? ResolveBillingForUserKeyForClientLocked(string clientId)
{ {
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = """ 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 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 FROM group_billing gb
INNER JOIN api_key_groups akg ON gb.group_id = akg.group_id INNER JOIN user_key_groups ukg ON gb.group_id = ukg.group_id
INNER JOIN request_metrics rm ON rm.api_key_id = akg.api_key_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 WHERE rm.client_id = $client_id AND gb.enabled = 1
ORDER BY gb.created_at_utc ASC ORDER BY gb.created_at_utc ASC
LIMIT 1 LIMIT 1
@@ -1889,12 +1889,12 @@ internal sealed class ManagementStore
ReadDateTimeOffset(reader.GetString(6))); ReadDateTimeOffset(reader.GetString(6)));
} }
private IReadOnlyList<string> GetApiKeyGroupIdsLocked(string apiKeyId) private IReadOnlyList<string> GetUserKeyGroupIdsLocked(string userKeyId)
{ {
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = "SELECT group_id FROM api_key_groups WHERE api_key_id = $api_key_id"; command.CommandText = "SELECT group_id FROM user_key_groups WHERE user_key_id = $user_key_id";
command.Parameters.AddWithValue("$api_key_id", apiKeyId); command.Parameters.AddWithValue("$user_key_id", userKeyId);
var result = new List<string>(); var result = new List<string>();
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
@@ -1940,7 +1940,7 @@ internal sealed class ManagementStore
updated_at_utc TEXT NOT NULL 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, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE, key_hash TEXT NOT NULL UNIQUE,
@@ -1957,6 +1957,10 @@ internal sealed class ManagementStore
path TEXT NOT NULL, path TEXT NOT NULL,
status_code INTEGER NULL, status_code INTEGER NULL,
token_count INTEGER NOT NULL DEFAULT 0, 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, started_at_utc TEXT NOT NULL,
completed_at_utc TEXT NOT NULL, completed_at_utc TEXT NOT NULL,
duration_ms REAL 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 CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started
ON request_metrics (model, started_at_utc); 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 ( CREATE TABLE IF NOT EXISTS groups (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -1986,10 +1993,10 @@ internal sealed class ManagementStore
CREATE INDEX IF NOT EXISTS idx_group_members_group_id CREATE INDEX IF NOT EXISTS idx_group_members_group_id
ON group_members (group_id); ON group_members (group_id);
CREATE TABLE IF NOT EXISTS api_key_groups ( CREATE TABLE IF NOT EXISTS user_key_groups (
api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, user_key_id TEXT NOT NULL REFERENCES user_keys(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES groups(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 ( CREATE TABLE IF NOT EXISTS client_keys (
@@ -2021,7 +2028,7 @@ internal sealed class ManagementStore
alter2.ExecuteNonQuery(); alter2.ExecuteNonQuery();
using var alter3 = connection.CreateCommand(); 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(); alter3.ExecuteNonQuery();
using var alter4 = connection.CreateCommand(); using var alter4 = connection.CreateCommand();
@@ -2029,9 +2036,43 @@ internal sealed class ManagementStore
alter4.ExecuteNonQuery(); alter4.ExecuteNonQuery();
using var idx = connection.CreateCommand(); 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(); 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()) using (var command2 = connection.CreateCommand())
@@ -2070,19 +2111,71 @@ internal sealed class ManagementStore
"""; """;
command2.ExecuteNonQuery(); 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()) using (var command = connection.CreateCommand())
{ {
command.CommandText = """ command.CommandText = """
SELECT id, name, key_hash, key_prefix, created_at_utc, last_used_at_utc 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(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
var state = new ApiKeyState var state = new KeyState
{ {
Id = reader.GetString(0), Id = reader.GetString(0),
Name = reader.GetString(1), Name = reader.GetString(1),
@@ -2092,13 +2185,13 @@ internal sealed class ManagementStore
LastUsedUtc = ReadNullableDateTimeOffset(reader, 5) LastUsedUtc = ReadNullableDateTimeOffset(reader, 5)
}; };
_apiKeysByHash[state.KeyHash] = state; _userKeysByHash[state.KeyHash] = state;
} }
} }
_logger.LogInformation( _logger.LogInformation(
"Loaded {ApiKeyCount} API key(s) from {DatabasePath}.", "Loaded {UserKeyCount} user key(s) from {DatabasePath}.",
_apiKeysByHash.Count, _userKeysByHash.Count,
_databasePath); _databasePath);
using (var command = connection.CreateCommand()) using (var command = connection.CreateCommand())
@@ -2111,7 +2204,7 @@ internal sealed class ManagementStore
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
var state = new ApiKeyState var state = new KeyState
{ {
Id = reader.GetString(0), Id = reader.GetString(0),
Name = reader.GetString(1), Name = reader.GetString(1),
@@ -2152,17 +2245,17 @@ internal sealed class ManagementStore
private static DateTimeOffset ReadDateTimeOffset(string value) => private static DateTimeOffset ReadDateTimeOffset(string value) =>
DateTimeOffset.TryParse(value, out var parsed) ? parsed : DateTimeOffset.MinValue; DateTimeOffset.TryParse(value, out var parsed) ? parsed : DateTimeOffset.MinValue;
private static string GenerateApiKey() private static string GenerateKey()
{ {
var bytes = RandomNumberGenerator.GetBytes(32); var bytes = RandomNumberGenerator.GetBytes(32);
return $"rl_{Base64UrlEncode(bytes)}"; return $"rl_{Base64UrlEncode(bytes)}";
} }
private static string HashApiKey(string apiKey) => private static string HashKey(string key) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(apiKey))).ToLowerInvariant(); Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))).ToLowerInvariant();
private static string GetKeyPrefix(string apiKey) => private static string GetKeyPrefix(string key) =>
apiKey.Length <= 12 ? apiKey : apiKey[..12]; key.Length <= 12 ? key : key[..12];
private static string Base64UrlEncode(byte[] bytes) => private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes) Convert.ToBase64String(bytes)
@@ -2183,7 +2276,7 @@ internal sealed class ManagementStore
return Path.Combine(AppContext.BaseDirectory, "App_Data", "management.sqlite"); return Path.Combine(AppContext.BaseDirectory, "App_Data", "management.sqlite");
} }
private sealed class ApiKeyState private sealed class KeyState
{ {
public string Id { get; init; } = ""; public string Id { get; init; } = "";
@@ -2208,14 +2301,14 @@ internal sealed record ClientAccess(
public static ClientAccess Enabled { get; } = new(false, null, false, null); public static ClientAccess Enabled { get; } = new(false, null, false, null);
} }
internal sealed record ApiKeyInfo( internal sealed record UserKeyInfo(
string Id, string Id,
string Name, string Name,
string KeyPrefix, string KeyPrefix,
DateTimeOffset CreatedAtUtc, DateTimeOffset CreatedAtUtc,
DateTimeOffset? LastUsedUtc); DateTimeOffset? LastUsedUtc);
internal sealed record CreatedApiKey( internal sealed record CreatedUserKey(
string Id, string Id,
string Name, string Name,
string KeyPrefix, string KeyPrefix,
@@ -2231,7 +2324,7 @@ internal sealed record RequestMetric(
int PromptTokens, int PromptTokens,
int CompletionTokens, int CompletionTokens,
int TokenCount, int TokenCount,
string? ApiKeyId, string? UserKeyId,
double Cost, double Cost,
DateTimeOffset StartedAtUtc, DateTimeOffset StartedAtUtc,
DateTimeOffset CompletedAtUtc, DateTimeOffset CompletedAtUtc,
@@ -2294,10 +2387,10 @@ internal sealed record TokenStatsByClient(
long TotalTokens, long TotalTokens,
long Requests); long Requests);
internal sealed record TokenStatsByApiKey( internal sealed record TokenStatsByUserKey(
string ApiKeyId, string UserKeyId,
string ApiKeyName, string UserKeyName,
string ApiKeyPrefix, string UserKeyPrefix,
long PromptTokens, long PromptTokens,
long CompletionTokens, long CompletionTokens,
long TotalTokens, long TotalTokens,
@@ -2329,10 +2422,10 @@ internal sealed record GroupClientInfo(
string? Model, string? Model,
string? ClientPattern); string? ClientPattern);
internal sealed record ApiKeyGroupInfo( internal sealed record UserKeyGroupInfo(
string ApiKeyId, string UserKeyId,
string ApiKeyName, string UserKeyName,
string ApiKeyPrefix, string UserKeyPrefix,
IReadOnlyList<string> GroupIds, IReadOnlyList<string> GroupIds,
IReadOnlyList<string> GroupNames); IReadOnlyList<string> GroupNames);
+4 -4
View File
@@ -35,7 +35,7 @@ if (settings.Keycloak.IsConfigured)
{ {
options.Cookie.Name = "ReverseLlama.Admin"; options.Cookie.Name = "ReverseLlama.Admin";
options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.LoginPath = "/admin/login"; options.LoginPath = "/admin/login";
options.LogoutPath = "/admin/logout"; options.LogoutPath = "/admin/logout";
}) })
@@ -50,9 +50,9 @@ if (settings.Keycloak.IsConfigured)
options.SaveTokens = true; options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true; options.GetClaimsFromUserInfoEndpoint = true;
options.CorrelationCookie.SameSite = SameSiteMode.Lax; 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.SameSite = SameSiteMode.Lax;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; options.NonceCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.Scope.Clear(); options.Scope.Clear();
options.Scope.Add("openid"); options.Scope.Add("openid");
options.Scope.Add("profile"); options.Scope.Add("profile");
@@ -105,7 +105,7 @@ if (!settings.Keycloak.IsConfigured)
{ {
options.Cookie.Name = "ReverseLlama.Admin"; options.Cookie.Name = "ReverseLlama.Admin";
options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.LoginPath = "/admin/login"; options.LoginPath = "/admin/login";
options.LogoutPath = "/admin/logout"; options.LogoutPath = "/admin/logout";
options.AccessDeniedPath = "/admin/login"; options.AccessDeniedPath = "/admin/login";
+16 -16
View File
@@ -44,7 +44,7 @@ internal static class ReverseProxyEndpoint
return; return;
} }
var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId); var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
if (!billingCheck.Allowed) if (!billingCheck.Allowed)
{ {
context.Response.StatusCode = StatusCodes.Status402PaymentRequired; context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
@@ -58,7 +58,7 @@ internal static class ReverseProxyEndpoint
return; 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); var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
if (!pathTokenRemoved) if (!pathTokenRemoved)
@@ -92,7 +92,7 @@ internal static class ReverseProxyEndpoint
embeddingCache, embeddingCache,
managementStore, managementStore,
groupAccess, groupAccess,
auth.ApiKeyId); auth.UserKeyId);
return; return;
} }
@@ -141,7 +141,7 @@ internal static class ReverseProxyEndpoint
embeddingCache, embeddingCache,
embeddingRequest, embeddingRequest,
managementStore, managementStore,
auth.ApiKeyId); auth.UserKeyId);
} }
public static async Task HandleClientAsync( public static async Task HandleClientAsync(
@@ -162,7 +162,7 @@ internal static class ReverseProxyEndpoint
return; return;
} }
var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId); var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
if (!billingCheck.Allowed) if (!billingCheck.Allowed)
{ {
context.Response.StatusCode = StatusCodes.Status402PaymentRequired; context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
@@ -176,7 +176,7 @@ internal static class ReverseProxyEndpoint
return; return;
} }
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
if (!groupAccess.IsClientAllowed(clientId)) if (!groupAccess.IsClientAllowed(clientId))
{ {
context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -197,7 +197,7 @@ internal static class ReverseProxyEndpoint
embeddingCache, embeddingCache,
managementStore, managementStore,
groupAccess, groupAccess,
auth.ApiKeyId); auth.UserKeyId);
} }
private static async Task ForwardToClientAsync( private static async Task ForwardToClientAsync(
@@ -211,7 +211,7 @@ internal static class ReverseProxyEndpoint
EmbeddingCache embeddingCache, EmbeddingCache embeddingCache,
ManagementStore managementStore, ManagementStore managementStore,
GroupAccess? groupAccess = null, GroupAccess? groupAccess = null,
string? apiKeyId = null) string? userKeyId = null)
{ {
var clientAccess = managementStore.GetClientAccess(clientId); var clientAccess = managementStore.GetClientAccess(clientId);
if (clientAccess.IsDisabled) if (clientAccess.IsDisabled)
@@ -254,7 +254,7 @@ internal static class ReverseProxyEndpoint
embeddingCache, embeddingCache,
embeddingRequest, embeddingRequest,
managementStore, managementStore,
apiKeyId); userKeyId);
} }
private static bool IsRootPath(PathString path) => private static bool IsRootPath(PathString path) =>
@@ -303,14 +303,14 @@ internal static class ReverseProxyEndpoint
return true; 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 GroupAccess.Unrestricted;
} }
return managementStore.ResolveGroupAccess(apiKeyId); return managementStore.ResolveGroupAccess(userKeyId);
} }
private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) => private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) =>
@@ -499,7 +499,7 @@ internal static class ReverseProxyEndpoint
EmbeddingCache embeddingCache, EmbeddingCache embeddingCache,
EmbeddingCacheRequest? embeddingRequest, EmbeddingCacheRequest? embeddingRequest,
ManagementStore managementStore, ManagementStore managementStore,
string? apiKeyId = null) string? userKeyId = null)
{ {
var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy"); var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy");
var requestId = Guid.NewGuid().ToString("n"); var requestId = Guid.NewGuid().ToString("n");
@@ -593,9 +593,9 @@ internal static class ReverseProxyEndpoint
var tokenCounts = tokenCounter.CountTokens(); var tokenCounts = tokenCounter.CountTokens();
var cost = 0.0; 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) if (billing is not null)
{ {
cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens); cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens);
@@ -611,7 +611,7 @@ internal static class ReverseProxyEndpoint
tokenCounts.PromptTokens, tokenCounts.PromptTokens,
tokenCounts.CompletionTokens, tokenCounts.CompletionTokens,
tokenCounts.TotalTokens, tokenCounts.TotalTokens,
apiKeyId, userKeyId,
cost, cost,
startedAt, startedAt,
completedAt, completedAt,
@@ -19,6 +19,8 @@ internal sealed class ServerSettings
public string? ManagementDatabasePath { get; init; } public string? ManagementDatabasePath { get; init; }
public bool SecureCookies { get; init; } = true;
public KeycloakSettings Keycloak { get; init; } = new(); public KeycloakSettings Keycloak { get; init; } = new();
public CorsSettings Cors { get; init; } = new(); public CorsSettings Cors { get; init; } = new();
@@ -42,6 +44,7 @@ internal sealed class ServerSettings
"ReverseLlama:ManagementDatabasePath", "ReverseLlama:ManagementDatabasePath",
"management-database-path", "management-database-path",
"REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH"), "REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH"),
SecureCookies = ReadBool(configuration, true, "ReverseLlama:SecureCookies", "secure-cookies", "REVERSE_LLAMA_SECURE_COOKIES"),
Keycloak = new KeycloakSettings Keycloak = new KeycloakSettings
{ {
Authority = Read(configuration, "Authentication:Keycloak:Authority", "REVERSE_LLAMA_KEYCLOAK_AUTHORITY"), Authority = Read(configuration, "Authentication:Keycloak:Authority", "REVERSE_LLAMA_KEYCLOAK_AUTHORITY"),
+21 -21
View File
@@ -19,7 +19,7 @@ internal static class TokenAuthentication
{ {
foreach (var value in headerValues) foreach (var value in headerValues)
{ {
var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true); var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: true);
if (result.IsAuthorized) if (result.IsAuthorized)
{ {
return result; return result;
@@ -33,7 +33,7 @@ internal static class TokenAuthentication
{ {
if (TryGetBearerToken(value, out var bearerToken)) 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) if (result.IsAuthorized)
{ {
return result; return result;
@@ -48,7 +48,7 @@ internal static class TokenAuthentication
if (allowPathToken if (allowPathToken
&& TryGetPathToken(request.Path, out var pathToken, out _)) && 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) if (result.IsAuthorized)
{ {
return result; return result;
@@ -63,7 +63,7 @@ internal static class TokenAuthentication
{ {
foreach (var value in queryValues) foreach (var value in queryValues)
{ {
var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true); var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: true);
if (result.IsAuthorized) if (result.IsAuthorized)
{ {
return result; return result;
@@ -159,7 +159,7 @@ internal static class TokenAuthentication
remainingPath = path; remainingPath = path;
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath) if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|| !IsApiTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false)) || !IsUserTokenAuthorized(pathToken, settings, managementStore, updateUserKeyLastUsed: false))
{ {
return false; return false;
} }
@@ -172,13 +172,13 @@ internal static class TokenAuthentication
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) => public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
TryGetBearerToken(value, out var token) 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, string? token,
ServerSettings settings, ServerSettings settings,
ManagementStore managementStore, ManagementStore managementStore,
bool updateApiKeyLastUsed) bool updateUserKeyLastUsed)
{ {
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
{ {
@@ -193,11 +193,11 @@ internal static class TokenAuthentication
return AuthResult.Success(null); return AuthResult.Success(null);
} }
var apiKeyId = managementStore.GetApiKeyId(token); var userKeyId = managementStore.GetUserKeyId(token);
if (apiKeyId is not null) if (userKeyId is not null)
{ {
managementStore.IsApiKeyValid(token, updateApiKeyLastUsed); managementStore.IsUserKeyValid(token, updateUserKeyLastUsed);
return AuthResult.Success(apiKeyId); return AuthResult.Success(userKeyId);
} }
return AuthResult.Failure; return AuthResult.Failure;
@@ -236,15 +236,15 @@ internal static class TokenAuthentication
string? token, string? token,
ServerSettings settings, ServerSettings settings,
ManagementStore managementStore, ManagementStore managementStore,
bool updateApiKeyLastUsed) => bool updateUserKeyLastUsed) =>
AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized;
private static bool IsApiTokenAuthorized( private static bool IsUserTokenAuthorized(
string? token, string? token,
ServerSettings settings, ServerSettings settings,
ManagementStore managementStore, ManagementStore managementStore,
bool updateApiKeyLastUsed) => bool updateUserKeyLastUsed) =>
AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized;
private static bool TryGetBearerToken(string? authorization, out string token) 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 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 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; IsAuthorized = isAuthorized;
ApiKeyId = apiKeyId; UserKeyId = userKeyId;
} }
} }
+50 -50
View File
@@ -77,12 +77,12 @@ content.addEventListener("click", async (event) => {
} }
if (action === "delete-key") { if (action === "delete-key") {
if (!confirm("Delete this API key?")) { if (!confirm("Delete this user key?")) {
return; return;
} }
await api(`/api-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" }); await api(`/user-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
setNotice("API key deleted."); setNotice("User key deleted.");
await refresh(); await refresh();
} }
@@ -98,7 +98,7 @@ content.addEventListener("click", async (event) => {
if (action === "copy-key") { if (action === "copy-key") {
await navigator.clipboard.writeText(button.dataset.key); await navigator.clipboard.writeText(button.dataset.key);
setNotice("API key copied."); setNotice("User key copied.");
} }
if (action === "delete-group") { if (action === "delete-group") {
@@ -123,7 +123,7 @@ content.addEventListener("click", async (event) => {
} }
if (action === "toggle-key-assignment") { 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") { if (action === "delete-billing-rule") {
@@ -176,12 +176,12 @@ content.addEventListener("submit", async (event) => {
await loadModelDetail(data.model, data.clientId); await loadModelDetail(data.model, data.clientId);
} }
if (form.dataset.form === "api-key") { if (form.dataset.form === "user-key") {
state.newKey = await api("/api-keys", { state.newKey = await api("/user-keys", {
method: "POST", method: "POST",
body: { name: data.name } body: { name: data.name }
}); });
setNotice("API key created."); setNotice("User key created.");
await refresh(); await refresh();
} }
@@ -374,8 +374,8 @@ async function renderRoute(showLoading = true) {
return; return;
} }
if (view === "api-keys") { if (view === "user-keys") {
renderApiKeys(); renderUserKeys();
return; return;
} }
@@ -408,7 +408,7 @@ function updateShell() {
<div>${summary.clients.length} clients</div> <div>${summary.clients.length} clients</div>
<div>${summary.models.length} models</div> <div>${summary.models.length} models</div>
<div>${(summary.clientKeys || []).length} client keys</div> <div>${(summary.clientKeys || []).length} client keys</div>
<div>${(summary.apiKeys || []).length} API keys</div> <div>${(summary.userKeys || []).length} user keys</div>
<div>${(summary.groups || []).length} groups</div> <div>${(summary.groups || []).length} groups</div>
<div>${formatDate(summary.generatedAtUtc)}</div> <div>${formatDate(summary.generatedAtUtc)}</div>
`); `);
@@ -708,22 +708,22 @@ async function runModelCommand(clientId, model, action) {
await refreshAfterModelCommand(model, clientId); await refreshAfterModelCommand(model, clientId);
} }
function renderApiKeys() { function renderUserKeys() {
const keys = state.summary?.apiKeys || []; const keys = state.summary?.userKeys || [];
pageTitle.textContent = "API Keys"; pageTitle.textContent = "User Keys";
pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path."; pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path.";
patchContent(` patchContent(`
${state.newKey ? newKeyPanel(state.newKey) : ""} ${state.newKey ? newKeyPanel(state.newKey) : ""}
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Create API key</h2> <h2>Create user key</h2>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<form class="form-row" data-form="api-key"> <form class="form-row" data-form="user-key">
<div class="field"> <div class="field">
<label for="apiKeyName">Name</label> <label for="userKeyName">Name</label>
<input class="input" id="apiKeyName" name="name" placeholder="e.g. openwebui_prod" required> <input class="input" id="userKeyName" name="name" placeholder="e.g. openwebui_prod" required>
</div> </div>
<button class="button" type="submit">Create</button> <button class="button" type="submit">Create</button>
</form> </form>
@@ -731,11 +731,11 @@ function renderApiKeys() {
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>API keys</h2> <h2>User keys</h2>
<span class="badge">${keys.length} total</span> <span class="badge">${keys.length} total</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")} ${keys.length ? userKeysTable(keys) : emptyState("No user keys have been created.")}
</div> </div>
</div> </div>
`); `);
@@ -777,7 +777,7 @@ function renderClientKeys() {
function newKeyPanel(key) { function newKeyPanel(key) {
return ` return `
<div class="new-key"> <div class="new-key">
<strong>New API key</strong> <strong>New user key</strong>
<code>${escapeHtml(key.key)}</code> <code>${escapeHtml(key.key)}</code>
<div class="actions"> <div class="actions">
<button class="button secondary" type="button" data-action="copy-key" data-key="${escapeAttr(key.key)}">Copy</button> <button class="button secondary" type="button" data-action="copy-key" data-key="${escapeAttr(key.key)}">Copy</button>
@@ -786,7 +786,7 @@ function newKeyPanel(key) {
`; `;
} }
function apiKeysTable(keys) { function userKeysTable(keys) {
const rows = keys.map((key) => ` const rows = keys.map((key) => `
<tr> <tr>
<td> <td>
@@ -849,7 +849,7 @@ function clientKeysTable(keys) {
function renderGroups() { function renderGroups() {
const groups = state.summary?.groups || []; const groups = state.summary?.groups || [];
pageTitle.textContent = "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(` patchContent(`
<div class="panel"> <div class="panel">
@@ -916,17 +916,17 @@ async function loadGroupDetail(groupId, showLoading = true) {
} }
try { 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)}`),
api(`/groups/${encodeURIComponent(groupId)}/clients`), api(`/groups/${encodeURIComponent(groupId)}/clients`),
api("/api-keys/groups"), api("/user-keys/groups"),
api(`/groups/${encodeURIComponent(groupId)}/billing`), api(`/groups/${encodeURIComponent(groupId)}/billing`),
api(`/groups/${encodeURIComponent(groupId)}/billing/rules`), api(`/groups/${encodeURIComponent(groupId)}/billing/rules`),
api(`/groups/${encodeURIComponent(groupId)}/billing/payments`), api(`/groups/${encodeURIComponent(groupId)}/billing/payments`),
api(`/groups/${encodeURIComponent(groupId)}/billing/balance`) 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(); renderGroupDetail();
} catch (error) { } catch (error) {
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`); patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
@@ -934,12 +934,12 @@ async function loadGroupDetail(groupId, showLoading = true) {
} }
function renderGroupDetail() { function renderGroupDetail() {
const { group, clients, apiKeyGroups, billing, rules, payments, balance } = state.groupDetail; const { group, clients, userKeyGroups, billing, rules, payments, balance } = state.groupDetail;
const allApiKeys = state.summary?.apiKeys || []; const allUserKeys = state.summary?.userKeys || [];
const assignedKeyIds = new Set( const assignedKeyIds = new Set(
apiKeyGroups userKeyGroups
.filter((akg) => akg.apiKeyId && (akg.groupIds || []).includes(group.id)) .filter((akg) => akg.userKeyId && (akg.groupIds || []).includes(group.id))
.map((akg) => akg.apiKeyId) .map((akg) => akg.userKeyId)
); );
pageTitle.textContent = "Group Detail"; pageTitle.textContent = "Group Detail";
@@ -997,10 +997,10 @@ function renderGroupDetail() {
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>API key assignments</h2> <h2>User key assignments</h2>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
${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.")}
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -1138,8 +1138,8 @@ function groupClientsTable(clients, groupId) {
`; `;
} }
function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) { function userKeyAssignmentTable(userKeys, groupId, assignedKeyIds) {
const rows = apiKeys.map((key) => { const rows = userKeys.map((key) => {
const isAssigned = assignedKeyIds.has(key.id); const isAssigned = assignedKeyIds.has(key.id);
return ` return `
<tr> <tr>
@@ -1162,7 +1162,7 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>API Key</th> <th>User key</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -1171,11 +1171,11 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
`; `;
} }
async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) { async function toggleUserKeyAssignment(groupId, keyId, currentlyAssigned) {
const apiKeyGroups = state.groupDetail?.apiKeyGroups || []; const userKeyGroups = state.groupDetail?.userKeyGroups || [];
const allApiKeys = state.summary?.apiKeys || []; 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] : []; const currentGroupIds = keyGroups ? [...keyGroups.groupIds] : [];
let newGroupIds; let newGroupIds;
@@ -1185,12 +1185,12 @@ async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) {
newGroupIds = [...currentGroupIds, groupId]; newGroupIds = [...currentGroupIds, groupId];
} }
await api(`/api-keys/${encodeURIComponent(keyId)}/groups`, { await api(`/user-keys/${encodeURIComponent(keyId)}/groups`, {
method: "PUT", method: "PUT",
body: { groupIds: newGroupIds } 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); await loadGroupDetail(groupId);
} }
@@ -1286,7 +1286,7 @@ function renderUsage() {
const byModel = usage.byModel || []; const byModel = usage.byModel || [];
const byClient = usage.byClient || []; const byClient = usage.byClient || [];
const byApiKey = usage.byApiKey || []; const byUserKey = usage.byUserKey || [];
const byGroup = usage.byGroup || []; const byGroup = usage.byGroup || [];
const clientRevenue = revenue || []; const clientRevenue = revenue || [];
@@ -1316,11 +1316,11 @@ function renderUsage() {
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Tokens by API key</h2> <h2>Tokens by user key</h2>
<span class="badge">${byApiKey.length} keys</span> <span class="badge">${byUserKey.length} keys</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
${byApiKey.length ? tokenStatsApiKeyTable(byApiKey) : emptyState("No token data yet.")} ${byUserKey.length ? tokenStatsUserKeyTable(byUserKey) : emptyState("No token data yet.")}
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -1393,12 +1393,12 @@ function tokenStatsClientTable(stats, revenueMap) {
`; `;
} }
function tokenStatsApiKeyTable(stats) { function tokenStatsUserKeyTable(stats) {
const rows = stats.map((s) => ` const rows = stats.map((s) => `
<tr> <tr>
<td> <td>
<div class="cell-main">${escapeHtml(s.apiKeyName)}</div> <div class="cell-main">${escapeHtml(s.userKeyName)}</div>
<div class="cell-sub">${escapeHtml(s.apiKeyPrefix)}...</div> <div class="cell-sub">${escapeHtml(s.userKeyPrefix)}...</div>
</td> </td>
<td>${number(s.promptTokens)}</td> <td>${number(s.promptTokens)}</td>
<td>${number(s.completionTokens)}</td> <td>${number(s.completionTokens)}</td>
@@ -1411,7 +1411,7 @@ function tokenStatsApiKeyTable(stats) {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>API key</th> <th>User key</th>
<th>Prompt tokens</th> <th>Prompt tokens</th>
<th>Completion tokens</th> <th>Completion tokens</th>
<th>Total tokens</th> <th>Total tokens</th>
@@ -20,7 +20,7 @@
<a href="#clients" data-nav="clients">Clients</a> <a href="#clients" data-nav="clients">Clients</a>
<a href="#models" data-nav="models">Models</a> <a href="#models" data-nav="models">Models</a>
<a href="#client-keys" data-nav="client-keys">Client keys</a> <a href="#client-keys" data-nav="client-keys">Client keys</a>
<a href="#api-keys" data-nav="api-keys">API keys</a> <a href="#user-keys" data-nav="user-keys">User keys</a>
<a href="#groups" data-nav="groups">Groups</a> <a href="#groups" data-nav="groups">Groups</a>
<a href="#usage" data-nav="usage">Usage</a> <a href="#usage" data-nav="usage">Usage</a>
</nav> </nav>