feat(server): splits API keys into user keys and client keys
Build & Deploy / build (push) Successful in 1m15s
Build & Deploy / build (push) Successful in 1m15s
This commit is contained in:
@@ -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
@@ -2,5 +2,5 @@
|
||||

|
||||
# Models view
|
||||

|
||||
# API Keys view
|
||||

|
||||
# User Keys view
|
||||

|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user