Skip to content

Commit 8e9ba84

Browse files
committed
feat(copilot/executor): add Copilot request executor and model registry
- Implement Copilot executor with header injection - Add VS Code version headers and integration IDs - Add agent header logic (X-Copilot-Initiator detection) - Add vision request header for image inputs - Implement dynamic model fetching and aliasing - Add management API endpoints for auth files - Add Copilot types for API responses - Evict reasoning cache on auth removal to prevent memory leaks
1 parent d167e17 commit 8e9ba84

File tree

9 files changed

+1389
-0
lines changed

9 files changed

+1389
-0
lines changed

cmd/server/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func main() {
5656
// Command-line flags to control the application's behavior.
5757
var login bool
5858
var codexLogin bool
59+
var copilotLogin bool
5960
var claudeLogin bool
6061
var qwenLogin bool
6162
var iflowLogin bool
@@ -70,6 +71,7 @@ func main() {
7071
// Define command-line flags for different operation modes.
7172
flag.BoolVar(&login, "login", false, "Login Google Account")
7273
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
74+
flag.BoolVar(&copilotLogin, "copilot-login", false, "Login to GitHub Copilot using device code flow")
7375
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
7476
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
7577
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
@@ -439,6 +441,9 @@ func main() {
439441
} else if codexLogin {
440442
// Handle Codex login
441443
cmd.DoCodexLogin(cfg, options)
444+
} else if copilotLogin {
445+
// Handle GitHub Copilot login
446+
cmd.DoCopilotLogin(cfg, options)
442447
} else if claudeLogin {
443448
// Handle Claude login
444449
cmd.DoClaudeLogin(cfg, options)

internal/api/handlers/management/auth_files.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/gin-gonic/gin"
2222
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
2323
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
24+
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
2425
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
2526
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
2627
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
@@ -2089,6 +2090,121 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
20892090
return true, nil
20902091
}
20912092

2093+
// validateCopilotAccountType validates the account_type query parameter.
2094+
// If invalid, it writes a 400 error to the context and returns false.
2095+
func validateCopilotAccountType(c *gin.Context) (copilot.AccountType, bool) {
2096+
accountTypeStr := c.DefaultQuery("account_type", "individual")
2097+
validation := copilot.ValidateAccountType(accountTypeStr)
2098+
if !validation.Valid {
2099+
c.JSON(http.StatusBadRequest, gin.H{
2100+
"error": validation.ErrorMessage,
2101+
"valid_values": validation.ValidValues,
2102+
"default": validation.DefaultValue,
2103+
})
2104+
return "", false
2105+
}
2106+
return validation.AccountType, true
2107+
}
2108+
2109+
// startCopilotAuthFlow starts the background polling for Copilot authentication.
2110+
func (h *Handler) startCopilotAuthFlow(ctx context.Context, state string, deviceCode *copilot.DeviceCodeResponse, accountType copilot.AccountType) {
2111+
go func() {
2112+
// Use a timeout based on the device code expiration
2113+
pollCtx, cancel := context.WithTimeout(ctx, time.Duration(deviceCode.ExpiresIn)*time.Second)
2114+
defer cancel()
2115+
2116+
copilotAuth := copilot.NewCopilotAuth(h.cfg)
2117+
result, authErr := copilotAuth.CompleteAuthWithDeviceCode(pollCtx, deviceCode, accountType)
2118+
if authErr != nil {
2119+
oauthStatus[state] = fmt.Sprintf("Authentication failed: %v", authErr)
2120+
return
2121+
}
2122+
2123+
if result == nil || result.Storage == nil {
2124+
oauthStatus[state] = "Authentication failed: no result returned"
2125+
return
2126+
}
2127+
2128+
principal := result.Storage.Username
2129+
if principal == "" {
2130+
principal = result.Storage.Email
2131+
}
2132+
2133+
// Ensure auth directory exists before saving
2134+
authDir, ensureErr := util.EnsureAuthDir(h.cfg.AuthDir)
2135+
if ensureErr != nil {
2136+
oauthStatus[state] = fmt.Sprintf("Failed to prepare auth directory: %v", ensureErr)
2137+
return
2138+
}
2139+
2140+
// Save token using the filename from the shared helper
2141+
tokenPath := filepath.Join(authDir, result.SuggestedFilename)
2142+
if saveErr := result.Storage.SaveTokenToFile(tokenPath); saveErr != nil {
2143+
oauthStatus[state] = fmt.Sprintf("Failed to save token: %v", saveErr)
2144+
return
2145+
}
2146+
2147+
log.Infof("copilot_auth_success: state=%s principal=%s", state, principal)
2148+
delete(oauthStatus, state)
2149+
}()
2150+
}
2151+
2152+
// RequestCopilotToken initiates GitHub Copilot device code authentication flow.
2153+
// Poll GetAuthStatus with the returned state to check progress. GetAuthStatus returns:
2154+
// - status="wait": authentication in progress
2155+
// - status="ok": authentication completed successfully
2156+
// - status="error": authentication failed (see error)
2157+
func (h *Handler) RequestCopilotToken(c *gin.Context) {
2158+
ctx := context.Background()
2159+
2160+
// Validate auth directory before starting auth flow
2161+
if h.cfg.AuthDir == "" {
2162+
log.Error("Copilot auth failed: auth directory not configured")
2163+
c.JSON(http.StatusInternalServerError, gin.H{"error": "auth directory not configured"})
2164+
return
2165+
}
2166+
2167+
// Get account type from query param and validate
2168+
accountType, ok := validateCopilotAccountType(c)
2169+
if !ok {
2170+
return
2171+
}
2172+
2173+
state, err := misc.GenerateRandomState()
2174+
if err != nil {
2175+
log.Errorf("Failed to generate state parameter: %v", err)
2176+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
2177+
return
2178+
}
2179+
2180+
// Initialize Copilot auth service
2181+
copilotAuth := copilot.NewCopilotAuth(h.cfg)
2182+
2183+
// Get device code first to return to user immediately
2184+
deviceCode, err := copilotAuth.GetDeviceCode(ctx)
2185+
if err != nil {
2186+
log.Errorf("Failed to get device code: %v", err)
2187+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get device code"})
2188+
return
2189+
}
2190+
2191+
// Track this auth flow
2192+
oauthStatus[state] = ""
2193+
2194+
// Start background goroutine to complete auth flow using shared helper
2195+
h.startCopilotAuthFlow(ctx, state, deviceCode, accountType)
2196+
2197+
// Return device code info to user
2198+
c.JSON(http.StatusOK, gin.H{
2199+
"status": "ok",
2200+
"state": state,
2201+
"user_code": deviceCode.UserCode,
2202+
"verification_uri": deviceCode.VerificationURI,
2203+
"expires_in": deviceCode.ExpiresIn,
2204+
"interval": deviceCode.Interval,
2205+
})
2206+
}
2207+
20922208
func (h *Handler) GetAuthStatus(c *gin.Context) {
20932209
state := c.Query("state")
20942210
if err, ok := oauthStatus[state]; ok {

internal/api/handlers/management/config_lists.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,3 +798,108 @@ func normalizeClaudeKey(entry *config.ClaudeKey) {
798798
}
799799
entry.Models = normalized
800800
}
801+
802+
// GetCopilotKeys returns the current Copilot API key configuration.
803+
func (h *Handler) GetCopilotKeys(c *gin.Context) {
804+
c.JSON(200, gin.H{"copilot-api-key": h.cfg.CopilotKey})
805+
}
806+
807+
// PutCopilotKeys replaces the Copilot API key configuration.
808+
func (h *Handler) PutCopilotKeys(c *gin.Context) {
809+
data, err := c.GetRawData()
810+
if err != nil {
811+
c.JSON(400, gin.H{"error": "failed to read body"})
812+
return
813+
}
814+
var arr []config.CopilotKey
815+
if err = json.Unmarshal(data, &arr); err != nil {
816+
var obj struct {
817+
Items []config.CopilotKey `json:"items"`
818+
}
819+
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
820+
c.JSON(400, gin.H{"error": "invalid body"})
821+
return
822+
}
823+
arr = obj.Items
824+
}
825+
// Normalize entries
826+
filtered := make([]config.CopilotKey, 0, len(arr))
827+
for i := range arr {
828+
entry := arr[i]
829+
entry.AccountType = strings.TrimSpace(entry.AccountType)
830+
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
831+
filtered = append(filtered, entry)
832+
}
833+
h.cfg.CopilotKey = filtered
834+
h.cfg.SanitizeCopilotKeys()
835+
h.persist(c)
836+
}
837+
838+
// PatchCopilotKey updates a single Copilot API key entry by index or match.
839+
func (h *Handler) PatchCopilotKey(c *gin.Context) {
840+
var body struct {
841+
Index *int `json:"index"`
842+
Match *string `json:"match"` // Match by account_type
843+
Value *config.CopilotKey `json:"value"`
844+
}
845+
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
846+
c.JSON(400, gin.H{"error": "invalid body"})
847+
return
848+
}
849+
value := *body.Value
850+
value.AccountType = strings.TrimSpace(value.AccountType)
851+
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
852+
853+
h.mu.Lock()
854+
defer h.mu.Unlock()
855+
856+
// Update by index
857+
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CopilotKey) {
858+
h.cfg.CopilotKey[*body.Index] = value
859+
h.cfg.SanitizeCopilotKeys()
860+
h.persist(c)
861+
return
862+
}
863+
// Update by matching account_type
864+
if body.Match != nil {
865+
for i := range h.cfg.CopilotKey {
866+
if h.cfg.CopilotKey[i].AccountType == *body.Match {
867+
h.cfg.CopilotKey[i] = value
868+
h.cfg.SanitizeCopilotKeys()
869+
h.persist(c)
870+
return
871+
}
872+
}
873+
}
874+
c.JSON(404, gin.H{"error": "item not found"})
875+
}
876+
877+
// DeleteCopilotKey removes a Copilot API key entry by index or account_type.
878+
func (h *Handler) DeleteCopilotKey(c *gin.Context) {
879+
h.mu.Lock()
880+
defer h.mu.Unlock()
881+
882+
if val := c.Query("account-type"); val != "" {
883+
out := make([]config.CopilotKey, 0, len(h.cfg.CopilotKey))
884+
for _, v := range h.cfg.CopilotKey {
885+
if v.AccountType != val {
886+
out = append(out, v)
887+
}
888+
}
889+
h.cfg.CopilotKey = out
890+
h.cfg.SanitizeCopilotKeys()
891+
h.persist(c)
892+
return
893+
}
894+
if idxStr := c.Query("index"); idxStr != "" {
895+
var idx int
896+
_, err := fmt.Sscanf(idxStr, "%d", &idx)
897+
if err == nil && idx >= 0 && idx < len(h.cfg.CopilotKey) {
898+
h.cfg.CopilotKey = append(h.cfg.CopilotKey[:idx], h.cfg.CopilotKey[idx+1:]...)
899+
h.cfg.SanitizeCopilotKeys()
900+
h.persist(c)
901+
return
902+
}
903+
}
904+
c.JSON(400, gin.H{"error": "missing or invalid account-type or index query param"})
905+
}

internal/api/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,11 @@ func (s *Server) registerManagementRoutes() {
538538
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
539539
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
540540

541+
mgmt.GET("/copilot-api-key", s.mgmt.GetCopilotKeys)
542+
mgmt.PUT("/copilot-api-key", s.mgmt.PutCopilotKeys)
543+
mgmt.PATCH("/copilot-api-key", s.mgmt.PatchCopilotKey)
544+
mgmt.DELETE("/copilot-api-key", s.mgmt.DeleteCopilotKey)
545+
541546
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
542547
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
543548
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
@@ -556,6 +561,7 @@ func (s *Server) registerManagementRoutes() {
556561

557562
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
558563
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
564+
mgmt.GET("/copilot-auth-url", s.mgmt.RequestCopilotToken)
559565
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
560566
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
561567
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)

internal/copilot/types.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package copilot
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// AccountType is the Copilot subscription type.
9+
type AccountType string
10+
11+
const (
12+
AccountTypeIndividual AccountType = "individual"
13+
AccountTypeBusiness AccountType = "business"
14+
AccountTypeEnterprise AccountType = "enterprise"
15+
)
16+
17+
// ValidAccountTypes is the canonical list of valid Copilot account types.
18+
var ValidAccountTypes = []string{string(AccountTypeIndividual), string(AccountTypeBusiness), string(AccountTypeEnterprise)}
19+
20+
const DefaultAccountType = AccountTypeIndividual
21+
22+
// AccountTypeValidationResult contains the result of account type validation.
23+
type AccountTypeValidationResult struct {
24+
AccountType AccountType
25+
Valid bool
26+
ValidValues []string
27+
DefaultValue string
28+
ErrorMessage string
29+
}
30+
31+
// ParseAccountType parses a string into an AccountType.
32+
// Returns the parsed type and whether the input was a valid account type.
33+
// Empty or invalid strings return (AccountTypeIndividual, false).
34+
func ParseAccountType(s string) (AccountType, bool) {
35+
switch strings.ToLower(strings.TrimSpace(s)) {
36+
case "individual":
37+
return AccountTypeIndividual, true
38+
case "business":
39+
return AccountTypeBusiness, true
40+
case "enterprise":
41+
return AccountTypeEnterprise, true
42+
default:
43+
return AccountTypeIndividual, false
44+
}
45+
}
46+
47+
// ValidateAccountType validates an account type string and returns details suitable for API responses.
48+
func ValidateAccountType(s string) AccountTypeValidationResult {
49+
accountType, valid := ParseAccountType(s)
50+
result := AccountTypeValidationResult{
51+
AccountType: accountType,
52+
Valid: valid,
53+
ValidValues: ValidAccountTypes,
54+
DefaultValue: string(DefaultAccountType),
55+
}
56+
if !valid && s != "" {
57+
result.ErrorMessage = fmt.Sprintf("invalid account_type '%s', valid values are: %s", s, strings.Join(ValidAccountTypes, ", "))
58+
}
59+
return result
60+
}

0 commit comments

Comments
 (0)