Author SHA1 Message Date
lucretia 24fd8562cf fix(routing): removes "/" route - now redirects to "/admin" 2026-07-18 11:03:42 +02:00
LD50andGitHub c1c2d08915 Merge pull request #15 from LD-Reborn/4-add-rudimentary-billing
Build & Deploy / build (push) Successful in 1m54s
feat(server): adds billing
2026-07-18 00:29:32 +02:00
lucretia 2f1a3687f4 feat(server): adds billing 2026-07-18 00:29:00 +02:00
LD50andGitHub 02c0b4243c Merge pull request #13 from LD-Reborn/3-add-group-management
Build & Deploy / build (push) Successful in 2m16s
3 add group management
2026-07-17 23:09:57 +02:00
8 changed files with 1590 additions and 63 deletions
+164
View File
@@ -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,
+855
View File
@@ -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,
+1 -8
View File
@@ -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) =>
{ {
+50 -42
View File
@@ -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));
+38 -1
View File
@@ -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 {
+424 -5
View File
@@ -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>