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
6262 var iflowCookie bool
6363 var noBrowser bool
6464 var antigravityLogin bool
65+ var githubCopilotLogin bool
6566 var projectID string
6667 var vertexImport string
6768 var configPath string
@@ -76,6 +77,7 @@ func main() { · 8 unmodified lines
7677 flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
7778 flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
7879 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")
7981 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
8082 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
8183 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
@@ -436,6 +438,9 @@ func main() { · 354 unmodified lines
436438 } else if antigravityLogin {
437439 // Handle Antigravity login
438440 cmd.DoAntigravityLogin(cfg, options)
441+ } else if githubCopilotLogin {
442+ // Handle GitHub Copilot login
443+ cmd.DoGitHubCopilotLogin(cfg, options)
439444 } else if codexLogin {
440445 // Handle Codex login
441446 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.
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+}
internal/auth/copilot/errors.go+183−0
@@ -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+}
internal/auth/copilot/oauth.go+259−0
@@ -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+}
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.
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+}
internal/cmd/auth_manager.go+2−1
@@ -6,7 +6,7 @@ import ( · 5 unmodified lines
66
77 // newAuthManager creates a new authentication manager instance with all supported
88 // 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.
1010 //
1111 // Returns:
1212 // - *sdkAuth.Manager: A configured authentication manager instance
@@ -19,6 +19,7 @@ func newAuthManager() *sdkAuth.Manager { · 6 unmodified lines
1919 sdkAuth.NewQwenAuthenticator(),
2020 sdkAuth.NewIFlowAuthenticator(),
2121 sdkAuth.NewAntigravityAuthenticator(),
22+ sdkAuth.NewGitHubCopilotAuthenticator(),
2223 )
2324 return manager
2425 }
internal/cmd/github_copilot_login.go+44−0
@@ -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+}
internal/registry/model_definitions.go+184−0
@@ -984,3 +984,187 @@ func GetIFlowModels() []*ModelInfo { · 983 unmodified lines
984984 }
985985 return models
986986 }
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+}
internal/runtime/executor/github_copilot_executor.go+354−0
@@ -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, &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.
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), &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.
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+}
sdk/auth/github_copilot.go+133−0
@@ -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+}
sdk/auth/refresh_registry.go+1−0
@@ -14,6 +14,7 @@ func init() { · 13 unmodified lines
1414 registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
1515 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
1616 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
17+ registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
1718 }
1819
1920 func 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
351351 s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
352352 case "iflow":
353353 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
354+ case "github-copilot":
355+ s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
354356 default:
355357 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
356358 if providerKey == "" {
@@ -662,6 +664,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 305 unmodified lines
662664 models = registry.GetQwenModels()
663665 case "iflow":
664666 models = registry.GetIFlowModels()
667+ case "github-copilot":
668+ models = registry.GetGitHubCopilotModels()
665669 default:
666670 // Handle OpenAI-compatibility providers by name using config
667671 if s.cfg != nil {