Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func main() {
// Command-line flags to control the application's behavior.
var login bool
var codexLogin bool
var copilotLogin bool
var claudeLogin bool
var qwenLogin bool
var iflowLogin bool
Expand All @@ -70,6 +71,7 @@ func main() {
// Define command-line flags for different operation modes.
flag.BoolVar(&login, "login", false, "Login Google Account")
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
flag.BoolVar(&copilotLogin, "copilot-login", false, "Login to GitHub Copilot using device code flow")
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
Expand Down Expand Up @@ -439,6 +441,9 @@ func main() {
} else if codexLogin {
// Handle Codex login
cmd.DoCodexLogin(cfg, options)
} else if copilotLogin {
// Handle GitHub Copilot login
cmd.DoCopilotLogin(cfg, options)
} else if claudeLogin {
// Handle Claude login
cmd.DoClaudeLogin(cfg, options)
Expand Down
20 changes: 20 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ ws-auth: false
# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)

# GitHub Copilot account configuration
# Note: Copilot uses OAuth device code authentication, NOT API keys or tokens.
# Do NOT paste your GitHub access token or Copilot bearer token here.
# Tokens are stored only in auth-dir JSON files, never in config.yaml.
#
# To authenticate:
# - CLI: run with -copilot-login flag
# - Web: use the /copilot-auth-url management endpoint
#
# After OAuth login, tokens are managed automatically and stored in auth-dir.
# The entries below only configure account type and optional proxy settings.
#copilot-api-key:
# - account-type: "individual" # Options: individual, business, enterprise
# proxy-url: "socks5://proxy.example.com:1080" # optional: proxy for Copilot requests

# # When set to true, this flag forces subsequent requests in a session (sharing the same prompt_cache_key)
# # to send the header "X-Initiator: agent" instead of "vscode". This mirrors VS Code's behavior for
# # long-running agent interactions and helps prevent hitting standard rate limits.
# agent-initiator-persist: true

# Claude API keys
#claude-api-key:
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
Expand Down
116 changes: 116 additions & 0 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
Expand Down Expand Up @@ -2089,6 +2090,121 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
return true, nil
}

// validateCopilotAccountType validates the account_type query parameter.
// If invalid, it writes a 400 error to the context and returns false.
func validateCopilotAccountType(c *gin.Context) (copilot.AccountType, bool) {
accountTypeStr := c.DefaultQuery("account_type", "individual")
validation := copilot.ValidateAccountType(accountTypeStr)
if !validation.Valid {
c.JSON(http.StatusBadRequest, gin.H{
"error": validation.ErrorMessage,
"valid_values": validation.ValidValues,
"default": validation.DefaultValue,
})
return "", false
}
return validation.AccountType, true
}

// startCopilotAuthFlow starts the background polling for Copilot authentication.
func (h *Handler) startCopilotAuthFlow(ctx context.Context, state string, deviceCode *copilot.DeviceCodeResponse, accountType copilot.AccountType) {
go func() {
// Use a timeout based on the device code expiration
pollCtx, cancel := context.WithTimeout(ctx, time.Duration(deviceCode.ExpiresIn)*time.Second)
defer cancel()

copilotAuth := copilot.NewCopilotAuth(h.cfg)
result, authErr := copilotAuth.CompleteAuthWithDeviceCode(pollCtx, deviceCode, accountType)
if authErr != nil {
oauthStatus[state] = fmt.Sprintf("Authentication failed: %v", authErr)
return
}

if result == nil || result.Storage == nil {
oauthStatus[state] = "Authentication failed: no result returned"
return
}

principal := result.Storage.Username
if principal == "" {
principal = result.Storage.Email
}

// Ensure auth directory exists before saving
authDir, ensureErr := util.EnsureAuthDir(h.cfg.AuthDir)
if ensureErr != nil {
oauthStatus[state] = fmt.Sprintf("Failed to prepare auth directory: %v", ensureErr)
return
}

// Save token using the filename from the shared helper
tokenPath := filepath.Join(authDir, result.SuggestedFilename)
if saveErr := result.Storage.SaveTokenToFile(tokenPath); saveErr != nil {
oauthStatus[state] = fmt.Sprintf("Failed to save token: %v", saveErr)
return
}

log.Infof("copilot_auth_success: state=%s principal=%s", state, principal)
delete(oauthStatus, state)
}()
}

// RequestCopilotToken initiates GitHub Copilot device code authentication flow.
// Poll GetAuthStatus with the returned state to check progress. GetAuthStatus returns:
// - status="wait": authentication in progress
// - status="ok": authentication completed successfully
// - status="error": authentication failed (see error)
func (h *Handler) RequestCopilotToken(c *gin.Context) {
ctx := context.Background()

// Validate auth directory before starting auth flow
if h.cfg.AuthDir == "" {
log.Error("Copilot auth failed: auth directory not configured")
c.JSON(http.StatusInternalServerError, gin.H{"error": "auth directory not configured"})
return
}

// Get account type from query param and validate
accountType, ok := validateCopilotAccountType(c)
if !ok {
return
}

state, err := misc.GenerateRandomState()
if err != nil {
log.Errorf("Failed to generate state parameter: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
return
}

// Initialize Copilot auth service
copilotAuth := copilot.NewCopilotAuth(h.cfg)

// Get device code first to return to user immediately
deviceCode, err := copilotAuth.GetDeviceCode(ctx)
if err != nil {
log.Errorf("Failed to get device code: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get device code"})
return
}

// Track this auth flow
oauthStatus[state] = ""

// Start background goroutine to complete auth flow using shared helper
h.startCopilotAuthFlow(ctx, state, deviceCode, accountType)

// Return device code info to user
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"state": state,
"user_code": deviceCode.UserCode,
"verification_uri": deviceCode.VerificationURI,
"expires_in": deviceCode.ExpiresIn,
"interval": deviceCode.Interval,
})
}

func (h *Handler) GetAuthStatus(c *gin.Context) {
state := c.Query("state")
if err, ok := oauthStatus[state]; ok {
Expand Down
105 changes: 105 additions & 0 deletions internal/api/handlers/management/config_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,3 +798,108 @@ func normalizeClaudeKey(entry *config.ClaudeKey) {
}
entry.Models = normalized
}

// GetCopilotKeys returns the current Copilot API key configuration.
func (h *Handler) GetCopilotKeys(c *gin.Context) {
c.JSON(200, gin.H{"copilot-api-key": h.cfg.CopilotKey})
}

// PutCopilotKeys replaces the Copilot API key configuration.
func (h *Handler) PutCopilotKeys(c *gin.Context) {
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
var arr []config.CopilotKey
if err = json.Unmarshal(data, &arr); err != nil {
var obj struct {
Items []config.CopilotKey `json:"items"`
}
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
arr = obj.Items
}
// Normalize entries
filtered := make([]config.CopilotKey, 0, len(arr))
for i := range arr {
entry := arr[i]
entry.AccountType = strings.TrimSpace(entry.AccountType)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
filtered = append(filtered, entry)
}
h.cfg.CopilotKey = filtered
h.cfg.SanitizeCopilotKeys()
h.persist(c)
}

// PatchCopilotKey updates a single Copilot API key entry by index or match.
func (h *Handler) PatchCopilotKey(c *gin.Context) {
var body struct {
Index *int `json:"index"`
Match *string `json:"match"` // Match by account_type
Value *config.CopilotKey `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
value := *body.Value
value.AccountType = strings.TrimSpace(value.AccountType)
value.ProxyURL = strings.TrimSpace(value.ProxyURL)

h.mu.Lock()
defer h.mu.Unlock()

// Update by index
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CopilotKey) {
h.cfg.CopilotKey[*body.Index] = value
h.cfg.SanitizeCopilotKeys()
h.persist(c)
return
}
// Update by matching account_type
if body.Match != nil {
for i := range h.cfg.CopilotKey {
if h.cfg.CopilotKey[i].AccountType == *body.Match {
h.cfg.CopilotKey[i] = value
h.cfg.SanitizeCopilotKeys()
h.persist(c)
return
}
}
}
c.JSON(404, gin.H{"error": "item not found"})
}

// DeleteCopilotKey removes a Copilot API key entry by index or account_type.
func (h *Handler) DeleteCopilotKey(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()

if val := c.Query("account-type"); val != "" {
out := make([]config.CopilotKey, 0, len(h.cfg.CopilotKey))
for _, v := range h.cfg.CopilotKey {
if v.AccountType != val {
out = append(out, v)
}
}
h.cfg.CopilotKey = out
h.cfg.SanitizeCopilotKeys()
h.persist(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.CopilotKey) {
h.cfg.CopilotKey = append(h.cfg.CopilotKey[:idx], h.cfg.CopilotKey[idx+1:]...)
h.cfg.SanitizeCopilotKeys()
h.persist(c)
return
}
}
c.JSON(400, gin.H{"error": "missing or invalid account-type or index query param"})
}
6 changes: 6 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,11 @@ func (s *Server) registerManagementRoutes() {
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)

mgmt.GET("/copilot-api-key", s.mgmt.GetCopilotKeys)
mgmt.PUT("/copilot-api-key", s.mgmt.PutCopilotKeys)
mgmt.PATCH("/copilot-api-key", s.mgmt.PatchCopilotKey)
mgmt.DELETE("/copilot-api-key", s.mgmt.DeleteCopilotKey)

mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
Expand All @@ -556,6 +561,7 @@ func (s *Server) registerManagementRoutes() {

mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
mgmt.GET("/copilot-auth-url", s.mgmt.RequestCopilotToken)
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
Expand Down
Loading