mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 07:58:47 +02:00
feat: surface Copilot usage and quota data across the UI
Add three layers of usage visibility: - ChatPane footer: premium request count, AIU consumed, and quota remaining below the existing context-window bar - Settings / CopilotStatusCard: on-demand account quota section with progress bars, overage indicators, and reset dates fetched via the new get-quota sidecar command - Activity Panel: per-agent token/cost/duration totals on each agent row and a Session Usage summary section between agents and timeline Backend (sidecar): - New get-quota command using SDK account.getQuota RPC - New assistant-usage turn-scoped event from SDK assistant.usage - QuotaSnapshotMapper for both typed and untyped SDK quota payloads - DTOs: GetQuotaCommandDto, QuotaSnapshotDto, AccountQuotaResultEventDto, AssistantUsageEventDto Frontend: - Shared types: AssistantUsageEvent, QuotaSnapshot, GetQuotaCommand - IPC bridge: getQuota channel, assistant-usage event dispatch - State: SessionRequestUsageMap accumulator with per-agent breakdown - 8 new tests for accumulator logic and formatting helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -224,6 +224,8 @@ public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class GetQuotaCommandDto : SidecarCommandEnvelope;
|
||||
|
||||
public sealed class RunTurnToolingConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
@@ -386,6 +388,37 @@ public sealed class HookLifecycleEventDto : SidecarEventDto
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class QuotaSnapshotDto
|
||||
{
|
||||
public double EntitlementRequests { get; init; }
|
||||
public double UsedRequests { get; init; }
|
||||
public double RemainingPercentage { get; init; }
|
||||
public double Overage { get; init; }
|
||||
public bool OverageAllowedWithExhaustedQuota { get; init; }
|
||||
public string? ResetDate { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountQuotaResultEventDto : SidecarEventDto
|
||||
{
|
||||
public Dictionary<string, QuotaSnapshotDto> QuotaSnapshots { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class AssistantUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public double? InputTokens { get; init; }
|
||||
public double? OutputTokens { get; init; }
|
||||
public double? CacheReadTokens { get; init; }
|
||||
public double? CacheWriteTokens { get; init; }
|
||||
public double? Cost { get; init; }
|
||||
public double? Duration { get; init; }
|
||||
public double? TotalNanoAiu { get; init; }
|
||||
public Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
AccountGetQuotaResult result = await client.Rpc.Account.GetQuotaAsync(cancellationToken).ConfigureAwait(false);
|
||||
return QuotaSnapshotMapper.Map(result.QuotaSnapshots);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -127,6 +127,10 @@ internal sealed class CopilotTurnExecutionState
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
|
||||
}
|
||||
break;
|
||||
case AssistantUsageEvent assistantUsage:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage.Data));
|
||||
break;
|
||||
case SessionUsageInfoEvent usageInfo:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
|
||||
@@ -410,6 +414,29 @@ internal sealed class CopilotTurnExecutionState
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantUsageEventDto CreateAssistantUsageEvent(
|
||||
AgentIdentity agent,
|
||||
AssistantUsageData? data)
|
||||
{
|
||||
return new AssistantUsageEventDto
|
||||
{
|
||||
Type = "assistant-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Model = data?.Model ?? string.Empty,
|
||||
InputTokens = data?.InputTokens,
|
||||
OutputTokens = data?.OutputTokens,
|
||||
CacheReadTokens = data?.CacheReadTokens,
|
||||
CacheWriteTokens = data?.CacheWriteTokens,
|
||||
Cost = data?.Cost,
|
||||
Duration = data?.Duration,
|
||||
TotalNanoAiu = data?.CopilotUsage?.TotalNanoAiu,
|
||||
QuotaSnapshots = QuotaSnapshotMapper.MapOrNull(data?.QuotaSnapshots),
|
||||
};
|
||||
}
|
||||
|
||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
|
||||
{
|
||||
return new SessionUsageEventDto
|
||||
|
||||
@@ -12,5 +12,8 @@ public interface ICopilotSessionManager
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class QuotaSnapshotMapper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static Dictionary<string, QuotaSnapshotDto> Map(
|
||||
IReadOnlyDictionary<string, AccountGetQuotaResultQuotaSnapshotsValue>? snapshots)
|
||||
{
|
||||
Dictionary<string, QuotaSnapshotDto> mapped = new(StringComparer.Ordinal);
|
||||
if (snapshots is null)
|
||||
{
|
||||
return mapped;
|
||||
}
|
||||
|
||||
foreach ((string key, AccountGetQuotaResultQuotaSnapshotsValue snapshot) in snapshots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mapped[key.Trim()] = Map(snapshot);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static Dictionary<string, QuotaSnapshotDto>? MapOrNull(
|
||||
IReadOnlyDictionary<string, object>? snapshots)
|
||||
{
|
||||
if (snapshots is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, QuotaSnapshotDto> mapped = new(StringComparer.Ordinal);
|
||||
foreach ((string key, object snapshot) in snapshots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QuotaSnapshotDto? mappedSnapshot = TryMap(snapshot);
|
||||
if (mappedSnapshot is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mapped[key.Trim()] = mappedSnapshot;
|
||||
}
|
||||
|
||||
return mapped.Count == 0 ? null : mapped;
|
||||
}
|
||||
|
||||
public static QuotaSnapshotDto Map(AccountGetQuotaResultQuotaSnapshotsValue snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
return new QuotaSnapshotDto
|
||||
{
|
||||
EntitlementRequests = snapshot.EntitlementRequests,
|
||||
UsedRequests = snapshot.UsedRequests,
|
||||
RemainingPercentage = snapshot.RemainingPercentage,
|
||||
Overage = snapshot.Overage,
|
||||
OverageAllowedWithExhaustedQuota = snapshot.OverageAllowedWithExhaustedQuota,
|
||||
ResetDate = snapshot.ResetDate,
|
||||
};
|
||||
}
|
||||
|
||||
private static QuotaSnapshotDto? TryMap(object? snapshot)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (snapshot is AccountGetQuotaResultQuotaSnapshotsValue typedSnapshot)
|
||||
{
|
||||
return Map(typedSnapshot);
|
||||
}
|
||||
|
||||
JsonElement element = snapshot is JsonElement jsonElement
|
||||
? jsonElement
|
||||
: JsonSerializer.SerializeToElement(snapshot, JsonOptions);
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AccountGetQuotaResultQuotaSnapshotsValue? deserialized =
|
||||
element.Deserialize<AccountGetQuotaResultQuotaSnapshotsValue>(JsonOptions);
|
||||
|
||||
return deserialized is null ? null : Map(deserialized);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed class SidecarProtocolHost
|
||||
private const string ListSessionsCommandType = "list-sessions";
|
||||
private const string DeleteSessionCommandType = "delete-session";
|
||||
private const string DisconnectSessionCommandType = "disconnect-session";
|
||||
private const string GetQuotaCommandType = "get-quota";
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -82,6 +83,7 @@ public sealed class SidecarProtocolHost
|
||||
[ListSessionsCommandType] = HandleListSessionsAsync,
|
||||
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
|
||||
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
|
||||
[GetQuotaCommandType] = HandleGetQuotaAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +315,23 @@ public sealed class SidecarProtocolHost
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleGetQuotaAsync(CommandContext context)
|
||||
{
|
||||
_ = DeserializeCommand<GetQuotaCommandDto>(context);
|
||||
IReadOnlyDictionary<string, QuotaSnapshotDto> quotaSnapshots =
|
||||
await _sessionManager.GetQuotaAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new AccountQuotaResultEventDto
|
||||
{
|
||||
Type = "quota-result",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
QuotaSnapshots = quotaSnapshots.ToDictionary(
|
||||
snapshot => snapshot.Key,
|
||||
snapshot => snapshot.Value,
|
||||
StringComparer.Ordinal),
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TCommand DeserializeCommand<TCommand>(CommandContext context)
|
||||
where TCommand : SidecarCommandEnvelope
|
||||
{
|
||||
|
||||
@@ -251,6 +251,69 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Empty(state.DrainPendingEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantUsage_QueuesAssistantUsageEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.usage",
|
||||
"data": {
|
||||
"model": "gpt-5.4",
|
||||
"inputTokens": 1200,
|
||||
"outputTokens": 300,
|
||||
"cacheReadTokens": 50,
|
||||
"cacheWriteTokens": 10,
|
||||
"cost": 0.42,
|
||||
"duration": 8200,
|
||||
"quotaSnapshots": {
|
||||
"premium_interactions": {
|
||||
"entitlementRequests": 50,
|
||||
"usedRequests": 12,
|
||||
"remainingPercentage": 76,
|
||||
"overage": 0,
|
||||
"overageAllowedWithExhaustedQuota": true,
|
||||
"resetDate": "2026-04-01T00:00:00Z"
|
||||
}
|
||||
},
|
||||
"copilotUsage": {
|
||||
"tokenDetails": [
|
||||
{
|
||||
"batchSize": 1,
|
||||
"costPerBatch": 1,
|
||||
"tokenCount": 1500,
|
||||
"tokenType": "input"
|
||||
}
|
||||
],
|
||||
"totalNanoAiu": 1200000000
|
||||
}
|
||||
},
|
||||
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
AssistantUsageEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<AssistantUsageEventDto>());
|
||||
Assert.Equal("session-1", evt.SessionId);
|
||||
Assert.Equal("agent-1", evt.AgentId);
|
||||
Assert.Equal("Primary", evt.AgentName);
|
||||
Assert.Equal("gpt-5.4", evt.Model);
|
||||
Assert.Equal(1200, evt.InputTokens);
|
||||
Assert.Equal(300, evt.OutputTokens);
|
||||
Assert.Equal(0.42, evt.Cost);
|
||||
Assert.Equal(8200, evt.Duration);
|
||||
Assert.Equal(1200000000, evt.TotalNanoAiu);
|
||||
QuotaSnapshotDto snapshot = Assert.Single(evt.QuotaSnapshots!.Values);
|
||||
Assert.Equal(50, snapshot.EntitlementRequests);
|
||||
Assert.Equal(12, snapshot.UsedRequests);
|
||||
Assert.Equal(76, snapshot.RemainingPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
|
||||
{
|
||||
|
||||
@@ -850,6 +850,44 @@ public sealed class SidecarProtocolHostTests
|
||||
Assert.Equal("session-1", sessionManager.DeletedAryxSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuotaCommand_ReturnsQuotaResultEvent()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
sessionManager: new FakeSessionManager
|
||||
{
|
||||
QuotaSnapshots = new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal)
|
||||
{
|
||||
["premium_interactions"] = new()
|
||||
{
|
||||
EntitlementRequests = 50,
|
||||
UsedRequests = 12,
|
||||
RemainingPercentage = 76,
|
||||
Overage = 0,
|
||||
OverageAllowedWithExhaustedQuota = true,
|
||||
ResetDate = "2026-04-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new GetQuotaCommandDto
|
||||
{
|
||||
Type = "get-quota",
|
||||
RequestId = "quota-1",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement quotaEvent = AssertSingleEvent(events, "quota-result", "quota-1");
|
||||
JsonElement snapshot = quotaEvent.GetProperty("quotaSnapshots").GetProperty("premium_interactions");
|
||||
Assert.Equal(50, snapshot.GetProperty("entitlementRequests").GetDouble());
|
||||
Assert.Equal(12, snapshot.GetProperty("usedRequests").GetDouble());
|
||||
Assert.Equal(76, snapshot.GetProperty("remainingPercentage").GetDouble());
|
||||
Assert.True(snapshot.GetProperty("overageAllowedWithExhaustedQuota").GetBoolean());
|
||||
Assert.Equal("2026-04-01T00:00:00Z", snapshot.GetProperty("resetDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession()
|
||||
{
|
||||
@@ -1115,6 +1153,9 @@ public sealed class SidecarProtocolHostTests
|
||||
|
||||
public IReadOnlyList<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, QuotaSnapshotDto> QuotaSnapshots { get; init; }
|
||||
= new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal);
|
||||
|
||||
public string? DeletedAryxSessionId { get; private set; }
|
||||
|
||||
public string? DeletedCopilotSessionId { get; private set; }
|
||||
@@ -1135,5 +1176,11 @@ public sealed class SidecarProtocolHostTests
|
||||
DeletedCopilotSessionId = copilotSessionId;
|
||||
return Task.FromResult(DeletedSessions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(QuotaSnapshots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user