From 53225fd8aeda9fb751c79e2a3e8b10501b9c1057 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 00:29:00 +0200 Subject: [PATCH] feat(server): adds billing --- src/ReverseLlama.Server/AdminEndpoints.cs | 164 ++++ src/ReverseLlama.Server/ManagementStore.cs | 855 ++++++++++++++++++ .../ResponseTokenCounter.cs | 92 +- .../ReverseProxyEndpoint.cs | 64 +- src/ReverseLlama.Server/wwwroot/admin/app.css | 39 +- src/ReverseLlama.Server/wwwroot/admin/app.js | 429 ++++++++- .../wwwroot/admin/index.html | 1 + 7 files changed, 1589 insertions(+), 55 deletions(-) diff --git a/src/ReverseLlama.Server/AdminEndpoints.cs b/src/ReverseLlama.Server/AdminEndpoints.cs index 973c0b4..d66bd42 100644 --- a/src/ReverseLlama.Server/AdminEndpoints.cs +++ b/src/ReverseLlama.Server/AdminEndpoints.cs @@ -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) => ServeAdminAsset(environment, null)); var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) => @@ -594,6 +740,24 @@ internal sealed record AddGroupClientRequest( internal sealed record SetApiKeyGroupsRequest(IReadOnlyList? 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( string Id, bool Connected, diff --git a/src/ReverseLlama.Server/ManagementStore.cs b/src/ReverseLlama.Server/ManagementStore.cs index 8ad2d01..04c9ff2 100644 --- a/src/ReverseLlama.Server/ManagementStore.cs +++ b/src/ReverseLlama.Server/ManagementStore.cs @@ -378,7 +378,11 @@ internal sealed class ManagementStore method, path, status_code, + prompt_tokens, + completion_tokens, token_count, + api_key_id, + cost, started_at_utc, completed_at_utc, duration_ms) @@ -388,7 +392,11 @@ internal sealed class ManagementStore $method, $path, $status_code, + $prompt_tokens, + $completion_tokens, $token_count, + $api_key_id, + $cost, $started_at_utc, $completed_at_utc, $duration_ms) @@ -398,7 +406,11 @@ internal sealed class ManagementStore command.Parameters.AddWithValue("$method", metric.Method); command.Parameters.AddWithValue("$path", metric.Path); 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("$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("$completed_at_utc", metric.CompletedAtUtc.ToString("O")); command.Parameters.AddWithValue("$duration_ms", metric.Duration.TotalMilliseconds); @@ -1007,6 +1019,710 @@ internal sealed class ManagementStore 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 GetApiKeyGroupIdsLocked(string apiKeyId) { using var connection = OpenConnection(); @@ -1111,6 +1827,74 @@ internal sealed class ManagementStore ); """; 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()) @@ -1240,7 +2024,11 @@ internal sealed record RequestMetric( string Method, string Path, int? StatusCode, + int PromptTokens, + int CompletionTokens, int TokenCount, + string? ApiKeyId, + double Cost, DateTimeOffset StartedAtUtc, DateTimeOffset CompletedAtUtc, TimeSpan Duration); @@ -1257,6 +2045,73 @@ internal sealed record ModelUsageStats( long TokensLast10Minutes, 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( string Id, string Name, diff --git a/src/ReverseLlama.Server/ResponseTokenCounter.cs b/src/ReverseLlama.Server/ResponseTokenCounter.cs index b2978bf..bbe661d 100644 --- a/src/ReverseLlama.Server/ResponseTokenCounter.cs +++ b/src/ReverseLlama.Server/ResponseTokenCounter.cs @@ -21,15 +21,16 @@ internal sealed class ResponseTokenCounter _buffer.Write(chunk[..length]); } - public int CountTokens() + public TokenCounts CountTokens() { if (_buffer.Length == 0) { - return 0; + return new TokenCounts(0, 0, 0); } var payload = Encoding.UTF8.GetString(_buffer.ToArray()); - var total = 0; + var totalPrompt = 0; + var totalCompletion = 0; var parsedLines = false; foreach (var rawLine in payload.Split('\n')) @@ -50,30 +51,37 @@ internal sealed class ResponseTokenCounter continue; } - if (TryExtractFromJson(line, out var lineTokens)) + if (TryExtractTokenCountsFromJson(line, out var prompt, out var completion)) { parsedLines = true; - total += lineTokens; + totalPrompt += prompt; + totalCompletion += completion; } } 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 { using var document = JsonDocument.Parse(json); - tokens = ExtractTokens(document.RootElement); - return tokens > 0; + ExtractTokenCounts(document.RootElement, out promptTokens, out completionTokens); + return promptTokens > 0 || completionTokens > 0; } 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) { - var total = 0; 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) { - return 0; + return; } 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, "prompt_tokens", out var promptTokens)) + if (TryGetInt(usage, "completion_tokens", out var ct)) { - 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; - } - - if (TryGetInt(usage, "output_tokens", out var outputTokens)) - { - usageTotal += outputTokens; - } - - if (usageTotal > 0) - { - return usageTotal; + return; } } - var ollamaTotal = 0; - if (TryGetInt(element, "prompt_eval_count", out var promptEvalCount)) + if (TryGetInt(element, "prompt_eval_count", out var promptEval)) { - 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) @@ -155,3 +161,5 @@ internal sealed class ResponseTokenCounter && property.TryGetInt32(out value); } } + +internal sealed record TokenCounts(int PromptTokens, int CompletionTokens, int TotalTokens); diff --git a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs index 0fbe21c..8f2c7c1 100644 --- a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs +++ b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs @@ -43,6 +43,20 @@ internal static class ReverseProxyEndpoint 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 pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath); @@ -76,7 +90,8 @@ internal static class ReverseProxyEndpoint loggerFactory, embeddingCache, managementStore, - groupAccess); + groupAccess, + auth.ApiKeyId); return; } @@ -124,7 +139,8 @@ internal static class ReverseProxyEndpoint loggerFactory, embeddingCache, embeddingRequest, - managementStore); + managementStore, + auth.ApiKeyId); } public static async Task HandleClientAsync( @@ -145,6 +161,20 @@ internal static class ReverseProxyEndpoint 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); if (!groupAccess.IsClientAllowed(clientId)) { @@ -165,7 +195,8 @@ internal static class ReverseProxyEndpoint loggerFactory, embeddingCache, managementStore, - groupAccess); + groupAccess, + auth.ApiKeyId); } private static async Task ForwardToClientAsync( @@ -178,7 +209,8 @@ internal static class ReverseProxyEndpoint ILoggerFactory loggerFactory, EmbeddingCache embeddingCache, ManagementStore managementStore, - GroupAccess? groupAccess = null) + GroupAccess? groupAccess = null, + string? apiKeyId = null) { var clientAccess = managementStore.GetClientAccess(clientId); if (clientAccess.IsDisabled) @@ -220,7 +252,8 @@ internal static class ReverseProxyEndpoint loggerFactory, embeddingCache, embeddingRequest, - managementStore); + managementStore, + apiKeyId); } private static bool IsRootPath(PathString path) => @@ -464,7 +497,8 @@ internal static class ReverseProxyEndpoint ILoggerFactory loggerFactory, EmbeddingCache embeddingCache, EmbeddingCacheRequest? embeddingRequest, - ManagementStore managementStore) + ManagementStore managementStore, + string? apiKeyId = null) { var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy"); var requestId = Guid.NewGuid().ToString("n"); @@ -549,13 +583,29 @@ internal static class ReverseProxyEndpoint { connection.RemovePending(requestId); 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( connection.ClientId, requestedModel, context.Request.Method, pathAndQuery, statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null), - tokenCounter.CountTokens(), + tokenCounts.PromptTokens, + tokenCounts.CompletionTokens, + tokenCounts.TotalTokens, + apiKeyId, + cost, startedAt, completedAt, completedAt - startedAt)); diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.css b/src/ReverseLlama.Server/wwwroot/admin/app.css index c653aa6..d2e8893 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.css +++ b/src/ReverseLlama.Server/wwwroot/admin/app.css @@ -479,6 +479,43 @@ tr:last-child td { 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) { .app-shell { grid-template-columns: 1fr; @@ -494,7 +531,7 @@ tr:last-child td { } .nav { - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); } .nav a { diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.js b/src/ReverseLlama.Server/wwwroot/admin/app.js index 68abf18..8364f2a 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.js +++ b/src/ReverseLlama.Server/wwwroot/admin/app.js @@ -3,7 +3,8 @@ const state = { detail: null, groupDetail: null, newKey: null, - loading: false + loading: false, + usageData: null }; const content = document.getElementById("content"); @@ -93,6 +94,28 @@ content.addEventListener("click", async (event) => { if (action === "toggle-key-assignment") { 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) { setNotice(error.message, true); } finally { @@ -178,6 +201,49 @@ content.addEventListener("submit", async (event) => { form.reset(); 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) { setNotice(error.message, true); } finally { @@ -278,6 +344,11 @@ async function renderRoute() { return; } + if (view === "usage") { + await loadUsage(); + return; + } + renderClients(); } @@ -731,13 +802,17 @@ async function loadGroupDetail(groupId) { content.innerHTML = `
Loading group...
`; 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)}/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(); } catch (error) { content.innerHTML = `
${escapeHtml(error.message)}
`; @@ -745,7 +820,7 @@ async function loadGroupDetail(groupId) { } function renderGroupDetail() { - const { group, clients, apiKeyGroups } = state.groupDetail; + const { group, clients, apiKeyGroups, billing, rules, payments, balance } = state.groupDetail; const allApiKeys = state.summary?.apiKeys || []; const assignedKeyIds = new Set( apiKeyGroups @@ -814,6 +889,96 @@ function renderGroupDetail() { ${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")} +
+
+

Billing

+ ${billing.enabled ? badge("Enabled", "good") : badge("Disabled", "")} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+

Balance

+
+
+
+
+ ${formatCurrency(balance.balance, balance.currency)} + Current balance +
+
+
${formatCurrency(balance.totalPayments, balance.currency)} payments
+
${formatCurrency(balance.totalCosts, balance.currency)} costs
+
+
+
+
+
+
+

Billing rules

+ ${rules.length} total +
+
+ ${rules.length ? billingRulesTable(rules, group.id) : emptyState("No billing rules. The default rate applies to all models.")} +
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+

Payments

+ ${payments.length} total +
+
+ ${payments.length ? paymentsTable(payments, group.id) : emptyState("No payments recorded.")} +
+
+
+
+ + +
+
+ + +
+ +
+
+
`; } @@ -915,6 +1080,260 @@ async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) { await loadGroupDetail(groupId); } +function billingRulesTable(rules, groupId) { + const rows = rules.map((rule) => ` + +
${escapeHtml(rule.modelRegex)}
+ ${rule.ratePer1k} + + + + + `).join(""); + + return ` + + + + + + + + + ${rows} +
Model regexRate / 1k tokens
+ `; +} + +function paymentsTable(payments, groupId) { + const rows = payments.map((payment) => ` + + ${formatCurrency(payment.amount, "")} + ${payment.description ? escapeHtml(payment.description) : `-`} + ${payment.createdBy ? escapeHtml(payment.createdBy) : `-`} + ${formatDate(payment.createdAtUtc)} + + + + + `).join(""); + + return ` + + + + + + + + + + + ${rows} +
AmountDescriptionCreated byDate
+ `; +} + +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 = `
Loading usage data...
`; + + try { + const [usage, revenue] = await Promise.all([ + api("/usage/tokens"), + api("/usage/revenue") + ]); + + state.usageData = { usage, revenue }; + renderUsage(); + } catch (error) { + content.innerHTML = `
${escapeHtml(error.message)}
`; + } +} + +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 = ` +
+
+

Tokens by model

+ ${byModel.length} models +
+
+ ${byModel.length ? tokenStatsModelTable(byModel) : emptyState("No token data yet.")} +
+
+
+
+

Tokens by machine

+ ${byClient.length} machines +
+
+ ${byClient.length ? tokenStatsClientTable(byClient, revenueMap) : emptyState("No token data yet.")} +
+
+
+
+

Tokens by API key

+ ${byApiKey.length} keys +
+
+ ${byApiKey.length ? tokenStatsApiKeyTable(byApiKey) : emptyState("No token data yet.")} +
+
+
+
+

Tokens by group

+ ${byGroup.length} groups +
+
+ ${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")} +
+
+ `; +} + +function tokenStatsModelTable(stats) { + const rows = stats.map((s) => ` + +
${escapeHtml(s.model)}
+ ${number(s.promptTokens)} + ${number(s.completionTokens)} + ${number(s.totalTokens)} + ${number(s.requests)} + + `).join(""); + + return ` + + + + + + + + + + + ${rows} +
ModelPrompt tokensCompletion tokensTotal tokensRequests
+ `; +} + +function tokenStatsClientTable(stats, revenueMap) { + const rows = stats.map((s) => { + const rev = revenueMap[s.clientId]; + return ` + +
${escapeHtml(s.clientId)}
+ ${number(s.promptTokens)} + ${number(s.completionTokens)} + ${number(s.totalTokens)} + ${number(s.requests)} + ${rev ? `${formatCurrency(rev.revenue, rev.currency)}` : `-`} + + `}).join(""); + + return ` + + + + + + + + + + + + ${rows} +
MachinePrompt tokensCompletion tokensTotal tokensRequestsRevenue
+ `; +} + +function tokenStatsApiKeyTable(stats) { + const rows = stats.map((s) => ` + + +
${escapeHtml(s.apiKeyName)}
+
${escapeHtml(s.apiKeyPrefix)}...
+ + ${number(s.promptTokens)} + ${number(s.completionTokens)} + ${number(s.totalTokens)} + ${number(s.requests)} + + `).join(""); + + return ` + + + + + + + + + + + ${rows} +
API keyPrompt tokensCompletion tokensTotal tokensRequests
+ `; +} + +function tokenStatsGroupTable(stats) { + const rows = stats.map((s) => ` + +
${escapeHtml(s.groupName)}
+ ${number(s.promptTokens)} + ${number(s.completionTokens)} + ${number(s.totalTokens)} + ${number(s.requests)} + + `).join(""); + + return ` + + + + + + + + + + + ${rows} +
GroupPrompt tokensCompletion tokensTotal tokensRequests
+ `; +} + function connectedClients() { return (state.summary?.clients || []).filter((client) => client.connected); } diff --git a/src/ReverseLlama.Server/wwwroot/admin/index.html b/src/ReverseLlama.Server/wwwroot/admin/index.html index d96d92b..e6ddab3 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/index.html +++ b/src/ReverseLlama.Server/wwwroot/admin/index.html @@ -21,6 +21,7 @@ Models API keys Groups + Usage