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
- 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 <path>`: defaults to `/_reverse-llama/status`.
- `--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.
- `--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:
- `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 <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`).
Client options:
+2 -2
View File
@@ -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)
# User Keys view
![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);
});
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<string>? GroupIds);
internal sealed record SetUserKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
internal sealed record UpdateBillingRequest(
string? Currency,
+227 -134
View File
@@ -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<string, ApiKeyState> _apiKeysByHash = new(StringComparer.Ordinal);
private readonly Dictionary<string, ApiKeyState> _clientKeysByHash = new(StringComparer.Ordinal);
private readonly Dictionary<string, KeyState> _userKeysByHash = new(StringComparer.Ordinal);
private readonly Dictionary<string, KeyState> _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<ApiKeyInfo> ListApiKeys()
public IReadOnlyList<UserKeyInfo> 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<ApiKeyInfo> ListClientKeys()
public IReadOnlyList<UserKeyInfo> 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<string> GetApiKeyGroupIds(string apiKeyId)
public IReadOnlyList<string> 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<string>();
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();
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<ApiKeyGroupInfo> ListApiKeyGroups()
public IReadOnlyList<UserKeyGroupInfo> 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<ApiKeyGroupInfo>();
var result = new List<UserKeyGroupInfo>();
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<string>(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<TokenStatsByApiKey> GetTokenStatsByApiKey()
public IReadOnlyList<TokenStatsByUserKey> 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<TokenStatsByApiKey>();
var result = new List<TokenStatsByUserKey>();
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<string> GetApiKeyGroupIdsLocked(string apiKeyId)
private IReadOnlyList<string> 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<string>();
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<string> GroupIds,
IReadOnlyList<string> GroupNames);
+4 -4
View File
@@ -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";
+16 -16
View File
@@ -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,
@@ -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"),
+21 -21
View File
@@ -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;
}
}
+50 -50
View File
@@ -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() {
<div>${summary.clients.length} clients</div>
<div>${summary.models.length} models</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>${formatDate(summary.generatedAtUtc)}</div>
`);
@@ -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) : ""}
<div class="panel">
<div class="panel-header">
<h2>Create API key</h2>
<h2>Create user key</h2>
</div>
<div class="panel-body">
<form class="form-row" data-form="api-key">
<form class="form-row" data-form="user-key">
<div class="field">
<label for="apiKeyName">Name</label>
<input class="input" id="apiKeyName" name="name" placeholder="e.g. openwebui_prod" required>
<label for="userKeyName">Name</label>
<input class="input" id="userKeyName" name="name" placeholder="e.g. openwebui_prod" required>
</div>
<button class="button" type="submit">Create</button>
</form>
@@ -731,11 +731,11 @@ function renderApiKeys() {
</div>
<div class="panel">
<div class="panel-header">
<h2>API keys</h2>
<h2>User keys</h2>
<span class="badge">${keys.length} total</span>
</div>
<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>
`);
@@ -777,7 +777,7 @@ function renderClientKeys() {
function newKeyPanel(key) {
return `
<div class="new-key">
<strong>New API key</strong>
<strong>New user key</strong>
<code>${escapeHtml(key.key)}</code>
<div class="actions">
<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) => `
<tr>
<td>
@@ -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(`
<div class="panel">
@@ -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(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
@@ -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() {
</div>
<div class="panel">
<div class="panel-header">
<h2>API key assignments</h2>
<h2>User key assignments</h2>
</div>
<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 class="panel">
@@ -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 `
<tr>
@@ -1162,7 +1162,7 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
<table>
<thead>
<tr>
<th>API Key</th>
<th>User key</th>
<th></th>
</tr>
</thead>
@@ -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() {
</div>
<div class="panel">
<div class="panel-header">
<h2>Tokens by API key</h2>
<span class="badge">${byApiKey.length} keys</span>
<h2>Tokens by user key</h2>
<span class="badge">${byUserKey.length} keys</span>
</div>
<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 class="panel">
@@ -1393,12 +1393,12 @@ function tokenStatsClientTable(stats, revenueMap) {
`;
}
function tokenStatsApiKeyTable(stats) {
function tokenStatsUserKeyTable(stats) {
const rows = stats.map((s) => `
<tr>
<td>
<div class="cell-main">${escapeHtml(s.apiKeyName)}</div>
<div class="cell-sub">${escapeHtml(s.apiKeyPrefix)}...</div>
<div class="cell-main">${escapeHtml(s.userKeyName)}</div>
<div class="cell-sub">${escapeHtml(s.userKeyPrefix)}...</div>
</td>
<td>${number(s.promptTokens)}</td>
<td>${number(s.completionTokens)}</td>
@@ -1411,7 +1411,7 @@ function tokenStatsApiKeyTable(stats) {
<table>
<thead>
<tr>
<th>API key</th>
<th>User key</th>
<th>Prompt tokens</th>
<th>Completion tokens</th>
<th>Total tokens</th>
@@ -20,7 +20,7 @@
<a href="#clients" data-nav="clients">Clients</a>
<a href="#models" data-nav="models">Models</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="#usage" data-nav="usage">Usage</a>
</nav>