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;
}