History

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.

Ernesto Martínez committed 11 months ago

Commit 3a9ac7e1 parent d2e463912 files · +1563 −1Browse files

Unified · Split

12 files
  1. cmd/server/main.go +5 −0
  2. internal/auth/copilot/copilot_auth.go +301 −0
  3. internal/auth/copilot/errors.go +183 −0
  4. internal/auth/copilot/oauth.go +259 −0
  5. internal/auth/copilot/token.go +93 −0
  6. internal/cmd/auth_manager.go +2 −1
  7. internal/cmd/github_copilot_login.go +44 −0
  8. internal/registry/model_definitions.go +184 −0
  9. internal/runtime/executor/github_copilot_executor.go +354 −0
  10. sdk/auth/github_copilot.go +133 −0
  11. sdk/auth/refresh_registry.go +1 −0
  12. sdk/cliproxy/service.go +4 −0
cmd/server/main.go+5−0
@@ -62,6 +62,7 @@ func main() { · 61 unmodified lines
62 var iflowCookie bool62 var iflowCookie bool
63 var noBrowser bool63 var noBrowser bool
64 var antigravityLogin bool64 var antigravityLogin bool
65 var githubCopilotLogin bool
65 var projectID string66 var projectID string
66 var vertexImport string67 var vertexImport string
67 var configPath string68 var configPath string
@@ -76,6 +77,7 @@ func main() { · 8 unmodified lines
76 flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")77 flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
77 flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")78 flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
78 flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")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 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")81 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
80 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")82 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
81 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")83 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
@@ -436,6 +438,9 @@ func main() { · 354 unmodified lines
436 } else if antigravityLogin {438 } else if antigravityLogin {
437 // Handle Antigravity login439 // Handle Antigravity login
438 cmd.DoAntigravityLogin(cfg, options)440 cmd.DoAntigravityLogin(cfg, options)
441 } else if githubCopilotLogin {
442 // Handle GitHub Copilot login
443 cmd.DoGitHubCopilotLogin(cfg, options)
439 } else if codexLogin {444 } else if codexLogin {
440 // Handle Codex login445 // Handle Codex login
441 cmd.DoCodexLogin(cfg, options)446 cmd.DoCodexLogin(cfg, options)
internal/auth/copilot/copilot_auth.go+301−0
@@ -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.
3package copilot
4
5import (
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
19const (
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.
27type 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.
49type 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.
57func 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.
67func (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.
72func (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.
93func (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.
142func (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.
156func (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.
168func (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.
188func (c *CopilotAuth) GetAPIEndpoint() string {
189 return copilotAPIEndpoint
190}
191
192// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API.
193func (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.
212type CachedAPIToken struct {
213 Token *CopilotAPIToken
214 ExpiresAt time.Time
215}
216
217// IsExpired checks if the cached token has expired.
218func (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.
227type 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.
234func 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.
242func (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.
268func (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.
277func (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.
283func BuildChatCompletionURL() string {
284 return copilotAPIEndpoint + "/chat/completions"
285}
286
287// BuildModelsURL builds the URL for listing available models.
288func BuildModelsURL() string {
289 return copilotAPIEndpoint + "/models"
290}
291
292// ExtractBearerToken extracts the bearer token from an Authorization header.
293func 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}
internal/auth/copilot/errors.go+183−0
@@ -0,0 +1,183 @@
1package copilot
2
3import (
4 "errors"
5 "fmt"
6 "net/http"
7)
8
9// OAuthError represents an OAuth-specific error.
10type 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.
22func (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.
30func 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.
39type 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.
51func (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.
59var (
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.
118func 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.
128func 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.
135func 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.
142func 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}
internal/auth/copilot/oauth.go+259−0
@@ -0,0 +1,259 @@
1package copilot
2
3import (
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
18const (
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.
34type DeviceFlowClient struct {
35 httpClient *http.Client
36 cfg *config.Config
37}
38
39// NewDeviceFlowClient creates a new device flow client.
40func 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.
52func (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.
88func (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.
150func (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.
219func (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}
internal/auth/copilot/token.go+93−0
@@ -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.
4package copilot
5
6import (
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.
18type 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.
34type 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.
44type 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.
52type 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
74func (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}
internal/cmd/auth_manager.go+2−1
@@ -6,7 +6,7 @@ import ( · 5 unmodified lines
6 6
7// newAuthManager creates a new authentication manager instance with all supported7// newAuthManager creates a new authentication manager instance with all supported
8// authenticators and a file-based token store. It initializes authenticators for8// 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// Returns:11// Returns:
12// - *sdkAuth.Manager: A configured authentication manager instance12// - *sdkAuth.Manager: A configured authentication manager instance
@@ -19,6 +19,7 @@ func newAuthManager() *sdkAuth.Manager { · 6 unmodified lines
19 sdkAuth.NewQwenAuthenticator(),19 sdkAuth.NewQwenAuthenticator(),
20 sdkAuth.NewIFlowAuthenticator(),20 sdkAuth.NewIFlowAuthenticator(),
21 sdkAuth.NewAntigravityAuthenticator(),21 sdkAuth.NewAntigravityAuthenticator(),
22 sdkAuth.NewGitHubCopilotAuthenticator(),
22 )23 )
23 return manager24 return manager
24}25}
internal/cmd/github_copilot_login.go+44−0
@@ -0,0 +1,44 @@
1package cmd
2
3import (
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
19func 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}
internal/registry/model_definitions.go+184−0
@@ -984,3 +984,187 @@ func GetIFlowModels() []*ModelInfo { · 983 unmodified lines
984 }984 }
985 return models985 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.
990func 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}
internal/runtime/executor/github_copilot_executor.go+354−0
@@ -0,0 +1,354 @@
1package executor
2
3import (
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
22const (
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.
30type GitHubCopilotExecutor struct {
31 cfg *config.Config
32}
33
34// NewGitHubCopilotExecutor constructs a new executor instance.
35func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
36 return &GitHubCopilotExecutor{cfg: cfg}
37}
38
39// Identifier implements ProviderExecutor.
40func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
41
42// PrepareRequest implements ProviderExecutor.
43func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
44 return nil
45}
46
47// Execute handles non-streaming requests to GitHub Copilot.
48func (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, &param)
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.
131func (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), &param)
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.
247func (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.
253func (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.
275func (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.
315func (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.
328func (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}
sdk/auth/github_copilot.go+133−0
@@ -0,0 +1,133 @@
1package auth
2
3import (
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.
16type GitHubCopilotAuthenticator struct{}
17
18// NewGitHubCopilotAuthenticator constructs a new GitHub Copilot authenticator.
19func NewGitHubCopilotAuthenticator() Authenticator {
20 return &GitHubCopilotAuthenticator{}
21}
22
23// Provider returns the provider key for github-copilot.
24func (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.
30func (GitHubCopilotAuthenticator) RefreshLead() *time.Duration {
31 return nil
32}
33
34// Login initiates the GitHub device flow authentication for Copilot access.
35func (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.
119func 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}
sdk/auth/refresh_registry.go+1−0
@@ -14,6 +14,7 @@ func init() { · 13 unmodified lines
14 registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })14 registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
15 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })15 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
16 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })16 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
17 registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
17}18}
18 19
19func registerRefreshLead(provider string, factory func() Authenticator) {20func registerRefreshLead(provider string, factory func() Authenticator) {
sdk/cliproxy/service.go+4−0
@@ -351,6 +351,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 350 unmodified lines
351 s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))351 s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
352 case "iflow":352 case "iflow":
353 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))353 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
354 case "github-copilot":
355 s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
354 default:356 default:
355 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))357 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
356 if providerKey == "" {358 if providerKey == "" {
@@ -662,6 +664,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 305 unmodified lines
662 models = registry.GetQwenModels()664 models = registry.GetQwenModels()
663 case "iflow":665 case "iflow":
664 models = registry.GetIFlowModels()666 models = registry.GetIFlowModels()
667 case "github-copilot":
668 models = registry.GetGitHubCopilotModels()
665 default:669 default:
666 // Handle OpenAI-compatibility providers by name using config670 // Handle OpenAI-compatibility providers by name using config
667 if s.cfg != nil {671 if s.cfg != nil {