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
|
The server provides
|
||||||
- An API with
|
- An API with
|
||||||
- Authentication via API keys
|
- Authentication via user keys
|
||||||
- Authorization (planned)
|
- Authorization (planned)
|
||||||
- Load balancing (Scale your AI strategy horizontally!)
|
- Load balancing (Scale your AI strategy horizontally!)
|
||||||
- (Ollama-only) Model management (install, remove, load, unload models)
|
- (Ollama-only) Model management (install, remove, load, unload models)
|
||||||
@@ -27,7 +27,7 @@ The server provides
|
|||||||
- What clients are mapped to which groups
|
- What clients are mapped to which groups
|
||||||
- Billing (planned)
|
- Billing (planned)
|
||||||
- (planned) Price per model per thousand tokens
|
- (planned) Price per model per thousand tokens
|
||||||
- (planned) Usage per API user
|
- (planned) Usage per user key
|
||||||
- (planned) Rate limiting
|
- (planned) Rate limiting
|
||||||
|
|
||||||
The client provides a persistent outbound connection to the server and forwards requests to the local Ollama (or vLLM, etc.) instance. Responses stream back through the tunnel with minimal overhead.
|
The client provides a persistent outbound connection to the server and forwards requests to the local Ollama (or vLLM, etc.) instance. Responses stream back through the tunnel with minimal overhead.
|
||||||
@@ -84,13 +84,14 @@ Server options:
|
|||||||
- `--status-path <path>`: defaults to `/_reverse-llama/status`.
|
- `--status-path <path>`: defaults to `/_reverse-llama/status`.
|
||||||
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
|
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
|
||||||
- `--embedding-cache-path <path>` or `REVERSE_LLAMA_EMBEDDING_CACHE_PATH`: SQLite cache file for embedding vectors. Defaults to `App_Data\embedding-cache.sqlite` under the server app directory.
|
- `--embedding-cache-path <path>` or `REVERSE_LLAMA_EMBEDDING_CACHE_PATH`: SQLite cache file for embedding vectors. Defaults to `App_Data\embedding-cache.sqlite` under the server app directory.
|
||||||
- `--management-database-path <path>` or `REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH`: SQLite database for admin API keys, client disable state, and request/model metrics. Defaults to `App_Data\management.sqlite` under the server app directory.
|
- `--management-database-path <path>` or `REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH`: SQLite database for admin user keys, client keys, client disable state, and request/model metrics. Defaults to `App_Data\management.sqlite` under the server app directory.
|
||||||
|
- `--secure-cookies` or `REVERSE_LLAMA_SECURE_COOKIES`: set to `false` to allow admin auth cookies over plain HTTP (for local development). Defaults to `true`.
|
||||||
|
|
||||||
Admin UI:
|
Admin UI:
|
||||||
|
|
||||||
- `GET /admin` opens the Keycloak-protected management UI.
|
- `GET /admin` opens the Keycloak-protected management UI.
|
||||||
- The temporary Keycloak settings live under `Authentication:Keycloak` in `appsettings.json`.
|
- The temporary Keycloak settings live under `Authentication:Keycloak` in `appsettings.json`.
|
||||||
- API keys created in the UI are accepted anywhere the shared token is accepted: `X-Reverse-Llama-Token`, `Authorization: Bearer <key>`, `?token=...`, and `/token/<key>/...`.
|
- User keys created in the UI are accepted anywhere the shared token is accepted: `X-Reverse-Llama-Token`, `Authorization: Bearer <key>`, `?token=...`, and `/token/<key>/...`.
|
||||||
- Model add/remove/load/unload commands are sent through the connected tunnel client to Ollama (`/api/pull`, `/api/delete`, `/api/generate`, and `/api/show`).
|
- Model add/remove/load/unload commands are sent through the connected tunnel client to Ollama (`/api/pull`, `/api/delete`, `/api/generate`, and `/api/show`).
|
||||||
|
|
||||||
Client options:
|
Client options:
|
||||||
|
|||||||
+2
-2
@@ -2,5 +2,5 @@
|
|||||||

|

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

|

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

|

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