feat(auth): add GitHub Copilot authentication and API integration

Add complete GitHub Copilot support including:
- Device flow OAuth authentication via GitHub's official client ID
- Token management with automatic caching (25 min TTL)
- OpenAI-compatible API executor for api.githubcopilot.com
- 16 model definitions (GPT-5 variants, Claude variants, Gemini, Grok, Raptor)
- CLI login command via -github-copilot-login flag
- SDK authenticator and refresh registry integration

Enables users to authenticate with their GitHub Copilot subscription and
use it as a backend provider alongside existing providers.
This commit is contained in:
Ernesto Martínez
2025-11-27 20:14:30 +01:00
parent d2e4639b2a
commit 3a9ac7ef33
12 changed files with 1563 additions and 1 deletions

View File

@@ -0,0 +1,301 @@
// Package copilot provides authentication and token management for GitHub Copilot API.
// It handles the OAuth2 device flow for secure authentication with the Copilot API.
package copilot
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
const (
// copilotAPITokenURL is the endpoint for getting Copilot API tokens from GitHub token.
copilotAPITokenURL = "https://api.github.com/copilot_internal/v2/token"
// copilotAPIEndpoint is the base URL for making API requests.
copilotAPIEndpoint = "https://api.githubcopilot.com"
)
// CopilotAPIToken represents the Copilot API token response.
type CopilotAPIToken struct {
// Token is the JWT token for authenticating with the Copilot API.
Token string `json:"token"`
// ExpiresAt is the Unix timestamp when the token expires.
ExpiresAt int64 `json:"expires_at"`
// Endpoints contains the available API endpoints.
Endpoints struct {
API string `json:"api"`
Proxy string `json:"proxy"`
OriginTracker string `json:"origin-tracker"`
Telemetry string `json:"telemetry"`
} `json:"endpoints,omitempty"`
// ErrorDetails contains error information if the request failed.
ErrorDetails *struct {
URL string `json:"url"`
Message string `json:"message"`
DocumentationURL string `json:"documentation_url"`
} `json:"error_details,omitempty"`
}
// CopilotAuth handles GitHub Copilot authentication flow.
// It provides methods for device flow authentication and token management.
type CopilotAuth struct {
httpClient *http.Client
deviceClient *DeviceFlowClient
cfg *config.Config
}
// NewCopilotAuth creates a new CopilotAuth service instance.
// It initializes an HTTP client with proxy settings from the provided configuration.
func NewCopilotAuth(cfg *config.Config) *CopilotAuth {
return &CopilotAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}),
deviceClient: NewDeviceFlowClient(cfg),
cfg: cfg,
}
}
// StartDeviceFlow initiates the device flow authentication.
// Returns the device code response containing the user code and verification URI.
func (c *CopilotAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
return c.deviceClient.RequestDeviceCode(ctx)
}
// WaitForAuthorization polls for user authorization and returns the auth bundle.
func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotAuthBundle, error) {
tokenData, err := c.deviceClient.PollForToken(ctx, deviceCode)
if err != nil {
return nil, err
}
// Fetch the GitHub username
username, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken)
if err != nil {
log.Warnf("copilot: failed to fetch user info: %v", err)
username = "unknown"
}
return &CopilotAuthBundle{
TokenData: tokenData,
Username: username,
}, nil
}
// GetCopilotAPIToken exchanges a GitHub access token for a Copilot API token.
// This token is used to make authenticated requests to the Copilot API.
func (c *CopilotAuth) GetCopilotAPIToken(ctx context.Context, githubAccessToken string) (*CopilotAPIToken, error) {
if githubAccessToken == "" {
return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("github access token is empty"))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotAPITokenURL, nil)
if err != nil {
return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
}
req.Header.Set("Authorization", "token "+githubAccessToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "GithubCopilot/1.0")
req.Header.Set("Editor-Version", "vscode/1.100.0")
req.Header.Set("Editor-Plugin-Version", "copilot/1.300.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("copilot api token: close body error: %v", errClose)
}
}()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, NewAuthenticationError(ErrTokenExchangeFailed,
fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
}
var apiToken CopilotAPIToken
if err = json.Unmarshal(bodyBytes, &apiToken); err != nil {
return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
}
if apiToken.Token == "" {
return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty copilot api token"))
}
return &apiToken, nil
}
// ValidateToken checks if a GitHub access token is valid by attempting to fetch user info.
func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bool, string, error) {
if accessToken == "" {
return false, "", nil
}
username, err := c.deviceClient.FetchUserInfo(ctx, accessToken)
if err != nil {
return false, "", err
}
return true, username, nil
}
// CreateTokenStorage creates a new CopilotTokenStorage from auth bundle.
func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage {
return &CopilotTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
TokenType: bundle.TokenData.TokenType,
Scope: bundle.TokenData.Scope,
Username: bundle.Username,
Type: "github-copilot",
}
}
// LoadAndValidateToken loads a token from storage and validates it.
// Returns the storage if valid, or an error if the token is invalid or expired.
func (c *CopilotAuth) LoadAndValidateToken(ctx context.Context, storage *CopilotTokenStorage) (bool, error) {
if storage == nil || storage.AccessToken == "" {
return false, fmt.Errorf("no token available")
}
// Check if we can still use the GitHub token to get a Copilot API token
apiToken, err := c.GetCopilotAPIToken(ctx, storage.AccessToken)
if err != nil {
return false, err
}
// Check if the API token is expired
if apiToken.ExpiresAt > 0 && time.Now().Unix() >= apiToken.ExpiresAt {
return false, fmt.Errorf("copilot api token expired")
}
return true, nil
}
// GetAPIEndpoint returns the Copilot API endpoint URL.
func (c *CopilotAuth) GetAPIEndpoint() string {
return copilotAPIEndpoint
}
// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API.
func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url string, body io.Reader, apiToken *CopilotAPIToken) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiToken.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "GithubCopilot/1.0")
req.Header.Set("Editor-Version", "vscode/1.100.0")
req.Header.Set("Editor-Plugin-Version", "copilot/1.300.0")
req.Header.Set("Openai-Intent", "conversation-panel")
req.Header.Set("Copilot-Integration-Id", "vscode-chat")
return req, nil
}
// CachedAPIToken manages caching of Copilot API tokens.
type CachedAPIToken struct {
Token *CopilotAPIToken
ExpiresAt time.Time
}
// IsExpired checks if the cached token has expired.
func (c *CachedAPIToken) IsExpired() bool {
if c.Token == nil {
return true
}
// Add a 5-minute buffer before expiration
return time.Now().Add(5 * time.Minute).After(c.ExpiresAt)
}
// TokenManager handles caching and refreshing of Copilot API tokens.
type TokenManager struct {
auth *CopilotAuth
githubToken string
cachedToken *CachedAPIToken
}
// NewTokenManager creates a new token manager for handling Copilot API tokens.
func NewTokenManager(auth *CopilotAuth, githubToken string) *TokenManager {
return &TokenManager{
auth: auth,
githubToken: githubToken,
}
}
// GetToken returns a valid Copilot API token, refreshing if necessary.
func (tm *TokenManager) GetToken(ctx context.Context) (*CopilotAPIToken, error) {
if tm.cachedToken != nil && !tm.cachedToken.IsExpired() {
return tm.cachedToken.Token, nil
}
// Fetch a new API token
apiToken, err := tm.auth.GetCopilotAPIToken(ctx, tm.githubToken)
if err != nil {
return nil, err
}
// Cache the token
expiresAt := time.Now().Add(30 * time.Minute) // Default 30 min cache
if apiToken.ExpiresAt > 0 {
expiresAt = time.Unix(apiToken.ExpiresAt, 0)
}
tm.cachedToken = &CachedAPIToken{
Token: apiToken,
ExpiresAt: expiresAt,
}
return apiToken, nil
}
// GetAuthorizationHeader returns the authorization header value for API requests.
func (tm *TokenManager) GetAuthorizationHeader(ctx context.Context) (string, error) {
token, err := tm.GetToken(ctx)
if err != nil {
return "", err
}
return "Bearer " + token.Token, nil
}
// UpdateGitHubToken updates the GitHub access token used for getting API tokens.
func (tm *TokenManager) UpdateGitHubToken(githubToken string) {
tm.githubToken = githubToken
tm.cachedToken = nil // Invalidate cache
}
// BuildChatCompletionURL builds the URL for chat completions API.
func BuildChatCompletionURL() string {
return copilotAPIEndpoint + "/chat/completions"
}
// BuildModelsURL builds the URL for listing available models.
func BuildModelsURL() string {
return copilotAPIEndpoint + "/models"
}
// ExtractBearerToken extracts the bearer token from an Authorization header.
func ExtractBearerToken(authHeader string) string {
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
if strings.HasPrefix(authHeader, "token ") {
return strings.TrimPrefix(authHeader, "token ")
}
return authHeader
}