Skip to content
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -7049,6 +7049,7 @@
"references": [
"AgentAuditLog",
"AgentStepUpChallengeState",
"AiUsageDaily",
"ApiKey",
"BlockedUser",
"ChecklistTemplate",
Expand Down Expand Up @@ -7258,6 +7259,7 @@
"testClass": "AccountDeletionServiceDbTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs",
"references": [
"AiUsageDaily",
"LogHabitCommand",
"ProcessedRequest",
"SentProactiveCheckin",
Expand Down
8 changes: 6 additions & 2 deletions src/Orbit.Domain/Entities/AiUsageDaily.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ public record AiUsageTotals(
decimal CostUsd);

/// <summary>
/// Aggregated AI token usage and computed dollar cost for a single (UTC date, model, purpose) triple.
/// Aggregated AI token usage and computed dollar cost for a single
/// (UTC date, model, purpose, optional user) tuple.
/// Rows are UPSERTed at the AI completion chokepoint and read once per day by the usage-summary job.
/// </summary>
public class AiUsageDaily : Entity
{
public DateOnly Date { get; private set; }
public string Model { get; private set; } = string.Empty;
public string Purpose { get; private set; } = string.Empty;
public Guid? UserId { get; private set; }
Comment thread
thomasluizon marked this conversation as resolved.
public long Calls { get; private set; }
public long CachedTokens { get; private set; }
public long PromptTokens { get; private set; }
Expand All @@ -33,13 +35,15 @@ public static AiUsageDaily Create(
DateOnly date,
string model,
string purpose,
AiUsageTotals totals)
AiUsageTotals totals,
Guid? userId = null)
{
return new AiUsageDaily
{
Date = date,
Model = model,
Purpose = purpose,
UserId = userId,
Calls = totals.Calls,
CachedTokens = totals.CachedTokens,
PromptTokens = totals.PromptTokens,
Expand Down
3 changes: 2 additions & 1 deletion src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
/// </summary>
public interface IAiUsageRecorder
{
Task RecordAsync(

Check warning on line 9 in src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 8 parameters, which is greater than the 7 authorized.

Check warning on line 9 in src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method 'Orbit.Domain.Interfaces.IAiUsageRecorder.RecordAsync(string, string, long, long, long, long, System.Threading.CancellationToken, System.Guid?)' should take CancellationToken as the last parameter

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ_flLiwwr7A0FAxCe7J&open=AZ_flLiwwr7A0FAxCe7J&pullRequest=464
string purpose,
string model,
long cachedTokens,
long promptTokens,
long completionTokens,
long totalTokens,
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
Guid? userId = null);

Check warning on line 17 in src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ_flLiwwr7A0FAxCe7I&open=AZ_flLiwwr7A0FAxCe7I&pullRequest=464
}
2 changes: 2 additions & 0 deletions src/Orbit.Domain/Models/AiToolModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public sealed class AiConversationContext
public object Messages { get; init; } = null!;
/// <summary>Opaque options object. Only consumed by AiIntentService.</summary>
public object Options { get; init; } = null!;
/// <summary>User attributed to each model round in this conversation.</summary>
public Guid? UserId { get; init; }
}

public record AiResponse
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Infrastructure/AI/AiCompletionClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ internal AiCompletionClient(
/// </summary>
public ChatClient ChatClient => _chatClient;

internal string ChatModel => _primaryModel;

/// <summary>
/// Requests a plain-text chat completion from the configured model tier.
/// </summary>
Expand Down
16 changes: 9 additions & 7 deletions src/Orbit.Infrastructure/AI/AiUsageRecorder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ namespace Orbit.Infrastructure.AI;

/// <summary>
/// Singleton recorder that converts one completion's tokens into a dollar cost from the configured
/// per-model price map and atomically UPSERTs it into the daily (date, model, purpose) aggregate via a
/// child DI scope, so the singleton AI client never holds a scoped DbContext. Best-effort: any write
/// failure is logged once at Warning and swallowed so the user's AI response is never affected.
/// per-model price map and atomically UPSERTs it into the daily
/// (date, model, purpose, optional user) aggregate via a child DI scope, so the singleton AI client
/// never holds a scoped DbContext. Best-effort: any write failure is logged once at Warning and
/// swallowed so the user's AI response is never affected.
/// </summary>
public sealed partial class AiUsageRecorder(
IServiceScopeFactory scopeFactory,
Expand All @@ -30,7 +31,8 @@ public async Task RecordAsync(
long promptTokens,
long completionTokens,
long totalTokens,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
Guid? userId = null)
{
var costUsd = ComputeCostUsd(_pricing.GetValueOrDefault(model), cachedTokens, promptTokens, completionTokens);
#pragma warning disable ORBIT0004 // WHY: pre-existing deliberate UTC-date window or UTC-keyed dedupe/aggregation bucket (not a user's calendar date), per-site justification ledger: https://github.com/thomasluizon/orbit-api/issues/431
Expand All @@ -43,9 +45,9 @@ public async Task RecordAsync(
var dbContext = scope.ServiceProvider.GetRequiredService<OrbitDbContext>();
await dbContext.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO "AiUsageDaily"
("Id", "Date", "Model", "Purpose", "Calls", "CachedTokens", "PromptTokens", "CompletionTokens", "TotalTokens", "CostUsd")
VALUES ({Guid.NewGuid()}, {date}, {model}, {purpose}, 1, {cachedTokens}, {promptTokens}, {completionTokens}, {totalTokens}, {costUsd})
ON CONFLICT ("Date", "Model", "Purpose") DO UPDATE SET
("Id", "Date", "Model", "Purpose", "UserId", "Calls", "CachedTokens", "PromptTokens", "CompletionTokens", "TotalTokens", "CostUsd")
VALUES ({Guid.NewGuid()}, {date}, {model}, {purpose}, {userId}, 1, {cachedTokens}, {promptTokens}, {completionTokens}, {totalTokens}, {costUsd})
ON CONFLICT ("Date", "Model", "Purpose", "UserId") DO UPDATE SET
"Calls" = "AiUsageDaily"."Calls" + 1,
"CachedTokens" = "AiUsageDaily"."CachedTokens" + EXCLUDED."CachedTokens",
"PromptTokens" = "AiUsageDaily"."PromptTokens" + EXCLUDED."PromptTokens",
Expand Down
Loading
Loading