fetchCopilotApiToken function

Future<CopilotApiToken> fetchCopilotApiToken({
  1. required String githubToken,
  2. Client? client,
})

Exchanges a GitHub token for a Copilot API token (goal: copilot-proxy-go internal/auth/github_client.go).

Only OAuth tokens (gho_/ghu_ from the Copilot CLI or gh apps) exchange. A fine-grained PAT (v2, github_pat_…) NEVER hits this function in PAT mode — the caller checks isFineGrainedGitHubPat and uses the raw PAT as the Copilot Bearer instead. Classic PATs (ghp_…) are not supported by Copilot at all.

Throws CopilotAuthException on a non-200 — HTTP 401 means the GitHub token is dead (the caller must trigger re-auth, not a token refresh); every other status carries the response body in the message.

Implementation

Future<CopilotApiToken> fetchCopilotApiToken({
  required String githubToken,
  http.Client? client,
}) async {
  final httpClient = client ?? sharedProviderHttpClient();
  final response = await httpClient
      .get(
        Uri.parse(copilotTokenExchangeUrl),
        headers: {
          // Bearer, not the legacy `token` scheme (pi oauth/github-copilot.ts
          // parity): device-flow OAuth tokens (gho_/ghu_) are Bearer
          // credentials, and the exchange 401s enterprise-managed (EMU)
          // accounts on the legacy scheme.
          'authorization': 'Bearer $githubToken',
          'accept': 'application/json',
          'editor-version': _copilotEditorVersion,
          'editor-plugin-version': _copilotPluginVersion,
          'user-agent': _copilotUserAgent,
          'copilot-integration-id': 'vscode-chat',
        },
      )
      .timeout(effectiveProviderConnectTimeout);
  if (response.statusCode == 401) {
    throw const CopilotAuthException(
      'GitHub token rejected (401) by the Copilot token exchange — '
      're-authorize Copilot (CLI: /provider copilot).',
    );
  }
  if (response.statusCode == 404) {
    if (isFineGrainedGitHubPat(githubToken)) {
      throw CopilotAuthException(
        'Copilot token exchange failed (HTTP 404): GitHub never grants a '
        'fine-grained PAT the internal token exchange — fa uses '
        'github_pat_… tokens directly against the Copilot API instead '
        '(recent fa versions, same as the official Copilot CLI), so a '
        'PAT should never be exchanged at all. For the PAT to work it '
        'must carry the "Copilot Requests" permission (github.com/'
        'settings/personal-access-tokens → edit → Permissions); the '
        'GitHub device flow (/provider copilot) mints a working OAuth '
        'token. Body: ${response.body.trim()}',
      );
    }
    throw CopilotAuthException(
      'Copilot token exchange failed (HTTP 404): GitHub does not grant '
      'this token Copilot access. A fine-grained PAT (github_pat_…) needs '
      'the "Copilot Requests" permission (github.com/settings/'
      'personal-access-tokens → edit → Permissions); classic PATs '
      '(ghp_…) are not supported by Copilot at all; or the account has '
      'no Copilot seat. The GitHub device flow (/provider copilot) mints '
      'a working OAuth token. Body: ${response.body.trim()}',
    );
  }
  if (response.statusCode != 200) {
    throw CopilotAuthException(
      'Copilot token exchange failed (HTTP ${response.statusCode}): '
      '${response.body.trim()}',
    );
  }
  final decoded = jsonDecode(response.body);
  if (decoded is! Map<String, dynamic>) {
    throw const CopilotAuthException(
      'Copilot token exchange returned an unexpected payload.',
    );
  }
  return CopilotApiToken.fromJson(decoded);
}