Skip to content

Bug: GitHubCopilotUsageProvider queries wrong endpoint (/copilot/billing/usage -> 404), yielding empty metrics and skipped account registration #1071

Description

@JoshuaRowePhantom

Summary

GitHubCopilotUsageProvider requests a non-existent endpoint, https://api.github.com/copilot/billing/usage, which returns 404. The provider treats 404 as "no data" and returns an empty metrics list. UsageMetricsService then sees zero metrics and skips registering the account, so the account is never added to UsageMetrics.Accounts, UsageTrackerViewModel.TopRightLabel stays null, and the toolbar usage indicator in MainWindow never appears. This happens even though a valid GitHub user-account entity exists (provider: "https://github.com", user-name: "JoshuaRowePhantom").

Observed runtime log warnings:

2026-07-29T00:01:12.864Z [Warning] Phantom.Workspaces.Services.UsageProviders.GitHubCopilotUsageProvider - GitHub Copilot usage provider returned 404 for https://api.github.com/copilot/billing/usage; returning empty metrics.
2026-07-29T00:01:12.864Z [Warning] Phantom.Workspaces.Services.UsageMetricsService - Usage metrics empty for account JoshuaRowePhantom (https://github.com/copilot); skipping account registration.

Fix: call the correct enhanced billing usage endpoint GET /users/{username}/settings/billing/usage (org variant GET /organizations/{org}/settings/billing/usage), building {username} from the discovered account, and parse the documented usageItems[] schema, mapping the Copilot line items into the metrics model.

Related to #1041.

Root Cause

1. The provider calls a URL that does not exist → 404

features/Phantom.Workspaces/Services/UsageProviders/GitHubCopilotUsageProvider.cs:100

"https://api.github.com/copilot/billing/usage"   // no {user}/{org} scope segment → 404

This is the request target (built in SendRequestAsync, used by GetMetricsAsync). There is no user/org scope segment, so GitHub returns 404 Not Found for a normal personal token.

The 404 is handled as "empty" — GitHubCopilotUsageProvider.cs:73-80:

if (response.StatusCode == HttpStatusCode.NotFound)
{
    this.logger.LogWarning(
        "GitHub Copilot usage provider returned {StatusCode} for {Endpoint}; returning empty metrics.",
        (int)response.StatusCode,
        "https://api.github.com/copilot/billing/usage");
    return [];   // 404 → empty list
}

This warning is the exact first line in the observed log. (401 is retried once after a token refresh then throws — cs:59-71; other non-success statuses log an error and throw via EnsureSuccessStatusCode()cs:82-90.)

Note: GetMetricsAsync(UsageAccount account, CancellationToken) (cs:52-54) receives the account but never uses it — the URL is a constant, so the username needed for the correct path is available (UsageAccount.UserName, features/Phantom.Workspaces/Models/UsageMetrics.cs:85) but ignored.

2. Empty metrics → account registration is skipped

features/Phantom.Workspaces/Services/UsageMetricsService.cs:234-280 (RefreshAccountAsync):

var hasMetrics = metrics.Count > 0;
var isCurrentlyVisible = this.usageMetrics.Accounts.Contains(account);

if (hasMetrics)
{
    await this.usageMetrics.MutateAsync(async () =>
    {
        account.Metrics.Clear();
        foreach (var metric in metrics) { account.Metrics.Add(metric); }
        if (!isCurrentlyVisible) { this.usageMetrics.Accounts.Add(account); } // only reached when hasMetrics
        await Task.CompletedTask;
    }).ConfigureAwait(false);
    ...
}
else
{
    this.logger.LogWarning(
        "Usage metrics empty for account {UserName} ({Provider}); skipping account registration.",  // cs:266-269
        discovered.UserName,
        discovered.ProviderUri);
    // account is NOT added (removed if previously visible)
}

This warning is the exact second line in the observed log. The account identity in the message comes from the discovered user-account entity: UserName from the entity's user-name property and ProviderUri from its provider property (discovery at UsageMetricsService.cs:128-194; host-based provider routing at cs:54-59, cs:173-189). Discovery and routing are correct (the #1041 fixes landed); the only remaining break is that the Copilot provider returns nothing.

Net mechanism

Account entity exists → discovered and routed to the Copilot provider → provider requests the unscoped /copilot/billing/usage404 → empty list (GitHubCopilotUsageProvider.cs:100, 73-80) → hasMetrics == false → account never added to UsageMetrics.Accounts (UsageMetricsService.cs:264-269) → TopRightLabel stays null → toolbar indicator hidden.

Affected Files

File Lines Role
Phantom.Workspaces/Services/UsageProviders/GitHubCopilotUsageProvider.cs 52-54, 73-90, 100, 112-160 Builds the wrong unscoped URL; 404→empty; parses the old seat_breakdown JSON shape; ignores account
Phantom.Workspaces/Services/UsageMetricsService.cs 234-280 Skips account registration when metrics are empty (logs the second warning)
Phantom.Workspaces/Models/UsageMetrics.cs 10-88 UsageMetric and UsageAccount models; UsageAccount.UserName (line 85) supplies the path segment
Phantom.Workspaces.Llm.Core/GitHubAuthTokenResolver.cs 41-63 Supplies the bearer token (GITHUB_TOKEN env var, else gh auth token)

Design / Fix

1. Request the correct, scoped endpoint

Change the request URL from the unscoped constant to the documented user billing usage endpoint, building the username from the account:

// GitHubCopilotUsageProvider.GetMetricsAsync / SendRequestAsync
var url =
    $"https://api.github.com/users/{Uri.EscapeDataString(account.UserName)}/settings/billing/usage";

var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2026-03-10"); // bump from 2022-11-28
// existing Bearer token + User-Agent headers retained
  • Path: GET /users/{username}/settings/billing/usage (verified live — returns 200 with real Copilot usage for JoshuaRowePhantom).
  • Query params (optional): year, month, day — omit to default to the current year/month; add year/month if the indicator should show the current billing period only.
  • Auth / scopes: fine-grained PAT (or GitHub App user token) with "Plan" user permissions (read). Endpoint is only available to accounts on the enhanced billing platform. Existing token resolution (GitHubAuthTokenResolver) is unchanged.
  • Headers: Accept: application/vnd.github+json, X-GitHub-Api-Version: 2026-03-10 (docs recommend this version for the enhanced-billing endpoints).

2. Support the organization variant (if org accounts are needed)

For an org-owned account, use GET /organizations/{org}/settings/billing/usage, which requires "Administration" organization permissions (read). Select user- vs org-path based on the discovered account's identity (personal handle vs org). If only personal Copilot usage is tracked, the user endpoint alone is sufficient.

3. Parse the usageItems[] schema and map Copilot line items

The response is no longer seat_breakdown / total_billed_amount. Replace the parser (cs:112-160) to read usageItems[]. Verified live shape:

{
  "usageItems": [
    { "date": "2026-07-01T00:00:00Z", "product": "copilot", "sku": "Copilot AI Credits",
      "quantity": 395199.59, "unitType": "AICredits", "pricePerUnit": 0.01,
      "grossAmount": 3951.99, "discountAmount": 197.41, "netAmount": 3754.58, "repositoryName": "" },
    { "date": "2026-05-01T00:00:00Z", "product": "copilot", "sku": "Copilot Premium Request",
      "quantity": 1244, "unitType": "Requests", "pricePerUnit": 0.04,
      "grossAmount": 49.76, "discountAmount": 49.76, "netAmount": 0.0, "repositoryName": "" }
  ]
}

Per-item fields: date, product, sku, quantity, unitType, pricePerUnit, grossAmount, discountAmount, netAmount, repositoryName. Mapping guidance:

  • Filter product == "copilot" (case-insensitive) to isolate Copilot usage from actions / models items.
  • Group/aggregate the Copilot SKUs (e.g. Copilot Premium Request, Copilot AI Credits) into UsageMetric entries — set Title from the SKU, QuantityUsed/Unit from quantity/unitType, and (optionally) a cost metric from netAmount. Keep the existing UsageMetric presentation-format fields.
  • Stamp LastUpdatedAt from the injected TimeProvider as today.

4. Distinguish genuine errors from "empty" and keep graceful handling

The current 404→empty behavior masked the real bug (the URL was simply wrong). After pointing at the correct endpoint:

  • A 404 on the correct path is a genuine "not on enhanced billing / no such user" condition — keep returning empty but log at Warning with the actual URL.
  • A 403 (insufficient Plan permission) should be logged distinctly (permission problem, not "no usage").
  • A 200 with zero Copilot items is legitimately empty.

Consider (separately) decoupling GUI visibility from a successful fetch so the indicator can show a neutral/pending state — tracked as background below, not required for this fix.

5. Diagnosability

Log lines for the 404/403/empty cases already exist; ensure they carry the real resolved URL and the account handle. (#1092 / #1086 add the file-logging facility that surfaces this chain from logs.)

Reference: GitHub REST billing usage docs — https://docs.github.com/en/rest/billing/usage?apiVersion=2026-03-10

Expected Tests

Style/class names follow the existing GitHubCopilotUsageProviderTests, UsageMetricsServiceTests, UsageTrackerViewModelTests, and MainWindowUsageTrackerTests suites (features/Phantom.Workspaces.Tests). Tests capture the outgoing request via the existing RequestCapturingHandler / StubHandler fakes and assert on the request URI.

Test Name Class What It Verifies
GetMetricsAsync_UsesUserScopedBillingUsageEndpoint_ForSignedInAccount GitHubCopilotUsageProviderTests Request URI is https://api.github.com/users/{UserName}/settings/billing/usage (built from account.UserName), not the unscoped /copilot/billing/usage
GetMetricsAsync_ParsesUsageItems_IntoCopilotMetrics GitHubCopilotUsageProviderTests A usageItems[] body with product":"copilot" items is parsed into UsageMetric entries (Premium Request / AI Credits)
GetMetricsAsync_IgnoresNonCopilotUsageItems GitHubCopilotUsageProviderTests actions/models line items are filtered out; only Copilot items become metrics
GetMetricsAsync_SendsEnhancedBillingHeaders GitHubCopilotUsageProviderTests Sends Accept: application/vnd.github+json and X-GitHub-Api-Version: 2026-03-10, with the Bearer token
GetMetricsAsync_WhenNotFound_ReturnsEmpty GitHubCopilotUsageProviderTests (update existing) 404 on the correct URL still returns empty and logs the resolved URL
GetMetricsAsync_WhenForbidden_LogsPermissionWarning GitHubCopilotUsageProviderTests 403 (missing Plan read) is logged distinctly from empty/404
UsageMetricsService_AccountAdded_WhenProviderReturnsMetrics UsageMetricsServiceTests (existing) a github.com user-account whose provider returns Copilot metrics is added to UsageMetrics.Accounts (no longer skipped)
UsageMetricsService_AccountNotAdded_WhenProviderReturnsEmptyMetrics UsageMetricsServiceTests (existing) genuinely empty result still skips registration and logs the warning
MainWindow_UsageTrackerPanel_Visible_WhenAccountWithUsageExists MainWindowUsageTrackerTests Indicator panel becomes visible (TopRightLabel != null) once a discovered account yields ≥1 metric
TopRightLabel_CopilotAccountAdded_BecomesNonNull UsageTrackerViewModelTests (existing) TopRightLabel becomes non-null once a Copilot account is added, un-hiding the panel

Considered / Background

Related to #1041.

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedqueuedIn the active work queue (tracked in work-queue.md)verified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions