mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user