using System.Text.Json.Nodes; using CareFix.Api.Infrastructure; using CareFix.Api.Options; using Dapper; using Microsoft.Extensions.Options; namespace CareFix.Api.Ai; /// Which ticket (if any) an AI call belongs to, for cost tracking. public sealed record UsageContext(int? TicketId, string Purpose); /// Records what every AI call cost and enforces the spend caps. public sealed class AiUsageService(ControlDb db, IOptions options, ILogger log) { private ClaudeOptions Cfg => options.Value.Claude; /// Reads a token count from the API's usage block. Missing or odd values count as zero. public static int Tokens(JsonNode? usage, string key) => usage?[key] is JsonValue v && v.TryGetValue(out var n) ? n : 0; public decimal CostOf(JsonNode? usage) { if (usage is null) return 0m; var p = Cfg.Pricing; decimal Mtok(string key) => Tokens(usage, key) / 1_000_000m; return Mtok("input_tokens") * p.InputPerMTok + Mtok("output_tokens") * p.OutputPerMTok + Mtok("cache_creation_input_tokens") * p.CacheWritePerMTok + Mtok("cache_read_input_tokens") * p.CacheReadPerMTok; } public async Task RecordAsync(string model, JsonNode? usage, UsageContext? ctx, CancellationToken ct) { if (usage is null || ctx is null) return; try { await using var c = await db.OpenAsync(ct); await c.ExecuteAsync(""" INSERT CF_AI_USAGE (TicketId, Purpose, Model, InputTokens, OutputTokens, CacheWriteTokens, CacheReadTokens, CostUsd) VALUES (@ticketId, @purpose, @model, @input, @output, @cacheWrite, @cacheRead, @cost) """, new { ticketId = ctx.TicketId, purpose = ctx.Purpose, model, input = Tokens(usage, "input_tokens"), output = Tokens(usage, "output_tokens"), cacheWrite = Tokens(usage, "cache_creation_input_tokens"), cacheRead = Tokens(usage, "cache_read_input_tokens"), cost = CostOf(usage), }); } catch (Exception ex) { // Never fail a ticket because the meter could not be written. log.LogWarning(ex, "Could not record AI usage for ticket {TicketId}", ctx.TicketId); } } public async Task MonthToDateAsync(CancellationToken ct) { await using var c = await db.OpenAsync(ct); return await c.ExecuteScalarAsync( "SELECT SUM(CostUsd) FROM CF_AI_USAGE WHERE CreatedAt >= DATEFROMPARTS(YEAR(SYSUTCDATETIME()), MONTH(SYSUTCDATETIME()), 1)") ?? 0m; } public async Task ForTicketAsync(int ticketId, CancellationToken ct) { await using var c = await db.OpenAsync(ct); return await c.ExecuteScalarAsync("SELECT SUM(CostUsd) FROM CF_AI_USAGE WHERE TicketId = @ticketId", new { ticketId }) ?? 0m; } /// Throws with a clear message when the monthly budget is used up. public async Task EnsureMonthlyBudgetAsync(CancellationToken ct) { if (Cfg.MonthlyBudgetUsd <= 0) return; var spent = await MonthToDateAsync(ct); if (spent >= Cfg.MonthlyBudgetUsd) throw new AppException(429, $"CareFix has reached this month's AI budget (${Cfg.MonthlyBudgetUsd:0.00}). An admin can raise CareFix:Claude:MonthlyBudgetUsd. Diagnosis stays paused until then; approvals, execution and rollback are unaffected."); } /// True when one ticket has spent more than its cap and the AI should stop. public async Task TicketOverCapAsync(int ticketId, CancellationToken ct) => Cfg.MaxUsdPerTicket > 0 && await ForTicketAsync(ticketId, ct) >= Cfg.MaxUsdPerTicket; }