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.
Unified · Split
| @@ -62,6 +62,7 @@ func main() { · 61 unmodified lines | ||
| 62 | 62 | var iflowCookie bool |
| 63 | 63 | var noBrowser bool |
| 64 | 64 | var antigravityLogin bool |
| 65 | + var githubCopilotLogin bool | |
| 65 | 66 | var projectID string |
| 66 | 67 | var vertexImport string |
| 67 | 68 | var configPath string |
| @@ -76,6 +77,7 @@ func main() { · 8 unmodified lines | ||
| 76 | 77 | flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") |
| 77 | 78 | flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") |
| 78 | 79 | flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth") |
| 80 | + flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow") | |
| 79 | 81 | flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") |
| 80 | 82 | flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") |
| 81 | 83 | flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") |
| @@ -436,6 +438,9 @@ func main() { · 354 unmodified lines | ||
| 436 | 438 | } else if antigravityLogin { |
| 437 | 439 | // Handle Antigravity login |
| 438 | 440 | cmd.DoAntigravityLogin(cfg, options) |
| 441 | + } else if githubCopilotLogin { | |
| 442 | + // Handle GitHub Copilot login | |
| 443 | + cmd.DoGitHubCopilotLogin(cfg, options) | |
| 439 | 444 | } else if codexLogin { |
| 440 | 445 | // Handle Codex login |
| 441 | 446 | cmd.DoCodexLogin(cfg, options) |
| @@ -0,0 +1,301 @@ | ||
| 1 | +// Package copilot provides authentication and token management for GitHub Copilot API. | |
| 2 | +// It handles the OAuth2 device flow for secure authentication with the Copilot API. | |
| 3 | +package copilot | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "encoding/json" | |
| 8 | + "fmt" | |
| 9 | + "io" | |
| 10 | + "net/http" | |
| 11 | + "strings" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | |
| 15 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" | |
| 16 | + log "github.com/sirupsen/logrus" | |
| 17 | +) | |
| 18 | + | |
| 19 | +const ( | |
| 20 | + // copilotAPITokenURL is the endpoint for getting Copilot API tokens from GitHub token. | |
| 21 | + copilotAPITokenURL = "https://api.github.com/copilot_internal/v2/token" | |
| 22 | + // copilotAPIEndpoint is the base URL for making API requests. | |
| 23 | + copilotAPIEndpoint = "https://api.githubcopilot.com" | |
| 24 | +) | |
| 25 | + | |
| 26 | +// CopilotAPIToken represents the Copilot API token response. | |
| 27 | +type CopilotAPIToken struct { | |
| 28 | + // Token is the JWT token for authenticating with the Copilot API. | |
| 29 | + Token string `json:"token"` | |
| 30 | + // ExpiresAt is the Unix timestamp when the token expires. | |
| 31 | + ExpiresAt int64 `json:"expires_at"` | |
| 32 | + // Endpoints contains the available API endpoints. | |
| 33 | + Endpoints struct { | |
| 34 | + API string `json:"api"` | |
| 35 | + Proxy string `json:"proxy"` | |
| 36 | + OriginTracker string `json:"origin-tracker"` | |
| 37 | + Telemetry string `json:"telemetry"` | |
| 38 | + } `json:"endpoints,omitempty"` | |
| 39 | + // ErrorDetails contains error information if the request failed. | |
| 40 | + ErrorDetails *struct { | |
| 41 | + URL string `json:"url"` | |
| 42 | + Message string `json:"message"` | |
| 43 | + DocumentationURL string `json:"documentation_url"` | |
| 44 | + } `json:"error_details,omitempty"` | |
| 45 | +} | |
| 46 | + | |
| 47 | +// CopilotAuth handles GitHub Copilot authentication flow. | |
| 48 | +// It provides methods for device flow authentication and token management. | |
| 49 | +type CopilotAuth struct { | |
| 50 | + httpClient *http.Client | |
| 51 | + deviceClient *DeviceFlowClient | |
| 52 | + cfg *config.Config | |
| 53 | +} | |
| 54 | + | |
| 55 | +// NewCopilotAuth creates a new CopilotAuth service instance. | |
| 56 | +// It initializes an HTTP client with proxy settings from the provided configuration. | |
| 57 | +func NewCopilotAuth(cfg *config.Config) *CopilotAuth { | |
| 58 | + return &CopilotAuth{ | |
| 59 | + httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}), | |
| 60 | + deviceClient: NewDeviceFlowClient(cfg), | |
| 61 | + cfg: cfg, | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +// StartDeviceFlow initiates the device flow authentication. | |
| 66 | +// Returns the device code response containing the user code and verification URI. | |
| 67 | +func (c *CopilotAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) { | |
| 68 | + return c.deviceClient.RequestDeviceCode(ctx) | |
| 69 | +} | |
| 70 | + | |
| 71 | +// WaitForAuthorization polls for user authorization and returns the auth bundle. | |
| 72 | +func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotAuthBundle, error) { | |
| 73 | + tokenData, err := c.deviceClient.PollForToken(ctx, deviceCode) | |
| 74 | + if err != nil { | |
| 75 | + return nil, err | |
| 76 | + } | |
| 77 | + | |
| 78 | + // Fetch the GitHub username | |
| 79 | + username, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) | |
| 80 | + if err != nil { | |
| 81 | + log.Warnf("copilot: failed to fetch user info: %v", err) | |
| 82 | + username = "unknown" | |
| 83 | + } | |
| 84 | + | |
| 85 | + return &CopilotAuthBundle{ | |
| 86 | + TokenData: tokenData, | |
| 87 | + Username: username, | |
| 88 | + }, nil | |
| 89 | +} | |
| 90 | + | |
| 91 | +// GetCopilotAPIToken exchanges a GitHub access token for a Copilot API token. | |
| 92 | +// This token is used to make authenticated requests to the Copilot API. | |
| 93 | +func (c *CopilotAuth) GetCopilotAPIToken(ctx context.Context, githubAccessToken string) (*CopilotAPIToken, error) { | |
| 94 | + if githubAccessToken == "" { | |
| 95 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("github access token is empty")) | |
| 96 | + } | |
| 97 | + | |
| 98 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotAPITokenURL, nil) | |
| 99 | + if err != nil { | |
| 100 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 101 | + } | |
| 102 | + | |
| 103 | + req.Header.Set("Authorization", "token "+githubAccessToken) | |
| 104 | + req.Header.Set("Accept", "application/json") | |
| 105 | + req.Header.Set("User-Agent", "GithubCopilot/1.0") | |
| 106 | + req.Header.Set("Editor-Version", "vscode/1.100.0") | |
| 107 | + req.Header.Set("Editor-Plugin-Version", "copilot/1.300.0") | |
| 108 | + | |
| 109 | + resp, err := c.httpClient.Do(req) | |
| 110 | + if err != nil { | |
| 111 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 112 | + } | |
| 113 | + defer func() { | |
| 114 | + if errClose := resp.Body.Close(); errClose != nil { | |
| 115 | + log.Errorf("copilot api token: close body error: %v", errClose) | |
| 116 | + } | |
| 117 | + }() | |
| 118 | + | |
| 119 | + bodyBytes, err := io.ReadAll(resp.Body) | |
| 120 | + if err != nil { | |
| 121 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 122 | + } | |
| 123 | + | |
| 124 | + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { | |
| 125 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, | |
| 126 | + fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))) | |
| 127 | + } | |
| 128 | + | |
| 129 | + var apiToken CopilotAPIToken | |
| 130 | + if err = json.Unmarshal(bodyBytes, &apiToken); err != nil { | |
| 131 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 132 | + } | |
| 133 | + | |
| 134 | + if apiToken.Token == "" { | |
| 135 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty copilot api token")) | |
| 136 | + } | |
| 137 | + | |
| 138 | + return &apiToken, nil | |
| 139 | +} | |
| 140 | + | |
| 141 | +// ValidateToken checks if a GitHub access token is valid by attempting to fetch user info. | |
| 142 | +func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bool, string, error) { | |
| 143 | + if accessToken == "" { | |
| 144 | + return false, "", nil | |
| 145 | + } | |
| 146 | + | |
| 147 | + username, err := c.deviceClient.FetchUserInfo(ctx, accessToken) | |
| 148 | + if err != nil { | |
| 149 | + return false, "", err | |
| 150 | + } | |
| 151 | + | |
| 152 | + return true, username, nil | |
| 153 | +} | |
| 154 | + | |
| 155 | +// CreateTokenStorage creates a new CopilotTokenStorage from auth bundle. | |
| 156 | +func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage { | |
| 157 | + return &CopilotTokenStorage{ | |
| 158 | + AccessToken: bundle.TokenData.AccessToken, | |
| 159 | + TokenType: bundle.TokenData.TokenType, | |
| 160 | + Scope: bundle.TokenData.Scope, | |
| 161 | + Username: bundle.Username, | |
| 162 | + Type: "github-copilot", | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 166 | +// LoadAndValidateToken loads a token from storage and validates it. | |
| 167 | +// Returns the storage if valid, or an error if the token is invalid or expired. | |
| 168 | +func (c *CopilotAuth) LoadAndValidateToken(ctx context.Context, storage *CopilotTokenStorage) (bool, error) { | |
| 169 | + if storage == nil || storage.AccessToken == "" { | |
| 170 | + return false, fmt.Errorf("no token available") | |
| 171 | + } | |
| 172 | + | |
| 173 | + // Check if we can still use the GitHub token to get a Copilot API token | |
| 174 | + apiToken, err := c.GetCopilotAPIToken(ctx, storage.AccessToken) | |
| 175 | + if err != nil { | |
| 176 | + return false, err | |
| 177 | + } | |
| 178 | + | |
| 179 | + // Check if the API token is expired | |
| 180 | + if apiToken.ExpiresAt > 0 && time.Now().Unix() >= apiToken.ExpiresAt { | |
| 181 | + return false, fmt.Errorf("copilot api token expired") | |
| 182 | + } | |
| 183 | + | |
| 184 | + return true, nil | |
| 185 | +} | |
| 186 | + | |
| 187 | +// GetAPIEndpoint returns the Copilot API endpoint URL. | |
| 188 | +func (c *CopilotAuth) GetAPIEndpoint() string { | |
| 189 | + return copilotAPIEndpoint | |
| 190 | +} | |
| 191 | + | |
| 192 | +// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API. | |
| 193 | +func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url string, body io.Reader, apiToken *CopilotAPIToken) (*http.Request, error) { | |
| 194 | + req, err := http.NewRequestWithContext(ctx, method, url, body) | |
| 195 | + if err != nil { | |
| 196 | + return nil, fmt.Errorf("failed to create request: %w", err) | |
| 197 | + } | |
| 198 | + | |
| 199 | + req.Header.Set("Authorization", "Bearer "+apiToken.Token) | |
| 200 | + req.Header.Set("Content-Type", "application/json") | |
| 201 | + req.Header.Set("Accept", "application/json") | |
| 202 | + req.Header.Set("User-Agent", "GithubCopilot/1.0") | |
| 203 | + req.Header.Set("Editor-Version", "vscode/1.100.0") | |
| 204 | + req.Header.Set("Editor-Plugin-Version", "copilot/1.300.0") | |
| 205 | + req.Header.Set("Openai-Intent", "conversation-panel") | |
| 206 | + req.Header.Set("Copilot-Integration-Id", "vscode-chat") | |
| 207 | + | |
| 208 | + return req, nil | |
| 209 | +} | |
| 210 | + | |
| 211 | +// CachedAPIToken manages caching of Copilot API tokens. | |
| 212 | +type CachedAPIToken struct { | |
| 213 | + Token *CopilotAPIToken | |
| 214 | + ExpiresAt time.Time | |
| 215 | +} | |
| 216 | + | |
| 217 | +// IsExpired checks if the cached token has expired. | |
| 218 | +func (c *CachedAPIToken) IsExpired() bool { | |
| 219 | + if c.Token == nil { | |
| 220 | + return true | |
| 221 | + } | |
| 222 | + // Add a 5-minute buffer before expiration | |
| 223 | + return time.Now().Add(5 * time.Minute).After(c.ExpiresAt) | |
| 224 | +} | |
| 225 | + | |
| 226 | +// TokenManager handles caching and refreshing of Copilot API tokens. | |
| 227 | +type TokenManager struct { | |
| 228 | + auth *CopilotAuth | |
| 229 | + githubToken string | |
| 230 | + cachedToken *CachedAPIToken | |
| 231 | +} | |
| 232 | + | |
| 233 | +// NewTokenManager creates a new token manager for handling Copilot API tokens. | |
| 234 | +func NewTokenManager(auth *CopilotAuth, githubToken string) *TokenManager { | |
| 235 | + return &TokenManager{ | |
| 236 | + auth: auth, | |
| 237 | + githubToken: githubToken, | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +// GetToken returns a valid Copilot API token, refreshing if necessary. | |
| 242 | +func (tm *TokenManager) GetToken(ctx context.Context) (*CopilotAPIToken, error) { | |
| 243 | + if tm.cachedToken != nil && !tm.cachedToken.IsExpired() { | |
| 244 | + return tm.cachedToken.Token, nil | |
| 245 | + } | |
| 246 | + | |
| 247 | + // Fetch a new API token | |
| 248 | + apiToken, err := tm.auth.GetCopilotAPIToken(ctx, tm.githubToken) | |
| 249 | + if err != nil { | |
| 250 | + return nil, err | |
| 251 | + } | |
| 252 | + | |
| 253 | + // Cache the token | |
| 254 | + expiresAt := time.Now().Add(30 * time.Minute) // Default 30 min cache | |
| 255 | + if apiToken.ExpiresAt > 0 { | |
| 256 | + expiresAt = time.Unix(apiToken.ExpiresAt, 0) | |
| 257 | + } | |
| 258 | + | |
| 259 | + tm.cachedToken = &CachedAPIToken{ | |
| 260 | + Token: apiToken, | |
| 261 | + ExpiresAt: expiresAt, | |
| 262 | + } | |
| 263 | + | |
| 264 | + return apiToken, nil | |
| 265 | +} | |
| 266 | + | |
| 267 | +// GetAuthorizationHeader returns the authorization header value for API requests. | |
| 268 | +func (tm *TokenManager) GetAuthorizationHeader(ctx context.Context) (string, error) { | |
| 269 | + token, err := tm.GetToken(ctx) | |
| 270 | + if err != nil { | |
| 271 | + return "", err | |
| 272 | + } | |
| 273 | + return "Bearer " + token.Token, nil | |
| 274 | +} | |
| 275 | + | |
| 276 | +// UpdateGitHubToken updates the GitHub access token used for getting API tokens. | |
| 277 | +func (tm *TokenManager) UpdateGitHubToken(githubToken string) { | |
| 278 | + tm.githubToken = githubToken | |
| 279 | + tm.cachedToken = nil // Invalidate cache | |
| 280 | +} | |
| 281 | + | |
| 282 | +// BuildChatCompletionURL builds the URL for chat completions API. | |
| 283 | +func BuildChatCompletionURL() string { | |
| 284 | + return copilotAPIEndpoint + "/chat/completions" | |
| 285 | +} | |
| 286 | + | |
| 287 | +// BuildModelsURL builds the URL for listing available models. | |
| 288 | +func BuildModelsURL() string { | |
| 289 | + return copilotAPIEndpoint + "/models" | |
| 290 | +} | |
| 291 | + | |
| 292 | +// ExtractBearerToken extracts the bearer token from an Authorization header. | |
| 293 | +func ExtractBearerToken(authHeader string) string { | |
| 294 | + if strings.HasPrefix(authHeader, "Bearer ") { | |
| 295 | + return strings.TrimPrefix(authHeader, "Bearer ") | |
| 296 | + } | |
| 297 | + if strings.HasPrefix(authHeader, "token ") { | |
| 298 | + return strings.TrimPrefix(authHeader, "token ") | |
| 299 | + } | |
| 300 | + return authHeader | |
| 301 | +} | |
| @@ -0,0 +1,183 @@ | ||
| 1 | +package copilot | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// OAuthError represents an OAuth-specific error. | |
| 10 | +type OAuthError struct { | |
| 11 | + // Code is the OAuth error code. | |
| 12 | + Code string `json:"error"` | |
| 13 | + // Description is a human-readable description of the error. | |
| 14 | + Description string `json:"error_description,omitempty"` | |
| 15 | + // URI is a URI identifying a human-readable web page with information about the error. | |
| 16 | + URI string `json:"error_uri,omitempty"` | |
| 17 | + // StatusCode is the HTTP status code associated with the error. | |
| 18 | + StatusCode int `json:"-"` | |
| 19 | +} | |
| 20 | + | |
| 21 | +// Error returns a string representation of the OAuth error. | |
| 22 | +func (e *OAuthError) Error() string { | |
| 23 | + if e.Description != "" { | |
| 24 | + return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description) | |
| 25 | + } | |
| 26 | + return fmt.Sprintf("OAuth error: %s", e.Code) | |
| 27 | +} | |
| 28 | + | |
| 29 | +// NewOAuthError creates a new OAuth error with the specified code, description, and status code. | |
| 30 | +func NewOAuthError(code, description string, statusCode int) *OAuthError { | |
| 31 | + return &OAuthError{ | |
| 32 | + Code: code, | |
| 33 | + Description: description, | |
| 34 | + StatusCode: statusCode, | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +// AuthenticationError represents authentication-related errors. | |
| 39 | +type AuthenticationError struct { | |
| 40 | + // Type is the type of authentication error. | |
| 41 | + Type string `json:"type"` | |
| 42 | + // Message is a human-readable message describing the error. | |
| 43 | + Message string `json:"message"` | |
| 44 | + // Code is the HTTP status code associated with the error. | |
| 45 | + Code int `json:"code"` | |
| 46 | + // Cause is the underlying error that caused this authentication error. | |
| 47 | + Cause error `json:"-"` | |
| 48 | +} | |
| 49 | + | |
| 50 | +// Error returns a string representation of the authentication error. | |
| 51 | +func (e *AuthenticationError) Error() string { | |
| 52 | + if e.Cause != nil { | |
| 53 | + return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause) | |
| 54 | + } | |
| 55 | + return fmt.Sprintf("%s: %s", e.Type, e.Message) | |
| 56 | +} | |
| 57 | + | |
| 58 | +// Common authentication error types for GitHub Copilot device flow. | |
| 59 | +var ( | |
| 60 | + // ErrDeviceCodeFailed represents an error when requesting the device code fails. | |
| 61 | + ErrDeviceCodeFailed = &AuthenticationError{ | |
| 62 | + Type: "device_code_failed", | |
| 63 | + Message: "Failed to request device code from GitHub", | |
| 64 | + Code: http.StatusBadRequest, | |
| 65 | + } | |
| 66 | + | |
| 67 | + // ErrDeviceCodeExpired represents an error when the device code has expired. | |
| 68 | + ErrDeviceCodeExpired = &AuthenticationError{ | |
| 69 | + Type: "device_code_expired", | |
| 70 | + Message: "Device code has expired. Please try again.", | |
| 71 | + Code: http.StatusGone, | |
| 72 | + } | |
| 73 | + | |
| 74 | + // ErrAuthorizationPending represents a pending authorization state (not an error, used for polling). | |
| 75 | + ErrAuthorizationPending = &AuthenticationError{ | |
| 76 | + Type: "authorization_pending", | |
| 77 | + Message: "Authorization is pending. Waiting for user to authorize.", | |
| 78 | + Code: http.StatusAccepted, | |
| 79 | + } | |
| 80 | + | |
| 81 | + // ErrSlowDown represents a request to slow down polling. | |
| 82 | + ErrSlowDown = &AuthenticationError{ | |
| 83 | + Type: "slow_down", | |
| 84 | + Message: "Polling too frequently. Slowing down.", | |
| 85 | + Code: http.StatusTooManyRequests, | |
| 86 | + } | |
| 87 | + | |
| 88 | + // ErrAccessDenied represents an error when the user denies authorization. | |
| 89 | + ErrAccessDenied = &AuthenticationError{ | |
| 90 | + Type: "access_denied", | |
| 91 | + Message: "User denied authorization", | |
| 92 | + Code: http.StatusForbidden, | |
| 93 | + } | |
| 94 | + | |
| 95 | + // ErrTokenExchangeFailed represents an error when token exchange fails. | |
| 96 | + ErrTokenExchangeFailed = &AuthenticationError{ | |
| 97 | + Type: "token_exchange_failed", | |
| 98 | + Message: "Failed to exchange device code for access token", | |
| 99 | + Code: http.StatusBadRequest, | |
| 100 | + } | |
| 101 | + | |
| 102 | + // ErrPollingTimeout represents an error when polling times out. | |
| 103 | + ErrPollingTimeout = &AuthenticationError{ | |
| 104 | + Type: "polling_timeout", | |
| 105 | + Message: "Timeout waiting for user authorization", | |
| 106 | + Code: http.StatusRequestTimeout, | |
| 107 | + } | |
| 108 | + | |
| 109 | + // ErrUserInfoFailed represents an error when fetching user info fails. | |
| 110 | + ErrUserInfoFailed = &AuthenticationError{ | |
| 111 | + Type: "user_info_failed", | |
| 112 | + Message: "Failed to fetch GitHub user information", | |
| 113 | + Code: http.StatusBadRequest, | |
| 114 | + } | |
| 115 | +) | |
| 116 | + | |
| 117 | +// NewAuthenticationError creates a new authentication error with a cause based on a base error. | |
| 118 | +func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError { | |
| 119 | + return &AuthenticationError{ | |
| 120 | + Type: baseErr.Type, | |
| 121 | + Message: baseErr.Message, | |
| 122 | + Code: baseErr.Code, | |
| 123 | + Cause: cause, | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +// IsAuthenticationError checks if an error is an authentication error. | |
| 128 | +func IsAuthenticationError(err error) bool { | |
| 129 | + var authenticationError *AuthenticationError | |
| 130 | + ok := errors.As(err, &authenticationError) | |
| 131 | + return ok | |
| 132 | +} | |
| 133 | + | |
| 134 | +// IsOAuthError checks if an error is an OAuth error. | |
| 135 | +func IsOAuthError(err error) bool { | |
| 136 | + var oAuthError *OAuthError | |
| 137 | + ok := errors.As(err, &oAuthError) | |
| 138 | + return ok | |
| 139 | +} | |
| 140 | + | |
| 141 | +// GetUserFriendlyMessage returns a user-friendly error message based on the error type. | |
| 142 | +func GetUserFriendlyMessage(err error) string { | |
| 143 | + switch { | |
| 144 | + case IsAuthenticationError(err): | |
| 145 | + var authErr *AuthenticationError | |
| 146 | + errors.As(err, &authErr) | |
| 147 | + switch authErr.Type { | |
| 148 | + case "device_code_failed": | |
| 149 | + return "Failed to start GitHub authentication. Please check your network connection and try again." | |
| 150 | + case "device_code_expired": | |
| 151 | + return "The authentication code has expired. Please try again." | |
| 152 | + case "authorization_pending": | |
| 153 | + return "Waiting for you to authorize the application on GitHub." | |
| 154 | + case "slow_down": | |
| 155 | + return "Please wait a moment before trying again." | |
| 156 | + case "access_denied": | |
| 157 | + return "Authentication was cancelled or denied." | |
| 158 | + case "token_exchange_failed": | |
| 159 | + return "Failed to complete authentication. Please try again." | |
| 160 | + case "polling_timeout": | |
| 161 | + return "Authentication timed out. Please try again." | |
| 162 | + case "user_info_failed": | |
| 163 | + return "Failed to get your GitHub account information. Please try again." | |
| 164 | + default: | |
| 165 | + return "Authentication failed. Please try again." | |
| 166 | + } | |
| 167 | + case IsOAuthError(err): | |
| 168 | + var oauthErr *OAuthError | |
| 169 | + errors.As(err, &oauthErr) | |
| 170 | + switch oauthErr.Code { | |
| 171 | + case "access_denied": | |
| 172 | + return "Authentication was cancelled or denied." | |
| 173 | + case "invalid_request": | |
| 174 | + return "Invalid authentication request. Please try again." | |
| 175 | + case "server_error": | |
| 176 | + return "GitHub server error. Please try again later." | |
| 177 | + default: | |
| 178 | + return fmt.Sprintf("Authentication failed: %s", oauthErr.Description) | |
| 179 | + } | |
| 180 | + default: | |
| 181 | + return "An unexpected error occurred. Please try again." | |
| 182 | + } | |
| 183 | +} | |
| @@ -0,0 +1,259 @@ | ||
| 1 | +package copilot | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 9 | + "net/url" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | |
| 14 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" | |
| 15 | + log "github.com/sirupsen/logrus" | |
| 16 | +) | |
| 17 | + | |
| 18 | +const ( | |
| 19 | + // copilotClientID is GitHub's Copilot CLI OAuth client ID. | |
| 20 | + copilotClientID = "Iv1.b507a08c87ecfe98" | |
| 21 | + // copilotDeviceCodeURL is the endpoint for requesting device codes. | |
| 22 | + copilotDeviceCodeURL = "https://github.com/login/device/code" | |
| 23 | + // copilotTokenURL is the endpoint for exchanging device codes for tokens. | |
| 24 | + copilotTokenURL = "https://github.com/login/oauth/access_token" | |
| 25 | + // copilotUserInfoURL is the endpoint for fetching GitHub user information. | |
| 26 | + copilotUserInfoURL = "https://api.github.com/user" | |
| 27 | + // defaultPollInterval is the default interval for polling token endpoint. | |
| 28 | + defaultPollInterval = 5 * time.Second | |
| 29 | + // maxPollDuration is the maximum time to wait for user authorization. | |
| 30 | + maxPollDuration = 15 * time.Minute | |
| 31 | +) | |
| 32 | + | |
| 33 | +// DeviceFlowClient handles the OAuth2 device flow for GitHub Copilot. | |
| 34 | +type DeviceFlowClient struct { | |
| 35 | + httpClient *http.Client | |
| 36 | + cfg *config.Config | |
| 37 | +} | |
| 38 | + | |
| 39 | +// NewDeviceFlowClient creates a new device flow client. | |
| 40 | +func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient { | |
| 41 | + client := &http.Client{Timeout: 30 * time.Second} | |
| 42 | + if cfg != nil { | |
| 43 | + client = util.SetProxy(&cfg.SDKConfig, client) | |
| 44 | + } | |
| 45 | + return &DeviceFlowClient{ | |
| 46 | + httpClient: client, | |
| 47 | + cfg: cfg, | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// RequestDeviceCode initiates the device flow by requesting a device code from GitHub. | |
| 52 | +func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) { | |
| 53 | + data := url.Values{} | |
| 54 | + data.Set("client_id", copilotClientID) | |
| 55 | + data.Set("scope", "user:email") | |
| 56 | + | |
| 57 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotDeviceCodeURL, strings.NewReader(data.Encode())) | |
| 58 | + if err != nil { | |
| 59 | + return nil, NewAuthenticationError(ErrDeviceCodeFailed, err) | |
| 60 | + } | |
| 61 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 62 | + req.Header.Set("Accept", "application/json") | |
| 63 | + | |
| 64 | + resp, err := c.httpClient.Do(req) | |
| 65 | + if err != nil { | |
| 66 | + return nil, NewAuthenticationError(ErrDeviceCodeFailed, err) | |
| 67 | + } | |
| 68 | + defer func() { | |
| 69 | + if errClose := resp.Body.Close(); errClose != nil { | |
| 70 | + log.Errorf("copilot device code: close body error: %v", errClose) | |
| 71 | + } | |
| 72 | + }() | |
| 73 | + | |
| 74 | + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { | |
| 75 | + bodyBytes, _ := io.ReadAll(resp.Body) | |
| 76 | + return nil, NewAuthenticationError(ErrDeviceCodeFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))) | |
| 77 | + } | |
| 78 | + | |
| 79 | + var deviceCode DeviceCodeResponse | |
| 80 | + if err = json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil { | |
| 81 | + return nil, NewAuthenticationError(ErrDeviceCodeFailed, err) | |
| 82 | + } | |
| 83 | + | |
| 84 | + return &deviceCode, nil | |
| 85 | +} | |
| 86 | + | |
| 87 | +// PollForToken polls the token endpoint until the user authorizes or the device code expires. | |
| 88 | +func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotTokenData, error) { | |
| 89 | + if deviceCode == nil { | |
| 90 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("device code is nil")) | |
| 91 | + } | |
| 92 | + | |
| 93 | + interval := time.Duration(deviceCode.Interval) * time.Second | |
| 94 | + if interval < defaultPollInterval { | |
| 95 | + interval = defaultPollInterval | |
| 96 | + } | |
| 97 | + | |
| 98 | + deadline := time.Now().Add(maxPollDuration) | |
| 99 | + if deviceCode.ExpiresIn > 0 { | |
| 100 | + codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second) | |
| 101 | + if codeDeadline.Before(deadline) { | |
| 102 | + deadline = codeDeadline | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + ticker := time.NewTicker(interval) | |
| 107 | + defer ticker.Stop() | |
| 108 | + | |
| 109 | + for { | |
| 110 | + select { | |
| 111 | + case <-ctx.Done(): | |
| 112 | + return nil, NewAuthenticationError(ErrPollingTimeout, ctx.Err()) | |
| 113 | + case <-ticker.C: | |
| 114 | + if time.Now().After(deadline) { | |
| 115 | + return nil, ErrPollingTimeout | |
| 116 | + } | |
| 117 | + | |
| 118 | + token, err := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode) | |
| 119 | + if err != nil { | |
| 120 | + var authErr *AuthenticationError | |
| 121 | + if IsAuthenticationError(err) { | |
| 122 | + if ok := (err.(*AuthenticationError)); ok != nil { | |
| 123 | + authErr = ok | |
| 124 | + } | |
| 125 | + } | |
| 126 | + if authErr != nil { | |
| 127 | + switch authErr.Type { | |
| 128 | + case ErrAuthorizationPending.Type: | |
| 129 | + // Continue polling | |
| 130 | + continue | |
| 131 | + case ErrSlowDown.Type: | |
| 132 | + // Increase interval and continue | |
| 133 | + interval += 5 * time.Second | |
| 134 | + ticker.Reset(interval) | |
| 135 | + continue | |
| 136 | + case ErrDeviceCodeExpired.Type: | |
| 137 | + return nil, err | |
| 138 | + case ErrAccessDenied.Type: | |
| 139 | + return nil, err | |
| 140 | + } | |
| 141 | + } | |
| 142 | + return nil, err | |
| 143 | + } | |
| 144 | + return token, nil | |
| 145 | + } | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +// exchangeDeviceCode attempts to exchange the device code for an access token. | |
| 150 | +func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*CopilotTokenData, error) { | |
| 151 | + data := url.Values{} | |
| 152 | + data.Set("client_id", copilotClientID) | |
| 153 | + data.Set("device_code", deviceCode) | |
| 154 | + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") | |
| 155 | + | |
| 156 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotTokenURL, strings.NewReader(data.Encode())) | |
| 157 | + if err != nil { | |
| 158 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 159 | + } | |
| 160 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 161 | + req.Header.Set("Accept", "application/json") | |
| 162 | + | |
| 163 | + resp, err := c.httpClient.Do(req) | |
| 164 | + if err != nil { | |
| 165 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 166 | + } | |
| 167 | + defer func() { | |
| 168 | + if errClose := resp.Body.Close(); errClose != nil { | |
| 169 | + log.Errorf("copilot token exchange: close body error: %v", errClose) | |
| 170 | + } | |
| 171 | + }() | |
| 172 | + | |
| 173 | + bodyBytes, err := io.ReadAll(resp.Body) | |
| 174 | + if err != nil { | |
| 175 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 176 | + } | |
| 177 | + | |
| 178 | + // GitHub returns 200 for both success and error cases in device flow | |
| 179 | + // Check for OAuth error response first | |
| 180 | + var oauthResp struct { | |
| 181 | + Error string `json:"error"` | |
| 182 | + ErrorDescription string `json:"error_description"` | |
| 183 | + AccessToken string `json:"access_token"` | |
| 184 | + TokenType string `json:"token_type"` | |
| 185 | + Scope string `json:"scope"` | |
| 186 | + } | |
| 187 | + | |
| 188 | + if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil { | |
| 189 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) | |
| 190 | + } | |
| 191 | + | |
| 192 | + if oauthResp.Error != "" { | |
| 193 | + switch oauthResp.Error { | |
| 194 | + case "authorization_pending": | |
| 195 | + return nil, ErrAuthorizationPending | |
| 196 | + case "slow_down": | |
| 197 | + return nil, ErrSlowDown | |
| 198 | + case "expired_token": | |
| 199 | + return nil, ErrDeviceCodeExpired | |
| 200 | + case "access_denied": | |
| 201 | + return nil, ErrAccessDenied | |
| 202 | + default: | |
| 203 | + return nil, NewOAuthError(oauthResp.Error, oauthResp.ErrorDescription, resp.StatusCode) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + if oauthResp.AccessToken == "" { | |
| 208 | + return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty access token")) | |
| 209 | + } | |
| 210 | + | |
| 211 | + return &CopilotTokenData{ | |
| 212 | + AccessToken: oauthResp.AccessToken, | |
| 213 | + TokenType: oauthResp.TokenType, | |
| 214 | + Scope: oauthResp.Scope, | |
| 215 | + }, nil | |
| 216 | +} | |
| 217 | + | |
| 218 | +// FetchUserInfo retrieves the GitHub username for the authenticated user. | |
| 219 | +func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string) (string, error) { | |
| 220 | + if accessToken == "" { | |
| 221 | + return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("access token is empty")) | |
| 222 | + } | |
| 223 | + | |
| 224 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotUserInfoURL, nil) | |
| 225 | + if err != nil { | |
| 226 | + return "", NewAuthenticationError(ErrUserInfoFailed, err) | |
| 227 | + } | |
| 228 | + req.Header.Set("Authorization", "Bearer "+accessToken) | |
| 229 | + req.Header.Set("Accept", "application/json") | |
| 230 | + req.Header.Set("User-Agent", "CLIProxyAPI") | |
| 231 | + | |
| 232 | + resp, err := c.httpClient.Do(req) | |
| 233 | + if err != nil { | |
| 234 | + return "", NewAuthenticationError(ErrUserInfoFailed, err) | |
| 235 | + } | |
| 236 | + defer func() { | |
| 237 | + if errClose := resp.Body.Close(); errClose != nil { | |
| 238 | + log.Errorf("copilot user info: close body error: %v", errClose) | |
| 239 | + } | |
| 240 | + }() | |
| 241 | + | |
| 242 | + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { | |
| 243 | + bodyBytes, _ := io.ReadAll(resp.Body) | |
| 244 | + return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))) | |
| 245 | + } | |
| 246 | + | |
| 247 | + var userInfo struct { | |
| 248 | + Login string `json:"login"` | |
| 249 | + } | |
| 250 | + if err = json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { | |
| 251 | + return "", NewAuthenticationError(ErrUserInfoFailed, err) | |
| 252 | + } | |
| 253 | + | |
| 254 | + if userInfo.Login == "" { | |
| 255 | + return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("empty username")) | |
| 256 | + } | |
| 257 | + | |
| 258 | + return userInfo.Login, nil | |
| 259 | +} | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +// Package copilot provides authentication and token management functionality | |
| 2 | +// for GitHub Copilot AI services. It handles OAuth2 device flow token storage, | |
| 3 | +// serialization, and retrieval for maintaining authenticated sessions with the Copilot API. | |
| 4 | +package copilot | |
| 5 | + | |
| 6 | +import ( | |
| 7 | + "encoding/json" | |
| 8 | + "fmt" | |
| 9 | + "os" | |
| 10 | + "path/filepath" | |
| 11 | + | |
| 12 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// CopilotTokenStorage stores OAuth2 token information for GitHub Copilot API authentication. | |
| 16 | +// It maintains compatibility with the existing auth system while adding Copilot-specific fields | |
| 17 | +// for managing access tokens and user account information. | |
| 18 | +type CopilotTokenStorage struct { | |
| 19 | + // AccessToken is the OAuth2 access token used for authenticating API requests. | |
| 20 | + AccessToken string `json:"access_token"` | |
| 21 | + // TokenType is the type of token, typically "bearer". | |
| 22 | + TokenType string `json:"token_type"` | |
| 23 | + // Scope is the OAuth2 scope granted to the token. | |
| 24 | + Scope string `json:"scope"` | |
| 25 | + // ExpiresAt is the timestamp when the access token expires (if provided). | |
| 26 | + ExpiresAt string `json:"expires_at,omitempty"` | |
| 27 | + // Username is the GitHub username associated with this token. | |
| 28 | + Username string `json:"username"` | |
| 29 | + // Type indicates the authentication provider type, always "github-copilot" for this storage. | |
| 30 | + Type string `json:"type"` | |
| 31 | +} | |
| 32 | + | |
| 33 | +// CopilotTokenData holds the raw OAuth token response from GitHub. | |
| 34 | +type CopilotTokenData struct { | |
| 35 | + // AccessToken is the OAuth2 access token. | |
| 36 | + AccessToken string `json:"access_token"` | |
| 37 | + // TokenType is the type of token, typically "bearer". | |
| 38 | + TokenType string `json:"token_type"` | |
| 39 | + // Scope is the OAuth2 scope granted to the token. | |
| 40 | + Scope string `json:"scope"` | |
| 41 | +} | |
| 42 | + | |
| 43 | +// CopilotAuthBundle bundles authentication data for storage. | |
| 44 | +type CopilotAuthBundle struct { | |
| 45 | + // TokenData contains the OAuth token information. | |
| 46 | + TokenData *CopilotTokenData | |
| 47 | + // Username is the GitHub username. | |
| 48 | + Username string | |
| 49 | +} | |
| 50 | + | |
| 51 | +// DeviceCodeResponse represents GitHub's device code response. | |
| 52 | +type DeviceCodeResponse struct { | |
| 53 | + // DeviceCode is the device verification code. | |
| 54 | + DeviceCode string `json:"device_code"` | |
| 55 | + // UserCode is the code the user must enter at the verification URI. | |
| 56 | + UserCode string `json:"user_code"` | |
| 57 | + // VerificationURI is the URL where the user should enter the code. | |
| 58 | + VerificationURI string `json:"verification_uri"` | |
| 59 | + // ExpiresIn is the number of seconds until the device code expires. | |
| 60 | + ExpiresIn int `json:"expires_in"` | |
| 61 | + // Interval is the minimum number of seconds to wait between polling requests. | |
| 62 | + Interval int `json:"interval"` | |
| 63 | +} | |
| 64 | + | |
| 65 | +// SaveTokenToFile serializes the Copilot token storage to a JSON file. | |
| 66 | +// This method creates the necessary directory structure and writes the token | |
| 67 | +// data in JSON format to the specified file path for persistent storage. | |
| 68 | +// | |
| 69 | +// Parameters: | |
| 70 | +// - authFilePath: The full path where the token file should be saved | |
| 71 | +// | |
| 72 | +// Returns: | |
| 73 | +// - error: An error if the operation fails, nil otherwise | |
| 74 | +func (ts *CopilotTokenStorage) SaveTokenToFile(authFilePath string) error { | |
| 75 | + misc.LogSavingCredentials(authFilePath) | |
| 76 | + ts.Type = "github-copilot" | |
| 77 | + if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { | |
| 78 | + return fmt.Errorf("failed to create directory: %v", err) | |
| 79 | + } | |
| 80 | + | |
| 81 | + f, err := os.Create(authFilePath) | |
| 82 | + if err != nil { | |
| 83 | + return fmt.Errorf("failed to create token file: %w", err) | |
| 84 | + } | |
| 85 | + defer func() { | |
| 86 | + _ = f.Close() | |
| 87 | + }() | |
| 88 | + | |
| 89 | + if err = json.NewEncoder(f).Encode(ts); err != nil { | |
| 90 | + return fmt.Errorf("failed to write token to file: %w", err) | |
| 91 | + } | |
| 92 | + return nil | |
| 93 | +} | |
| @@ -6,7 +6,7 @@ import ( · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | // newAuthManager creates a new authentication manager instance with all supported |
| 8 | 8 | // authenticators and a file-based token store. It initializes authenticators for |
| 9 | −// Gemini, Codex, Claude, and Qwen providers. | |
| 9 | +// Gemini, Codex, Claude, Qwen, IFlow, Antigravity, and GitHub Copilot providers. | |
| 10 | 10 | // |
| 11 | 11 | // Returns: |
| 12 | 12 | // - *sdkAuth.Manager: A configured authentication manager instance |
| @@ -19,6 +19,7 @@ func newAuthManager() *sdkAuth.Manager { · 6 unmodified lines | ||
| 19 | 19 | sdkAuth.NewQwenAuthenticator(), |
| 20 | 20 | sdkAuth.NewIFlowAuthenticator(), |
| 21 | 21 | sdkAuth.NewAntigravityAuthenticator(), |
| 22 | + sdkAuth.NewGitHubCopilotAuthenticator(), | |
| 22 | 23 | ) |
| 23 | 24 | return manager |
| 24 | 25 | } |
| @@ -0,0 +1,44 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + | |
| 7 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | |
| 8 | + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" | |
| 9 | + log "github.com/sirupsen/logrus" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// DoGitHubCopilotLogin triggers the OAuth device flow for GitHub Copilot and saves tokens. | |
| 13 | +// It initiates the device flow authentication, displays the user code for the user to enter | |
| 14 | +// at GitHub's verification URL, and waits for authorization before saving the tokens. | |
| 15 | +// | |
| 16 | +// Parameters: | |
| 17 | +// - cfg: The application configuration containing proxy and auth directory settings | |
| 18 | +// - options: Login options including browser behavior settings | |
| 19 | +func DoGitHubCopilotLogin(cfg *config.Config, options *LoginOptions) { | |
| 20 | + if options == nil { | |
| 21 | + options = &LoginOptions{} | |
| 22 | + } | |
| 23 | + | |
| 24 | + manager := newAuthManager() | |
| 25 | + authOpts := &sdkAuth.LoginOptions{ | |
| 26 | + NoBrowser: options.NoBrowser, | |
| 27 | + Metadata: map[string]string{}, | |
| 28 | + Prompt: options.Prompt, | |
| 29 | + } | |
| 30 | + | |
| 31 | + record, savedPath, err := manager.Login(context.Background(), "github-copilot", cfg, authOpts) | |
| 32 | + if err != nil { | |
| 33 | + log.Errorf("GitHub Copilot authentication failed: %v", err) | |
| 34 | + return | |
| 35 | + } | |
| 36 | + | |
| 37 | + if savedPath != "" { | |
| 38 | + fmt.Printf("Authentication saved to %s\n", savedPath) | |
| 39 | + } | |
| 40 | + if record != nil && record.Label != "" { | |
| 41 | + fmt.Printf("Authenticated as %s\n", record.Label) | |
| 42 | + } | |
| 43 | + fmt.Println("GitHub Copilot authentication successful!") | |
| 44 | +} | |
| @@ -984,3 +984,187 @@ func GetIFlowModels() []*ModelInfo { · 983 unmodified lines | ||
| 984 | 984 | } |
| 985 | 985 | return models |
| 986 | 986 | } |
| 987 | + | |
| 988 | +// GetGitHubCopilotModels returns the available models for GitHub Copilot. | |
| 989 | +// These models are available through the GitHub Copilot API at api.githubcopilot.com. | |
| 990 | +func GetGitHubCopilotModels() []*ModelInfo { | |
| 991 | + now := int64(1732752000) // 2024-11-27 | |
| 992 | + return []*ModelInfo{ | |
| 993 | + { | |
| 994 | + ID: "gpt-4.1", | |
| 995 | + Object: "model", | |
| 996 | + Created: now, | |
| 997 | + OwnedBy: "github-copilot", | |
| 998 | + Type: "github-copilot", | |
| 999 | + DisplayName: "GPT-4.1", | |
| 1000 | + Description: "OpenAI GPT-4.1 via GitHub Copilot", | |
| 1001 | + ContextLength: 128000, | |
| 1002 | + MaxCompletionTokens: 16384, | |
| 1003 | + }, | |
| 1004 | + { | |
| 1005 | + ID: "gpt-5", | |
| 1006 | + Object: "model", | |
| 1007 | + Created: now, | |
| 1008 | + OwnedBy: "github-copilot", | |
| 1009 | + Type: "github-copilot", | |
| 1010 | + DisplayName: "GPT-5", | |
| 1011 | + Description: "OpenAI GPT-5 via GitHub Copilot", | |
| 1012 | + ContextLength: 200000, | |
| 1013 | + MaxCompletionTokens: 32768, | |
| 1014 | + }, | |
| 1015 | + { | |
| 1016 | + ID: "gpt-5-mini", | |
| 1017 | + Object: "model", | |
| 1018 | + Created: now, | |
| 1019 | + OwnedBy: "github-copilot", | |
| 1020 | + Type: "github-copilot", | |
| 1021 | + DisplayName: "GPT-5 Mini", | |
| 1022 | + Description: "OpenAI GPT-5 Mini via GitHub Copilot", | |
| 1023 | + ContextLength: 128000, | |
| 1024 | + MaxCompletionTokens: 16384, | |
| 1025 | + }, | |
| 1026 | + { | |
| 1027 | + ID: "gpt-5-codex", | |
| 1028 | + Object: "model", | |
| 1029 | + Created: now, | |
| 1030 | + OwnedBy: "github-copilot", | |
| 1031 | + Type: "github-copilot", | |
| 1032 | + DisplayName: "GPT-5 Codex", | |
| 1033 | + Description: "OpenAI GPT-5 Codex via GitHub Copilot", | |
| 1034 | + ContextLength: 200000, | |
| 1035 | + MaxCompletionTokens: 32768, | |
| 1036 | + }, | |
| 1037 | + { | |
| 1038 | + ID: "gpt-5.1", | |
| 1039 | + Object: "model", | |
| 1040 | + Created: now, | |
| 1041 | + OwnedBy: "github-copilot", | |
| 1042 | + Type: "github-copilot", | |
| 1043 | + DisplayName: "GPT-5.1", | |
| 1044 | + Description: "OpenAI GPT-5.1 via GitHub Copilot", | |
| 1045 | + ContextLength: 200000, | |
| 1046 | + MaxCompletionTokens: 32768, | |
| 1047 | + }, | |
| 1048 | + { | |
| 1049 | + ID: "gpt-5.1-codex", | |
| 1050 | + Object: "model", | |
| 1051 | + Created: now, | |
| 1052 | + OwnedBy: "github-copilot", | |
| 1053 | + Type: "github-copilot", | |
| 1054 | + DisplayName: "GPT-5.1 Codex", | |
| 1055 | + Description: "OpenAI GPT-5.1 Codex via GitHub Copilot", | |
| 1056 | + ContextLength: 200000, | |
| 1057 | + MaxCompletionTokens: 32768, | |
| 1058 | + }, | |
| 1059 | + { | |
| 1060 | + ID: "gpt-5.1-codex-mini", | |
| 1061 | + Object: "model", | |
| 1062 | + Created: now, | |
| 1063 | + OwnedBy: "github-copilot", | |
| 1064 | + Type: "github-copilot", | |
| 1065 | + DisplayName: "GPT-5.1 Codex Mini", | |
| 1066 | + Description: "OpenAI GPT-5.1 Codex Mini via GitHub Copilot", | |
| 1067 | + ContextLength: 128000, | |
| 1068 | + MaxCompletionTokens: 16384, | |
| 1069 | + }, | |
| 1070 | + { | |
| 1071 | + ID: "claude-haiku-4.5", | |
| 1072 | + Object: "model", | |
| 1073 | + Created: now, | |
| 1074 | + OwnedBy: "github-copilot", | |
| 1075 | + Type: "github-copilot", | |
| 1076 | + DisplayName: "Claude Haiku 4.5", | |
| 1077 | + Description: "Anthropic Claude Haiku 4.5 via GitHub Copilot", | |
| 1078 | + ContextLength: 200000, | |
| 1079 | + MaxCompletionTokens: 64000, | |
| 1080 | + }, | |
| 1081 | + { | |
| 1082 | + ID: "claude-opus-4.1", | |
| 1083 | + Object: "model", | |
| 1084 | + Created: now, | |
| 1085 | + OwnedBy: "github-copilot", | |
| 1086 | + Type: "github-copilot", | |
| 1087 | + DisplayName: "Claude Opus 4.1", | |
| 1088 | + Description: "Anthropic Claude Opus 4.1 via GitHub Copilot", | |
| 1089 | + ContextLength: 200000, | |
| 1090 | + MaxCompletionTokens: 32000, | |
| 1091 | + }, | |
| 1092 | + { | |
| 1093 | + ID: "claude-opus-4.5", | |
| 1094 | + Object: "model", | |
| 1095 | + Created: now, | |
| 1096 | + OwnedBy: "github-copilot", | |
| 1097 | + Type: "github-copilot", | |
| 1098 | + DisplayName: "Claude Opus 4.5", | |
| 1099 | + Description: "Anthropic Claude Opus 4.5 via GitHub Copilot", | |
| 1100 | + ContextLength: 200000, | |
| 1101 | + MaxCompletionTokens: 64000, | |
| 1102 | + }, | |
| 1103 | + { | |
| 1104 | + ID: "claude-sonnet-4", | |
| 1105 | + Object: "model", | |
| 1106 | + Created: now, | |
| 1107 | + OwnedBy: "github-copilot", | |
| 1108 | + Type: "github-copilot", | |
| 1109 | + DisplayName: "Claude Sonnet 4", | |
| 1110 | + Description: "Anthropic Claude Sonnet 4 via GitHub Copilot", | |
| 1111 | + ContextLength: 200000, | |
| 1112 | + MaxCompletionTokens: 64000, | |
| 1113 | + }, | |
| 1114 | + { | |
| 1115 | + ID: "claude-sonnet-4.5", | |
| 1116 | + Object: "model", | |
| 1117 | + Created: now, | |
| 1118 | + OwnedBy: "github-copilot", | |
| 1119 | + Type: "github-copilot", | |
| 1120 | + DisplayName: "Claude Sonnet 4.5", | |
| 1121 | + Description: "Anthropic Claude Sonnet 4.5 via GitHub Copilot", | |
| 1122 | + ContextLength: 200000, | |
| 1123 | + MaxCompletionTokens: 64000, | |
| 1124 | + }, | |
| 1125 | + { | |
| 1126 | + ID: "gemini-2.5-pro", | |
| 1127 | + Object: "model", | |
| 1128 | + Created: now, | |
| 1129 | + OwnedBy: "github-copilot", | |
| 1130 | + Type: "github-copilot", | |
| 1131 | + DisplayName: "Gemini 2.5 Pro", | |
| 1132 | + Description: "Google Gemini 2.5 Pro via GitHub Copilot", | |
| 1133 | + ContextLength: 1048576, | |
| 1134 | + MaxCompletionTokens: 65536, | |
| 1135 | + }, | |
| 1136 | + { | |
| 1137 | + ID: "gemini-3-pro", | |
| 1138 | + Object: "model", | |
| 1139 | + Created: now, | |
| 1140 | + OwnedBy: "github-copilot", | |
| 1141 | + Type: "github-copilot", | |
| 1142 | + DisplayName: "Gemini 3 Pro", | |
| 1143 | + Description: "Google Gemini 3 Pro via GitHub Copilot", | |
| 1144 | + ContextLength: 1048576, | |
| 1145 | + MaxCompletionTokens: 65536, | |
| 1146 | + }, | |
| 1147 | + { | |
| 1148 | + ID: "grok-code-fast-1", | |
| 1149 | + Object: "model", | |
| 1150 | + Created: now, | |
| 1151 | + OwnedBy: "github-copilot", | |
| 1152 | + Type: "github-copilot", | |
| 1153 | + DisplayName: "Grok Code Fast 1", | |
| 1154 | + Description: "xAI Grok Code Fast 1 via GitHub Copilot", | |
| 1155 | + ContextLength: 128000, | |
| 1156 | + MaxCompletionTokens: 16384, | |
| 1157 | + }, | |
| 1158 | + { | |
| 1159 | + ID: "raptor-mini", | |
| 1160 | + Object: "model", | |
| 1161 | + Created: now, | |
| 1162 | + OwnedBy: "github-copilot", | |
| 1163 | + Type: "github-copilot", | |
| 1164 | + DisplayName: "Raptor Mini", | |
| 1165 | + Description: "Raptor Mini via GitHub Copilot", | |
| 1166 | + ContextLength: 128000, | |
| 1167 | + MaxCompletionTokens: 16384, | |
| 1168 | + }, | |
| 1169 | + } | |
| 1170 | +} | |
| @@ -0,0 +1,354 @@ | ||
| 1 | +package executor | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bufio" | |
| 5 | + "bytes" | |
| 6 | + "context" | |
| 7 | + "fmt" | |
| 8 | + "io" | |
| 9 | + "net/http" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "github.com/google/uuid" | |
| 13 | + copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" | |
| 14 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | |
| 15 | + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" | |
| 16 | + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" | |
| 17 | + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" | |
| 18 | + log "github.com/sirupsen/logrus" | |
| 19 | + "github.com/tidwall/sjson" | |
| 20 | +) | |
| 21 | + | |
| 22 | +const ( | |
| 23 | + githubCopilotBaseURL = "https://api.githubcopilot.com" | |
| 24 | + githubCopilotChatPath = "/chat/completions" | |
| 25 | + githubCopilotAuthType = "github-copilot" | |
| 26 | + githubCopilotTokenCacheTTL = 25 * time.Minute | |
| 27 | +) | |
| 28 | + | |
| 29 | +// GitHubCopilotExecutor handles requests to the GitHub Copilot API. | |
| 30 | +type GitHubCopilotExecutor struct { | |
| 31 | + cfg *config.Config | |
| 32 | +} | |
| 33 | + | |
| 34 | +// NewGitHubCopilotExecutor constructs a new executor instance. | |
| 35 | +func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor { | |
| 36 | + return &GitHubCopilotExecutor{cfg: cfg} | |
| 37 | +} | |
| 38 | + | |
| 39 | +// Identifier implements ProviderExecutor. | |
| 40 | +func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType } | |
| 41 | + | |
| 42 | +// PrepareRequest implements ProviderExecutor. | |
| 43 | +func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { | |
| 44 | + return nil | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Execute handles non-streaming requests to GitHub Copilot. | |
| 48 | +func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { | |
| 49 | + apiToken, errToken := e.ensureAPIToken(ctx, auth) | |
| 50 | + if errToken != nil { | |
| 51 | + return resp, errToken | |
| 52 | + } | |
| 53 | + | |
| 54 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 55 | + defer reporter.trackFailure(ctx, &err) | |
| 56 | + | |
| 57 | + from := opts.SourceFormat | |
| 58 | + to := sdktranslator.FromString("openai") | |
| 59 | + body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) | |
| 60 | + body = e.normalizeModel(req.Model, body) | |
| 61 | + body = applyPayloadConfig(e.cfg, req.Model, body) | |
| 62 | + body, _ = sjson.SetBytes(body, "stream", false) | |
| 63 | + | |
| 64 | + url := githubCopilotBaseURL + githubCopilotChatPath | |
| 65 | + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) | |
| 66 | + if err != nil { | |
| 67 | + return resp, err | |
| 68 | + } | |
| 69 | + e.applyHeaders(httpReq, apiToken) | |
| 70 | + | |
| 71 | + var authID, authLabel, authType, authValue string | |
| 72 | + if auth != nil { | |
| 73 | + authID = auth.ID | |
| 74 | + authLabel = auth.Label | |
| 75 | + authType, authValue = auth.AccountInfo() | |
| 76 | + } | |
| 77 | + recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ | |
| 78 | + URL: url, | |
| 79 | + Method: http.MethodPost, | |
| 80 | + Headers: httpReq.Header.Clone(), | |
| 81 | + Body: body, | |
| 82 | + Provider: e.Identifier(), | |
| 83 | + AuthID: authID, | |
| 84 | + AuthLabel: authLabel, | |
| 85 | + AuthType: authType, | |
| 86 | + AuthValue: authValue, | |
| 87 | + }) | |
| 88 | + | |
| 89 | + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) | |
| 90 | + httpResp, err := httpClient.Do(httpReq) | |
| 91 | + if err != nil { | |
| 92 | + recordAPIResponseError(ctx, e.cfg, err) | |
| 93 | + return resp, err | |
| 94 | + } | |
| 95 | + defer func() { | |
| 96 | + if errClose := httpResp.Body.Close(); errClose != nil { | |
| 97 | + log.Errorf("github-copilot executor: close response body error: %v", errClose) | |
| 98 | + } | |
| 99 | + }() | |
| 100 | + | |
| 101 | + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) | |
| 102 | + | |
| 103 | + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { | |
| 104 | + data, _ := io.ReadAll(httpResp.Body) | |
| 105 | + appendAPIResponseChunk(ctx, e.cfg, data) | |
| 106 | + log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) | |
| 107 | + err = statusErr{code: httpResp.StatusCode, msg: string(data)} | |
| 108 | + return resp, err | |
| 109 | + } | |
| 110 | + | |
| 111 | + data, err := io.ReadAll(httpResp.Body) | |
| 112 | + if err != nil { | |
| 113 | + recordAPIResponseError(ctx, e.cfg, err) | |
| 114 | + return resp, err | |
| 115 | + } | |
| 116 | + appendAPIResponseChunk(ctx, e.cfg, data) | |
| 117 | + | |
| 118 | + detail := parseOpenAIUsage(data) | |
| 119 | + if detail.TotalTokens > 0 { | |
| 120 | + reporter.publish(ctx, detail) | |
| 121 | + } | |
| 122 | + | |
| 123 | + var param any | |
| 124 | + converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) | |
| 125 | + resp = cliproxyexecutor.Response{Payload: []byte(converted)} | |
| 126 | + reporter.ensurePublished(ctx) | |
| 127 | + return resp, nil | |
| 128 | +} | |
| 129 | + | |
| 130 | +// ExecuteStream handles streaming requests to GitHub Copilot. | |
| 131 | +func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) { | |
| 132 | + apiToken, errToken := e.ensureAPIToken(ctx, auth) | |
| 133 | + if errToken != nil { | |
| 134 | + return nil, errToken | |
| 135 | + } | |
| 136 | + | |
| 137 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 138 | + defer reporter.trackFailure(ctx, &err) | |
| 139 | + | |
| 140 | + from := opts.SourceFormat | |
| 141 | + to := sdktranslator.FromString("openai") | |
| 142 | + body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) | |
| 143 | + body = e.normalizeModel(req.Model, body) | |
| 144 | + body = applyPayloadConfig(e.cfg, req.Model, body) | |
| 145 | + body, _ = sjson.SetBytes(body, "stream", true) | |
| 146 | + // Enable stream options for usage stats in stream | |
| 147 | + body, _ = sjson.SetBytes(body, "stream_options.include_usage", true) | |
| 148 | + | |
| 149 | + url := githubCopilotBaseURL + githubCopilotChatPath | |
| 150 | + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) | |
| 151 | + if err != nil { | |
| 152 | + return nil, err | |
| 153 | + } | |
| 154 | + e.applyHeaders(httpReq, apiToken) | |
| 155 | + | |
| 156 | + var authID, authLabel, authType, authValue string | |
| 157 | + if auth != nil { | |
| 158 | + authID = auth.ID | |
| 159 | + authLabel = auth.Label | |
| 160 | + authType, authValue = auth.AccountInfo() | |
| 161 | + } | |
| 162 | + recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ | |
| 163 | + URL: url, | |
| 164 | + Method: http.MethodPost, | |
| 165 | + Headers: httpReq.Header.Clone(), | |
| 166 | + Body: body, | |
| 167 | + Provider: e.Identifier(), | |
| 168 | + AuthID: authID, | |
| 169 | + AuthLabel: authLabel, | |
| 170 | + AuthType: authType, | |
| 171 | + AuthValue: authValue, | |
| 172 | + }) | |
| 173 | + | |
| 174 | + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) | |
| 175 | + httpResp, err := httpClient.Do(httpReq) | |
| 176 | + if err != nil { | |
| 177 | + recordAPIResponseError(ctx, e.cfg, err) | |
| 178 | + return nil, err | |
| 179 | + } | |
| 180 | + | |
| 181 | + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) | |
| 182 | + | |
| 183 | + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { | |
| 184 | + data, readErr := io.ReadAll(httpResp.Body) | |
| 185 | + if errClose := httpResp.Body.Close(); errClose != nil { | |
| 186 | + log.Errorf("github-copilot executor: close response body error: %v", errClose) | |
| 187 | + } | |
| 188 | + if readErr != nil { | |
| 189 | + recordAPIResponseError(ctx, e.cfg, readErr) | |
| 190 | + return nil, readErr | |
| 191 | + } | |
| 192 | + appendAPIResponseChunk(ctx, e.cfg, data) | |
| 193 | + log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) | |
| 194 | + err = statusErr{code: httpResp.StatusCode, msg: string(data)} | |
| 195 | + return nil, err | |
| 196 | + } | |
| 197 | + | |
| 198 | + out := make(chan cliproxyexecutor.StreamChunk) | |
| 199 | + stream = out | |
| 200 | + | |
| 201 | + go func() { | |
| 202 | + defer close(out) | |
| 203 | + defer func() { | |
| 204 | + if errClose := httpResp.Body.Close(); errClose != nil { | |
| 205 | + log.Errorf("github-copilot executor: close response body error: %v", errClose) | |
| 206 | + } | |
| 207 | + }() | |
| 208 | + | |
| 209 | + scanner := bufio.NewScanner(httpResp.Body) | |
| 210 | + scanner.Buffer(nil, 20_971_520) | |
| 211 | + var param any | |
| 212 | + | |
| 213 | + for scanner.Scan() { | |
| 214 | + line := scanner.Bytes() | |
| 215 | + appendAPIResponseChunk(ctx, e.cfg, line) | |
| 216 | + | |
| 217 | + // Parse SSE data | |
| 218 | + if bytes.HasPrefix(line, dataTag) { | |
| 219 | + data := bytes.TrimSpace(line[5:]) | |
| 220 | + if bytes.Equal(data, []byte("[DONE]")) { | |
| 221 | + continue | |
| 222 | + } | |
| 223 | + if detail, ok := parseOpenAIStreamUsage(line); ok { | |
| 224 | + reporter.publish(ctx, detail) | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) | |
| 229 | + for i := range chunks { | |
| 230 | + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} | |
| 231 | + } | |
| 232 | + } | |
| 233 | + | |
| 234 | + if errScan := scanner.Err(); errScan != nil { | |
| 235 | + recordAPIResponseError(ctx, e.cfg, errScan) | |
| 236 | + reporter.publishFailure(ctx) | |
| 237 | + out <- cliproxyexecutor.StreamChunk{Err: errScan} | |
| 238 | + } else { | |
| 239 | + reporter.ensurePublished(ctx) | |
| 240 | + } | |
| 241 | + }() | |
| 242 | + | |
| 243 | + return stream, nil | |
| 244 | +} | |
| 245 | + | |
| 246 | +// CountTokens is not supported for GitHub Copilot. | |
| 247 | +func (e *GitHubCopilotExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { | |
| 248 | + return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for github-copilot"} | |
| 249 | +} | |
| 250 | + | |
| 251 | +// Refresh validates the GitHub token is still working. | |
| 252 | +// GitHub OAuth tokens don't expire traditionally, so we just validate. | |
| 253 | +func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { | |
| 254 | + if auth == nil { | |
| 255 | + return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"} | |
| 256 | + } | |
| 257 | + | |
| 258 | + // Get the GitHub access token | |
| 259 | + accessToken := metaStringValue(auth.Metadata, "access_token") | |
| 260 | + if accessToken == "" { | |
| 261 | + return auth, nil | |
| 262 | + } | |
| 263 | + | |
| 264 | + // Validate the token can still get a Copilot API token | |
| 265 | + copilotAuth := copilotauth.NewCopilotAuth(e.cfg) | |
| 266 | + _, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken) | |
| 267 | + if err != nil { | |
| 268 | + return nil, statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("github-copilot token validation failed: %v", err)} | |
| 269 | + } | |
| 270 | + | |
| 271 | + return auth, nil | |
| 272 | +} | |
| 273 | + | |
| 274 | +// ensureAPIToken gets or refreshes the Copilot API token. | |
| 275 | +func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) { | |
| 276 | + if auth == nil { | |
| 277 | + return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"} | |
| 278 | + } | |
| 279 | + | |
| 280 | + // Check for cached API token | |
| 281 | + if cachedToken := metaStringValue(auth.Metadata, "copilot_api_token"); cachedToken != "" { | |
| 282 | + if expiresAt := tokenExpiry(auth.Metadata); expiresAt.After(time.Now().Add(5 * time.Minute)) { | |
| 283 | + return cachedToken, nil | |
| 284 | + } | |
| 285 | + } | |
| 286 | + | |
| 287 | + // Get the GitHub access token | |
| 288 | + accessToken := metaStringValue(auth.Metadata, "access_token") | |
| 289 | + if accessToken == "" { | |
| 290 | + return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"} | |
| 291 | + } | |
| 292 | + | |
| 293 | + // Get a new Copilot API token | |
| 294 | + copilotAuth := copilotauth.NewCopilotAuth(e.cfg) | |
| 295 | + apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken) | |
| 296 | + if err != nil { | |
| 297 | + return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)} | |
| 298 | + } | |
| 299 | + | |
| 300 | + // Cache the token in metadata (will be persisted on next save) | |
| 301 | + if auth.Metadata == nil { | |
| 302 | + auth.Metadata = make(map[string]any) | |
| 303 | + } | |
| 304 | + auth.Metadata["copilot_api_token"] = apiToken.Token | |
| 305 | + if apiToken.ExpiresAt > 0 { | |
| 306 | + auth.Metadata["expired"] = time.Unix(apiToken.ExpiresAt, 0).Format(time.RFC3339) | |
| 307 | + } else { | |
| 308 | + auth.Metadata["expired"] = time.Now().Add(githubCopilotTokenCacheTTL).Format(time.RFC3339) | |
| 309 | + } | |
| 310 | + | |
| 311 | + return apiToken.Token, nil | |
| 312 | +} | |
| 313 | + | |
| 314 | +// applyHeaders sets the required headers for GitHub Copilot API requests. | |
| 315 | +func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) { | |
| 316 | + r.Header.Set("Content-Type", "application/json") | |
| 317 | + r.Header.Set("Authorization", "Bearer "+apiToken) | |
| 318 | + r.Header.Set("Accept", "application/json") | |
| 319 | + r.Header.Set("User-Agent", "GithubCopilot/1.0") | |
| 320 | + r.Header.Set("Editor-Version", "vscode/1.100.0") | |
| 321 | + r.Header.Set("Editor-Plugin-Version", "copilot/1.300.0") | |
| 322 | + r.Header.Set("Openai-Intent", "conversation-panel") | |
| 323 | + r.Header.Set("Copilot-Integration-Id", "vscode-chat") | |
| 324 | + r.Header.Set("X-Request-Id", uuid.NewString()) | |
| 325 | +} | |
| 326 | + | |
| 327 | +// normalizeModel ensures the model name is correct for the API. | |
| 328 | +func (e *GitHubCopilotExecutor) normalizeModel(requestedModel string, body []byte) []byte { | |
| 329 | + // Map friendly names to API model names | |
| 330 | + modelMap := map[string]string{ | |
| 331 | + "gpt-4.1": "gpt-4.1", | |
| 332 | + "gpt-5": "gpt-5", | |
| 333 | + "gpt-5-mini": "gpt-5-mini", | |
| 334 | + "gpt-5-codex": "gpt-5-codex", | |
| 335 | + "gpt-5.1": "gpt-5.1", | |
| 336 | + "gpt-5.1-codex": "gpt-5.1-codex", | |
| 337 | + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", | |
| 338 | + "claude-haiku-4.5": "claude-haiku-4.5", | |
| 339 | + "claude-opus-4.1": "claude-opus-4.1", | |
| 340 | + "claude-opus-4.5": "claude-opus-4.5", | |
| 341 | + "claude-sonnet-4": "claude-sonnet-4", | |
| 342 | + "claude-sonnet-4.5": "claude-sonnet-4.5", | |
| 343 | + "gemini-2.5-pro": "gemini-2.5-pro", | |
| 344 | + "gemini-3-pro": "gemini-3-pro", | |
| 345 | + "grok-code-fast-1": "grok-code-fast-1", | |
| 346 | + "raptor-mini": "raptor-mini", | |
| 347 | + } | |
| 348 | + | |
| 349 | + if mapped, ok := modelMap[requestedModel]; ok { | |
| 350 | + body, _ = sjson.SetBytes(body, "model", mapped) | |
| 351 | + } | |
| 352 | + | |
| 353 | + return body | |
| 354 | +} | |
| @@ -0,0 +1,133 @@ | ||
| 1 | +package auth | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "time" | |
| 7 | + | |
| 8 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" | |
| 9 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" | |
| 10 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | |
| 11 | + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" | |
| 12 | + log "github.com/sirupsen/logrus" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// GitHubCopilotAuthenticator implements the OAuth device flow login for GitHub Copilot. | |
| 16 | +type GitHubCopilotAuthenticator struct{} | |
| 17 | + | |
| 18 | +// NewGitHubCopilotAuthenticator constructs a new GitHub Copilot authenticator. | |
| 19 | +func NewGitHubCopilotAuthenticator() Authenticator { | |
| 20 | + return &GitHubCopilotAuthenticator{} | |
| 21 | +} | |
| 22 | + | |
| 23 | +// Provider returns the provider key for github-copilot. | |
| 24 | +func (GitHubCopilotAuthenticator) Provider() string { | |
| 25 | + return "github-copilot" | |
| 26 | +} | |
| 27 | + | |
| 28 | +// RefreshLead returns nil since GitHub OAuth tokens don't expire in the traditional sense. | |
| 29 | +// The token remains valid until the user revokes it or the Copilot subscription expires. | |
| 30 | +func (GitHubCopilotAuthenticator) RefreshLead() *time.Duration { | |
| 31 | + return nil | |
| 32 | +} | |
| 33 | + | |
| 34 | +// Login initiates the GitHub device flow authentication for Copilot access. | |
| 35 | +func (a GitHubCopilotAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { | |
| 36 | + if cfg == nil { | |
| 37 | + return nil, fmt.Errorf("cliproxy auth: configuration is required") | |
| 38 | + } | |
| 39 | + if ctx == nil { | |
| 40 | + ctx = context.Background() | |
| 41 | + } | |
| 42 | + if opts == nil { | |
| 43 | + opts = &LoginOptions{} | |
| 44 | + } | |
| 45 | + | |
| 46 | + authSvc := copilot.NewCopilotAuth(cfg) | |
| 47 | + | |
| 48 | + // Start the device flow | |
| 49 | + fmt.Println("Starting GitHub Copilot authentication...") | |
| 50 | + deviceCode, err := authSvc.StartDeviceFlow(ctx) | |
| 51 | + if err != nil { | |
| 52 | + return nil, fmt.Errorf("github-copilot: failed to start device flow: %w", err) | |
| 53 | + } | |
| 54 | + | |
| 55 | + // Display the user code and verification URL | |
| 56 | + fmt.Printf("\nTo authenticate, please visit: %s\n", deviceCode.VerificationURI) | |
| 57 | + fmt.Printf("And enter the code: %s\n\n", deviceCode.UserCode) | |
| 58 | + | |
| 59 | + // Try to open the browser automatically | |
| 60 | + if !opts.NoBrowser { | |
| 61 | + if browser.IsAvailable() { | |
| 62 | + if errOpen := browser.OpenURL(deviceCode.VerificationURI); errOpen != nil { | |
| 63 | + log.Warnf("Failed to open browser automatically: %v", errOpen) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + fmt.Println("Waiting for GitHub authorization...") | |
| 69 | + fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn) | |
| 70 | + | |
| 71 | + // Wait for user authorization | |
| 72 | + authBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode) | |
| 73 | + if err != nil { | |
| 74 | + errMsg := copilot.GetUserFriendlyMessage(err) | |
| 75 | + return nil, fmt.Errorf("github-copilot: %s", errMsg) | |
| 76 | + } | |
| 77 | + | |
| 78 | + // Verify the token can get a Copilot API token | |
| 79 | + fmt.Println("Verifying Copilot access...") | |
| 80 | + apiToken, err := authSvc.GetCopilotAPIToken(ctx, authBundle.TokenData.AccessToken) | |
| 81 | + if err != nil { | |
| 82 | + return nil, fmt.Errorf("github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: %w", err) | |
| 83 | + } | |
| 84 | + | |
| 85 | + // Create the token storage | |
| 86 | + tokenStorage := authSvc.CreateTokenStorage(authBundle) | |
| 87 | + | |
| 88 | + // Build metadata | |
| 89 | + metadata := map[string]any{ | |
| 90 | + "type": "github-copilot", | |
| 91 | + "username": authBundle.Username, | |
| 92 | + "access_token": authBundle.TokenData.AccessToken, | |
| 93 | + "token_type": authBundle.TokenData.TokenType, | |
| 94 | + "scope": authBundle.TokenData.Scope, | |
| 95 | + "timestamp": time.Now().UnixMilli(), | |
| 96 | + "api_endpoint": copilot.BuildChatCompletionURL(), | |
| 97 | + } | |
| 98 | + | |
| 99 | + if apiToken.ExpiresAt > 0 { | |
| 100 | + metadata["api_token_expires_at"] = apiToken.ExpiresAt | |
| 101 | + } | |
| 102 | + | |
| 103 | + fileName := fmt.Sprintf("github-copilot-%s.json", authBundle.Username) | |
| 104 | + | |
| 105 | + fmt.Printf("\nGitHub Copilot authentication successful for user: %s\n", authBundle.Username) | |
| 106 | + | |
| 107 | + return &coreauth.Auth{ | |
| 108 | + ID: fileName, | |
| 109 | + Provider: a.Provider(), | |
| 110 | + FileName: fileName, | |
| 111 | + Label: authBundle.Username, | |
| 112 | + Storage: tokenStorage, | |
| 113 | + Metadata: metadata, | |
| 114 | + }, nil | |
| 115 | +} | |
| 116 | + | |
| 117 | +// RefreshGitHubCopilotToken validates and returns the current token status. | |
| 118 | +// GitHub OAuth tokens don't need traditional refresh - we just validate they still work. | |
| 119 | +func RefreshGitHubCopilotToken(ctx context.Context, cfg *config.Config, storage *copilot.CopilotTokenStorage) error { | |
| 120 | + if storage == nil || storage.AccessToken == "" { | |
| 121 | + return fmt.Errorf("no token available") | |
| 122 | + } | |
| 123 | + | |
| 124 | + authSvc := copilot.NewCopilotAuth(cfg) | |
| 125 | + | |
| 126 | + // Validate the token can still get a Copilot API token | |
| 127 | + _, err := authSvc.GetCopilotAPIToken(ctx, storage.AccessToken) | |
| 128 | + if err != nil { | |
| 129 | + return fmt.Errorf("token validation failed: %w", err) | |
| 130 | + } | |
| 131 | + | |
| 132 | + return nil | |
| 133 | +} | |
| @@ -14,6 +14,7 @@ func init() { · 13 unmodified lines | ||
| 14 | 14 | registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() }) |
| 15 | 15 | registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() }) |
| 16 | 16 | registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() }) |
| 17 | + registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() }) | |
| 17 | 18 | } |
| 18 | 19 | |
| 19 | 20 | func registerRefreshLead(provider string, factory func() Authenticator) { |
| @@ -351,6 +351,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 350 unmodified lines | ||
| 351 | 351 | s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg)) |
| 352 | 352 | case "iflow": |
| 353 | 353 | s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg)) |
| 354 | + case "github-copilot": | |
| 355 | + s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) | |
| 354 | 356 | default: |
| 355 | 357 | providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) |
| 356 | 358 | if providerKey == "" { |
| @@ -662,6 +664,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 305 unmodified lines | ||
| 662 | 664 | models = registry.GetQwenModels() |
| 663 | 665 | case "iflow": |
| 664 | 666 | models = registry.GetIFlowModels() |
| 667 | + case "github-copilot": | |
| 668 | + models = registry.GetGitHubCopilotModels() | |
| 665 | 669 | default: |
| 666 | 670 | // Handle OpenAI-compatibility providers by name using config |
| 667 | 671 | if s.cfg != nil { |