Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24fd8562cf | ||
|
|
c1c2d08915 | ||
|
|
2f1a3687f4 | ||
|
|
02c0b4243c |
@@ -294,6 +294,152 @@ internal static class AdminEndpoints
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var billing = store.GetGroupBilling(id);
|
||||||
|
return billing is not null
|
||||||
|
? Results.Json(billing)
|
||||||
|
: Results.Json(new GroupBillingInfo(id, "EUR", 0, 0, false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPut("/groups/{id}/billing", (string id, UpdateBillingRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var billing = store.UpsertGroupBilling(
|
||||||
|
id,
|
||||||
|
request.Currency ?? "EUR",
|
||||||
|
request.DefaultRatePer1k,
|
||||||
|
request.RefuseBelowBalance,
|
||||||
|
request.Enabled);
|
||||||
|
return Results.Ok(billing);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing/rules", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.ListGroupBillingRules(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/groups/{id}/billing/rules", (string id, AddBillingRuleRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rule = store.AddBillingRule(id, request.ModelRegex, request.RatePer1k);
|
||||||
|
return Results.Json(rule);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPut("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, UpdateBillingRuleRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return store.UpdateBillingRule(ruleId, request.ModelRegex, request.RatePer1k)
|
||||||
|
? Results.Ok(new { id = ruleId })
|
||||||
|
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." });
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, ManagementStore store) =>
|
||||||
|
store.DeleteBillingRule(ruleId)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." }));
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing/payments", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.ListGroupPayments(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/groups/{id}/billing/payments", (string id, AddPaymentRequest request, ManagementStore store, HttpContext context) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userName = GetUserName(context.User);
|
||||||
|
var payment = store.AddPayment(id, request.Amount, request.Description, userName);
|
||||||
|
return Results.Json(payment);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/groups/{id}/billing/payments/{paymentId:long}", (string id, long paymentId, ManagementStore store) =>
|
||||||
|
store.DeletePayment(paymentId)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Payment '{paymentId}' was not found." }));
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing/balance", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.GetGroupBalance(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/usage/tokens", (ManagementStore store) =>
|
||||||
|
Results.Json(new
|
||||||
|
{
|
||||||
|
byModel = store.GetTokenStatsByModel(),
|
||||||
|
byClient = store.GetTokenStatsByClient(),
|
||||||
|
byApiKey = store.GetTokenStatsByApiKey(),
|
||||||
|
byGroup = store.GetTokenStatsByGroup()
|
||||||
|
}));
|
||||||
|
|
||||||
|
api.MapGet("/usage/revenue", (ManagementStore store) =>
|
||||||
|
Results.Json(store.GetClientRevenue()));
|
||||||
|
|
||||||
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
||||||
ServeAdminAsset(environment, null));
|
ServeAdminAsset(environment, null));
|
||||||
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
||||||
@@ -594,6 +740,24 @@ internal sealed record AddGroupClientRequest(
|
|||||||
|
|
||||||
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
|
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
|
||||||
|
|
||||||
|
internal sealed record UpdateBillingRequest(
|
||||||
|
string? Currency,
|
||||||
|
double DefaultRatePer1k,
|
||||||
|
double RefuseBelowBalance,
|
||||||
|
bool Enabled);
|
||||||
|
|
||||||
|
internal sealed record AddBillingRuleRequest(
|
||||||
|
string ModelRegex,
|
||||||
|
double RatePer1k);
|
||||||
|
|
||||||
|
internal sealed record UpdateBillingRuleRequest(
|
||||||
|
string ModelRegex,
|
||||||
|
double RatePer1k);
|
||||||
|
|
||||||
|
internal sealed record AddPaymentRequest(
|
||||||
|
double Amount,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
internal sealed record ClientSummary(
|
internal sealed record ClientSummary(
|
||||||
string Id,
|
string Id,
|
||||||
bool Connected,
|
bool Connected,
|
||||||
|
|||||||
@@ -378,7 +378,11 @@ internal sealed class ManagementStore
|
|||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
status_code,
|
status_code,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
token_count,
|
token_count,
|
||||||
|
api_key_id,
|
||||||
|
cost,
|
||||||
started_at_utc,
|
started_at_utc,
|
||||||
completed_at_utc,
|
completed_at_utc,
|
||||||
duration_ms)
|
duration_ms)
|
||||||
@@ -388,7 +392,11 @@ internal sealed class ManagementStore
|
|||||||
$method,
|
$method,
|
||||||
$path,
|
$path,
|
||||||
$status_code,
|
$status_code,
|
||||||
|
$prompt_tokens,
|
||||||
|
$completion_tokens,
|
||||||
$token_count,
|
$token_count,
|
||||||
|
$api_key_id,
|
||||||
|
$cost,
|
||||||
$started_at_utc,
|
$started_at_utc,
|
||||||
$completed_at_utc,
|
$completed_at_utc,
|
||||||
$duration_ms)
|
$duration_ms)
|
||||||
@@ -398,7 +406,11 @@ internal sealed class ManagementStore
|
|||||||
command.Parameters.AddWithValue("$method", metric.Method);
|
command.Parameters.AddWithValue("$method", metric.Method);
|
||||||
command.Parameters.AddWithValue("$path", metric.Path);
|
command.Parameters.AddWithValue("$path", metric.Path);
|
||||||
command.Parameters.AddWithValue("$status_code", metric.StatusCode is null ? DBNull.Value : metric.StatusCode.Value);
|
command.Parameters.AddWithValue("$status_code", metric.StatusCode is null ? DBNull.Value : metric.StatusCode.Value);
|
||||||
|
command.Parameters.AddWithValue("$prompt_tokens", metric.PromptTokens);
|
||||||
|
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("$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"));
|
||||||
command.Parameters.AddWithValue("$duration_ms", metric.Duration.TotalMilliseconds);
|
command.Parameters.AddWithValue("$duration_ms", metric.Duration.TotalMilliseconds);
|
||||||
@@ -1007,6 +1019,710 @@ internal sealed class ManagementStore
|
|||||||
return frozen;
|
return frozen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GroupBillingInfo? GetGroupBilling(string groupId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(groupId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT group_id, currency, default_rate_per_1k, refuse_below_balance, enabled, created_at_utc, updated_at_utc
|
||||||
|
FROM group_billing WHERE group_id = $group_id
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (!reader.Read())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupBillingInfo(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetDouble(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
reader.GetInt32(4) != 0,
|
||||||
|
ReadDateTimeOffset(reader.GetString(5)),
|
||||||
|
ReadDateTimeOffset(reader.GetString(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBillingInfo UpsertGroupBilling(
|
||||||
|
string groupId,
|
||||||
|
string currency,
|
||||||
|
double defaultRatePer1k,
|
||||||
|
double refuseBelowBalance,
|
||||||
|
bool enabled)
|
||||||
|
{
|
||||||
|
EnsureAvailable();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Group id is required.", nameof(groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var cur = string.IsNullOrWhiteSpace(currency) ? "EUR" : currency.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO group_billing (group_id, currency, default_rate_per_1k, refuse_below_balance, enabled, created_at_utc, updated_at_utc)
|
||||||
|
VALUES ($group_id, $currency, $default_rate_per_1k, $refuse_below_balance, $enabled, $created_at_utc, $updated_at_utc)
|
||||||
|
ON CONFLICT(group_id) DO UPDATE SET
|
||||||
|
currency = excluded.currency,
|
||||||
|
default_rate_per_1k = excluded.default_rate_per_1k,
|
||||||
|
refuse_below_balance = excluded.refuse_below_balance,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
updated_at_utc = excluded.updated_at_utc
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
command.Parameters.AddWithValue("$currency", cur);
|
||||||
|
command.Parameters.AddWithValue("$default_rate_per_1k", defaultRatePer1k);
|
||||||
|
command.Parameters.AddWithValue("$refuse_below_balance", refuseBelowBalance);
|
||||||
|
command.Parameters.AddWithValue("$enabled", enabled ? 1 : 0);
|
||||||
|
command.Parameters.AddWithValue("$created_at_utc", now.ToString("O"));
|
||||||
|
command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O"));
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupBillingInfo(groupId, cur, defaultRatePer1k, refuseBelowBalance, enabled, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<GroupBillingRule> ListGroupBillingRules(string groupId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(groupId))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT id, group_id, model_regex, rate_per_1k, created_at_utc
|
||||||
|
FROM group_billing_rules WHERE group_id = $group_id ORDER BY id
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
|
||||||
|
var result = new List<GroupBillingRule>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new GroupBillingRule(
|
||||||
|
reader.GetInt64(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
ReadDateTimeOffset(reader.GetString(4))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBillingRule AddBillingRule(string groupId, string modelRegex, double ratePer1k)
|
||||||
|
{
|
||||||
|
EnsureAvailable();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(modelRegex))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Group id and model regex are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = Regex.IsMatch("", modelRegex);
|
||||||
|
}
|
||||||
|
catch (RegexParseException ex)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid regex: {ex.Message}", nameof(modelRegex));
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO group_billing_rules (group_id, model_regex, rate_per_1k, created_at_utc)
|
||||||
|
VALUES ($group_id, $model_regex, $rate_per_1k, $created_at_utc)
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
command.Parameters.AddWithValue("$model_regex", modelRegex.Trim());
|
||||||
|
command.Parameters.AddWithValue("$rate_per_1k", ratePer1k);
|
||||||
|
command.Parameters.AddWithValue("$created_at_utc", now.ToString("O"));
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var idCommand = connection.CreateCommand();
|
||||||
|
idCommand.CommandText = "SELECT last_insert_rowid()";
|
||||||
|
var insertedId = (long)idCommand.ExecuteScalar()!;
|
||||||
|
return new GroupBillingRule(insertedId, groupId, modelRegex.Trim(), ratePer1k, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool UpdateBillingRule(long ruleId, string modelRegex, double ratePer1k)
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = Regex.IsMatch("", modelRegex);
|
||||||
|
}
|
||||||
|
catch (RegexParseException ex)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid regex: {ex.Message}", nameof(modelRegex));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
UPDATE group_billing_rules SET model_regex = $model_regex, rate_per_1k = $rate_per_1k WHERE id = $id
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$id", ruleId);
|
||||||
|
command.Parameters.AddWithValue("$model_regex", modelRegex.Trim());
|
||||||
|
command.Parameters.AddWithValue("$rate_per_1k", ratePer1k);
|
||||||
|
return command.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteBillingRule(long ruleId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM group_billing_rules WHERE id = $id";
|
||||||
|
command.Parameters.AddWithValue("$id", ruleId);
|
||||||
|
return command.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<GroupPayment> ListGroupPayments(string groupId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(groupId))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT id, group_id, amount, description, created_at_utc, created_by
|
||||||
|
FROM group_payments WHERE group_id = $group_id ORDER BY created_at_utc DESC
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
|
||||||
|
var result = new List<GroupPayment>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new GroupPayment(
|
||||||
|
reader.GetInt64(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetDouble(2),
|
||||||
|
reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
ReadDateTimeOffset(reader.GetString(4)),
|
||||||
|
reader.IsDBNull(5) ? null : reader.GetString(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupPayment AddPayment(string groupId, double amount, string? description, string? createdBy)
|
||||||
|
{
|
||||||
|
EnsureAvailable();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Group id is required.", nameof(groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO group_payments (group_id, amount, description, created_at_utc, created_by)
|
||||||
|
VALUES ($group_id, $amount, $description, $created_at_utc, $created_by)
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
command.Parameters.AddWithValue("$amount", amount);
|
||||||
|
command.Parameters.AddWithValue("$description", string.IsNullOrWhiteSpace(description) ? DBNull.Value : description.Trim());
|
||||||
|
command.Parameters.AddWithValue("$created_at_utc", now.ToString("O"));
|
||||||
|
command.Parameters.AddWithValue("$created_by", string.IsNullOrWhiteSpace(createdBy) ? DBNull.Value : createdBy);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var idCommand = connection.CreateCommand();
|
||||||
|
idCommand.CommandText = "SELECT last_insert_rowid()";
|
||||||
|
var insertedId = (long)idCommand.ExecuteScalar()!;
|
||||||
|
return new GroupPayment(insertedId, groupId, amount, description, now, createdBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeletePayment(long paymentId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM group_payments WHERE id = $id";
|
||||||
|
command.Parameters.AddWithValue("$id", paymentId);
|
||||||
|
return command.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBalance GetGroupBalance(string groupId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(groupId))
|
||||||
|
{
|
||||||
|
return new GroupBalance(groupId ?? "", "EUR", 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
|
||||||
|
string currency = "EUR";
|
||||||
|
using (var billingCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
billingCmd.CommandText = "SELECT currency FROM group_billing WHERE group_id = $group_id";
|
||||||
|
billingCmd.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
var curResult = billingCmd.ExecuteScalar();
|
||||||
|
if (curResult is not null)
|
||||||
|
{
|
||||||
|
currency = curResult.ToString() ?? "EUR";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double totalPayments = 0;
|
||||||
|
using (var payCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
payCmd.CommandText = "SELECT COALESCE(SUM(amount), 0) FROM group_payments WHERE group_id = $group_id";
|
||||||
|
payCmd.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
totalPayments = Convert.ToDouble(payCmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
|
double totalCosts = 0;
|
||||||
|
using (var costCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
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
|
||||||
|
""";
|
||||||
|
costCmd.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
totalCosts = Convert.ToDouble(costCmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupBalance(groupId, currency, totalPayments, totalCosts, totalPayments - totalCosts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBillingInfo? ResolveBillingForApiKey(string? apiKeyId)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
WHERE akg.api_key_id = $api_key_id AND gb.enabled = 1
|
||||||
|
ORDER BY gb.created_at_utc ASC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$api_key_id", apiKeyId);
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (!reader.Read())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupBillingInfo(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetDouble(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
reader.GetInt32(4) != 0,
|
||||||
|
ReadDateTimeOffset(reader.GetString(5)),
|
||||||
|
ReadDateTimeOffset(reader.GetString(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double CalculateCost(string groupId, string? model, int totalTokens)
|
||||||
|
{
|
||||||
|
if (totalTokens <= 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var rules = ListGroupBillingRulesLocked(groupId);
|
||||||
|
var billing = GetGroupBillingLocked(groupId);
|
||||||
|
|
||||||
|
if (billing is null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ratePer1k = billing.DefaultRatePer1k;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Regex.IsMatch(model, rule.ModelRegex, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
ratePer1k = rule.RatePer1k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (RegexParseException)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (totalTokens / 1000.0) * ratePer1k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool Allowed, double Balance, string Currency, double Threshold) CheckBalanceForApiKey(string? apiKeyId)
|
||||||
|
{
|
||||||
|
var billing = ResolveBillingForApiKey(apiKeyId);
|
||||||
|
if (billing is null)
|
||||||
|
{
|
||||||
|
return (true, 0, "", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var balance = GetGroupBalance(billing.GroupId);
|
||||||
|
var allowed = balance.Balance >= billing.RefuseBelowBalance;
|
||||||
|
return (allowed, balance.Balance, billing.Currency, billing.RefuseBelowBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TokenStatsByModel> GetTokenStatsByModel()
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT model,
|
||||||
|
COALESCE(SUM(prompt_tokens), 0),
|
||||||
|
COALESCE(SUM(completion_tokens), 0),
|
||||||
|
COALESCE(SUM(token_count), 0),
|
||||||
|
COUNT(*)
|
||||||
|
FROM request_metrics
|
||||||
|
WHERE model IS NOT NULL AND model <> ''
|
||||||
|
GROUP BY model
|
||||||
|
ORDER BY model
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new List<TokenStatsByModel>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new TokenStatsByModel(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetInt64(1),
|
||||||
|
reader.GetInt64(2),
|
||||||
|
reader.GetInt64(3),
|
||||||
|
reader.GetInt64(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TokenStatsByClient> GetTokenStatsByClient()
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT client_id,
|
||||||
|
COALESCE(SUM(prompt_tokens), 0),
|
||||||
|
COALESCE(SUM(completion_tokens), 0),
|
||||||
|
COALESCE(SUM(token_count), 0),
|
||||||
|
COUNT(*)
|
||||||
|
FROM request_metrics
|
||||||
|
GROUP BY client_id
|
||||||
|
ORDER BY client_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new List<TokenStatsByClient>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new TokenStatsByClient(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetInt64(1),
|
||||||
|
reader.GetInt64(2),
|
||||||
|
reader.GetInt64(3),
|
||||||
|
reader.GetInt64(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TokenStatsByApiKey> GetTokenStatsByApiKey()
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT rm.api_key_id, COALESCE(ak.name, 'Unknown'), COALESCE(ak.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
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new List<TokenStatsByApiKey>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new TokenStatsByApiKey(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetInt64(3),
|
||||||
|
reader.GetInt64(4),
|
||||||
|
reader.GetInt64(5),
|
||||||
|
reader.GetInt64(6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TokenStatsByGroup> GetTokenStatsByGroup()
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT akg.group_id, COALESCE(g.name, 'Unknown'),
|
||||||
|
COALESCE(SUM(rm.prompt_tokens), 0),
|
||||||
|
COALESCE(SUM(rm.completion_tokens), 0),
|
||||||
|
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 groups g ON akg.group_id = g.id
|
||||||
|
WHERE rm.api_key_id IS NOT NULL
|
||||||
|
GROUP BY akg.group_id
|
||||||
|
ORDER BY g.name
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new List<TokenStatsByGroup>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new TokenStatsByGroup(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetInt64(2),
|
||||||
|
reader.GetInt64(3),
|
||||||
|
reader.GetInt64(4),
|
||||||
|
reader.GetInt64(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ClientRevenue> GetClientRevenue()
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
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
|
||||||
|
GROUP BY rm.client_id
|
||||||
|
ORDER BY rm.client_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new List<ClientRevenue>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var clientId = reader.GetString(0);
|
||||||
|
var revenue = reader.GetDouble(1);
|
||||||
|
|
||||||
|
var billing = ResolveBillingForApiKeyForClientLocked(clientId);
|
||||||
|
var currency = billing?.Currency ?? "EUR";
|
||||||
|
|
||||||
|
result.Add(new ClientRevenue(clientId, revenue, currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<GroupBillingRule> ListGroupBillingRulesLocked(string groupId)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT id, group_id, model_regex, rate_per_1k, created_at_utc
|
||||||
|
FROM group_billing_rules WHERE group_id = $group_id ORDER BY id
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
|
||||||
|
var result = new List<GroupBillingRule>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new GroupBillingRule(
|
||||||
|
reader.GetInt64(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
ReadDateTimeOffset(reader.GetString(4))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBillingInfo? GetGroupBillingLocked(string groupId)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT group_id, currency, default_rate_per_1k, refuse_below_balance, enabled, created_at_utc, updated_at_utc
|
||||||
|
FROM group_billing WHERE group_id = $group_id
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$group_id", groupId);
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (!reader.Read())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupBillingInfo(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetDouble(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
reader.GetInt32(4) != 0,
|
||||||
|
ReadDateTimeOffset(reader.GetString(5)),
|
||||||
|
ReadDateTimeOffset(reader.GetString(6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBillingInfo? ResolveBillingForApiKeyForClientLocked(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
|
||||||
|
WHERE rm.client_id = $client_id AND gb.enabled = 1
|
||||||
|
ORDER BY gb.created_at_utc ASC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$client_id", clientId);
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (!reader.Read())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupBillingInfo(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetDouble(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
reader.GetInt32(4) != 0,
|
||||||
|
ReadDateTimeOffset(reader.GetString(5)),
|
||||||
|
ReadDateTimeOffset(reader.GetString(6)));
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<string> GetApiKeyGroupIdsLocked(string apiKeyId)
|
private IReadOnlyList<string> GetApiKeyGroupIdsLocked(string apiKeyId)
|
||||||
{
|
{
|
||||||
using var connection = OpenConnection();
|
using var connection = OpenConnection();
|
||||||
@@ -1111,6 +1827,74 @@ internal sealed class ManagementStore
|
|||||||
);
|
);
|
||||||
""";
|
""";
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using (var migrate = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
migrate.CommandText = """
|
||||||
|
SELECT COUNT(*) FROM pragma_table_info('request_metrics') WHERE name = 'prompt_tokens'
|
||||||
|
""";
|
||||||
|
var hasColumn = (long)migrate.ExecuteScalar()! > 0;
|
||||||
|
|
||||||
|
if (!hasColumn)
|
||||||
|
{
|
||||||
|
using var alter1 = connection.CreateCommand();
|
||||||
|
alter1.CommandText = "ALTER TABLE request_metrics ADD COLUMN prompt_tokens INTEGER NOT NULL DEFAULT 0";
|
||||||
|
alter1.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var alter2 = connection.CreateCommand();
|
||||||
|
alter2.CommandText = "ALTER TABLE request_metrics ADD COLUMN completion_tokens INTEGER NOT NULL DEFAULT 0";
|
||||||
|
alter2.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var alter3 = connection.CreateCommand();
|
||||||
|
alter3.CommandText = "ALTER TABLE request_metrics ADD COLUMN api_key_id TEXT NULL";
|
||||||
|
alter3.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var alter4 = connection.CreateCommand();
|
||||||
|
alter4.CommandText = "ALTER TABLE request_metrics ADD COLUMN cost REAL NOT NULL DEFAULT 0";
|
||||||
|
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.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var command2 = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command2.CommandText = """
|
||||||
|
CREATE TABLE IF NOT EXISTS group_billing (
|
||||||
|
group_id TEXT NOT NULL PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
default_rate_per_1k REAL NOT NULL DEFAULT 0.0,
|
||||||
|
refuse_below_balance REAL NOT NULL DEFAULT 0.0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS group_billing_rules (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
model_regex TEXT NOT NULL,
|
||||||
|
rate_per_1k REAL NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_billing_rules_group ON group_billing_rules (group_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS group_payments (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
created_by TEXT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_payments_group ON group_payments (group_id);
|
||||||
|
""";
|
||||||
|
command2.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var command = connection.CreateCommand())
|
using (var command = connection.CreateCommand())
|
||||||
@@ -1240,7 +2024,11 @@ internal sealed record RequestMetric(
|
|||||||
string Method,
|
string Method,
|
||||||
string Path,
|
string Path,
|
||||||
int? StatusCode,
|
int? StatusCode,
|
||||||
|
int PromptTokens,
|
||||||
|
int CompletionTokens,
|
||||||
int TokenCount,
|
int TokenCount,
|
||||||
|
string? ApiKeyId,
|
||||||
|
double Cost,
|
||||||
DateTimeOffset StartedAtUtc,
|
DateTimeOffset StartedAtUtc,
|
||||||
DateTimeOffset CompletedAtUtc,
|
DateTimeOffset CompletedAtUtc,
|
||||||
TimeSpan Duration);
|
TimeSpan Duration);
|
||||||
@@ -1257,6 +2045,73 @@ internal sealed record ModelUsageStats(
|
|||||||
long TokensLast10Minutes,
|
long TokensLast10Minutes,
|
||||||
long TokensLastHour);
|
long TokensLastHour);
|
||||||
|
|
||||||
|
internal sealed record GroupBillingInfo(
|
||||||
|
string GroupId,
|
||||||
|
string Currency,
|
||||||
|
double DefaultRatePer1k,
|
||||||
|
double RefuseBelowBalance,
|
||||||
|
bool Enabled,
|
||||||
|
DateTimeOffset CreatedAtUtc,
|
||||||
|
DateTimeOffset UpdatedAtUtc);
|
||||||
|
|
||||||
|
internal sealed record GroupBillingRule(
|
||||||
|
long Id,
|
||||||
|
string GroupId,
|
||||||
|
string ModelRegex,
|
||||||
|
double RatePer1k,
|
||||||
|
DateTimeOffset CreatedAtUtc);
|
||||||
|
|
||||||
|
internal sealed record GroupPayment(
|
||||||
|
long Id,
|
||||||
|
string GroupId,
|
||||||
|
double Amount,
|
||||||
|
string? Description,
|
||||||
|
DateTimeOffset CreatedAtUtc,
|
||||||
|
string? CreatedBy);
|
||||||
|
|
||||||
|
internal sealed record GroupBalance(
|
||||||
|
string GroupId,
|
||||||
|
string Currency,
|
||||||
|
double TotalPayments,
|
||||||
|
double TotalCosts,
|
||||||
|
double Balance);
|
||||||
|
|
||||||
|
internal sealed record TokenStatsByModel(
|
||||||
|
string Model,
|
||||||
|
long PromptTokens,
|
||||||
|
long CompletionTokens,
|
||||||
|
long TotalTokens,
|
||||||
|
long Requests);
|
||||||
|
|
||||||
|
internal sealed record TokenStatsByClient(
|
||||||
|
string ClientId,
|
||||||
|
long PromptTokens,
|
||||||
|
long CompletionTokens,
|
||||||
|
long TotalTokens,
|
||||||
|
long Requests);
|
||||||
|
|
||||||
|
internal sealed record TokenStatsByApiKey(
|
||||||
|
string ApiKeyId,
|
||||||
|
string ApiKeyName,
|
||||||
|
string ApiKeyPrefix,
|
||||||
|
long PromptTokens,
|
||||||
|
long CompletionTokens,
|
||||||
|
long TotalTokens,
|
||||||
|
long Requests);
|
||||||
|
|
||||||
|
internal sealed record TokenStatsByGroup(
|
||||||
|
string GroupId,
|
||||||
|
string GroupName,
|
||||||
|
long PromptTokens,
|
||||||
|
long CompletionTokens,
|
||||||
|
long TotalTokens,
|
||||||
|
long Requests);
|
||||||
|
|
||||||
|
internal sealed record ClientRevenue(
|
||||||
|
string ClientId,
|
||||||
|
double Revenue,
|
||||||
|
string Currency);
|
||||||
|
|
||||||
internal sealed record GroupInfo(
|
internal sealed record GroupInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -108,14 +108,7 @@ app.UseWebSockets(new WebSocketOptions
|
|||||||
|
|
||||||
app.MapAdminEndpoints(settings);
|
app.MapAdminEndpoints(settings);
|
||||||
|
|
||||||
app.MapGet("/", (TunnelHub hub) =>
|
app.MapGet("/", () => Results.Redirect("/admin"));
|
||||||
Results.Json(new
|
|
||||||
{
|
|
||||||
status = "ok",
|
|
||||||
connected = hub.HasClient,
|
|
||||||
pendingRequests = hub.PendingRequestCount,
|
|
||||||
clients = hub.ClientsSnapshot.Count
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,15 +21,16 @@ internal sealed class ResponseTokenCounter
|
|||||||
_buffer.Write(chunk[..length]);
|
_buffer.Write(chunk[..length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int CountTokens()
|
public TokenCounts CountTokens()
|
||||||
{
|
{
|
||||||
if (_buffer.Length == 0)
|
if (_buffer.Length == 0)
|
||||||
{
|
{
|
||||||
return 0;
|
return new TokenCounts(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload = Encoding.UTF8.GetString(_buffer.ToArray());
|
var payload = Encoding.UTF8.GetString(_buffer.ToArray());
|
||||||
var total = 0;
|
var totalPrompt = 0;
|
||||||
|
var totalCompletion = 0;
|
||||||
var parsedLines = false;
|
var parsedLines = false;
|
||||||
|
|
||||||
foreach (var rawLine in payload.Split('\n'))
|
foreach (var rawLine in payload.Split('\n'))
|
||||||
@@ -50,30 +51,37 @@ internal sealed class ResponseTokenCounter
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryExtractFromJson(line, out var lineTokens))
|
if (TryExtractTokenCountsFromJson(line, out var prompt, out var completion))
|
||||||
{
|
{
|
||||||
parsedLines = true;
|
parsedLines = true;
|
||||||
total += lineTokens;
|
totalPrompt += prompt;
|
||||||
|
totalCompletion += completion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedLines)
|
if (parsedLines)
|
||||||
{
|
{
|
||||||
return total;
|
return new TokenCounts(totalPrompt, totalCompletion, totalPrompt + totalCompletion);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryExtractFromJson(payload, out var tokens) ? tokens : 0;
|
if (TryExtractTokenCountsFromJson(payload, out var promptFallback, out var completionFallback))
|
||||||
|
{
|
||||||
|
return new TokenCounts(promptFallback, completionFallback, promptFallback + completionFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TokenCounts(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryExtractFromJson(string json, out int tokens)
|
private static bool TryExtractTokenCountsFromJson(string json, out int promptTokens, out int completionTokens)
|
||||||
{
|
{
|
||||||
tokens = 0;
|
promptTokens = 0;
|
||||||
|
completionTokens = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var document = JsonDocument.Parse(json);
|
using var document = JsonDocument.Parse(json);
|
||||||
tokens = ExtractTokens(document.RootElement);
|
ExtractTokenCounts(document.RootElement, out promptTokens, out completionTokens);
|
||||||
return tokens > 0;
|
return promptTokens > 0 || completionTokens > 0;
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -81,70 +89,68 @@ internal sealed class ResponseTokenCounter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ExtractTokens(JsonElement element)
|
private static void ExtractTokenCounts(JsonElement element, out int promptTokens, out int completionTokens)
|
||||||
{
|
{
|
||||||
|
promptTokens = 0;
|
||||||
|
completionTokens = 0;
|
||||||
|
|
||||||
if (element.ValueKind == JsonValueKind.Array)
|
if (element.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
var total = 0;
|
|
||||||
foreach (var item in element.EnumerateArray())
|
foreach (var item in element.EnumerateArray())
|
||||||
{
|
{
|
||||||
total += ExtractTokens(item);
|
ExtractTokenCounts(item, out var itemPrompt, out var itemCompletion);
|
||||||
|
promptTokens += itemPrompt;
|
||||||
|
completionTokens += itemCompletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
return total;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.ValueKind != JsonValueKind.Object)
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
return 0;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
if (element.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
if (TryGetInt(usage, "total_tokens", out var totalTokens))
|
if (TryGetInt(usage, "prompt_tokens", out var pt))
|
||||||
{
|
{
|
||||||
return totalTokens;
|
promptTokens += pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
var usageTotal = 0;
|
if (TryGetInt(usage, "completion_tokens", out var ct))
|
||||||
if (TryGetInt(usage, "prompt_tokens", out var promptTokens))
|
|
||||||
{
|
{
|
||||||
usageTotal += promptTokens;
|
completionTokens += ct;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryGetInt(usage, "completion_tokens", out var completionTokens))
|
if (promptTokens == 0 && completionTokens == 0)
|
||||||
{
|
{
|
||||||
usageTotal += completionTokens;
|
if (TryGetInt(usage, "input_tokens", out var it))
|
||||||
|
{
|
||||||
|
promptTokens += it;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetInt(usage, "output_tokens", out var ot))
|
||||||
|
{
|
||||||
|
completionTokens += ot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryGetInt(usage, "input_tokens", out var inputTokens))
|
if (promptTokens > 0 || completionTokens > 0)
|
||||||
{
|
{
|
||||||
usageTotal += inputTokens;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (TryGetInt(usage, "output_tokens", out var outputTokens))
|
|
||||||
{
|
|
||||||
usageTotal += outputTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usageTotal > 0)
|
|
||||||
{
|
|
||||||
return usageTotal;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ollamaTotal = 0;
|
if (TryGetInt(element, "prompt_eval_count", out var promptEval))
|
||||||
if (TryGetInt(element, "prompt_eval_count", out var promptEvalCount))
|
|
||||||
{
|
{
|
||||||
ollamaTotal += promptEvalCount;
|
promptTokens += promptEval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryGetInt(element, "eval_count", out var evalCount))
|
if (TryGetInt(element, "eval_count", out var eval))
|
||||||
{
|
{
|
||||||
ollamaTotal += evalCount;
|
completionTokens += eval;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ollamaTotal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
|
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
|
||||||
@@ -155,3 +161,5 @@ internal sealed class ResponseTokenCounter
|
|||||||
&& property.TryGetInt32(out value);
|
&& property.TryGetInt32(out value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record TokenCounts(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ internal static class ReverseProxyEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId);
|
||||||
|
if (!billingCheck.Allowed)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
error = "Insufficient balance.",
|
||||||
|
balance = billingCheck.Balance,
|
||||||
|
currency = billingCheck.Currency,
|
||||||
|
threshold = billingCheck.Threshold
|
||||||
|
}, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, 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);
|
||||||
@@ -76,7 +90,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
managementStore,
|
managementStore,
|
||||||
groupAccess);
|
groupAccess,
|
||||||
|
auth.ApiKeyId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +139,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
embeddingRequest,
|
embeddingRequest,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
auth.ApiKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task HandleClientAsync(
|
public static async Task HandleClientAsync(
|
||||||
@@ -145,6 +161,20 @@ internal static class ReverseProxyEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId);
|
||||||
|
if (!billingCheck.Allowed)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
error = "Insufficient balance.",
|
||||||
|
balance = billingCheck.Balance,
|
||||||
|
currency = billingCheck.Currency,
|
||||||
|
threshold = billingCheck.Threshold
|
||||||
|
}, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||||
if (!groupAccess.IsClientAllowed(clientId))
|
if (!groupAccess.IsClientAllowed(clientId))
|
||||||
{
|
{
|
||||||
@@ -165,7 +195,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
managementStore,
|
managementStore,
|
||||||
groupAccess);
|
groupAccess,
|
||||||
|
auth.ApiKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ForwardToClientAsync(
|
private static async Task ForwardToClientAsync(
|
||||||
@@ -178,7 +209,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore,
|
ManagementStore managementStore,
|
||||||
GroupAccess? groupAccess = null)
|
GroupAccess? groupAccess = null,
|
||||||
|
string? apiKeyId = null)
|
||||||
{
|
{
|
||||||
var clientAccess = managementStore.GetClientAccess(clientId);
|
var clientAccess = managementStore.GetClientAccess(clientId);
|
||||||
if (clientAccess.IsDisabled)
|
if (clientAccess.IsDisabled)
|
||||||
@@ -220,7 +252,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
embeddingRequest,
|
embeddingRequest,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
apiKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsRootPath(PathString path) =>
|
private static bool IsRootPath(PathString path) =>
|
||||||
@@ -464,7 +497,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
EmbeddingCacheRequest? embeddingRequest,
|
EmbeddingCacheRequest? embeddingRequest,
|
||||||
ManagementStore managementStore)
|
ManagementStore managementStore,
|
||||||
|
string? apiKeyId = 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");
|
||||||
@@ -549,13 +583,29 @@ internal static class ReverseProxyEndpoint
|
|||||||
{
|
{
|
||||||
connection.RemovePending(requestId);
|
connection.RemovePending(requestId);
|
||||||
var completedAt = DateTimeOffset.UtcNow;
|
var completedAt = DateTimeOffset.UtcNow;
|
||||||
|
var tokenCounts = tokenCounter.CountTokens();
|
||||||
|
|
||||||
|
var cost = 0.0;
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKeyId) && tokenCounts.TotalTokens > 0)
|
||||||
|
{
|
||||||
|
var billing = managementStore.ResolveBillingForApiKey(apiKeyId);
|
||||||
|
if (billing is not null)
|
||||||
|
{
|
||||||
|
cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
managementStore.RecordRequest(new RequestMetric(
|
managementStore.RecordRequest(new RequestMetric(
|
||||||
connection.ClientId,
|
connection.ClientId,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
context.Request.Method,
|
context.Request.Method,
|
||||||
pathAndQuery,
|
pathAndQuery,
|
||||||
statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null),
|
statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null),
|
||||||
tokenCounter.CountTokens(),
|
tokenCounts.PromptTokens,
|
||||||
|
tokenCounts.CompletionTokens,
|
||||||
|
tokenCounts.TotalTokens,
|
||||||
|
apiKeyId,
|
||||||
|
cost,
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt,
|
completedAt,
|
||||||
completedAt - startedAt));
|
completedAt - startedAt));
|
||||||
|
|||||||
@@ -479,6 +479,43 @@ tr:last-child td {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-current strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-current span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-detail {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -494,7 +531,7 @@ tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ const state = {
|
|||||||
detail: null,
|
detail: null,
|
||||||
groupDetail: null,
|
groupDetail: null,
|
||||||
newKey: null,
|
newKey: null,
|
||||||
loading: false
|
loading: false,
|
||||||
|
usageData: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = document.getElementById("content");
|
const content = document.getElementById("content");
|
||||||
@@ -93,6 +94,28 @@ content.addEventListener("click", async (event) => {
|
|||||||
if (action === "toggle-key-assignment") {
|
if (action === "toggle-key-assignment") {
|
||||||
await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true");
|
await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "delete-billing-rule") {
|
||||||
|
const ruleId = button.dataset.ruleId;
|
||||||
|
if (!confirm("Delete this billing rule?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api(`/groups/${encodeURIComponent(groupId)}/billing/rules/${ruleId}`, { method: "DELETE" });
|
||||||
|
setNotice("Billing rule deleted.");
|
||||||
|
await loadGroupDetail(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "delete-payment") {
|
||||||
|
const paymentId = button.dataset.paymentId;
|
||||||
|
if (!confirm("Delete this payment record?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api(`/groups/${encodeURIComponent(groupId)}/billing/payments/${paymentId}`, { method: "DELETE" });
|
||||||
|
setNotice("Payment deleted.");
|
||||||
|
await loadGroupDetail(groupId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice(error.message, true);
|
setNotice(error.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -178,6 +201,49 @@ content.addEventListener("submit", async (event) => {
|
|||||||
form.reset();
|
form.reset();
|
||||||
await loadGroupDetail(groupId);
|
await loadGroupDetail(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.dataset.form === "billing-config") {
|
||||||
|
const groupId = form.dataset.groupId;
|
||||||
|
await api(`/groups/${encodeURIComponent(groupId)}/billing`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: {
|
||||||
|
currency: data.currency,
|
||||||
|
defaultRatePer1k: parseFloat(data.defaultRatePer1k) || 0,
|
||||||
|
refuseBelowBalance: parseFloat(data.refuseBelowBalance) || 0,
|
||||||
|
enabled: data.enabled === "on"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setNotice("Billing configuration saved.");
|
||||||
|
await loadGroupDetail(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.dataset.form === "add-billing-rule") {
|
||||||
|
const groupId = form.dataset.groupId;
|
||||||
|
await api(`/groups/${encodeURIComponent(groupId)}/billing/rules`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
modelRegex: data.modelRegex,
|
||||||
|
ratePer1k: parseFloat(data.ratePer1k) || 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setNotice("Billing rule added.");
|
||||||
|
form.reset();
|
||||||
|
await loadGroupDetail(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.dataset.form === "add-payment") {
|
||||||
|
const groupId = form.dataset.groupId;
|
||||||
|
await api(`/groups/${encodeURIComponent(groupId)}/billing/payments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
amount: parseFloat(data.amount) || 0,
|
||||||
|
description: data.description || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setNotice("Payment recorded.");
|
||||||
|
form.reset();
|
||||||
|
await loadGroupDetail(groupId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice(error.message, true);
|
setNotice(error.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -278,6 +344,11 @@ async function renderRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view === "usage") {
|
||||||
|
await loadUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renderClients();
|
renderClients();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,13 +802,17 @@ async function loadGroupDetail(groupId) {
|
|||||||
content.innerHTML = `<div class="panel"><div class="empty">Loading group...</div></div>`;
|
content.innerHTML = `<div class="panel"><div class="empty">Loading group...</div></div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [group, clients, apiKeyGroups] = await Promise.all([
|
const [group, clients, apiKeyGroups, 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("/api-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 };
|
state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance };
|
||||||
renderGroupDetail();
|
renderGroupDetail();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||||
@@ -745,7 +820,7 @@ async function loadGroupDetail(groupId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupDetail() {
|
function renderGroupDetail() {
|
||||||
const { group, clients, apiKeyGroups } = state.groupDetail;
|
const { group, clients, apiKeyGroups, billing, rules, payments, balance } = state.groupDetail;
|
||||||
const allApiKeys = state.summary?.apiKeys || [];
|
const allApiKeys = state.summary?.apiKeys || [];
|
||||||
const assignedKeyIds = new Set(
|
const assignedKeyIds = new Set(
|
||||||
apiKeyGroups
|
apiKeyGroups
|
||||||
@@ -814,6 +889,96 @@ function renderGroupDetail() {
|
|||||||
${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")}
|
${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Billing</h2>
|
||||||
|
${billing.enabled ? badge("Enabled", "good") : badge("Disabled", "")}
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="form-row" data-form="billing-config" data-group-id="${escapeAttr(group.id)}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="billingCurrency">Currency</label>
|
||||||
|
<select class="select" id="billingCurrency" name="currency">
|
||||||
|
${["EUR", "USD", "GBP", "INR", "JPY", "CAD", "AUD", "CHF"].map(c => `<option value="${c}" ${billing.currency === c ? "selected" : ""}>${c}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="billingDefaultRate">Default rate / 1k tokens</label>
|
||||||
|
<input class="input" id="billingDefaultRate" name="defaultRatePer1k" type="number" step="0.0001" min="0" value="${billing.defaultRatePer1k || 0}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="billingRefuseBelow">Refuse below balance</label>
|
||||||
|
<input class="input" id="billingRefuseBelow" name="refuseBelowBalance" type="number" step="0.01" value="${billing.refuseBelowBalance || 0}">
|
||||||
|
</div>
|
||||||
|
<div class="field billing-toggle">
|
||||||
|
<label for="billingEnabled">Enabled</label>
|
||||||
|
<input type="checkbox" id="billingEnabled" name="enabled" ${billing.enabled ? "checked" : ""}>
|
||||||
|
</div>
|
||||||
|
<button class="button" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Balance</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="balance-display">
|
||||||
|
<div class="balance-current">
|
||||||
|
<strong>${formatCurrency(balance.balance, balance.currency)}</strong>
|
||||||
|
<span>Current balance</span>
|
||||||
|
</div>
|
||||||
|
<div class="balance-detail">
|
||||||
|
<div>${formatCurrency(balance.totalPayments, balance.currency)} payments</div>
|
||||||
|
<div>${formatCurrency(balance.totalCosts, balance.currency)} costs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Billing rules</h2>
|
||||||
|
<span class="badge">${rules.length} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${rules.length ? billingRulesTable(rules, group.id) : emptyState("No billing rules. The default rate applies to all models.")}
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="form-row" data-form="add-billing-rule" data-group-id="${escapeAttr(group.id)}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="ruleModelRegex">Model regex</label>
|
||||||
|
<input class="input" id="ruleModelRegex" name="modelRegex" placeholder="llama.*" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ruleRate">Rate / 1k tokens</label>
|
||||||
|
<input class="input" id="ruleRate" name="ratePer1k" type="number" step="0.0001" min="0" required>
|
||||||
|
</div>
|
||||||
|
<button class="button" type="submit">Add rule</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Payments</h2>
|
||||||
|
<span class="badge">${payments.length} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${payments.length ? paymentsTable(payments, group.id) : emptyState("No payments recorded.")}
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="form-row" data-form="add-payment" data-group-id="${escapeAttr(group.id)}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="paymentAmount">Amount</label>
|
||||||
|
<input class="input" id="paymentAmount" name="amount" type="number" step="0.01" min="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="paymentDescription">Description</label>
|
||||||
|
<input class="input" id="paymentDescription" name="description" placeholder="Invoice #1234">
|
||||||
|
</div>
|
||||||
|
<button class="button" type="submit">Record payment</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,6 +1080,260 @@ async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) {
|
|||||||
await loadGroupDetail(groupId);
|
await loadGroupDetail(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function billingRulesTable(rules, groupId) {
|
||||||
|
const rows = rules.map((rule) => `
|
||||||
|
<tr>
|
||||||
|
<td><div class="code-inline">${escapeHtml(rule.modelRegex)}</div></td>
|
||||||
|
<td>${rule.ratePer1k}</td>
|
||||||
|
<td>
|
||||||
|
<button class="button danger" data-action="delete-billing-rule" data-group-id="${escapeAttr(groupId)}" data-rule-id="${rule.id}">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Model regex</th>
|
||||||
|
<th>Rate / 1k tokens</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentsTable(payments, groupId) {
|
||||||
|
const rows = payments.map((payment) => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatCurrency(payment.amount, "")}</td>
|
||||||
|
<td>${payment.description ? escapeHtml(payment.description) : `<span class="cell-sub">-</span>`}</td>
|
||||||
|
<td>${payment.createdBy ? escapeHtml(payment.createdBy) : `<span class="cell-sub">-</span>`}</td>
|
||||||
|
<td>${formatDate(payment.createdAtUtc)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="button danger" data-action="delete-payment" data-group-id="${escapeAttr(groupId)}" data-payment-id="${payment.id}">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Created by</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value, currency) {
|
||||||
|
const num = typeof value === "number" ? value : parseFloat(value) || 0;
|
||||||
|
const formatted = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(num);
|
||||||
|
if (currency && typeof currency === "string" && currency.length > 0) {
|
||||||
|
return `${formatted} ${currency}`;
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsage() {
|
||||||
|
pageTitle.textContent = "Usage";
|
||||||
|
pageSubtitle.textContent = "Token usage and revenue statistics.";
|
||||||
|
content.innerHTML = `<div class="panel"><div class="empty">Loading usage data...</div></div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [usage, revenue] = await Promise.all([
|
||||||
|
api("/usage/tokens"),
|
||||||
|
api("/usage/revenue")
|
||||||
|
]);
|
||||||
|
|
||||||
|
state.usageData = { usage, revenue };
|
||||||
|
renderUsage();
|
||||||
|
} catch (error) {
|
||||||
|
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsage() {
|
||||||
|
const { usage, revenue } = state.usageData;
|
||||||
|
const hasAnyBilling = (state.summary?.groups || []).some((g) => {
|
||||||
|
const billing = state.groupDetail?.billing;
|
||||||
|
return billing?.enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
const byModel = usage.byModel || [];
|
||||||
|
const byClient = usage.byClient || [];
|
||||||
|
const byApiKey = usage.byApiKey || [];
|
||||||
|
const byGroup = usage.byGroup || [];
|
||||||
|
const clientRevenue = revenue || [];
|
||||||
|
|
||||||
|
const revenueMap = {};
|
||||||
|
for (const r of clientRevenue) {
|
||||||
|
revenueMap[r.clientId] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Tokens by model</h2>
|
||||||
|
<span class="badge">${byModel.length} models</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${byModel.length ? tokenStatsModelTable(byModel) : emptyState("No token data yet.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Tokens by machine</h2>
|
||||||
|
<span class="badge">${byClient.length} machines</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${byClient.length ? tokenStatsClientTable(byClient, revenueMap) : emptyState("No token data yet.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Tokens by API key</h2>
|
||||||
|
<span class="badge">${byApiKey.length} keys</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${byApiKey.length ? tokenStatsApiKeyTable(byApiKey) : emptyState("No token data yet.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Tokens by group</h2>
|
||||||
|
<span class="badge">${byGroup.length} groups</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenStatsModelTable(stats) {
|
||||||
|
const rows = stats.map((s) => `
|
||||||
|
<tr>
|
||||||
|
<td><div class="cell-main">${escapeHtml(s.model)}</div></td>
|
||||||
|
<td>${number(s.promptTokens)}</td>
|
||||||
|
<td>${number(s.completionTokens)}</td>
|
||||||
|
<td>${number(s.totalTokens)}</td>
|
||||||
|
<td>${number(s.requests)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Prompt tokens</th>
|
||||||
|
<th>Completion tokens</th>
|
||||||
|
<th>Total tokens</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenStatsClientTable(stats, revenueMap) {
|
||||||
|
const rows = stats.map((s) => {
|
||||||
|
const rev = revenueMap[s.clientId];
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><div class="cell-main">${escapeHtml(s.clientId)}</div></td>
|
||||||
|
<td>${number(s.promptTokens)}</td>
|
||||||
|
<td>${number(s.completionTokens)}</td>
|
||||||
|
<td>${number(s.totalTokens)}</td>
|
||||||
|
<td>${number(s.requests)}</td>
|
||||||
|
${rev ? `<td>${formatCurrency(rev.revenue, rev.currency)}</td>` : `<td><span class="cell-sub">-</span></td>`}
|
||||||
|
</tr>
|
||||||
|
`}).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Machine</th>
|
||||||
|
<th>Prompt tokens</th>
|
||||||
|
<th>Completion tokens</th>
|
||||||
|
<th>Total tokens</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
<th>Revenue</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenStatsApiKeyTable(stats) {
|
||||||
|
const rows = stats.map((s) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="cell-main">${escapeHtml(s.apiKeyName)}</div>
|
||||||
|
<div class="cell-sub">${escapeHtml(s.apiKeyPrefix)}...</div>
|
||||||
|
</td>
|
||||||
|
<td>${number(s.promptTokens)}</td>
|
||||||
|
<td>${number(s.completionTokens)}</td>
|
||||||
|
<td>${number(s.totalTokens)}</td>
|
||||||
|
<td>${number(s.requests)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>API key</th>
|
||||||
|
<th>Prompt tokens</th>
|
||||||
|
<th>Completion tokens</th>
|
||||||
|
<th>Total tokens</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenStatsGroupTable(stats) {
|
||||||
|
const rows = stats.map((s) => `
|
||||||
|
<tr>
|
||||||
|
<td><div class="cell-main">${escapeHtml(s.groupName)}</div></td>
|
||||||
|
<td>${number(s.promptTokens)}</td>
|
||||||
|
<td>${number(s.completionTokens)}</td>
|
||||||
|
<td>${number(s.totalTokens)}</td>
|
||||||
|
<td>${number(s.requests)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Prompt tokens</th>
|
||||||
|
<th>Completion tokens</th>
|
||||||
|
<th>Total tokens</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function connectedClients() {
|
function connectedClients() {
|
||||||
return (state.summary?.clients || []).filter((client) => client.connected);
|
return (state.summary?.clients || []).filter((client) => client.connected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<a href="#models" data-nav="models">Models</a>
|
<a href="#models" data-nav="models">Models</a>
|
||||||
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
<a href="#api-keys" data-nav="api-keys">API 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>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user