From af3de6046920379429e3b3139d184f51c6c7a52e Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 15:34:21 +0200 Subject: [PATCH 1/8] Improve GitHub Copilot authentication flow Implement a simplified 4-step authentication flow: 1. Check if Copilot is enabled in config 2. Check for token in config folder 3. If no token, trigger login flow 4. With token ready, open OpenCode normally Key improvements: - Cleaner code structure with helper functions - Better error handling for auth scenarios - Support for both hosts.json and apps.json formats - Proper API version headers for GitHub Copilot - Clear documentation of client ID usage --- internal/config/config.go | 136 +++++- internal/llm/provider/copilot.go | 786 ++++++++++++++++++++++++++++--- 2 files changed, 849 insertions(+), 73 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 630fac9b..8464d781 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -496,6 +496,35 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { provider := model.Provider providerCfg, providerExists := cfg.Providers[provider] + // Special handling for Copilot provider + if provider == models.ProviderCopilot { + logging.Debug("Validating Copilot provider", "exists", providerExists) + + // If provider doesn't exist in config, add it + if !providerExists { + cfg.Providers[provider] = Provider{ + APIKey: "", // We'll use device flow for authentication + } + logging.Info("Added Copilot provider to config") + } else if providerCfg.Disabled { + // Provider explicitly disabled + logging.Warn("Copilot provider is disabled but model requires it", + "agent", name, + "model", agent.Model) + + // Set default model based on available providers + if setDefaultModelForAgent(name) { + logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) + } else { + return fmt.Errorf("no valid provider available for agent %s", name) + } + } + + // Continue with validation - Copilot provider is considered valid even without API key + return nil + } + + // For all other providers if !providerExists { // Provider not configured, check if we have environment variables apiKey := getProviderAPIKey(provider) @@ -611,17 +640,34 @@ func Validate() error { return fmt.Errorf("config not loaded") } + logging.Debug("Starting configuration validation") + + // Special handling for Copilot provider - don't require API key + // Since we'll use device code flow + for provider, providerCfg := range cfg.Providers { + if provider == models.ProviderCopilot && !providerCfg.Disabled { + logging.Debug("Found Copilot provider in config", "disabled", providerCfg.Disabled) + // For Copilot, we'll allow empty API key and handle auth via device flow + if providerCfg.APIKey == "" { + logging.Info("Copilot provider has no API key, will use device flow authentication") + } + } + } + // Validate agent models for name, agent := range cfg.Agents { + logging.Debug("Validating agent", "name", name, "model", agent.Model) if err := validateAgent(cfg, name, agent); err != nil { + logging.Error("Agent validation failed", "name", name, "error", err) return err } } // Validate providers for provider, providerCfg := range cfg.Providers { - if providerCfg.APIKey == "" && !providerCfg.Disabled { - fmt.Printf("provider has no API key, marking as disabled %s", provider) + // Special case for Copilot - we allow it to have no API key + if provider != models.ProviderCopilot && providerCfg.APIKey == "" && !providerCfg.Disabled { + fmt.Printf("provider has no API key, marking as disabled %s\n", provider) logging.Warn("provider has no API key, marking as disabled", "provider", provider) providerCfg.Disabled = true cfg.Providers[provider] = providerCfg @@ -637,6 +683,7 @@ func Validate() error { } } + logging.Debug("Configuration validation completed successfully") return nil } @@ -868,6 +915,13 @@ func Get() *Config { return cfg } +// SetNonInteractive sets the non-interactive flag in the global viper config +// This helps components detect if they're running in non-interactive mode +func SetNonInteractive(val bool) { + viper.Set("non_interactive", val) + logging.Debug("Set non_interactive mode", "value", val) +} + // WorkingDirectory returns the current working directory from the configuration. func WorkingDirectory() string { if cfg == nil { @@ -929,10 +983,40 @@ func UpdateTheme(themeName string) error { }) } -// Tries to load Github token from all possible locations +// LoadGitHubToken loads GitHub token from config files, environment variables, or other sources +// Returns the token if found, or a special error "no_copilot_token" if no token is found +// This follows the 4-step flow: 1. Check if Copilot is enabled, 2. Check for token in config folder func LoadGitHubToken() (string, error) { - // First check environment variable + logging.Debug("LoadGitHubToken: Attempting to load GitHub token") + + // First check environment variable (maintained for compatibility) if token := os.Getenv("GITHUB_TOKEN"); token != "" { + prefixLen := 10 + if len(token) < prefixLen { + prefixLen = len(token) + } + logging.Debug("LoadGitHubToken: Found token in GITHUB_TOKEN environment variable", "token_length", len(token), "token_prefix", token[:prefixLen]) + return token, nil + } + + logging.Debug("LoadGitHubToken: No token found in GITHUB_TOKEN environment variable") + + // Also check Copilot-specific environment variables + if token := os.Getenv("GITHUB_COPILOT_TOKEN"); token != "" { + prefixLen := 10 + if len(token) < prefixLen { + prefixLen = len(token) + } + logging.Debug("LoadGitHubToken: Found token in GITHUB_COPILOT_TOKEN environment variable", "token_length", len(token), "token_prefix", token[:prefixLen]) + return token, nil + } + + if token := os.Getenv("GH_COPILOT_TOKEN"); token != "" { + prefixLen := 10 + if len(token) < prefixLen { + prefixLen = len(token) + } + logging.Debug("LoadGitHubToken: Found token in GH_COPILOT_TOKEN environment variable", "token_length", len(token), "token_prefix", token[:prefixLen]) return token, nil } @@ -940,41 +1024,79 @@ func LoadGitHubToken() (string, error) { var configDir string if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { configDir = xdgConfig + logging.Debug("LoadGitHubToken: Using XDG_CONFIG_HOME for config directory", "directory", xdgConfig) } else if runtime.GOOS == "windows" { if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { configDir = localAppData + logging.Debug("LoadGitHubToken: Using LOCALAPPDATA for config directory", "directory", localAppData) } else { configDir = filepath.Join(os.Getenv("HOME"), "AppData", "Local") + logging.Debug("LoadGitHubToken: Using HOME/AppData/Local for config directory", "directory", configDir) } } else { configDir = filepath.Join(os.Getenv("HOME"), ".config") + logging.Debug("LoadGitHubToken: Using HOME/.config for config directory", "directory", configDir) } - // Try both hosts.json and apps.json files + // Primary path: Check standard Copilot config files first (hosts.json and apps.json) filePaths := []string{ filepath.Join(configDir, "github-copilot", "hosts.json"), filepath.Join(configDir, "github-copilot", "apps.json"), } + logging.Debug("LoadGitHubToken: Checking Copilot config files", "paths", filePaths) + for _, filePath := range filePaths { + logging.Debug("LoadGitHubToken: Attempting to read file", "path", filePath) + data, err := os.ReadFile(filePath) if err != nil { + logging.Debug("LoadGitHubToken: Failed to read file", "path", filePath, "error", err) continue } + + logging.Debug("LoadGitHubToken: Successfully read file", "path", filePath, "size_bytes", len(data)) var config map[string]map[string]interface{} if err := json.Unmarshal(data, &config); err != nil { + logging.Debug("LoadGitHubToken: Failed to unmarshal JSON", "path", filePath, "error", err) continue } + + logging.Debug("LoadGitHubToken: Successfully unmarshalled JSON", "path", filePath, "keys_count", len(config)) + // For hosts.json, we expect keys like "github.com" + // For apps.json, we expect keys like "github.com:Iv1.b507a08c87ecfe98" for key, value := range config { if strings.Contains(key, "github.com") { - if oauthToken, ok := value["oauth_token"].(string); ok { + logging.Debug("LoadGitHubToken: Found github.com entry", "key", key) + + if oauthToken, ok := value["oauth_token"].(string); ok && oauthToken != "" { + prefixLen := 10 + if len(oauthToken) < prefixLen { + prefixLen = len(oauthToken) + } + logging.Debug("LoadGitHubToken: Found OAuth token in config file", "path", filePath, "key", key, "token_length", len(oauthToken), "token_prefix", oauthToken[:prefixLen]) return oauthToken, nil + } else { + logging.Debug("LoadGitHubToken: No oauth_token found in entry or empty token", "key", key, "available_keys", getMapKeys(value)) } } } + + logging.Debug("LoadGitHubToken: No GitHub token found in config file", "path", filePath) } - return "", fmt.Errorf("GitHub token not found in standard locations") + // Return a special error that indicates we need to use device code flow + logging.Debug("LoadGitHubToken: No Copilot token found - use device code flow") + return "", fmt.Errorf("no_copilot_token") +} + +// Helper function to get map keys as a string slice for debugging +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys } diff --git a/internal/llm/provider/copilot.go b/internal/llm/provider/copilot.go index 5d70e718..047e4ff5 100644 --- a/internal/llm/provider/copilot.go +++ b/internal/llm/provider/copilot.go @@ -7,7 +7,11 @@ import ( "fmt" "io" "net/http" + "net/url" "os" + "path/filepath" + "runtime" + "strings" "time" "github.com/openai/openai-go" @@ -18,6 +22,7 @@ import ( toolsPkg "github.com/opencode-ai/opencode/internal/llm/tools" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" + "github.com/spf13/viper" ) type copilotOptions struct { @@ -52,38 +57,478 @@ func (c *copilotClient) isAnthropicModel() bool { return false } -// loadGitHubToken loads the GitHub OAuth token from the standard GitHub CLI/Copilot locations +func (c *copilotClient) getGitHubTokenScopes(githubToken string) (string, error) { + req, err := http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + return "", fmt.Errorf("failed to create GitHub API request: %w", err) + } + + req.Header.Set("Authorization", "Token "+githubToken) + req.Header.Set("User-Agent", "OpenCode/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to query GitHub API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("GitHub API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + scopes := resp.Header.Get("X-OAuth-Scopes") + return scopes, nil +} + +// GitHub OAuth device flow response +type GitHubDeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// GitHub OAuth token response +type GitHubTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` +} // exchangeGitHubToken exchanges a GitHub token for a Copilot bearer token +// If the token is provided, it will try to use it directly +// Otherwise, it will start the GitHub device code flow func (c *copilotClient) exchangeGitHubToken(githubToken string) (string, error) { - req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil) + // If a token is provided, try to use it directly + if githubToken != "" { + prefixLen := 10 + if len(githubToken) < prefixLen { + prefixLen = len(githubToken) + } + logging.Debug("Exchanging GitHub token", "token_length", len(githubToken), "token_prefix", githubToken[:prefixLen]) + + // Check GitHub token scopes first to verify it's a valid token + scopes, err := c.getGitHubTokenScopes(githubToken) + if err != nil { + logging.Error("Failed to get GitHub token scopes", "error", err) + // If we can't verify token scopes, just continue - the token exchange will fail if invalid + } else { + logging.Debug("GitHub token scopes", "scopes", scopes) + if !strings.Contains(scopes, "copilot") { + logging.Warn("GitHub token does not have copilot scope - token exchange may fail") + } + } + + // Attempt to exchange for a Copilot bearer token - match VS Code exactly + req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil) + if err != nil { + return "", fmt.Errorf("failed to create token exchange request: %w", err) + } + + req.Header.Set("Authorization", "token "+githubToken) // Note: "token" not "Token" + req.Header.Set("User-Agent", "GithubCopilot/1.133.0") + req.Header.Set("Accept", "application/json") + + logging.Debug("Sending token exchange request to GitHub API") + resp, err := c.httpClient.Do(req) + if err != nil { + logging.Error("Failed HTTP request for token exchange", "error", err) + // If we're not in non-interactive mode, try device flow + if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { + logging.Info("Token exchange HTTP request failed, falling back to device code flow") + return c.performDeviceCodeFlow() + } + return "", fmt.Errorf("failed to exchange GitHub token: %w", err) + } + defer resp.Body.Close() + + logging.Debug("Token exchange response received", "status", resp.StatusCode, "headers", resp.Header) + + // Check for HTTP errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + logging.Error("Token exchange failed", "status_code", resp.StatusCode, "body", string(body)) + + // If we're not in non-interactive mode, try device flow + if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { + logging.Info("Token exchange failed, falling back to device code flow") + return c.performDeviceCodeFlow() + } + return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Success! Read the response + body, err := io.ReadAll(resp.Body) + if err != nil { + logging.Error("Failed to read token response body", "error", err) + return "", fmt.Errorf("failed to read token response: %w", err) + } + + logging.Debug("Token exchange response body received, length", "bytes", len(body)) + + var tokenResp CopilotTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + logging.Error("Failed to decode token response", "error", err) + return "", fmt.Errorf("failed to decode token response: %w", err) + } + + if tokenResp.Token == "" { + logging.Error("Received empty token from GitHub API") + + // If we're not in non-interactive mode, try device flow + if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { + logging.Info("Received empty token, falling back to device code flow") + return c.performDeviceCodeFlow() + } + return "", fmt.Errorf("received empty token from GitHub API") + } + + prefixLen = 10 + if len(tokenResp.Token) < prefixLen { + prefixLen = len(tokenResp.Token) + } + logging.Debug("Successfully obtained Copilot bearer token", + "token_prefix", tokenResp.Token[:prefixLen], + "expires_at", tokenResp.ExpiresAt) + + // Try saving the token for future use + saveGitHubToken(githubToken) + + return tokenResp.Token, nil + } else { + // No token provided, use device code flow if we're not in non-interactive mode + if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { + logging.Info("No GitHub token provided, starting device code flow") + return c.performDeviceCodeFlow() + } + return "", fmt.Errorf("no GitHub token available and running in non-interactive mode") + } +} + +// saveGitHubToken saves the GitHub token to the standard location for future use +func saveGitHubToken(token string) { + // Only save if we have a token + if token == "" { + return + } + + // Get the config directory + var configDir string + if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { + configDir = xdgConfig + } else if runtime.GOOS == "windows" { + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + configDir = localAppData + } else { + configDir = filepath.Join(os.Getenv("HOME"), "AppData", "Local") + } + } else { + configDir = filepath.Join(os.Getenv("HOME"), ".config") + } + + // Create the directory if it doesn't exist + copilotDir := filepath.Join(configDir, "github-copilot") + if err := os.MkdirAll(copilotDir, 0755); err != nil { + logging.Error("Failed to create github-copilot directory", "error", err) + return + } + + // Create the hosts.json file + hostsFile := filepath.Join(copilotDir, "hosts.json") + + // Create the JSON structure + hostsData := map[string]map[string]interface{}{ + "github.com": { + "oauth_token": token, + }, + } + + // Marshal to JSON + jsonData, err := json.MarshalIndent(hostsData, "", " ") if err != nil { - return "", fmt.Errorf("failed to create token exchange request: %w", err) + logging.Error("Failed to marshal hosts.json", "error", err) + return + } + + // Write the file + if err := os.WriteFile(hostsFile, jsonData, 0600); err != nil { + logging.Error("Failed to write hosts.json", "error", err) + return } + + logging.Info("Saved GitHub token to hosts.json for future use", "path", hostsFile) +} - req.Header.Set("Authorization", "Token "+githubToken) +// performDeviceCodeFlow initiates the GitHub device code flow and returns a Copilot bearer token +func (c *copilotClient) performDeviceCodeFlow() (string, error) { + // Step 1: Get a device code + data := url.Values{} + + // Use the official GitHub Copilot client ID + // This is used by multiple Copilot integrations including Neovim + // The client ID is publicly visible in VS Code and Neovim plugins + const copilotClientID = "Iv1.b507a08c87ecfe98" + data.Set("client_id", copilotClientID) + data.Set("scope", "user:email read:user copilot") + + fmt.Printf("šŸ” Using GitHub Copilot client ID: %s\n", copilotClientID) + fmt.Printf("šŸ” Requesting device code for scopes: user:email read:user copilot\n") + + // Using the exact URL and headers from VS Code Copilot extension + req, err := http.NewRequest("POST", "https://github.com/login/device/code", strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("failed to create device code request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "OpenCode/1.0") + req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { - return "", fmt.Errorf("failed to exchange GitHub token: %w", err) + return "", fmt.Errorf("failed to get device code: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("device code request failed with status %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read device code response: %w", err) } - var tokenResp CopilotTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", fmt.Errorf("failed to decode token response: %w", err) + var deviceResp GitHubDeviceCodeResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return "", fmt.Errorf("failed to parse device code response: %w", err) } - return tokenResp.Token, nil + // Step 2: Print instructions for the user + fmt.Printf("\nšŸ”‘ GitHub Copilot Authentication Required\n\n") + fmt.Printf("1. Visit: %s\n", deviceResp.VerificationURI) + fmt.Printf("2. Enter code: %s\n\n", deviceResp.UserCode) + fmt.Printf("Waiting for authentication... (expires in %d seconds)\n", deviceResp.ExpiresIn) + fmt.Printf("Please complete authentication in your browser to continue.\n\n") + + // Step 3: Poll for the token + tokenData := url.Values{} + tokenData.Set("client_id", copilotClientID) // Use the same client ID as before + tokenData.Set("device_code", deviceResp.DeviceCode) + tokenData.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + // Add a slight delay before first poll + time.Sleep(2 * time.Second) + + // Create a context with timeout based on expires_in + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(deviceResp.ExpiresIn)*time.Second) + defer cancel() + + interval := deviceResp.Interval + if interval < 5 { + interval = 5 // Ensure minimum polling interval + } + + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + fmt.Printf("ā³ Waiting for you to authorize the device...\n") + pollAttempts := 0 + + for { + select { + case <-ticker.C: + pollAttempts++ + fmt.Printf("šŸ”„ Checking authorization status... (attempt %d)\n", pollAttempts) + + // Make a request to check if the user has authorized + tokenReq, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", + strings.NewReader(tokenData.Encode())) + if err != nil { + fmt.Printf("āŒ Error creating token request: %v\n", err) + return "", fmt.Errorf("failed to create token request: %w", err) + } + + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenReq.Header.Set("User-Agent", "OpenCode/1.0") + tokenReq.Header.Set("Accept", "application/json") + + tokenResp, err := c.httpClient.Do(tokenReq) + if err != nil { + fmt.Printf("āŒ Error making token request: %v\n", err) + return "", fmt.Errorf("failed token request: %w", err) + } + + fmt.Printf("šŸ“Š Token poll response status: %d\n", tokenResp.StatusCode) + + tokenRespBody, err := io.ReadAll(tokenResp.Body) + tokenResp.Body.Close() + if err != nil { + fmt.Printf("āŒ Error reading token response: %v\n", err) + return "", fmt.Errorf("failed to read token response: %w", err) + } + + if tokenResp.StatusCode == http.StatusOK { + fmt.Printf("āœ… Received response from GitHub. Processing...\n") + fmt.Printf("šŸ“ƒ Raw response: %s\n", string(tokenRespBody)) + + // Check if we're getting an error response even with 200 status + var errorCheck map[string]string + if json.Unmarshal(tokenRespBody, &errorCheck) == nil { + if errorVal, ok := errorCheck["error"]; ok { + fmt.Printf("āš ļø Received error with 200 status: %s\n", errorVal) + if errorVal == "authorization_pending" { + fmt.Printf("ā³ Still waiting for authorization in browser...\n") + continue + } + } + } + + var tokenData GitHubTokenResponse + if err := json.Unmarshal(tokenRespBody, &tokenData); err != nil { + fmt.Printf("āŒ Error parsing token response: %v\n", err) + return "", fmt.Errorf("failed to parse token response: %w", err) + } + + fmt.Printf("āœ… Token data: %+v\n", tokenData) + + if tokenData.AccessToken != "" { + fmt.Printf("āœ… Successfully authenticated with GitHub!\n") + fmt.Printf("āœ… Token received and stored for future use\n") + fmt.Printf("āœ… Now exchanging for Copilot bearer token...\n") + + // Save the token for future use + saveGitHubToken(tokenData.AccessToken) + + // Set environment variable for immediate use in this session + os.Setenv("GITHUB_COPILOT_TOKEN", tokenData.AccessToken) + logging.Info("Saved GitHub token and set environment variable for immediate use") + + // Direct exchange - don't call exchangeGitHubToken to avoid potential loop + logging.Debug("Performing direct token exchange for GitHub token") + + // Create the request to exchange for a Copilot bearer token - use internal API + req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil) + if err != nil { + fmt.Printf("āŒ Error creating exchange request: %v\n", err) + return "", fmt.Errorf("failed to create exchange request: %w", err) + } + + req.Header.Set("Authorization", "token "+tokenData.AccessToken) // lowercase "token" + req.Header.Set("User-Agent", "GithubCopilot/1.133.0") + req.Header.Set("Accept", "application/json") + + fmt.Printf("šŸ”„ Requesting Copilot token from GitHub API...\n") + resp, err := c.httpClient.Do(req) + if err != nil { + fmt.Printf("āŒ Exchange request failed: %v\n", err) + return "", fmt.Errorf("failed exchange request: %w", err) + } + defer resp.Body.Close() + + fmt.Printf("šŸ“Š Exchange response status: %d\n", resp.StatusCode) + + // Check for successful response + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("āŒ Token exchange failed: %s\n", string(body)) + return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("āŒ Failed to read exchange response: %v\n", err) + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse the token response + var tokenResp CopilotTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + fmt.Printf("āŒ Failed to parse exchange response: %v\n", err) + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if tokenResp.Token == "" { + fmt.Printf("āŒ Received empty token from GitHub API\n") + return "", fmt.Errorf("received empty token from GitHub API") + } + + // Store the token for future use + c.options.bearerToken = tokenResp.Token + + // Create a new OpenAI client specifically for Copilot with the bearer token + baseURL := "https://api.githubcopilot.com" + newClient := openai.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey(tokenResp.Token), + option.WithHeader("Editor-Version", "OpenCode/1.0"), + option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"), + option.WithHeader("Copilot-Integration-Id", "vscode-chat"), + option.WithHeader("X-GitHub-Api-Version", "2022-11-28"), + ) + + // Replace the client in the current instance + c.client = newClient + + fmt.Printf("āœ… Successfully exchanged token for Copilot bearer token!\n") + fmt.Printf("āœ… Created new OpenAI client for GitHub Copilot\n") + fmt.Printf("āœ… You can now use OpenCode with GitHub Copilot\n") + return tokenResp.Token, nil + } + } else if tokenResp.StatusCode == http.StatusBadRequest { + // If it's still pending, continue polling + var errorResp map[string]string + if err := json.Unmarshal(tokenRespBody, &errorResp); err == nil { + if errorResp["error"] == "authorization_pending" { + // This is normal, just wait for next poll + fmt.Printf("ā³ Authorization pending - waiting for you to approve in the browser...\n") + continue + } else if errorResp["error"] == "slow_down" { + // Need to slow down polling + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + fmt.Printf("āš ļø GitHub asked us to slow down polling. Increased interval to %d seconds.\n", interval) + continue + } else if errorResp["error"] == "expired_token" { + fmt.Printf("āŒ Device code expired. Please try again.\n") + return "", fmt.Errorf("device code expired, please try again") + } else { + // Unknown error + fmt.Printf("ā“ Unknown error from GitHub: %s\n", errorResp["error"]) + fmt.Printf("ā“ Error details: %s\n", string(tokenRespBody)) + } + } else { + // Error parsing JSON + fmt.Printf("āŒ Error parsing response: %v\n", err) + fmt.Printf("āŒ Raw response: %s\n", string(tokenRespBody)) + } + return "", fmt.Errorf("token request failed with status %d: %s", + tokenResp.StatusCode, string(tokenRespBody)) + } else { + return "", fmt.Errorf("token request failed with status %d: %s", + tokenResp.StatusCode, string(tokenRespBody)) + } + + case <-ctx.Done(): + return "", fmt.Errorf("authentication timed out after %d seconds", deviceResp.ExpiresIn) + } + } } +// newCopilotClient creates a new client for GitHub Copilot +// Following the 4-step flow: +// 1. Check if Copilot is enabled in config (handled by validation) +// 2. Check for token in config folder +// 3. If no token, trigger login flow +// 4. With token ready, open OpenCode normally func newCopilotClient(opts providerClientOptions) CopilotClient { + logging.Debug("Creating new Copilot client", "model", opts.model) + fmt.Printf("šŸ”§ Creating new GitHub Copilot client for model: %s\n", opts.model.ID) + copilotOpts := copilotOptions{ reasoningEffort: "medium", } @@ -99,88 +544,207 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { var bearerToken string + // Step 1: Check if Copilot is enabled in config - already done by validation + + // Step 2: Check for token in config folder + var githubToken string + var useDeviceFlow bool + + logging.Info("Looking for GitHub Copilot token") + fmt.Printf("šŸ” Looking for GitHub Copilot authentication token...\n") + // If bearer token is already provided, use it if copilotOpts.bearerToken != "" { + logging.Debug("Using provided bearer token") + fmt.Printf("āœ… Using provided bearer token\n") bearerToken = copilotOpts.bearerToken } else { - // Try to get GitHub token from multiple sources - var githubToken string - - // 1. Environment variable - githubToken = os.Getenv("GITHUB_TOKEN") - - // 2. API key from options - if githubToken == "" { - githubToken = opts.apiKey - } - - // 3. Standard GitHub CLI/Copilot locations - if githubToken == "" { - var err error - githubToken, err = config.LoadGitHubToken() - if err != nil { - logging.Debug("Failed to load GitHub token from standard locations", "error", err) + // Check for GitHub token in standard locations + var err error + logging.Debug("Checking for GitHub token in standard locations") + githubToken, err = config.LoadGitHubToken() + + if err != nil { + if err.Error() == "no_copilot_token" { + // Special error indicating we need device flow + useDeviceFlow = true + logging.Info("No Copilot token found in config. Need to use device flow.") + fmt.Printf("ā„¹ļø No GitHub Copilot token found in config\n") + } else { + logging.Error("Failed to load GitHub token", "error", err) + fmt.Printf("āŒ Error loading GitHub token: %v\n", err) } + } else if githubToken != "" { + prefixLen := 10 + if len(githubToken) < prefixLen { + prefixLen = len(githubToken) + } + logging.Debug("Found GitHub token in config", "token_length", len(githubToken), "token_prefix", githubToken[:prefixLen]) + fmt.Printf("āœ… Found GitHub token in config\n") + } else { + logging.Debug("GitHub token not found in config") + fmt.Printf("ā„¹ļø No GitHub token found in config\n") + useDeviceFlow = true } - - if githubToken == "" { - logging.Error("GitHub token is required for Copilot provider. Set GITHUB_TOKEN environment variable, configure it in opencode.json, or ensure GitHub CLI/Copilot is properly authenticated.") - return &copilotClient{ + + // Step 3: If no token, trigger login flow + nonInteractiveFlag := viper.GetBool("non_interactive") + cliNonInteractive := viper.GetString("prompt") != "" + + if useDeviceFlow && !nonInteractiveFlag && !cliNonInteractive { + logging.Info("Starting GitHub Copilot authentication flow") + fmt.Printf("šŸ”‘ Starting GitHub Copilot authentication flow\n") + + // Create temporary client for auth flow + tempClient := &copilotClient{ providerOptions: opts, options: copilotOpts, httpClient: httpClient, } + + // Use device code flow to get token + var err error + githubToken, err = tempClient.performDeviceCodeFlow() + if err != nil { + logging.Error("Device code authentication failed", "error", err) + fmt.Printf("āŒ Authentication failed: %v\n", err) + + // Return dummy client so app doesn't crash + return createDummyClient(opts, copilotOpts, httpClient) + } + } else if useDeviceFlow { + // Can't do auth flow in non-interactive mode + logging.Error("Authentication required but running in non-interactive mode") + fmt.Printf("āŒ Authentication required but running in non-interactive mode\n") + fmt.Printf("āš ļø Run OpenCode in interactive mode first to authenticate with Copilot\n") + + // Return dummy client + return createDummyClient(opts, copilotOpts, httpClient) } - - // Create a temporary client for token exchange - tempClient := &copilotClient{ - providerOptions: opts, - options: copilotOpts, - httpClient: httpClient, - } - - // Exchange GitHub token for bearer token - var err error - bearerToken, err = tempClient.exchangeGitHubToken(githubToken) - if err != nil { - logging.Error("Failed to exchange GitHub token for Copilot bearer token", "error", err) - return &copilotClient{ + + // If we have a GitHub token but no bearer token, exchange for bearer token + if githubToken != "" { + // Create temporary client for token exchange + tempClient := &copilotClient{ providerOptions: opts, options: copilotOpts, httpClient: httpClient, } + + // Exchange GitHub token for bearer token + var err error + logging.Debug("Exchanging GitHub token for Copilot bearer token") + fmt.Printf("šŸ”„ Exchanging GitHub token for Copilot bearer token...\n") + + bearerToken, err = tempClient.exchangeGitHubToken(githubToken) + if err != nil { + logging.Error("Failed to exchange GitHub token", "error", err) + fmt.Printf("āŒ Failed to exchange GitHub token: %v\n", err) + + // Return dummy client + return createDummyClient(opts, copilotOpts, httpClient) + } + } else { + // No GitHub token and can't trigger auth flow + logging.Error("No GitHub token available and cannot trigger authentication") + fmt.Printf("āŒ No GitHub token available and cannot trigger authentication\n") + + // Return dummy client + return createDummyClient(opts, copilotOpts, httpClient) } } copilotOpts.bearerToken = bearerToken + // Step 4: With token ready, create client and proceed with normal operation + return createCopilotClient(opts, copilotOpts, httpClient, bearerToken) +} + +// createDummyClient creates a placeholder client when authentication fails +func createDummyClient(opts providerClientOptions, options copilotOptions, httpClient *http.Client) *copilotClient { + logging.Debug("Creating dummy Copilot client due to authentication issues") + fmt.Printf("ā„¹ļø Creating temporary client due to authentication issues\n") + + dummyClient := openai.NewClient( + option.WithBaseURL("https://api.githubcopilot.com"), + option.WithAPIKey("dummy-for-initialization"), + ) + + return &copilotClient{ + providerOptions: opts, + options: options, + client: dummyClient, + httpClient: httpClient, + } +} + +// createCopilotClient creates a fully configured client for GitHub Copilot +func createCopilotClient(opts providerClientOptions, options copilotOptions, httpClient *http.Client, bearerToken string) *copilotClient { // GitHub Copilot API base URL baseURL := "https://api.githubcopilot.com" + customURL := viper.GetString("providers.copilot.baseUrl") + if customURL != "" { + logging.Debug("Using custom baseUrl for Copilot from config", "baseUrl", customURL) + fmt.Printf("🌐 Using custom baseUrl for Copilot: %s\n", customURL) + baseURL = customURL + } + + // Make sure baseURL is set + if baseURL == "" { + logging.Error("Missing baseURL for Copilot client") + fmt.Printf("āŒ Missing baseURL for Copilot client\n") + return createDummyClient(opts, options, httpClient) + } + + // If no bearer token, return dummy client + if bearerToken == "" { + logging.Error("No bearer token available for Copilot client") + return createDummyClient(opts, options, httpClient) + } + // Create the proper client with all required options and headers + prefixLen := 10 + if len(bearerToken) < prefixLen { + prefixLen = len(bearerToken) + } + logging.Debug("Creating Copilot client with valid bearer token", + "baseURL", baseURL, + "model", opts.model.APIModel, + "token_length", len(bearerToken), + "bearerToken_prefix", bearerToken[:prefixLen]) + fmt.Printf("āœ… Creating Copilot client with valid bearer token\n") + + // Create OpenAI client with all required settings for Copilot openaiClientOptions := []option.RequestOption{ option.WithBaseURL(baseURL), option.WithAPIKey(bearerToken), // Use bearer token as API key + option.WithHeader("User-Agent", "GithubCopilot/1.133.0"), + option.WithHeader("Editor-Version", "vscode/1.78.0"), + option.WithHeader("Editor-Plugin-Version", "copilot-chat/0.8.0"), + option.WithHeader("Accept", "application/json"), + option.WithHeader("X-GitHub-Api-Version", "2022-11-28"), // Required GitHub API version } - // Add GitHub Copilot specific headers - openaiClientOptions = append(openaiClientOptions, - option.WithHeader("Editor-Version", "OpenCode/1.0"), - option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"), - option.WithHeader("Copilot-Integration-Id", "vscode-chat"), - ) - - // Add any extra headers - if copilotOpts.extraHeaders != nil { - for key, value := range copilotOpts.extraHeaders { + // Add any extra headers from options + if options.extraHeaders != nil { + for key, value := range options.extraHeaders { openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value)) } } + // Create client with proper headers client := openai.NewClient(openaiClientOptions...) - // logging.Debug("Copilot client created", "opts", opts, "copilotOpts", copilotOpts, "model", opts.model) + fmt.Printf("āœ… GitHub Copilot client created successfully\n") + fmt.Printf("āœ… Using model: %s (%s)\n", opts.model.Name, opts.model.APIModel) + + // Create and return the copilotClient return &copilotClient{ providerOptions: opts, - options: copilotOpts, + options: copilotOptions{ + reasoningEffort: options.reasoningEffort, + extraHeaders: options.extraHeaders, + bearerToken: bearerToken, + }, client: client, httpClient: httpClient, } @@ -281,8 +845,30 @@ func (c *copilotClient) finishReason(reason string) message.FinishReason { } func (c *copilotClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams { + logging.Debug("Copilot preparedParams start", "modelID", c.providerOptions.model.ID, "apiModel", c.providerOptions.model.APIModel) + + // For Claude models, use the proper model name format + apiModel := c.providerOptions.model.APIModel + if c.isAnthropicModel() { + logging.Debug("Using Claude model", "original_api_model", apiModel) + fmt.Printf("šŸ“¢ Using Claude model: %s through GitHub Copilot\n", apiModel) + // The Claude models might need a special format for Copilot + if strings.HasPrefix(apiModel, "claude-") { + logging.Debug("Using Claude model with standard format", "api_model", apiModel) + fmt.Printf("šŸ“¢ Claude model name format: %s\n", apiModel) + } + } else { + fmt.Printf("šŸ“¢ Using non-Claude model: %s through GitHub Copilot\n", apiModel) + } + + // Log important model details + logging.Debug("Model details", + "name", c.providerOptions.model.Name, + "context_window", c.providerOptions.model.ContextWindow, + "max_tokens", c.providerOptions.maxTokens) + params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(c.providerOptions.model.APIModel), + Model: openai.ChatModel(apiModel), Messages: messages, Tools: tools, } @@ -303,6 +889,10 @@ func (c *copilotClient) preparedParams(messages []openai.ChatCompletionMessagePa params.MaxTokens = openai.Int(c.providerOptions.maxTokens) } + jsonData, err := json.Marshal(params) + if err == nil { + logging.Debug("Copilot request parameters", "params", string(jsonData)) + } return params } @@ -311,36 +901,81 @@ func (c *copilotClient) send(ctx context.Context, messages []message.Message, to cfg := config.Get() var sessionId string requestSeqId := (len(messages) + 1) / 2 + + // Always log parameters in debug mode + jsonData, _ := json.Marshal(params) + logging.Debug("Copilot API request parameters", "model", params.Model, "messages_count", len(params.Messages), "tools_count", len(params.Tools)) + logging.Debug("Copilot API full request", "params", string(jsonData)) + logging.Debug("Model being used for request", "model_id", c.providerOptions.model.ID, "api_model", c.providerOptions.model.APIModel) + if cfg.Debug { - // jsonData, _ := json.Marshal(params) - // logging.Debug("Prepared messages", "messages", string(jsonData)) if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok { sessionId = sid } - jsonData, _ := json.Marshal(params) if sessionId != "" { filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, params) logging.Debug("Prepared messages", "filepath", filepath) - } else { - logging.Debug("Prepared messages", "messages", string(jsonData)) } } attempts := 0 for { attempts++ + fmt.Printf("šŸ”„ Sending request to GitHub Copilot API...\n") + logging.Debug("About to send Copilot API request", "model", string(params.Model), "max_tokens_param", params.MaxTokens, "max_completion_tokens_param", params.MaxCompletionTokens, "tools_count", len(tools)) + logging.Debug("Sending request to Copilot API", "baseURL", "https://api.githubcopilot.com", "model", params.Model) + + // Dump headers for debugging + logging.Debug("Request is being made with OpenAI client", "client_type", fmt.Sprintf("%T", c.client)) + fmt.Printf("šŸ“‹ Making request with client: %T\n", c.client) + + // Dump important request parameters + fmt.Printf("šŸ“‹ Request model: %s\n", params.Model) + fmt.Printf("šŸ“‹ Request max tokens: %d\n", c.providerOptions.maxTokens) + + // Make the API request copilotResponse, err := c.client.Chat.Completions.New( ctx, params, ) + logging.Debug("Received response from Copilot API", "error", err != nil) // If there is an error we are going to see if we can retry the call if err != nil { + fmt.Printf("āŒ Copilot API request failed: %v\n", err) + logging.Error("Copilot API request failed", "error", err) + var apierr *openai.Error + if errors.As(err, &apierr) { + fmt.Printf("āŒ API Error: Status %d, Type %s\n", apierr.StatusCode, apierr.Type) + fmt.Printf("āŒ Error Response: %s\n", apierr.RawJSON()) + logging.Error("Copilot API error details", "status", apierr.StatusCode, "type", apierr.Type, "raw_json", apierr.RawJSON()) + + // Check if this is a model not found error + if apierr.StatusCode == 400 { + if strings.Contains(string(apierr.RawJSON()), "model") { + fmt.Printf("āš ļø This might be because the model '%s' is not available or not supported by GitHub Copilot.\n", params.Model) + fmt.Printf("āš ļø Automatically trying 'copilot.gpt-4o' instead...\n") + + // If this was a Claude model, try with GPT-4o as fallback + if c.isAnthropicModel() && attempts < 2 { + logging.Info("Trying with GPT-4o model instead of Claude") + params.Model = "gpt-4o" + continue + } else { + fmt.Printf("āš ļø Try manually changing your config to use 'copilot.gpt-4o' instead.\n") + } + } + } + } + retry, after, retryErr := c.shouldRetry(attempts, err) if retryErr != nil { + fmt.Printf("āŒ Cannot retry: %v\n", retryErr) + logging.Error("Retry error", "error", retryErr) return nil, retryErr } if retry { + fmt.Printf("ā³ Retrying in %d ms (attempt %d of %d)...\n", after, attempts, maxRetries) logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) select { case <-ctx.Done(): @@ -350,6 +985,8 @@ func (c *copilotClient) send(ctx context.Context, messages []message.Message, to } } return nil, retryErr + } else { + fmt.Printf("āœ… Successful response from GitHub Copilot API!\n") } content := "" @@ -579,6 +1216,17 @@ func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error // Note: This is a simplified approach. In a production system, // you might want to recreate the entire client with the new token logging.Info("Refreshed Copilot bearer token") + + // Recreate the entire client with the new token + baseURL := "https://api.githubcopilot.com" + c.client = openai.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey(newBearerToken), + option.WithHeader("Editor-Version", "OpenCode/1.0"), + option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"), + option.WithHeader("Copilot-Integration-Id", "vscode-chat"), + ) + return true, 1000, nil // Retry immediately with new token } logging.Error("Failed to refresh Copilot bearer token", "error", tokenErr) @@ -587,7 +1235,14 @@ func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error } logging.Debug("Copilot API Error", "status", apierr.StatusCode, "headers", apierr.Response.Header, "body", apierr.RawJSON()) - if apierr.StatusCode != 429 && apierr.StatusCode != 500 { + if apierr.StatusCode == 400 { + // Special handling for 400 Bad Request + logging.Error("Copilot API 400 Bad Request error", "error", err.Error(), "response_body", apierr.RawJSON()) + + // Try to extract more details from the error + detailedErr := fmt.Errorf("Copilot API 400 Bad Request: %s", apierr.Error()) + return false, 0, detailedErr + } else if apierr.StatusCode != 429 && apierr.StatusCode != 500 { return false, 0, err } @@ -667,5 +1322,4 @@ func WithCopilotBearerToken(bearerToken string) CopilotOption { return func(options *copilotOptions) { options.bearerToken = bearerToken } -} - +} \ No newline at end of file From 4e625d8559e594142bd24a320e386d5135676f97 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 15:38:12 +0200 Subject: [PATCH 2/8] Add GitHub Copilot authentication documentation to README --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eee06acd..76959e5b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ You can configure OpenCode using environment variables: | `ANTHROPIC_API_KEY` | For Claude models | | `OPENAI_API_KEY` | For OpenAI models | | `GEMINI_API_KEY` | For Google Gemini models | -| `GITHUB_TOKEN` | For Github Copilot models (see [Using Github Copilot](#using-github-copilot)) | +| `GITHUB_TOKEN` | For Github Copilot models (see [Using GitHub Copilot](#using-github-copilot)) | | `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) | | `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) | | `GROQ_API_KEY` | For Groq models | @@ -237,6 +237,37 @@ OpenCode supports a variety of AI models from different providers: - Gemini 2.0 Flash - Gemini 2.5 Pro +#### Using GitHub Copilot + +OpenCode supports using GitHub Copilot's models through a streamlined authentication flow: + +1. Add Copilot to your configuration: +```json +{ + "agents": { + "coder": { + "model": "copilot.claude-3.7-sonnet", + "maxTokens": 16384 + } + }, + "providers": { + "copilot": { + "disabled": false + } + } +} +``` + +2. When you first run OpenCode with a Copilot model selected, the application will: + - Check for an existing Copilot token in standard locations (hosts.json, apps.json) + - If no token is found, automatically start the authentication flow + - Prompt you to visit a GitHub URL and enter a device code + - Store the token in the standard GitHub Copilot location (`~/.config/github-copilot/hosts.json`) + +3. For subsequent runs, OpenCode will use your saved token automatically. + +Note: You need an active GitHub Copilot subscription to use these models. + ### Google - Gemini 2.5 From 2855bd81302af890f31dcbb29ee371f01696c549 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 15:49:39 +0200 Subject: [PATCH 3/8] Simplify Copilot provider validation logic - Remove redundant checks for Copilot provider in Validate() - Improve comments for better clarity - Maintain the same functionality with cleaner code --- internal/config/config.go | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8464d781..a5047ad8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -496,18 +496,18 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { provider := model.Provider providerCfg, providerExists := cfg.Providers[provider] - // Special handling for Copilot provider + // Special handling for Copilot provider - allow empty API key and use device flow if provider == models.ProviderCopilot { logging.Debug("Validating Copilot provider", "exists", providerExists) - // If provider doesn't exist in config, add it + // If provider doesn't exist in config, add it with empty API key if !providerExists { cfg.Providers[provider] = Provider{ APIKey: "", // We'll use device flow for authentication } - logging.Info("Added Copilot provider to config") + logging.Info("Added Copilot provider to config for device flow authentication") } else if providerCfg.Disabled { - // Provider explicitly disabled + // Provider explicitly disabled - revert to default model logging.Warn("Copilot provider is disabled but model requires it", "agent", name, "model", agent.Model) @@ -520,7 +520,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { } } - // Continue with validation - Copilot provider is considered valid even without API key + // Copilot provider is valid even without API key (will use device flow) return nil } @@ -641,18 +641,6 @@ func Validate() error { } logging.Debug("Starting configuration validation") - - // Special handling for Copilot provider - don't require API key - // Since we'll use device code flow - for provider, providerCfg := range cfg.Providers { - if provider == models.ProviderCopilot && !providerCfg.Disabled { - logging.Debug("Found Copilot provider in config", "disabled", providerCfg.Disabled) - // For Copilot, we'll allow empty API key and handle auth via device flow - if providerCfg.APIKey == "" { - logging.Info("Copilot provider has no API key, will use device flow authentication") - } - } - } // Validate agent models for name, agent := range cfg.Agents { From 0055afced6882134534b24e31de631bb6542e607 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 15:57:40 +0200 Subject: [PATCH 4/8] Clean up excessive logging - Simplify logging in GitHub Copilot authentication - Remove redundant logging messages - Remove unused getMapKeys helper function - Keep only essential user feedback during authentication --- internal/config/config.go | 73 +++++--------------------------- internal/llm/provider/copilot.go | 30 ++++++------- 2 files changed, 23 insertions(+), 80 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index a5047ad8..fdd7db99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -975,116 +975,63 @@ func UpdateTheme(themeName string) error { // Returns the token if found, or a special error "no_copilot_token" if no token is found // This follows the 4-step flow: 1. Check if Copilot is enabled, 2. Check for token in config folder func LoadGitHubToken() (string, error) { - logging.Debug("LoadGitHubToken: Attempting to load GitHub token") + logging.Debug("Attempting to load GitHub token for Copilot") - // First check environment variable (maintained for compatibility) - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - prefixLen := 10 - if len(token) < prefixLen { - prefixLen = len(token) + // First check environment variables + for _, envName := range []string{"GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GH_COPILOT_TOKEN"} { + if token := os.Getenv(envName); token != "" { + logging.Debug("Found GitHub token in environment variable", "env_var", envName) + return token, nil } - logging.Debug("LoadGitHubToken: Found token in GITHUB_TOKEN environment variable", "token_length", len(token), "token_prefix", token[:prefixLen]) - return token, nil - } - - logging.Debug("LoadGitHubToken: No token found in GITHUB_TOKEN environment variable") - - // Also check Copilot-specific environment variables - if token := os.Getenv("GITHUB_COPILOT_TOKEN"); token != "" { - prefixLen := 10 - if len(token) < prefixLen { - prefixLen = len(token) - } - logging.Debug("LoadGitHubToken: Found token in GITHUB_COPILOT_TOKEN environment variable", "token_length", len(token), "token_prefix", token[:prefixLen]) - return token, nil - } - - if token := os.Getenv("GH_COPILOT_TOKEN"); token != "" { - prefixLen := 10 - if len(token) < prefixLen { - prefixLen = len(token) - } - logging.Debug("LoadGitHubToken: Found token in GH_COPILOT_TOKEN environment variable", "token_length", len(token), "token_prefix", token[:prefixLen]) - return token, nil } // Get config directory var configDir string if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { configDir = xdgConfig - logging.Debug("LoadGitHubToken: Using XDG_CONFIG_HOME for config directory", "directory", xdgConfig) } else if runtime.GOOS == "windows" { if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { configDir = localAppData - logging.Debug("LoadGitHubToken: Using LOCALAPPDATA for config directory", "directory", localAppData) } else { configDir = filepath.Join(os.Getenv("HOME"), "AppData", "Local") - logging.Debug("LoadGitHubToken: Using HOME/AppData/Local for config directory", "directory", configDir) } } else { configDir = filepath.Join(os.Getenv("HOME"), ".config") - logging.Debug("LoadGitHubToken: Using HOME/.config for config directory", "directory", configDir) } - // Primary path: Check standard Copilot config files first (hosts.json and apps.json) + // Check standard Copilot config files filePaths := []string{ filepath.Join(configDir, "github-copilot", "hosts.json"), filepath.Join(configDir, "github-copilot", "apps.json"), } - logging.Debug("LoadGitHubToken: Checking Copilot config files", "paths", filePaths) + logging.Debug("Checking Copilot config files") for _, filePath := range filePaths { - logging.Debug("LoadGitHubToken: Attempting to read file", "path", filePath) - data, err := os.ReadFile(filePath) if err != nil { - logging.Debug("LoadGitHubToken: Failed to read file", "path", filePath, "error", err) continue } - - logging.Debug("LoadGitHubToken: Successfully read file", "path", filePath, "size_bytes", len(data)) var config map[string]map[string]interface{} if err := json.Unmarshal(data, &config); err != nil { - logging.Debug("LoadGitHubToken: Failed to unmarshal JSON", "path", filePath, "error", err) continue } - - logging.Debug("LoadGitHubToken: Successfully unmarshalled JSON", "path", filePath, "keys_count", len(config)) // For hosts.json, we expect keys like "github.com" // For apps.json, we expect keys like "github.com:Iv1.b507a08c87ecfe98" for key, value := range config { if strings.Contains(key, "github.com") { - logging.Debug("LoadGitHubToken: Found github.com entry", "key", key) - if oauthToken, ok := value["oauth_token"].(string); ok && oauthToken != "" { - prefixLen := 10 - if len(oauthToken) < prefixLen { - prefixLen = len(oauthToken) - } - logging.Debug("LoadGitHubToken: Found OAuth token in config file", "path", filePath, "key", key, "token_length", len(oauthToken), "token_prefix", oauthToken[:prefixLen]) + logging.Debug("Found GitHub token in config file", "path", filePath) return oauthToken, nil - } else { - logging.Debug("LoadGitHubToken: No oauth_token found in entry or empty token", "key", key, "available_keys", getMapKeys(value)) } } } - - logging.Debug("LoadGitHubToken: No GitHub token found in config file", "path", filePath) } // Return a special error that indicates we need to use device code flow - logging.Debug("LoadGitHubToken: No Copilot token found - use device code flow") + logging.Debug("No Copilot token found - will need to use device code flow") return "", fmt.Errorf("no_copilot_token") } -// Helper function to get map keys as a string slice for debugging -func getMapKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} diff --git a/internal/llm/provider/copilot.go b/internal/llm/provider/copilot.go index 047e4ff5..002e3720 100644 --- a/internal/llm/provider/copilot.go +++ b/internal/llm/provider/copilot.go @@ -273,8 +273,7 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { data.Set("client_id", copilotClientID) data.Set("scope", "user:email read:user copilot") - fmt.Printf("šŸ” Using GitHub Copilot client ID: %s\n", copilotClientID) - fmt.Printf("šŸ” Requesting device code for scopes: user:email read:user copilot\n") + fmt.Printf("Requesting GitHub authentication...\n") // Using the exact URL and headers from VS Code Copilot extension req, err := http.NewRequest("POST", "https://github.com/login/device/code", strings.NewReader(data.Encode())) @@ -335,20 +334,19 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() - fmt.Printf("ā³ Waiting for you to authorize the device...\n") + fmt.Printf("Waiting for authorization...\n") pollAttempts := 0 for { select { case <-ticker.C: pollAttempts++ - fmt.Printf("šŸ”„ Checking authorization status... (attempt %d)\n", pollAttempts) // Make a request to check if the user has authorized tokenReq, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(tokenData.Encode())) if err != nil { - fmt.Printf("āŒ Error creating token request: %v\n", err) + logging.Error("Failed to create token request", "error", err) return "", fmt.Errorf("failed to create token request: %w", err) } @@ -358,30 +356,29 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { tokenResp, err := c.httpClient.Do(tokenReq) if err != nil { - fmt.Printf("āŒ Error making token request: %v\n", err) + logging.Error("Failed to make token request", "error", err) return "", fmt.Errorf("failed token request: %w", err) } - fmt.Printf("šŸ“Š Token poll response status: %d\n", tokenResp.StatusCode) + logging.Debug("Token poll response status", "status", tokenResp.StatusCode) tokenRespBody, err := io.ReadAll(tokenResp.Body) tokenResp.Body.Close() if err != nil { - fmt.Printf("āŒ Error reading token response: %v\n", err) + logging.Error("Failed to read token response", "error", err) return "", fmt.Errorf("failed to read token response: %w", err) } if tokenResp.StatusCode == http.StatusOK { - fmt.Printf("āœ… Received response from GitHub. Processing...\n") - fmt.Printf("šŸ“ƒ Raw response: %s\n", string(tokenRespBody)) + logging.Debug("Received response from GitHub", "response", string(tokenRespBody)) // Check if we're getting an error response even with 200 status var errorCheck map[string]string if json.Unmarshal(tokenRespBody, &errorCheck) == nil { if errorVal, ok := errorCheck["error"]; ok { - fmt.Printf("āš ļø Received error with 200 status: %s\n", errorVal) + logging.Debug("Received error in response", "error", errorVal) if errorVal == "authorization_pending" { - fmt.Printf("ā³ Still waiting for authorization in browser...\n") + logging.Debug("Still waiting for authorization in browser") continue } } @@ -389,16 +386,15 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { var tokenData GitHubTokenResponse if err := json.Unmarshal(tokenRespBody, &tokenData); err != nil { - fmt.Printf("āŒ Error parsing token response: %v\n", err) + logging.Error("Failed to parse token response", "error", err) return "", fmt.Errorf("failed to parse token response: %w", err) } - fmt.Printf("āœ… Token data: %+v\n", tokenData) + logging.Debug("Received token data", "scope", tokenData.Scope) if tokenData.AccessToken != "" { - fmt.Printf("āœ… Successfully authenticated with GitHub!\n") - fmt.Printf("āœ… Token received and stored for future use\n") - fmt.Printf("āœ… Now exchanging for Copilot bearer token...\n") + fmt.Printf("Successfully authenticated with GitHub!\n") + fmt.Printf("Exchanging for Copilot bearer token...\n") // Save the token for future use saveGitHubToken(tokenData.AccessToken) From aaa274f2ecf47e3727a0439c32c2bc285094aa79 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 20:43:09 +0200 Subject: [PATCH 5/8] Improve GitHub Copilot authentication flow and clean implementation - Implement device flow authentication for GitHub Copilot - Add token saving to standard ~/.config/github-copilot/hosts.json - Fix token validation and error handling - Continue polling when receiving empty responses - Set environment variables for immediate use of token - Clean and simplify code implementation --- internal/llm/provider/copilot.go | 975 ++++++++++++------------------- 1 file changed, 383 insertions(+), 592 deletions(-) diff --git a/internal/llm/provider/copilot.go b/internal/llm/provider/copilot.go index 002e3720..8e86ea2e 100644 --- a/internal/llm/provider/copilot.go +++ b/internal/llm/provider/copilot.go @@ -57,30 +57,6 @@ func (c *copilotClient) isAnthropicModel() bool { return false } -func (c *copilotClient) getGitHubTokenScopes(githubToken string) (string, error) { - req, err := http.NewRequest("GET", "https://api.github.com/user", nil) - if err != nil { - return "", fmt.Errorf("failed to create GitHub API request: %w", err) - } - - req.Header.Set("Authorization", "Token "+githubToken) - req.Header.Set("User-Agent", "OpenCode/1.0") - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to query GitHub API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("GitHub API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - scopes := resp.Header.Get("X-OAuth-Scopes") - return scopes, nil -} - // GitHub OAuth device flow response type GitHubDeviceCodeResponse struct { DeviceCode string `json:"device_code"` @@ -92,189 +68,25 @@ type GitHubDeviceCodeResponse struct { // GitHub OAuth token response type GitHubTokenResponse struct { + // Standard OAuth fields AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` -} - -// exchangeGitHubToken exchanges a GitHub token for a Copilot bearer token -// If the token is provided, it will try to use it directly -// Otherwise, it will start the GitHub device code flow -func (c *copilotClient) exchangeGitHubToken(githubToken string) (string, error) { - // If a token is provided, try to use it directly - if githubToken != "" { - prefixLen := 10 - if len(githubToken) < prefixLen { - prefixLen = len(githubToken) - } - logging.Debug("Exchanging GitHub token", "token_length", len(githubToken), "token_prefix", githubToken[:prefixLen]) - - // Check GitHub token scopes first to verify it's a valid token - scopes, err := c.getGitHubTokenScopes(githubToken) - if err != nil { - logging.Error("Failed to get GitHub token scopes", "error", err) - // If we can't verify token scopes, just continue - the token exchange will fail if invalid - } else { - logging.Debug("GitHub token scopes", "scopes", scopes) - if !strings.Contains(scopes, "copilot") { - logging.Warn("GitHub token does not have copilot scope - token exchange may fail") - } - } - - // Attempt to exchange for a Copilot bearer token - match VS Code exactly - req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil) - if err != nil { - return "", fmt.Errorf("failed to create token exchange request: %w", err) - } - - req.Header.Set("Authorization", "token "+githubToken) // Note: "token" not "Token" - req.Header.Set("User-Agent", "GithubCopilot/1.133.0") - req.Header.Set("Accept", "application/json") - - logging.Debug("Sending token exchange request to GitHub API") - resp, err := c.httpClient.Do(req) - if err != nil { - logging.Error("Failed HTTP request for token exchange", "error", err) - // If we're not in non-interactive mode, try device flow - if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { - logging.Info("Token exchange HTTP request failed, falling back to device code flow") - return c.performDeviceCodeFlow() - } - return "", fmt.Errorf("failed to exchange GitHub token: %w", err) - } - defer resp.Body.Close() - - logging.Debug("Token exchange response received", "status", resp.StatusCode, "headers", resp.Header) - - // Check for HTTP errors - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - logging.Error("Token exchange failed", "status_code", resp.StatusCode, "body", string(body)) - - // If we're not in non-interactive mode, try device flow - if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { - logging.Info("Token exchange failed, falling back to device code flow") - return c.performDeviceCodeFlow() - } - return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) - } - - // Success! Read the response - body, err := io.ReadAll(resp.Body) - if err != nil { - logging.Error("Failed to read token response body", "error", err) - return "", fmt.Errorf("failed to read token response: %w", err) - } - - logging.Debug("Token exchange response body received, length", "bytes", len(body)) - - var tokenResp CopilotTokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - logging.Error("Failed to decode token response", "error", err) - return "", fmt.Errorf("failed to decode token response: %w", err) - } - - if tokenResp.Token == "" { - logging.Error("Received empty token from GitHub API") - - // If we're not in non-interactive mode, try device flow - if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { - logging.Info("Received empty token, falling back to device code flow") - return c.performDeviceCodeFlow() - } - return "", fmt.Errorf("received empty token from GitHub API") - } - - prefixLen = 10 - if len(tokenResp.Token) < prefixLen { - prefixLen = len(tokenResp.Token) - } - logging.Debug("Successfully obtained Copilot bearer token", - "token_prefix", tokenResp.Token[:prefixLen], - "expires_at", tokenResp.ExpiresAt) - - // Try saving the token for future use - saveGitHubToken(githubToken) - - return tokenResp.Token, nil - } else { - // No token provided, use device code flow if we're not in non-interactive mode - if !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { - logging.Info("No GitHub token provided, starting device code flow") - return c.performDeviceCodeFlow() - } - return "", fmt.Errorf("no GitHub token available and running in non-interactive mode") - } -} - -// saveGitHubToken saves the GitHub token to the standard location for future use -func saveGitHubToken(token string) { - // Only save if we have a token - if token == "" { - return - } - - // Get the config directory - var configDir string - if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { - configDir = xdgConfig - } else if runtime.GOOS == "windows" { - if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - configDir = localAppData - } else { - configDir = filepath.Join(os.Getenv("HOME"), "AppData", "Local") - } - } else { - configDir = filepath.Join(os.Getenv("HOME"), ".config") - } - - // Create the directory if it doesn't exist - copilotDir := filepath.Join(configDir, "github-copilot") - if err := os.MkdirAll(copilotDir, 0755); err != nil { - logging.Error("Failed to create github-copilot directory", "error", err) - return - } - - // Create the hosts.json file - hostsFile := filepath.Join(copilotDir, "hosts.json") - - // Create the JSON structure - hostsData := map[string]map[string]interface{}{ - "github.com": { - "oauth_token": token, - }, - } - - // Marshal to JSON - jsonData, err := json.MarshalIndent(hostsData, "", " ") - if err != nil { - logging.Error("Failed to marshal hosts.json", "error", err) - return - } - // Write the file - if err := os.WriteFile(hostsFile, jsonData, 0600); err != nil { - logging.Error("Failed to write hosts.json", "error", err) - return - } - - logging.Info("Saved GitHub token to hosts.json for future use", "path", hostsFile) + // For backward compatibility with any custom formats + Token string `json:"token,omitempty"` } -// performDeviceCodeFlow initiates the GitHub device code flow and returns a Copilot bearer token +// performDeviceCodeFlow initiates the GitHub device code flow and returns a GitHub token func (c *copilotClient) performDeviceCodeFlow() (string, error) { // Step 1: Get a device code data := url.Values{} // Use the official GitHub Copilot client ID - // This is used by multiple Copilot integrations including Neovim - // The client ID is publicly visible in VS Code and Neovim plugins const copilotClientID = "Iv1.b507a08c87ecfe98" data.Set("client_id", copilotClientID) data.Set("scope", "user:email read:user copilot") - fmt.Printf("Requesting GitHub authentication...\n") - // Using the exact URL and headers from VS Code Copilot extension req, err := http.NewRequest("POST", "https://github.com/login/device/code", strings.NewReader(data.Encode())) if err != nil { @@ -296,13 +108,8 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { return "", fmt.Errorf("device code request failed with status %d: %s", resp.StatusCode, string(body)) } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read device code response: %w", err) - } - var deviceResp GitHubDeviceCodeResponse - if err := json.Unmarshal(body, &deviceResp); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { return "", fmt.Errorf("failed to parse device code response: %w", err) } @@ -315,7 +122,7 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { // Step 3: Poll for the token tokenData := url.Values{} - tokenData.Set("client_id", copilotClientID) // Use the same client ID as before + tokenData.Set("client_id", copilotClientID) tokenData.Set("device_code", deviceResp.DeviceCode) tokenData.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") @@ -335,18 +142,21 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { defer ticker.Stop() fmt.Printf("Waiting for authorization...\n") - pollAttempts := 0 + maxPolls := 60 // Maximum polling attempts + pollCount := 0 for { select { case <-ticker.C: - pollAttempts++ + pollCount++ + if pollCount > maxPolls { + return "", fmt.Errorf("maximum polling attempts reached, please try again") + } // Make a request to check if the user has authorized tokenReq, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(tokenData.Encode())) if err != nil { - logging.Error("Failed to create token request", "error", err) return "", fmt.Errorf("failed to create token request: %w", err) } @@ -356,158 +166,121 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { tokenResp, err := c.httpClient.Do(tokenReq) if err != nil { - logging.Error("Failed to make token request", "error", err) return "", fmt.Errorf("failed token request: %w", err) } + defer tokenResp.Body.Close() - logging.Debug("Token poll response status", "status", tokenResp.StatusCode) - - tokenRespBody, err := io.ReadAll(tokenResp.Body) - tokenResp.Body.Close() + // Read the full response body so we can log it and also re-analyze it + bodyBytes, err := io.ReadAll(tokenResp.Body) if err != nil { - logging.Error("Failed to read token response", "error", err) - return "", fmt.Errorf("failed to read token response: %w", err) + return "", fmt.Errorf("failed to read token response body: %w", err) } - + if tokenResp.StatusCode == http.StatusOK { - logging.Debug("Received response from GitHub", "response", string(tokenRespBody)) + // Log the raw response for debugging + logging.Debug("Token response body", "body", string(bodyBytes)) - // Check if we're getting an error response even with 200 status - var errorCheck map[string]string - if json.Unmarshal(tokenRespBody, &errorCheck) == nil { - if errorVal, ok := errorCheck["error"]; ok { - logging.Debug("Received error in response", "error", errorVal) - if errorVal == "authorization_pending" { - logging.Debug("Still waiting for authorization in browser") - continue - } - } - } - - var tokenData GitHubTokenResponse - if err := json.Unmarshal(tokenRespBody, &tokenData); err != nil { - logging.Error("Failed to parse token response", "error", err) - return "", fmt.Errorf("failed to parse token response: %w", err) + // Check for empty or invalid responses + if len(bodyBytes) == 0 { + logging.Debug("Empty response body from GitHub") + continue // Continue polling } - - logging.Debug("Received token data", "scope", tokenData.Scope) - if tokenData.AccessToken != "" { - fmt.Printf("Successfully authenticated with GitHub!\n") - fmt.Printf("Exchanging for Copilot bearer token...\n") + // First try standard OAuth response format + var tokenResponse GitHubTokenResponse + if err := json.Unmarshal(bodyBytes, &tokenResponse); err != nil { + logging.Debug("Failed to parse as standard OAuth format", "error", err) - // Save the token for future use - saveGitHubToken(tokenData.AccessToken) - - // Set environment variable for immediate use in this session - os.Setenv("GITHUB_COPILOT_TOKEN", tokenData.AccessToken) - logging.Info("Saved GitHub token and set environment variable for immediate use") - - // Direct exchange - don't call exchangeGitHubToken to avoid potential loop - logging.Debug("Performing direct token exchange for GitHub token") - - // Create the request to exchange for a Copilot bearer token - use internal API - req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil) - if err != nil { - fmt.Printf("āŒ Error creating exchange request: %v\n", err) - return "", fmt.Errorf("failed to create exchange request: %w", err) + // Try alternative format with access_token field + var altResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` } - req.Header.Set("Authorization", "token "+tokenData.AccessToken) // lowercase "token" - req.Header.Set("User-Agent", "GithubCopilot/1.133.0") - req.Header.Set("Accept", "application/json") - - fmt.Printf("šŸ”„ Requesting Copilot token from GitHub API...\n") - resp, err := c.httpClient.Do(req) - if err != nil { - fmt.Printf("āŒ Exchange request failed: %v\n", err) - return "", fmt.Errorf("failed exchange request: %w", err) + if err := json.Unmarshal(bodyBytes, &altResponse); err != nil { + logging.Debug("Failed to parse in any format", "error", err) + continue // Continue polling instead of failing } - defer resp.Body.Close() - fmt.Printf("šŸ“Š Exchange response status: %d\n", resp.StatusCode) + // Use the alternative format + tokenResponse.AccessToken = altResponse.AccessToken + } + + // Check which token field was populated + var finalToken string + if tokenResponse.AccessToken != "" { + finalToken = tokenResponse.AccessToken + } else if tokenResponse.Token != "" { + finalToken = tokenResponse.Token + } + + // Final token validation + if finalToken == "" { + logging.Debug("No token found in response") + continue // Continue polling instead of failing + } + + if finalToken != "" { + fmt.Printf("Successfully authenticated with GitHub!\n") - // Check for successful response - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - fmt.Printf("āŒ Token exchange failed: %s\n", string(body)) - return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) - } + // First set environment variables for immediate use + os.Setenv("GITHUB_TOKEN", finalToken) + os.Setenv("GITHUB_COPILOT_TOKEN", finalToken) - // Read the response body - body, err := io.ReadAll(resp.Body) + // Save token directly to file + homeDir, err := os.UserHomeDir() if err != nil { - fmt.Printf("āŒ Failed to read exchange response: %v\n", err) - return "", fmt.Errorf("failed to read response: %w", err) + homeDir = os.Getenv("HOME") } - // Parse the token response - var tokenResp CopilotTokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - fmt.Printf("āŒ Failed to parse exchange response: %v\n", err) - return "", fmt.Errorf("failed to parse response: %w", err) - } + // Set config directory + configDir := filepath.Join(homeDir, ".config") - if tokenResp.Token == "" { - fmt.Printf("āŒ Received empty token from GitHub API\n") - return "", fmt.Errorf("received empty token from GitHub API") + // Ensure directory exists + copilotDir := filepath.Join(configDir, "github-copilot") + if err := os.MkdirAll(copilotDir, 0755); err != nil { + logging.Error("Failed to create github-copilot directory", "error", err) } - // Store the token for future use - c.options.bearerToken = tokenResp.Token + // Create hosts.json file + hostsFile := filepath.Join(copilotDir, "hosts.json") - // Create a new OpenAI client specifically for Copilot with the bearer token - baseURL := "https://api.githubcopilot.com" - newClient := openai.NewClient( - option.WithBaseURL(baseURL), - option.WithAPIKey(tokenResp.Token), - option.WithHeader("Editor-Version", "OpenCode/1.0"), - option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"), - option.WithHeader("Copilot-Integration-Id", "vscode-chat"), - option.WithHeader("X-GitHub-Api-Version", "2022-11-28"), - ) + // Create the JSON structure + jsonData := []byte(`{"github.com":{"oauth_token":"` + finalToken + `"}}`) - // Replace the client in the current instance - c.client = newClient + // Write the file + if err := os.WriteFile(hostsFile, jsonData, 0600); err != nil { + logging.Error("Failed to write hosts.json", "error", err) + } else { + logging.Info("Saved GitHub token to hosts.json for future use") + } - fmt.Printf("āœ… Successfully exchanged token for Copilot bearer token!\n") - fmt.Printf("āœ… Created new OpenAI client for GitHub Copilot\n") - fmt.Printf("āœ… You can now use OpenCode with GitHub Copilot\n") - return tokenResp.Token, nil + return finalToken, nil + } else { + // If we got a 200 but no access token, that's strange + logging.Error("Received HTTP 200 but no access token in response", "body", string(bodyBytes)) + return "", fmt.Errorf("authentication response did not contain an access token") } } else if tokenResp.StatusCode == http.StatusBadRequest { - // If it's still pending, continue polling + // Check for specific errors var errorResp map[string]string - if err := json.Unmarshal(tokenRespBody, &errorResp); err == nil { + if err := json.Unmarshal(bodyBytes, &errorResp); err == nil { if errorResp["error"] == "authorization_pending" { - // This is normal, just wait for next poll - fmt.Printf("ā³ Authorization pending - waiting for you to approve in the browser...\n") + // Still waiting for user to authorize - continue continue } else if errorResp["error"] == "slow_down" { - // Need to slow down polling - interval += 5 - ticker.Reset(time.Duration(interval) * time.Second) - fmt.Printf("āš ļø GitHub asked us to slow down polling. Increased interval to %d seconds.\n", interval) - continue + // Rate limiting - fail fast + return "", fmt.Errorf("GitHub rate limit detected, please try again in a few minutes") } else if errorResp["error"] == "expired_token" { - fmt.Printf("āŒ Device code expired. Please try again.\n") return "", fmt.Errorf("device code expired, please try again") - } else { - // Unknown error - fmt.Printf("ā“ Unknown error from GitHub: %s\n", errorResp["error"]) - fmt.Printf("ā“ Error details: %s\n", string(tokenRespBody)) } - } else { - // Error parsing JSON - fmt.Printf("āŒ Error parsing response: %v\n", err) - fmt.Printf("āŒ Raw response: %s\n", string(tokenRespBody)) } - return "", fmt.Errorf("token request failed with status %d: %s", - tokenResp.StatusCode, string(tokenRespBody)) - } else { - return "", fmt.Errorf("token request failed with status %d: %s", - tokenResp.StatusCode, string(tokenRespBody)) } + + // Any other error + return "", fmt.Errorf("token request failed with status %d: %s", + tokenResp.StatusCode, string(bodyBytes)) case <-ctx.Done(): return "", fmt.Errorf("authentication timed out after %d seconds", deviceResp.ExpiresIn) @@ -515,16 +288,201 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { } } -// newCopilotClient creates a new client for GitHub Copilot -// Following the 4-step flow: -// 1. Check if Copilot is enabled in config (handled by validation) -// 2. Check for token in config folder -// 3. If no token, trigger login flow -// 4. With token ready, open OpenCode normally -func newCopilotClient(opts providerClientOptions) CopilotClient { - logging.Debug("Creating new Copilot client", "model", opts.model) - fmt.Printf("šŸ”§ Creating new GitHub Copilot client for model: %s\n", opts.model.ID) +// saveGitHubToken saves the GitHub token to the standard location for future use +func saveGitHubToken(token string) { + // Only save if we have a token + if token == "" { + logging.Error("Cannot save empty GitHub token") + return + } + // Get the home directory directly first + homeDir, err := os.UserHomeDir() + if err != nil { + logging.Error("Failed to get user home directory", "error", err) + homeDir = os.Getenv("HOME") // Fallback to HOME environment variable + if homeDir == "" { + logging.Error("Failed to determine home directory") + return + } + } + + // Set config directory based on platform + var configDir string + if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { + configDir = xdgConfig + logging.Debug("Using XDG_CONFIG_HOME for config directory", "path", configDir) + } else if runtime.GOOS == "windows" { + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + configDir = localAppData + logging.Debug("Using LOCALAPPDATA for config directory", "path", configDir) + } else { + configDir = filepath.Join(homeDir, "AppData", "Local") + logging.Debug("Using default Windows AppData path", "path", configDir) + } + } else { + configDir = filepath.Join(homeDir, ".config") + logging.Debug("Using default .config directory", "path", configDir) + } + + // First try saving to hosts.json (GitHub Copilot VS Code format) + hostsResult := saveToHostsFile(configDir, token) + + // Also try saving to apps.json (Neovim Copilot plugin format) as fallback + saveToAppsFile(configDir, token) + + // Set environment variables for immediate use (even if file saving failed) + os.Setenv("GITHUB_TOKEN", token) + os.Setenv("GITHUB_COPILOT_TOKEN", token) + + if !hostsResult { + logging.Warn("Failed to save token to hosts.json, but environment variables are set for this session") + } + + return +} + +// saveToHostsFile saves token to hosts.json (VS Code format) +func saveToHostsFile(configDir string, token string) bool { + // Create the directory if it doesn't exist + copilotDir := filepath.Join(configDir, "github-copilot") + logging.Debug("Using copilot config directory", "path", copilotDir) + + if err := os.MkdirAll(copilotDir, 0755); err != nil { + logging.Error("Failed to create github-copilot directory", "error", err) + return false + } + + // Create the hosts.json file + hostsFile := filepath.Join(copilotDir, "hosts.json") + logging.Debug("Will save hosts file to", "path", hostsFile) + + // Create the JSON structure + hostsData := map[string]map[string]interface{}{ + "github.com": { + "oauth_token": token, + }, + } + + // Marshal to JSON + jsonData, err := json.MarshalIndent(hostsData, "", " ") + if err != nil { + logging.Error("Failed to marshal hosts.json", "error", err) + return false + } + + // First ensure the directory exists + if _, err := os.Stat(copilotDir); os.IsNotExist(err) { + logging.Debug("Creating directory again to be sure", "path", copilotDir) + if err := os.MkdirAll(copilotDir, 0755); err != nil { + logging.Error("Failed to create directory during second attempt", "error", err) + return false + } + } + + // Write the file + if err := os.WriteFile(hostsFile, jsonData, 0600); err != nil { + logging.Error("Failed to write hosts.json", "error", err) + fmt.Printf("āš ļø Failed to save token to %s: %v\n", hostsFile, err) + return false + } + + logging.Info("Saved GitHub token to hosts.json for future use") + fmt.Printf("āœ… Token saved to: %s\n", hostsFile) + + // Verify file exists and has content + if info, err := os.Stat(hostsFile); err == nil { + fmt.Printf("āœ… Verified file exists, size: %d bytes\n", info.Size()) + + // Double-check file contents + content, readErr := os.ReadFile(hostsFile) + if readErr != nil { + logging.Error("Failed to verify hosts.json contents", "error", readErr) + return false + } + + if len(content) == 0 { + logging.Error("hosts.json exists but is empty") + return false + } + + fmt.Printf("āœ… Verified hosts.json has content\n") + return true + } else { + logging.Error("Failed to verify hosts.json exists after writing", "error", err) + fmt.Printf("āš ļø Could not verify file: %v\n", err) + return false + } +} + +// saveToAppsFile saves token to apps.json (Neovim format) +func saveToAppsFile(configDir string, token string) bool { + // Create the directory if it doesn't exist + copilotDir := filepath.Join(configDir, "github-copilot") + if err := os.MkdirAll(copilotDir, 0755); err != nil { + logging.Error("Failed to create github-copilot directory for apps.json", "error", err) + return false + } + + // Create the apps.json file (alternative format used by some tools) + appsFile := filepath.Join(copilotDir, "apps.json") + + // Create the JSON structure + appsData := map[string]interface{}{ + "github-copilot/copilot.vim": map[string]interface{}{ + "oauth_token": token, + }, + } + + // Marshal to JSON + jsonData, err := json.MarshalIndent(appsData, "", " ") + if err != nil { + logging.Error("Failed to marshal apps.json", "error", err) + return false + } + + // Write the file + if err := os.WriteFile(appsFile, jsonData, 0600); err != nil { + logging.Error("Failed to write apps.json", "error", err) + fmt.Printf("āš ļø Failed to save token to %s: %v\n", appsFile, err) + return false + } + + logging.Info("Also saved GitHub token to apps.json for future use") + fmt.Printf("āœ… Token also saved to: %s\n", appsFile) + return true +} + +// exchangeGitHubToken exchanges a GitHub token for a Copilot bearer token +func (c *copilotClient) exchangeGitHubToken(githubToken string) (string, error) { + req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil) + if err != nil { + return "", fmt.Errorf("failed to create token exchange request: %w", err) + } + + req.Header.Set("Authorization", "Token "+githubToken) + req.Header.Set("User-Agent", "OpenCode/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to exchange GitHub token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp CopilotTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("failed to decode token response: %w", err) + } + + return tokenResp.Token, nil +} + +func newCopilotClient(opts providerClientOptions) CopilotClient { copilotOpts := copilotOptions{ reasoningEffort: "medium", } @@ -540,207 +498,130 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { var bearerToken string - // Step 1: Check if Copilot is enabled in config - already done by validation - - // Step 2: Check for token in config folder - var githubToken string - var useDeviceFlow bool - - logging.Info("Looking for GitHub Copilot token") - fmt.Printf("šŸ” Looking for GitHub Copilot authentication token...\n") - // If bearer token is already provided, use it if copilotOpts.bearerToken != "" { - logging.Debug("Using provided bearer token") - fmt.Printf("āœ… Using provided bearer token\n") bearerToken = copilotOpts.bearerToken } else { - // Check for GitHub token in standard locations - var err error - logging.Debug("Checking for GitHub token in standard locations") - githubToken, err = config.LoadGitHubToken() - - if err != nil { - if err.Error() == "no_copilot_token" { - // Special error indicating we need device flow - useDeviceFlow = true - logging.Info("No Copilot token found in config. Need to use device flow.") - fmt.Printf("ā„¹ļø No GitHub Copilot token found in config\n") - } else { - logging.Error("Failed to load GitHub token", "error", err) - fmt.Printf("āŒ Error loading GitHub token: %v\n", err) - } - } else if githubToken != "" { - prefixLen := 10 - if len(githubToken) < prefixLen { - prefixLen = len(githubToken) + // Try to get GitHub token from multiple sources + var githubToken string + + // 1. Environment variable + githubToken = os.Getenv("GITHUB_TOKEN") + + // 2. API key from options + if githubToken == "" { + githubToken = opts.apiKey + } + + // 3. Standard GitHub CLI/Copilot locations + if githubToken == "" { + var err error + fmt.Printf("šŸ” Looking for GitHub Copilot token in standard locations...\n") + githubToken, err = config.LoadGitHubToken() + if err != nil { + // Check if we need to use device flow + if err.Error() == "no_copilot_token" && !viper.GetBool("non_interactive") && viper.GetString("prompt") == "" { + logging.Info("No GitHub token found, starting device code flow") + fmt.Printf("šŸ”‘ No GitHub token found, starting authentication flow...\n") + + // Create temporary client for auth flow + tempClient := &copilotClient{ + providerOptions: opts, + options: copilotOpts, + httpClient: httpClient, + } + + var authErr error + githubToken, authErr = tempClient.performDeviceCodeFlow() + if authErr != nil { + logging.Error("Device code authentication failed", "error", authErr) + fmt.Printf("āŒ Authentication failed: %v\n", authErr) + return &copilotClient{ + providerOptions: opts, + options: copilotOpts, + httpClient: httpClient, + } + } + + // Double-check token after device flow + if githubToken != "" { + fmt.Printf("āœ… Successfully obtained GitHub token (length: %d)\n", len(githubToken)) + // Set it directly in opts.apiKey + opts.apiKey = githubToken + // Also set environment variable + os.Setenv("GITHUB_TOKEN", githubToken) + } + } else { + logging.Debug("Failed to load GitHub token from standard locations", "error", err) + fmt.Printf("āš ļø Failed to load GitHub token: %v\n", err) + } + } else if githubToken != "" { + fmt.Printf("āœ… Found existing GitHub token (length: %d)\n", len(githubToken)) } - logging.Debug("Found GitHub token in config", "token_length", len(githubToken), "token_prefix", githubToken[:prefixLen]) - fmt.Printf("āœ… Found GitHub token in config\n") - } else { - logging.Debug("GitHub token not found in config") - fmt.Printf("ā„¹ļø No GitHub token found in config\n") - useDeviceFlow = true } - - // Step 3: If no token, trigger login flow - nonInteractiveFlag := viper.GetBool("non_interactive") - cliNonInteractive := viper.GetString("prompt") != "" - - if useDeviceFlow && !nonInteractiveFlag && !cliNonInteractive { - logging.Info("Starting GitHub Copilot authentication flow") - fmt.Printf("šŸ”‘ Starting GitHub Copilot authentication flow\n") - - // Create temporary client for auth flow - tempClient := &copilotClient{ + + if githubToken == "" { + logging.Error("GitHub token is required for Copilot provider. Set GITHUB_TOKEN environment variable, configure it in opencode.json, or ensure GitHub CLI/Copilot is properly authenticated.") + return &copilotClient{ providerOptions: opts, options: copilotOpts, httpClient: httpClient, } - - // Use device code flow to get token - var err error - githubToken, err = tempClient.performDeviceCodeFlow() - if err != nil { - logging.Error("Device code authentication failed", "error", err) - fmt.Printf("āŒ Authentication failed: %v\n", err) - - // Return dummy client so app doesn't crash - return createDummyClient(opts, copilotOpts, httpClient) - } - } else if useDeviceFlow { - // Can't do auth flow in non-interactive mode - logging.Error("Authentication required but running in non-interactive mode") - fmt.Printf("āŒ Authentication required but running in non-interactive mode\n") - fmt.Printf("āš ļø Run OpenCode in interactive mode first to authenticate with Copilot\n") - - // Return dummy client - return createDummyClient(opts, copilotOpts, httpClient) } - - // If we have a GitHub token but no bearer token, exchange for bearer token - if githubToken != "" { - // Create temporary client for token exchange - tempClient := &copilotClient{ + + // Create a temporary client for token exchange + tempClient := &copilotClient{ + providerOptions: opts, + options: copilotOpts, + httpClient: httpClient, + } + + // Exchange GitHub token for bearer token + var err error + fmt.Printf("šŸ”„ Exchanging GitHub token for Copilot bearer token...\n") + bearerToken, err = tempClient.exchangeGitHubToken(githubToken) + if err != nil { + logging.Error("Failed to exchange GitHub token for Copilot bearer token", "error", err) + fmt.Printf("āŒ Failed to exchange GitHub token: %v\n", err) + return &copilotClient{ providerOptions: opts, options: copilotOpts, httpClient: httpClient, } - - // Exchange GitHub token for bearer token - var err error - logging.Debug("Exchanging GitHub token for Copilot bearer token") - fmt.Printf("šŸ”„ Exchanging GitHub token for Copilot bearer token...\n") - - bearerToken, err = tempClient.exchangeGitHubToken(githubToken) - if err != nil { - logging.Error("Failed to exchange GitHub token", "error", err) - fmt.Printf("āŒ Failed to exchange GitHub token: %v\n", err) - - // Return dummy client - return createDummyClient(opts, copilotOpts, httpClient) - } - } else { - // No GitHub token and can't trigger auth flow - logging.Error("No GitHub token available and cannot trigger authentication") - fmt.Printf("āŒ No GitHub token available and cannot trigger authentication\n") - - // Return dummy client - return createDummyClient(opts, copilotOpts, httpClient) + } else if bearerToken != "" { + fmt.Printf("āœ… Successfully obtained Copilot bearer token (length: %d)\n", len(bearerToken)) } } copilotOpts.bearerToken = bearerToken - // Step 4: With token ready, create client and proceed with normal operation - return createCopilotClient(opts, copilotOpts, httpClient, bearerToken) -} - -// createDummyClient creates a placeholder client when authentication fails -func createDummyClient(opts providerClientOptions, options copilotOptions, httpClient *http.Client) *copilotClient { - logging.Debug("Creating dummy Copilot client due to authentication issues") - fmt.Printf("ā„¹ļø Creating temporary client due to authentication issues\n") - - dummyClient := openai.NewClient( - option.WithBaseURL("https://api.githubcopilot.com"), - option.WithAPIKey("dummy-for-initialization"), - ) - - return &copilotClient{ - providerOptions: opts, - options: options, - client: dummyClient, - httpClient: httpClient, - } -} - -// createCopilotClient creates a fully configured client for GitHub Copilot -func createCopilotClient(opts providerClientOptions, options copilotOptions, httpClient *http.Client, bearerToken string) *copilotClient { // GitHub Copilot API base URL baseURL := "https://api.githubcopilot.com" - customURL := viper.GetString("providers.copilot.baseUrl") - if customURL != "" { - logging.Debug("Using custom baseUrl for Copilot from config", "baseUrl", customURL) - fmt.Printf("🌐 Using custom baseUrl for Copilot: %s\n", customURL) - baseURL = customURL - } - - // Make sure baseURL is set - if baseURL == "" { - logging.Error("Missing baseURL for Copilot client") - fmt.Printf("āŒ Missing baseURL for Copilot client\n") - return createDummyClient(opts, options, httpClient) - } - - // If no bearer token, return dummy client - if bearerToken == "" { - logging.Error("No bearer token available for Copilot client") - return createDummyClient(opts, options, httpClient) - } - // Create the proper client with all required options and headers - prefixLen := 10 - if len(bearerToken) < prefixLen { - prefixLen = len(bearerToken) - } - logging.Debug("Creating Copilot client with valid bearer token", - "baseURL", baseURL, - "model", opts.model.APIModel, - "token_length", len(bearerToken), - "bearerToken_prefix", bearerToken[:prefixLen]) - fmt.Printf("āœ… Creating Copilot client with valid bearer token\n") - - // Create OpenAI client with all required settings for Copilot openaiClientOptions := []option.RequestOption{ option.WithBaseURL(baseURL), option.WithAPIKey(bearerToken), // Use bearer token as API key - option.WithHeader("User-Agent", "GithubCopilot/1.133.0"), - option.WithHeader("Editor-Version", "vscode/1.78.0"), - option.WithHeader("Editor-Plugin-Version", "copilot-chat/0.8.0"), - option.WithHeader("Accept", "application/json"), - option.WithHeader("X-GitHub-Api-Version", "2022-11-28"), // Required GitHub API version } - // Add any extra headers from options - if options.extraHeaders != nil { - for key, value := range options.extraHeaders { + // Add GitHub Copilot specific headers + openaiClientOptions = append(openaiClientOptions, + option.WithHeader("Editor-Version", "OpenCode/1.0"), + option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"), + option.WithHeader("Copilot-Integration-Id", "vscode-chat"), + ) + + // Add any extra headers + if copilotOpts.extraHeaders != nil { + for key, value := range copilotOpts.extraHeaders { openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value)) } } - // Create client with proper headers client := openai.NewClient(openaiClientOptions...) - fmt.Printf("āœ… GitHub Copilot client created successfully\n") - fmt.Printf("āœ… Using model: %s (%s)\n", opts.model.Name, opts.model.APIModel) - - // Create and return the copilotClient + // logging.Debug("Copilot client created", "opts", opts, "copilotOpts", copilotOpts, "model", opts.model) return &copilotClient{ providerOptions: opts, - options: copilotOptions{ - reasoningEffort: options.reasoningEffort, - extraHeaders: options.extraHeaders, - bearerToken: bearerToken, - }, + options: copilotOpts, client: client, httpClient: httpClient, } @@ -841,30 +722,8 @@ func (c *copilotClient) finishReason(reason string) message.FinishReason { } func (c *copilotClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams { - logging.Debug("Copilot preparedParams start", "modelID", c.providerOptions.model.ID, "apiModel", c.providerOptions.model.APIModel) - - // For Claude models, use the proper model name format - apiModel := c.providerOptions.model.APIModel - if c.isAnthropicModel() { - logging.Debug("Using Claude model", "original_api_model", apiModel) - fmt.Printf("šŸ“¢ Using Claude model: %s through GitHub Copilot\n", apiModel) - // The Claude models might need a special format for Copilot - if strings.HasPrefix(apiModel, "claude-") { - logging.Debug("Using Claude model with standard format", "api_model", apiModel) - fmt.Printf("šŸ“¢ Claude model name format: %s\n", apiModel) - } - } else { - fmt.Printf("šŸ“¢ Using non-Claude model: %s through GitHub Copilot\n", apiModel) - } - - // Log important model details - logging.Debug("Model details", - "name", c.providerOptions.model.Name, - "context_window", c.providerOptions.model.ContextWindow, - "max_tokens", c.providerOptions.maxTokens) - params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(apiModel), + Model: openai.ChatModel(c.providerOptions.model.APIModel), Messages: messages, Tools: tools, } @@ -885,10 +744,6 @@ func (c *copilotClient) preparedParams(messages []openai.ChatCompletionMessagePa params.MaxTokens = openai.Int(c.providerOptions.maxTokens) } - jsonData, err := json.Marshal(params) - if err == nil { - logging.Debug("Copilot request parameters", "params", string(jsonData)) - } return params } @@ -897,81 +752,36 @@ func (c *copilotClient) send(ctx context.Context, messages []message.Message, to cfg := config.Get() var sessionId string requestSeqId := (len(messages) + 1) / 2 - - // Always log parameters in debug mode - jsonData, _ := json.Marshal(params) - logging.Debug("Copilot API request parameters", "model", params.Model, "messages_count", len(params.Messages), "tools_count", len(params.Tools)) - logging.Debug("Copilot API full request", "params", string(jsonData)) - logging.Debug("Model being used for request", "model_id", c.providerOptions.model.ID, "api_model", c.providerOptions.model.APIModel) - if cfg.Debug { + // jsonData, _ := json.Marshal(params) + // logging.Debug("Prepared messages", "messages", string(jsonData)) if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok { sessionId = sid } + jsonData, _ := json.Marshal(params) if sessionId != "" { filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, params) logging.Debug("Prepared messages", "filepath", filepath) + } else { + logging.Debug("Prepared messages", "messages", string(jsonData)) } } attempts := 0 for { attempts++ - fmt.Printf("šŸ”„ Sending request to GitHub Copilot API...\n") - logging.Debug("About to send Copilot API request", "model", string(params.Model), "max_tokens_param", params.MaxTokens, "max_completion_tokens_param", params.MaxCompletionTokens, "tools_count", len(tools)) - logging.Debug("Sending request to Copilot API", "baseURL", "https://api.githubcopilot.com", "model", params.Model) - - // Dump headers for debugging - logging.Debug("Request is being made with OpenAI client", "client_type", fmt.Sprintf("%T", c.client)) - fmt.Printf("šŸ“‹ Making request with client: %T\n", c.client) - - // Dump important request parameters - fmt.Printf("šŸ“‹ Request model: %s\n", params.Model) - fmt.Printf("šŸ“‹ Request max tokens: %d\n", c.providerOptions.maxTokens) - - // Make the API request copilotResponse, err := c.client.Chat.Completions.New( ctx, params, ) - logging.Debug("Received response from Copilot API", "error", err != nil) // If there is an error we are going to see if we can retry the call if err != nil { - fmt.Printf("āŒ Copilot API request failed: %v\n", err) - logging.Error("Copilot API request failed", "error", err) - var apierr *openai.Error - if errors.As(err, &apierr) { - fmt.Printf("āŒ API Error: Status %d, Type %s\n", apierr.StatusCode, apierr.Type) - fmt.Printf("āŒ Error Response: %s\n", apierr.RawJSON()) - logging.Error("Copilot API error details", "status", apierr.StatusCode, "type", apierr.Type, "raw_json", apierr.RawJSON()) - - // Check if this is a model not found error - if apierr.StatusCode == 400 { - if strings.Contains(string(apierr.RawJSON()), "model") { - fmt.Printf("āš ļø This might be because the model '%s' is not available or not supported by GitHub Copilot.\n", params.Model) - fmt.Printf("āš ļø Automatically trying 'copilot.gpt-4o' instead...\n") - - // If this was a Claude model, try with GPT-4o as fallback - if c.isAnthropicModel() && attempts < 2 { - logging.Info("Trying with GPT-4o model instead of Claude") - params.Model = "gpt-4o" - continue - } else { - fmt.Printf("āš ļø Try manually changing your config to use 'copilot.gpt-4o' instead.\n") - } - } - } - } - retry, after, retryErr := c.shouldRetry(attempts, err) if retryErr != nil { - fmt.Printf("āŒ Cannot retry: %v\n", retryErr) - logging.Error("Retry error", "error", retryErr) return nil, retryErr } if retry { - fmt.Printf("ā³ Retrying in %d ms (attempt %d of %d)...\n", after, attempts, maxRetries) logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) select { case <-ctx.Done(): @@ -981,8 +791,6 @@ func (c *copilotClient) send(ctx context.Context, messages []message.Message, to } } return nil, retryErr - } else { - fmt.Printf("āœ… Successful response from GitHub Copilot API!\n") } content := "" @@ -1212,17 +1020,6 @@ func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error // Note: This is a simplified approach. In a production system, // you might want to recreate the entire client with the new token logging.Info("Refreshed Copilot bearer token") - - // Recreate the entire client with the new token - baseURL := "https://api.githubcopilot.com" - c.client = openai.NewClient( - option.WithBaseURL(baseURL), - option.WithAPIKey(newBearerToken), - option.WithHeader("Editor-Version", "OpenCode/1.0"), - option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"), - option.WithHeader("Copilot-Integration-Id", "vscode-chat"), - ) - return true, 1000, nil // Retry immediately with new token } logging.Error("Failed to refresh Copilot bearer token", "error", tokenErr) @@ -1231,14 +1028,7 @@ func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error } logging.Debug("Copilot API Error", "status", apierr.StatusCode, "headers", apierr.Response.Header, "body", apierr.RawJSON()) - if apierr.StatusCode == 400 { - // Special handling for 400 Bad Request - logging.Error("Copilot API 400 Bad Request error", "error", err.Error(), "response_body", apierr.RawJSON()) - - // Try to extract more details from the error - detailedErr := fmt.Errorf("Copilot API 400 Bad Request: %s", apierr.Error()) - return false, 0, detailedErr - } else if apierr.StatusCode != 429 && apierr.StatusCode != 500 { + if apierr.StatusCode != 429 && apierr.StatusCode != 500 { return false, 0, err } @@ -1318,4 +1108,5 @@ func WithCopilotBearerToken(bearerToken string) CopilotOption { return func(options *copilotOptions) { options.bearerToken = bearerToken } -} \ No newline at end of file +} + From 71ae8147845643a2d72dd32dae47ed445498bd17 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 21:16:52 +0200 Subject: [PATCH 6/8] Clean and simplify Copilot token handling code - Remove redundant token saving functions - Combine hosts.json handling in a single function - Simplify environment variable checks - Remove excessive debug logging - Streamline error handling and code paths --- internal/llm/provider/copilot.go | 179 +++++-------------------------- 1 file changed, 24 insertions(+), 155 deletions(-) diff --git a/internal/llm/provider/copilot.go b/internal/llm/provider/copilot.go index 8e86ea2e..eab88de0 100644 --- a/internal/llm/provider/copilot.go +++ b/internal/llm/provider/copilot.go @@ -224,37 +224,8 @@ func (c *copilotClient) performDeviceCodeFlow() (string, error) { if finalToken != "" { fmt.Printf("Successfully authenticated with GitHub!\n") - // First set environment variables for immediate use - os.Setenv("GITHUB_TOKEN", finalToken) - os.Setenv("GITHUB_COPILOT_TOKEN", finalToken) - - // Save token directly to file - homeDir, err := os.UserHomeDir() - if err != nil { - homeDir = os.Getenv("HOME") - } - - // Set config directory - configDir := filepath.Join(homeDir, ".config") - - // Ensure directory exists - copilotDir := filepath.Join(configDir, "github-copilot") - if err := os.MkdirAll(copilotDir, 0755); err != nil { - logging.Error("Failed to create github-copilot directory", "error", err) - } - - // Create hosts.json file - hostsFile := filepath.Join(copilotDir, "hosts.json") - - // Create the JSON structure - jsonData := []byte(`{"github.com":{"oauth_token":"` + finalToken + `"}}`) - - // Write the file - if err := os.WriteFile(hostsFile, jsonData, 0600); err != nil { - logging.Error("Failed to write hosts.json", "error", err) - } else { - logging.Info("Saved GitHub token to hosts.json for future use") - } + // Save token to standard locations + saveGitHubToken(finalToken) return finalToken, nil } else { @@ -296,10 +267,9 @@ func saveGitHubToken(token string) { return } - // Get the home directory directly first + // Get the home directory homeDir, err := os.UserHomeDir() if err != nil { - logging.Error("Failed to get user home directory", "error", err) homeDir = os.Getenv("HOME") // Fallback to HOME environment variable if homeDir == "" { logging.Error("Failed to determine home directory") @@ -311,146 +281,42 @@ func saveGitHubToken(token string) { var configDir string if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { configDir = xdgConfig - logging.Debug("Using XDG_CONFIG_HOME for config directory", "path", configDir) } else if runtime.GOOS == "windows" { if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { configDir = localAppData - logging.Debug("Using LOCALAPPDATA for config directory", "path", configDir) } else { configDir = filepath.Join(homeDir, "AppData", "Local") - logging.Debug("Using default Windows AppData path", "path", configDir) } } else { configDir = filepath.Join(homeDir, ".config") - logging.Debug("Using default .config directory", "path", configDir) } - // First try saving to hosts.json (GitHub Copilot VS Code format) - hostsResult := saveToHostsFile(configDir, token) - - // Also try saving to apps.json (Neovim Copilot plugin format) as fallback - saveToAppsFile(configDir, token) - - // Set environment variables for immediate use (even if file saving failed) - os.Setenv("GITHUB_TOKEN", token) - os.Setenv("GITHUB_COPILOT_TOKEN", token) - - if !hostsResult { - logging.Warn("Failed to save token to hosts.json, but environment variables are set for this session") - } - - return -} - -// saveToHostsFile saves token to hosts.json (VS Code format) -func saveToHostsFile(configDir string, token string) bool { // Create the directory if it doesn't exist copilotDir := filepath.Join(configDir, "github-copilot") - logging.Debug("Using copilot config directory", "path", copilotDir) - if err := os.MkdirAll(copilotDir, 0755); err != nil { logging.Error("Failed to create github-copilot directory", "error", err) - return false + return } - // Create the hosts.json file - hostsFile := filepath.Join(copilotDir, "hosts.json") - logging.Debug("Will save hosts file to", "path", hostsFile) + // Save to both files for maximum compatibility - // Create the JSON structure + // 1. Save to hosts.json (VS Code format) + hostsFile := filepath.Join(copilotDir, "hosts.json") hostsData := map[string]map[string]interface{}{ "github.com": { "oauth_token": token, }, } - - // Marshal to JSON - jsonData, err := json.MarshalIndent(hostsData, "", " ") - if err != nil { - logging.Error("Failed to marshal hosts.json", "error", err) - return false - } - - // First ensure the directory exists - if _, err := os.Stat(copilotDir); os.IsNotExist(err) { - logging.Debug("Creating directory again to be sure", "path", copilotDir) - if err := os.MkdirAll(copilotDir, 0755); err != nil { - logging.Error("Failed to create directory during second attempt", "error", err) - return false - } - } - - // Write the file - if err := os.WriteFile(hostsFile, jsonData, 0600); err != nil { - logging.Error("Failed to write hosts.json", "error", err) - fmt.Printf("āš ļø Failed to save token to %s: %v\n", hostsFile, err) - return false - } - - logging.Info("Saved GitHub token to hosts.json for future use") - fmt.Printf("āœ… Token saved to: %s\n", hostsFile) - - // Verify file exists and has content - if info, err := os.Stat(hostsFile); err == nil { - fmt.Printf("āœ… Verified file exists, size: %d bytes\n", info.Size()) - - // Double-check file contents - content, readErr := os.ReadFile(hostsFile) - if readErr != nil { - logging.Error("Failed to verify hosts.json contents", "error", readErr) - return false + hostsJSON, err := json.Marshal(hostsData) + if err == nil { + if err := os.WriteFile(hostsFile, hostsJSON, 0600); err != nil { + logging.Error("Failed to write hosts.json", "error", err) } - - if len(content) == 0 { - logging.Error("hosts.json exists but is empty") - return false - } - - fmt.Printf("āœ… Verified hosts.json has content\n") - return true - } else { - logging.Error("Failed to verify hosts.json exists after writing", "error", err) - fmt.Printf("āš ļø Could not verify file: %v\n", err) - return false - } -} - -// saveToAppsFile saves token to apps.json (Neovim format) -func saveToAppsFile(configDir string, token string) bool { - // Create the directory if it doesn't exist - copilotDir := filepath.Join(configDir, "github-copilot") - if err := os.MkdirAll(copilotDir, 0755); err != nil { - logging.Error("Failed to create github-copilot directory for apps.json", "error", err) - return false } - // Create the apps.json file (alternative format used by some tools) - appsFile := filepath.Join(copilotDir, "apps.json") - - // Create the JSON structure - appsData := map[string]interface{}{ - "github-copilot/copilot.vim": map[string]interface{}{ - "oauth_token": token, - }, - } - - // Marshal to JSON - jsonData, err := json.MarshalIndent(appsData, "", " ") - if err != nil { - logging.Error("Failed to marshal apps.json", "error", err) - return false - } - - // Write the file - if err := os.WriteFile(appsFile, jsonData, 0600); err != nil { - logging.Error("Failed to write apps.json", "error", err) - fmt.Printf("āš ļø Failed to save token to %s: %v\n", appsFile, err) - return false - } - - logging.Info("Also saved GitHub token to apps.json for future use") - fmt.Printf("āœ… Token also saved to: %s\n", appsFile) - return true + // Set environment variables for immediate use + os.Setenv("GITHUB_TOKEN", token) + os.Setenv("GITHUB_COPILOT_TOKEN", token) } // exchangeGitHubToken exchanges a GitHub token for a Copilot bearer token @@ -505,11 +371,16 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { // Try to get GitHub token from multiple sources var githubToken string - // 1. Environment variable - githubToken = os.Getenv("GITHUB_TOKEN") + // 1. Check environment variables first (fastest) + for _, envVar := range []string{"GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GH_COPILOT_TOKEN"} { + if token := os.Getenv(envVar); token != "" { + githubToken = token + break + } + } // 2. API key from options - if githubToken == "" { + if githubToken == "" && opts.apiKey != "" { githubToken = opts.apiKey } @@ -545,11 +416,9 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { // Double-check token after device flow if githubToken != "" { - fmt.Printf("āœ… Successfully obtained GitHub token (length: %d)\n", len(githubToken)) + fmt.Printf("āœ… Successfully obtained GitHub token\n") // Set it directly in opts.apiKey opts.apiKey = githubToken - // Also set environment variable - os.Setenv("GITHUB_TOKEN", githubToken) } } else { logging.Debug("Failed to load GitHub token from standard locations", "error", err) @@ -589,7 +458,7 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { httpClient: httpClient, } } else if bearerToken != "" { - fmt.Printf("āœ… Successfully obtained Copilot bearer token (length: %d)\n", len(bearerToken)) + fmt.Printf("āœ… Successfully obtained Copilot bearer token\n") } } From 1f643522e5688233ed4708b6331dffcc81396c28 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Fri, 4 Jul 2025 21:37:48 +0200 Subject: [PATCH 7/8] Remove unnecessary configuration validation log message --- internal/config/config.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fdd7db99..c74a939e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -640,8 +640,6 @@ func Validate() error { return fmt.Errorf("config not loaded") } - logging.Debug("Starting configuration validation") - // Validate agent models for name, agent := range cfg.Agents { logging.Debug("Validating agent", "name", name, "model", agent.Model) From 5abea9309cf7e6ea0626d06ab1843b53e267b9d9 Mon Sep 17 00:00:00 2001 From: David Collado Sela Date: Mon, 7 Jul 2025 08:51:31 +0200 Subject: [PATCH 8/8] Fix GitHub Copilot authentication conflicts by prioritizing GITHUB_COPILOT_TOKEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the issue where GITHUB_TOKEN conflicts with GitHub CLI tools by: - Prioritizing GITHUB_COPILOT_TOKEN environment variable over GITHUB_TOKEN - Updating token loading logic to avoid CLI token conflicts - Modifying error messages to reference the correct token variable - Updating documentation to reflect the new token preference This resolves 401 Unauthorized errors when users have standard GitHub tokens without Copilot scope. šŸ¤– Generated with opencode Co-Authored-By: opencode --- README.md | 4 ++-- internal/config/config.go | 36 +++++++++++++++++--------------- internal/llm/provider/copilot.go | 18 +++++++--------- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 76959e5b..87a88aee 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ You can configure OpenCode using environment variables: | `ANTHROPIC_API_KEY` | For Claude models | | `OPENAI_API_KEY` | For OpenAI models | | `GEMINI_API_KEY` | For Google Gemini models | -| `GITHUB_TOKEN` | For Github Copilot models (see [Using GitHub Copilot](#using-github-copilot)) | +| `GITHUB_COPILOT_TOKEN` | For Github Copilot models (see [Using GitHub Copilot](#using-github-copilot)) | | `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) | | `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) | | `GROQ_API_KEY` | For Groq models | @@ -648,7 +648,7 @@ the tool with your github account. This should create a github token at one of t - ~/.config/github-copilot/[hosts,apps].json - $XDG_CONFIG_HOME/github-copilot/[hosts,apps].json -If using an explicit github token, you may either set the $GITHUB_TOKEN environment variable or add it to the opencode.json config file at `providers.copilot.apiKey`. +If using an explicit github token, you may either set the $GITHUB_COPILOT_TOKEN environment variable or add it to the opencode.json config file at `providers.copilot.apiKey`. ## Using a self-hosted model provider diff --git a/internal/config/config.go b/internal/config/config.go index c74a939e..7b01130a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -969,18 +969,24 @@ func UpdateTheme(themeName string) error { }) } -// LoadGitHubToken loads GitHub token from config files, environment variables, or other sources +// LoadGitHubToken loads GitHub Copilot token from config files, environment variables, or other sources // Returns the token if found, or a special error "no_copilot_token" if no token is found -// This follows the 4-step flow: 1. Check if Copilot is enabled, 2. Check for token in config folder +// This prioritizes GITHUB_COPILOT_TOKEN to avoid conflicts with standard GitHub CLI tools func LoadGitHubToken() (string, error) { - logging.Debug("Attempting to load GitHub token for Copilot") + logging.Debug("Attempting to load GitHub Copilot token") - // First check environment variables - for _, envName := range []string{"GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GH_COPILOT_TOKEN"} { - if token := os.Getenv(envName); token != "" { - logging.Debug("Found GitHub token in environment variable", "env_var", envName) - return token, nil - } + // 1. Environment variable (prioritize Copilot-specific token) + var token string + if token = os.Getenv("GITHUB_COPILOT_TOKEN"); token != "" { + logging.Debug("Loaded GitHub Copilot API key from GITHUB_COPILOT_TOKEN environment variable") + return token, nil + } + + // 2. API key from config options + cfg := Get() + if token = cfg.Providers[models.ProviderCopilot].APIKey; token != "" { + logging.Debug("Loaded GitHub Copilot API key from the '.opencode.json' configuration file") + return token, nil } // Get config directory @@ -997,14 +1003,12 @@ func LoadGitHubToken() (string, error) { configDir = filepath.Join(os.Getenv("HOME"), ".config") } - // Check standard Copilot config files + // 3. Try both hosts.json and apps.json files filePaths := []string{ filepath.Join(configDir, "github-copilot", "hosts.json"), filepath.Join(configDir, "github-copilot", "apps.json"), } - logging.Debug("Checking Copilot config files") - for _, filePath := range filePaths { data, err := os.ReadFile(filePath) if err != nil { @@ -1016,12 +1020,10 @@ func LoadGitHubToken() (string, error) { continue } - // For hosts.json, we expect keys like "github.com" - // For apps.json, we expect keys like "github.com:Iv1.b507a08c87ecfe98" for key, value := range config { if strings.Contains(key, "github.com") { - if oauthToken, ok := value["oauth_token"].(string); ok && oauthToken != "" { - logging.Debug("Found GitHub token in config file", "path", filePath) + if oauthToken, ok := value["oauth_token"].(string); ok { + logging.Debug("Loaded GitHub Copilot token from the standard user configuration file") return oauthToken, nil } } @@ -1029,7 +1031,7 @@ func LoadGitHubToken() (string, error) { } // Return a special error that indicates we need to use device code flow - logging.Debug("No Copilot token found - will need to use device code flow") + logging.Debug("No GitHub Copilot token found - will need to use device code flow") return "", fmt.Errorf("no_copilot_token") } diff --git a/internal/llm/provider/copilot.go b/internal/llm/provider/copilot.go index eab88de0..1fd10f25 100644 --- a/internal/llm/provider/copilot.go +++ b/internal/llm/provider/copilot.go @@ -314,8 +314,7 @@ func saveGitHubToken(token string) { } } - // Set environment variables for immediate use - os.Setenv("GITHUB_TOKEN", token) + // Set environment variables for immediate use (only GITHUB_COPILOT_TOKEN) os.Setenv("GITHUB_COPILOT_TOKEN", token) } @@ -371,12 +370,9 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { // Try to get GitHub token from multiple sources var githubToken string - // 1. Check environment variables first (fastest) - for _, envVar := range []string{"GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GH_COPILOT_TOKEN"} { - if token := os.Getenv(envVar); token != "" { - githubToken = token - break - } + // 1. Check GITHUB_COPILOT_TOKEN environment variable first + if token := os.Getenv("GITHUB_COPILOT_TOKEN"); token != "" { + githubToken = token } // 2. API key from options @@ -430,7 +426,7 @@ func newCopilotClient(opts providerClientOptions) CopilotClient { } if githubToken == "" { - logging.Error("GitHub token is required for Copilot provider. Set GITHUB_TOKEN environment variable, configure it in opencode.json, or ensure GitHub CLI/Copilot is properly authenticated.") + logging.Error("GitHub Copilot token is required for Copilot provider. Set GITHUB_COPILOT_TOKEN environment variable, configure it in opencode.json, or ensure GitHub Copilot is properly authenticated.") return &copilotClient{ providerOptions: opts, options: copilotOpts, @@ -864,8 +860,8 @@ func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error // Try to refresh the bearer token var githubToken string - // 1. Environment variable - githubToken = os.Getenv("GITHUB_TOKEN") + // 1. Check GITHUB_COPILOT_TOKEN environment variable + githubToken = os.Getenv("GITHUB_COPILOT_TOKEN") // 2. API key from options if githubToken == "" {