Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -7258,6 +7258,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