on main help sign in
feat: add github copilot provider (#230)
b9bedbaBryan Vaz
.gitignore+1
@@ -44,3 +44,4 @@ Thumbs.db
44.opencode/
44.opencode/
45
45
46opencode
46opencode
47+opencode.md
README.md+56−16
@@ -96,22 +96,23 @@ You can enable or disable this feature in your configuration file:
96
96
97You can configure OpenCode using environment variables:
97You can configure OpenCode using environment variables:
98
98
99−| Environment Variable | Purpose |
99+| Environment Variable | Purpose |
100−| -------------------------- | ------------------------------------------------------ |
100+| -------------------------- | -------------------------------------------------------------------------------- |
101−| `ANTHROPIC_API_KEY` | For Claude models |
101+| `ANTHROPIC_API_KEY` | For Claude models |
102−| `OPENAI_API_KEY` | For OpenAI models |
102+| `OPENAI_API_KEY` | For OpenAI models |
103−| `GEMINI_API_KEY` | For Google Gemini models |
103+| `GEMINI_API_KEY` | For Google Gemini models |
104+| `GITHUB_TOKEN` | For Github Copilot models (see [Using Github Copilot](#using-github-copilot)) |
104−| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
105+| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
105−| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
106+| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
106−| `GROQ_API_KEY` | For Groq models |
107+| `GROQ_API_KEY` | For Groq models |
107−| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
108+| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
108−| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
109+| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
109−| `AWS_REGION` | For AWS Bedrock (Claude) |
110+| `AWS_REGION` | For AWS Bedrock (Claude) |
110−| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
111+| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
111−| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
112+| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
112−| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
113+| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
113−| `LOCAL_ENDPOINT` | For self-hosted models |
114+| `LOCAL_ENDPOINT` | For self-hosted models |
114−| `SHELL` | Default shell to use (if not specified in config) |
115+| `SHELL` | Default shell to use (if not specified in config) |
115
116
116### Shell Configuration
117### Shell Configuration
117
118
@@ -146,6 +147,9 @@ This is useful if you want to use a different shell than your default system she
146 "apiKey": "your-api-key",
147 "apiKey": "your-api-key",
147 "disabled": false
148 "disabled": false
148 },
149 },
150+ "copilot": {
151+ "disabled": false
152+ },
149 "groq": {
153 "groq": {
150 "apiKey": "your-api-key",
154 "apiKey": "your-api-key",
151 "disabled": false
155 "disabled": false
@@ -216,6 +220,23 @@ OpenCode supports a variety of AI models from different providers:
216- Claude 3 Haiku
220- Claude 3 Haiku
217- Claude 3 Opus
221- Claude 3 Opus
218
222
223+### GitHub Copilot
224+
225+- GPT-3.5 Turbo
226+- GPT-4
227+- GPT-4o
228+- GPT-4o Mini
229+- GPT-4.1
230+- Claude 3.5 Sonnet
231+- Claude 3.7 Sonnet
232+- Claude 3.7 Sonnet Thinking
233+- Claude Sonnet 4
234+- O1
235+- O3 Mini
236+- O4 Mini
237+- Gemini 2.0 Flash
238+- Gemini 2.5 Pro
239+
219### Google
240### Google
220
241
221- Gemini 2.5
242- Gemini 2.5
@@ -579,6 +600,25 @@ The AI assistant can access LSP features through the `diagnostics` tool, allowin
579
600
580While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
601While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
581
602
603+## Using Github Copilot
604+
605+_Copilot support is currently experimental._
606+
607+### Requirements
608+- [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings
609+- One of:
610+ - VSCode Github Copilot chat extension
611+ - Github `gh` CLI
612+ - Neovim Github Copilot plugin (`copilot.vim` or `copilot.lua`)
613+ - Github token with copilot permissions
614+
615+If using one of the above plugins or cli tools, make sure you use the authenticate
616+the tool with your github account. This should create a github token at one of the following locations:
617+- ~/.config/github-copilot/[hosts,apps].json
618+- $XDG_CONFIG_HOME/github-copilot/[hosts,apps].json
619+
620+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`.
621+
582## Using a self-hosted model provider
622## Using a self-hosted model provider
583
623
584OpenCode can also load and use models from a self-hosted (OpenAI-like) provider.
624OpenCode can also load and use models from a self-hosted (OpenAI-like) provider.
internal/config/config.go+108−8
@@ -7,6 +7,7 @@ import (
7 "log/slog"
7 "log/slog"
8 "os"
8 "os"
9 "path/filepath"
9 "path/filepath"
10+ "runtime"
10 "strings"
11 "strings"
11
12
12 "github.com/opencode-ai/opencode/internal/llm/models"
13 "github.com/opencode-ai/opencode/internal/llm/models"
@@ -161,6 +162,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
161 }
162 }
162 if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
163 if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
163 loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
164 loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
165+ messagesPath := fmt.Sprintf("%s/%s", cfg.Data.Directory, "messages")
164
166
165 // if file does not exist create it
167 // if file does not exist create it
166 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
168 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
@@ -172,6 +174,13 @@ func Load(workingDir string, debug bool) (*Config, error) {
172 }
174 }
173 }
175 }
174
176
177+ if _, err := os.Stat(messagesPath); os.IsNotExist(err) {
178+ if err := os.MkdirAll(messagesPath, 0o756); err != nil {
179+ return cfg, fmt.Errorf("failed to create directory: %w", err)
180+ }
181+ }
182+ logging.MessageDir = messagesPath
183+
175 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
184 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
176 if err != nil {
185 if err != nil {
177 return cfg, fmt.Errorf("failed to open log file: %w", err)
186 return cfg, fmt.Errorf("failed to open log file: %w", err)
@@ -245,6 +254,7 @@ func setDefaults(debug bool) {
245// environment variables and configuration file.
254// environment variables and configuration file.
246func setProviderDefaults() {
255func setProviderDefaults() {
247 // Set all API keys we can find in the environment
256 // Set all API keys we can find in the environment
257+ // Note: Viper does not default if the json apiKey is ""
248 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
258 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
249 viper.SetDefault("providers.anthropic.apiKey", apiKey)
259 viper.SetDefault("providers.anthropic.apiKey", apiKey)
250 }
260 }
@@ -267,16 +277,32 @@ func setProviderDefaults() {
267 // api-key may be empty when using Entra ID credentials – that's okay
277 // api-key may be empty when using Entra ID credentials – that's okay
268 viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
278 viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
269 }
279 }
280+ if apiKey, err := LoadGitHubToken(); err == nil && apiKey != "" {
281+ viper.SetDefault("providers.copilot.apiKey", apiKey)
282+ if viper.GetString("providers.copilot.apiKey") == "" {
283+ viper.Set("providers.copilot.apiKey", apiKey)
284+ }
285+ }
270
286
271 // Use this order to set the default models
287 // Use this order to set the default models
272− // 1. Anthropic
288+ // 1. Copilot
273− // 2. OpenAI
289+ // 2. Anthropic
274− // 3. Google Gemini
290+ // 3. OpenAI
275− // 4. Groq
291+ // 4. Google Gemini
276− // 5. OpenRouter
292+ // 5. Groq
293+ // 6. OpenRouter
277− // 6. AWS Bedrock
294+ // 7. AWS Bedrock
278− // 7. Azure
295+ // 8. Azure
279− // 8. Google Cloud VertexAI
296+ // 9. Google Cloud VertexAI
297+
298+ // copilot configuration
299+ if key := viper.GetString("providers.copilot.apiKey"); strings.TrimSpace(key) != "" {
300+ viper.SetDefault("agents.coder.model", models.CopilotGPT4o)
301+ viper.SetDefault("agents.summarizer.model", models.CopilotGPT4o)
302+ viper.SetDefault("agents.task.model", models.CopilotGPT4o)
303+ viper.SetDefault("agents.title.model", models.CopilotGPT4o)
304+ return
305+ }
280
306
281 // Anthropic configuration
307 // Anthropic configuration
282 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
308 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
@@ -399,6 +425,14 @@ func hasVertexAICredentials() bool {
399 return false
425 return false
400}
426}
401
427
428+func hasCopilotCredentials() bool {
429+ // Check for explicit Copilot parameters
430+ if token, _ := LoadGitHubToken(); token != "" {
431+ return true
432+ }
433+ return false
434+}
435+
402// readConfig handles the result of reading a configuration file.
436// readConfig handles the result of reading a configuration file.
403func readConfig(err error) error {
437func readConfig(err error) error {
404 if err == nil {
438 if err == nil {
@@ -440,6 +474,9 @@ func applyDefaultValues() {
440// It validates model IDs and providers, ensuring they are supported.
474// It validates model IDs and providers, ensuring they are supported.
441func validateAgent(cfg *Config, name AgentName, agent Agent) error {
475func validateAgent(cfg *Config, name AgentName, agent Agent) error {
442 // Check if model exists
476 // Check if model exists
477+ // TODO: If a copilot model is specified, but model is not found,
478+ // it might be new model. The https://api.githubcopilot.com/models
479+ // endpoint should be queried to validate if the model is supported.
443 model, modelExists := models.SupportedModels[agent.Model]
480 model, modelExists := models.SupportedModels[agent.Model]
444 if !modelExists {
481 if !modelExists {
445 logging.Warn("unsupported model configured, reverting to default",
482 logging.Warn("unsupported model configured, reverting to default",
@@ -584,6 +621,7 @@ func Validate() error {
584 // Validate providers
621 // Validate providers
585 for provider, providerCfg := range cfg.Providers {
622 for provider, providerCfg := range cfg.Providers {
586 if providerCfg.APIKey == "" && !providerCfg.Disabled {
623 if providerCfg.APIKey == "" && !providerCfg.Disabled {
624+ fmt.Printf("provider has no API key, marking as disabled %s", provider)
587 logging.Warn("provider has no API key, marking as disabled", "provider", provider)
625 logging.Warn("provider has no API key, marking as disabled", "provider", provider)
588 providerCfg.Disabled = true
626 providerCfg.Disabled = true
589 cfg.Providers[provider] = providerCfg
627 cfg.Providers[provider] = providerCfg
@@ -631,6 +669,18 @@ func getProviderAPIKey(provider models.ModelProvider) string {
631
669
632// setDefaultModelForAgent sets a default model for an agent based on available providers
670// setDefaultModelForAgent sets a default model for an agent based on available providers
633func setDefaultModelForAgent(agent AgentName) bool {
671func setDefaultModelForAgent(agent AgentName) bool {
672+ if hasCopilotCredentials() {
673+ maxTokens := int64(5000)
674+ if agent == AgentTitle {
675+ maxTokens = 80
676+ }
677+
678+ cfg.Agents[agent] = Agent{
679+ Model: models.CopilotGPT4o,
680+ MaxTokens: maxTokens,
681+ }
682+ return true
683+ }
634 // Check providers in order of preference
684 // Check providers in order of preference
635 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
685 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
636 maxTokens := int64(5000)
686 maxTokens := int64(5000)
@@ -878,3 +928,53 @@ func UpdateTheme(themeName string) error {
878 config.TUI.Theme = themeName
928 config.TUI.Theme = themeName
879 })
929 })
880}
930}
931+
932+// Tries to load Github token from all possible locations
933+func LoadGitHubToken() (string, error) {
934+ // First check environment variable
935+ if token := os.Getenv("GITHUB_TOKEN"); token != "" {
936+ return token, nil
937+ }
938+
939+ // Get config directory
940+ var configDir string
941+ if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
942+ configDir = xdgConfig
943+ } else if runtime.GOOS == "windows" {
944+ if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
945+ configDir = localAppData
946+ } else {
947+ configDir = filepath.Join(os.Getenv("HOME"), "AppData", "Local")
948+ }
949+ } else {
950+ configDir = filepath.Join(os.Getenv("HOME"), ".config")
951+ }
952+
953+ // Try both hosts.json and apps.json files
954+ filePaths := []string{
955+ filepath.Join(configDir, "github-copilot", "hosts.json"),
956+ filepath.Join(configDir, "github-copilot", "apps.json"),
957+ }
958+
959+ for _, filePath := range filePaths {
960+ data, err := os.ReadFile(filePath)
961+ if err != nil {
962+ continue
963+ }
964+
965+ var config map[string]map[string]interface{}
966+ if err := json.Unmarshal(data, &config); err != nil {
967+ continue
968+ }
969+
970+ for key, value := range config {
971+ if strings.Contains(key, "github.com") {
972+ if oauthToken, ok := value["oauth_token"].(string); ok {
973+ return oauthToken, nil
974+ }
975+ }
976+ }
977+ }
978+
979+ return "", fmt.Errorf("GitHub token not found in standard locations")
980+}
internal/llm/agent/agent.go+21−5
@@ -162,6 +162,7 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
162 if err != nil {
162 if err != nil {
163 return err
163 return err
164 }
164 }
165+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
165 parts := []message.ContentPart{message.TextContent{Text: content}}
166 parts := []message.ContentPart{message.TextContent{Text: content}}
166 response, err := a.titleProvider.SendMessages(
167 response, err := a.titleProvider.SendMessages(
167 ctx,
168 ctx,
@@ -230,6 +231,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
230}
231}
231
232
232func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
233func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
234+ cfg := config.Get()
233 // List existing messages; if none, start title generation asynchronously.
235 // List existing messages; if none, start title generation asynchronously.
234 msgs, err := a.messages.List(ctx, sessionID)
236 msgs, err := a.messages.List(ctx, sessionID)
235 if err != nil {
237 if err != nil {
@@ -288,7 +290,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
288 }
290 }
289 return a.err(fmt.Errorf("failed to process events: %w", err))
291 return a.err(fmt.Errorf("failed to process events: %w", err))
290 }
292 }
293+ if cfg.Debug {
294+ seqId := (len(msgHistory) + 1) / 2
295+ toolResultFilepath := logging.WriteToolResultsJson(sessionID, seqId, toolResults)
296+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", "{}", "filepath", toolResultFilepath)
297+ } else {
291− logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
298+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
299+ }
292 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
300 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
293 // We are not done, we need to respond with the tool response
301 // We are not done, we need to respond with the tool response
294 msgHistory = append(msgHistory, agentMessage, *toolResults)
302 msgHistory = append(msgHistory, agentMessage, *toolResults)
@@ -312,6 +320,7 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
312}
320}
313
321
314func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
322func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
323+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
315 eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
324 eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
316
325
317 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
326 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
@@ -325,7 +334,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
325
334
326 // Add the session and message ID into the context if needed by tools.
335 // Add the session and message ID into the context if needed by tools.
327 ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
336 ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
328− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
329
337
330 // Process each event in the stream.
338 // Process each event in the stream.
331 for event := range eventChan {
339 for event := range eventChan {
@@ -357,10 +365,17 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
357 default:
365 default:
358 // Continue processing
366 // Continue processing
359 var tool tools.BaseTool
367 var tool tools.BaseTool
360− for _, availableTools := range a.tools {
368+ for _, availableTool := range a.tools {
361− if availableTools.Info().Name == toolCall.Name {
369+ if availableTool.Info().Name == toolCall.Name {
362− tool = availableTools
370+ tool = availableTool
371+ break
363 }
372 }
373+ // Monkey patch for Copilot Sonnet-4 tool repetition obfuscation
374+ // if strings.HasPrefix(toolCall.Name, availableTool.Info().Name) &&
375+ // strings.HasPrefix(toolCall.Name, availableTool.Info().Name+availableTool.Info().Name) {
376+ // tool = availableTool
377+ // break
378+ // }
364 }
379 }
365
380
366 // Tool not found
381 // Tool not found
@@ -553,6 +568,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
553 a.Publish(pubsub.CreatedEvent, event)
568 a.Publish(pubsub.CreatedEvent, event)
554 return
569 return
555 }
570 }
571+ summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
556
572
557 if len(msgs) == 0 {
573 if len(msgs) == 0 {
558 event = AgentEvent{
574 event = AgentEvent{
internal/llm/models/copilot.gonew file+219
@@ -0,0 +1,219 @@
1+package models
2+
3+const (
4+ ProviderCopilot ModelProvider = "copilot"
5+
6+ // GitHub Copilot models
7+ CopilotGTP35Turbo ModelID = "copilot.gpt-3.5-turbo"
8+ CopilotGPT4o ModelID = "copilot.gpt-4o"
9+ CopilotGPT4oMini ModelID = "copilot.gpt-4o-mini"
10+ CopilotGPT41 ModelID = "copilot.gpt-4.1"
11+ CopilotClaude35 ModelID = "copilot.claude-3.5-sonnet"
12+ CopilotClaude37 ModelID = "copilot.claude-3.7-sonnet"
13+ CopilotClaude4 ModelID = "copilot.claude-sonnet-4"
14+ CopilotO1 ModelID = "copilot.o1"
15+ CopilotO3Mini ModelID = "copilot.o3-mini"
16+ CopilotO4Mini ModelID = "copilot.o4-mini"
17+ CopilotGemini20 ModelID = "copilot.gemini-2.0-flash"
18+ CopilotGemini25 ModelID = "copilot.gemini-2.5-pro"
19+ CopilotGPT4 ModelID = "copilot.gpt-4"
20+ CopilotClaude37Thought ModelID = "copilot.claude-3.7-sonnet-thought"
21+)
22+
23+var CopilotAnthropicModels = []ModelID{
24+ CopilotClaude35,
25+ CopilotClaude37,
26+ CopilotClaude37Thought,
27+ CopilotClaude4,
28+}
29+
30+// GitHub Copilot models available through GitHub's API
31+var CopilotModels = map[ModelID]Model{
32+ CopilotGTP35Turbo: {
33+ ID: CopilotGTP35Turbo,
34+ Name: "GitHub Copilot GPT-3.5-turbo",
35+ Provider: ProviderCopilot,
36+ APIModel: "gpt-3.5-turbo",
37+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
38+ CostPer1MInCached: 0.0,
39+ CostPer1MOutCached: 0.0,
40+ CostPer1MOut: 0.0,
41+ ContextWindow: 16_384,
42+ DefaultMaxTokens: 4096,
43+ SupportsAttachments: true,
44+ },
45+ CopilotGPT4o: {
46+ ID: CopilotGPT4o,
47+ Name: "GitHub Copilot GPT-4o",
48+ Provider: ProviderCopilot,
49+ APIModel: "gpt-4o",
50+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
51+ CostPer1MInCached: 0.0,
52+ CostPer1MOutCached: 0.0,
53+ CostPer1MOut: 0.0,
54+ ContextWindow: 128_000,
55+ DefaultMaxTokens: 16384,
56+ SupportsAttachments: true,
57+ },
58+ CopilotGPT4oMini: {
59+ ID: CopilotGPT4oMini,
60+ Name: "GitHub Copilot GPT-4o Mini",
61+ Provider: ProviderCopilot,
62+ APIModel: "gpt-4o-mini",
63+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
64+ CostPer1MInCached: 0.0,
65+ CostPer1MOutCached: 0.0,
66+ CostPer1MOut: 0.0,
67+ ContextWindow: 128_000,
68+ DefaultMaxTokens: 4096,
69+ SupportsAttachments: true,
70+ },
71+ CopilotGPT41: {
72+ ID: CopilotGPT41,
73+ Name: "GitHub Copilot GPT-4.1",
74+ Provider: ProviderCopilot,
75+ APIModel: "gpt-4.1",
76+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
77+ CostPer1MInCached: 0.0,
78+ CostPer1MOutCached: 0.0,
79+ CostPer1MOut: 0.0,
80+ ContextWindow: 128_000,
81+ DefaultMaxTokens: 16384,
82+ CanReason: true,
83+ SupportsAttachments: true,
84+ },
85+ CopilotClaude35: {
86+ ID: CopilotClaude35,
87+ Name: "GitHub Copilot Claude 3.5 Sonnet",
88+ Provider: ProviderCopilot,
89+ APIModel: "claude-3.5-sonnet",
90+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
91+ CostPer1MInCached: 0.0,
92+ CostPer1MOutCached: 0.0,
93+ CostPer1MOut: 0.0,
94+ ContextWindow: 90_000,
95+ DefaultMaxTokens: 8192,
96+ SupportsAttachments: true,
97+ },
98+ CopilotClaude37: {
99+ ID: CopilotClaude37,
100+ Name: "GitHub Copilot Claude 3.7 Sonnet",
101+ Provider: ProviderCopilot,
102+ APIModel: "claude-3.7-sonnet",
103+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
104+ CostPer1MInCached: 0.0,
105+ CostPer1MOutCached: 0.0,
106+ CostPer1MOut: 0.0,
107+ ContextWindow: 200_000,
108+ DefaultMaxTokens: 16384,
109+ SupportsAttachments: true,
110+ },
111+ CopilotClaude4: {
112+ ID: CopilotClaude4,
113+ Name: "GitHub Copilot Claude Sonnet 4",
114+ Provider: ProviderCopilot,
115+ APIModel: "claude-sonnet-4",
116+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
117+ CostPer1MInCached: 0.0,
118+ CostPer1MOutCached: 0.0,
119+ CostPer1MOut: 0.0,
120+ ContextWindow: 128_000,
121+ DefaultMaxTokens: 16000,
122+ SupportsAttachments: true,
123+ },
124+ CopilotO1: {
125+ ID: CopilotO1,
126+ Name: "GitHub Copilot o1",
127+ Provider: ProviderCopilot,
128+ APIModel: "o1",
129+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
130+ CostPer1MInCached: 0.0,
131+ CostPer1MOutCached: 0.0,
132+ CostPer1MOut: 0.0,
133+ ContextWindow: 200_000,
134+ DefaultMaxTokens: 100_000,
135+ CanReason: true,
136+ SupportsAttachments: false,
137+ },
138+ CopilotO3Mini: {
139+ ID: CopilotO3Mini,
140+ Name: "GitHub Copilot o3-mini",
141+ Provider: ProviderCopilot,
142+ APIModel: "o3-mini",
143+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
144+ CostPer1MInCached: 0.0,
145+ CostPer1MOutCached: 0.0,
146+ CostPer1MOut: 0.0,
147+ ContextWindow: 200_000,
148+ DefaultMaxTokens: 100_000,
149+ CanReason: true,
150+ SupportsAttachments: false,
151+ },
152+ CopilotO4Mini: {
153+ ID: CopilotO4Mini,
154+ Name: "GitHub Copilot o4-mini",
155+ Provider: ProviderCopilot,
156+ APIModel: "o4-mini",
157+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
158+ CostPer1MInCached: 0.0,
159+ CostPer1MOutCached: 0.0,
160+ CostPer1MOut: 0.0,
161+ ContextWindow: 128_000,
162+ DefaultMaxTokens: 16_384,
163+ CanReason: true,
164+ SupportsAttachments: true,
165+ },
166+ CopilotGemini20: {
167+ ID: CopilotGemini20,
168+ Name: "GitHub Copilot Gemini 2.0 Flash",
169+ Provider: ProviderCopilot,
170+ APIModel: "gemini-2.0-flash-001",
171+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
172+ CostPer1MInCached: 0.0,
173+ CostPer1MOutCached: 0.0,
174+ CostPer1MOut: 0.0,
175+ ContextWindow: 1_000_000,
176+ DefaultMaxTokens: 8192,
177+ SupportsAttachments: true,
178+ },
179+ CopilotGemini25: {
180+ ID: CopilotGemini25,
181+ Name: "GitHub Copilot Gemini 2.5 Pro",
182+ Provider: ProviderCopilot,
183+ APIModel: "gemini-2.5-pro",
184+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
185+ CostPer1MInCached: 0.0,
186+ CostPer1MOutCached: 0.0,
187+ CostPer1MOut: 0.0,
188+ ContextWindow: 128_000,
189+ DefaultMaxTokens: 64000,
190+ SupportsAttachments: true,
191+ },
192+ CopilotGPT4: {
193+ ID: CopilotGPT4,
194+ Name: "GitHub Copilot GPT-4",
195+ Provider: ProviderCopilot,
196+ APIModel: "gpt-4",
197+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
198+ CostPer1MInCached: 0.0,
199+ CostPer1MOutCached: 0.0,
200+ CostPer1MOut: 0.0,
201+ ContextWindow: 32_768,
202+ DefaultMaxTokens: 4096,
203+ SupportsAttachments: true,
204+ },
205+ CopilotClaude37Thought: {
206+ ID: CopilotClaude37Thought,
207+ Name: "GitHub Copilot Claude 3.7 Sonnet Thinking",
208+ Provider: ProviderCopilot,
209+ APIModel: "claude-3.7-sonnet-thought",
210+ CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
211+ CostPer1MInCached: 0.0,
212+ CostPer1MOutCached: 0.0,
213+ CostPer1MOut: 0.0,
214+ ContextWindow: 200_000,
215+ DefaultMaxTokens: 16384,
216+ CanReason: true,
217+ SupportsAttachments: true,
218+ },
219+}
internal/llm/models/models.go+10−8
@@ -36,14 +36,15 @@ const (
36
36
37// Providers in order of popularity
37// Providers in order of popularity
38var ProviderPopularity = map[ModelProvider]int{
38var ProviderPopularity = map[ModelProvider]int{
39+ ProviderCopilot: 1,
39− ProviderAnthropic: 1,
40+ ProviderAnthropic: 2,
40− ProviderOpenAI: 2,
41+ ProviderOpenAI: 3,
41− ProviderGemini: 3,
42+ ProviderGemini: 4,
42− ProviderGROQ: 4,
43+ ProviderGROQ: 5,
43− ProviderOpenRouter: 5,
44+ ProviderOpenRouter: 6,
44− ProviderBedrock: 6,
45+ ProviderBedrock: 7,
45− ProviderAzure: 7,
46+ ProviderAzure: 8,
46− ProviderVertexAI: 8,
47+ ProviderVertexAI: 9,
47}
48}
48
49
49var SupportedModels = map[ModelID]Model{
50var SupportedModels = map[ModelID]Model{
@@ -93,4 +94,5 @@ func init() {
93 maps.Copy(SupportedModels, OpenRouterModels)
94 maps.Copy(SupportedModels, OpenRouterModels)
94 maps.Copy(SupportedModels, XAIModels)
95 maps.Copy(SupportedModels, XAIModels)
95 maps.Copy(SupportedModels, VertexAIGeminiModels)
96 maps.Copy(SupportedModels, VertexAIGeminiModels)
97+ maps.Copy(SupportedModels, CopilotModels)
96}
98}
internal/llm/provider/anthropic.go+18−6
@@ -14,7 +14,7 @@ import (
14 "github.com/anthropics/anthropic-sdk-go/option"
14 "github.com/anthropics/anthropic-sdk-go/option"
15 "github.com/opencode-ai/opencode/internal/config"
15 "github.com/opencode-ai/opencode/internal/config"
16 "github.com/opencode-ai/opencode/internal/llm/models"
16 "github.com/opencode-ai/opencode/internal/llm/models"
17− "github.com/opencode-ai/opencode/internal/llm/tools"
17+ toolsPkg "github.com/opencode-ai/opencode/internal/llm/tools"
18 "github.com/opencode-ai/opencode/internal/logging"
18 "github.com/opencode-ai/opencode/internal/logging"
19 "github.com/opencode-ai/opencode/internal/message"
19 "github.com/opencode-ai/opencode/internal/message"
20)
20)
@@ -118,7 +118,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
118 return
118 return
119}
119}
120
120
121−func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
121+func (a *anthropicClient) convertTools(tools []toolsPkg.BaseTool) []anthropic.ToolUnionParam {
122 anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
122 anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
123
123
124 for i, tool := range tools {
124 for i, tool := range tools {
@@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
195 }
195 }
196}
196}
197
197
198−func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
198+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) (resposne *ProviderResponse, err error) {
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200 cfg := config.Get()
200 cfg := config.Get()
201 if cfg.Debug {
201 if cfg.Debug {
@@ -244,12 +244,24 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
244 }
244 }
245}
245}
246
246
247−func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
247+func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) <-chan ProviderEvent {
248 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
248 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
249 cfg := config.Get()
249 cfg := config.Get()
250+
251+ var sessionId string
252+ requestSeqId := (len(messages) + 1) / 2
250 if cfg.Debug {
253 if cfg.Debug {
254+ if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
255+ sessionId = sid
256+ }
251− // jsonData, _ := json.Marshal(preparedMessages)
257+ jsonData, _ := json.Marshal(preparedMessages)
258+ if sessionId != "" {
259+ filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, preparedMessages)
252− // logging.Debug("Prepared messages", "messages", string(jsonData))
260+ logging.Debug("Prepared messages", "filepath", filepath)
261+ } else {
262+ logging.Debug("Prepared messages", "messages", string(jsonData))
263+ }
264+
253 }
265 }
254 attempts := 0
266 attempts := 0
255 eventChan := make(chan ProviderEvent)
267 eventChan := make(chan ProviderEvent)
internal/llm/provider/copilot.gonew file+671
@@ -0,0 +1,671 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "os"
11+ "time"
12+
13+ "github.com/openai/openai-go"
14+ "github.com/openai/openai-go/option"
15+ "github.com/openai/openai-go/shared"
16+ "github.com/opencode-ai/opencode/internal/config"
17+ "github.com/opencode-ai/opencode/internal/llm/models"
18+ toolsPkg "github.com/opencode-ai/opencode/internal/llm/tools"
19+ "github.com/opencode-ai/opencode/internal/logging"
20+ "github.com/opencode-ai/opencode/internal/message"
21+)
22+
23+type copilotOptions struct {
24+ reasoningEffort string
25+ extraHeaders map[string]string
26+ bearerToken string
27+}
28+
29+type CopilotOption func(*copilotOptions)
30+
31+type copilotClient struct {
32+ providerOptions providerClientOptions
33+ options copilotOptions
34+ client openai.Client
35+ httpClient *http.Client
36+}
37+
38+type CopilotClient ProviderClient
39+
40+// CopilotTokenResponse represents the response from GitHub's token exchange endpoint
41+type CopilotTokenResponse struct {
42+ Token string `json:"token"`
43+ ExpiresAt int64 `json:"expires_at"`
44+}
45+
46+func (c *copilotClient) isAnthropicModel() bool {
47+ for _, modelId := range models.CopilotAnthropicModels {
48+ if c.providerOptions.model.ID == modelId {
49+ return true
50+ }
51+ }
52+ return false
53+}
54+
55+// loadGitHubToken loads the GitHub OAuth token from the standard GitHub CLI/Copilot locations
56+
57+// exchangeGitHubToken exchanges a GitHub token for a Copilot bearer token
58+func (c *copilotClient) exchangeGitHubToken(githubToken string) (string, error) {
59+ req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil)
60+ if err != nil {
61+ return "", fmt.Errorf("failed to create token exchange request: %w", err)
62+ }
63+
64+ req.Header.Set("Authorization", "Token "+githubToken)
65+ req.Header.Set("User-Agent", "OpenCode/1.0")
66+
67+ resp, err := c.httpClient.Do(req)
68+ if err != nil {
69+ return "", fmt.Errorf("failed to exchange GitHub token: %w", err)
70+ }
71+ defer resp.Body.Close()
72+
73+ if resp.StatusCode != http.StatusOK {
74+ body, _ := io.ReadAll(resp.Body)
75+ return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
76+ }
77+
78+ var tokenResp CopilotTokenResponse
79+ if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
80+ return "", fmt.Errorf("failed to decode token response: %w", err)
81+ }
82+
83+ return tokenResp.Token, nil
84+}
85+
86+func newCopilotClient(opts providerClientOptions) CopilotClient {
87+ copilotOpts := copilotOptions{
88+ reasoningEffort: "medium",
89+ }
90+ // Apply copilot-specific options
91+ for _, o := range opts.copilotOptions {
92+ o(&copilotOpts)
93+ }
94+
95+ // Create HTTP client for token exchange
96+ httpClient := &http.Client{
97+ Timeout: 30 * time.Second,
98+ }
99+
100+ var bearerToken string
101+
102+ // If bearer token is already provided, use it
103+ if copilotOpts.bearerToken != "" {
104+ bearerToken = copilotOpts.bearerToken
105+ } else {
106+ // Try to get GitHub token from multiple sources
107+ var githubToken string
108+
109+ // 1. Environment variable
110+ githubToken = os.Getenv("GITHUB_TOKEN")
111+
112+ // 2. API key from options
113+ if githubToken == "" {
114+ githubToken = opts.apiKey
115+ }
116+
117+ // 3. Standard GitHub CLI/Copilot locations
118+ if githubToken == "" {
119+ var err error
120+ githubToken, err = config.LoadGitHubToken()
121+ if err != nil {
122+ logging.Debug("Failed to load GitHub token from standard locations", "error", err)
123+ }
124+ }
125+
126+ if githubToken == "" {
127+ 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.")
128+ return &copilotClient{
129+ providerOptions: opts,
130+ options: copilotOpts,
131+ httpClient: httpClient,
132+ }
133+ }
134+
135+ // Create a temporary client for token exchange
136+ tempClient := &copilotClient{
137+ providerOptions: opts,
138+ options: copilotOpts,
139+ httpClient: httpClient,
140+ }
141+
142+ // Exchange GitHub token for bearer token
143+ var err error
144+ bearerToken, err = tempClient.exchangeGitHubToken(githubToken)
145+ if err != nil {
146+ logging.Error("Failed to exchange GitHub token for Copilot bearer token", "error", err)
147+ return &copilotClient{
148+ providerOptions: opts,
149+ options: copilotOpts,
150+ httpClient: httpClient,
151+ }
152+ }
153+ }
154+
155+ copilotOpts.bearerToken = bearerToken
156+
157+ // GitHub Copilot API base URL
158+ baseURL := "https://api.githubcopilot.com"
159+
160+ openaiClientOptions := []option.RequestOption{
161+ option.WithBaseURL(baseURL),
162+ option.WithAPIKey(bearerToken), // Use bearer token as API key
163+ }
164+
165+ // Add GitHub Copilot specific headers
166+ openaiClientOptions = append(openaiClientOptions,
167+ option.WithHeader("Editor-Version", "OpenCode/1.0"),
168+ option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"),
169+ option.WithHeader("Copilot-Integration-Id", "vscode-chat"),
170+ )
171+
172+ // Add any extra headers
173+ if copilotOpts.extraHeaders != nil {
174+ for key, value := range copilotOpts.extraHeaders {
175+ openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
176+ }
177+ }
178+
179+ client := openai.NewClient(openaiClientOptions...)
180+ // logging.Debug("Copilot client created", "opts", opts, "copilotOpts", copilotOpts, "model", opts.model)
181+ return &copilotClient{
182+ providerOptions: opts,
183+ options: copilotOpts,
184+ client: client,
185+ httpClient: httpClient,
186+ }
187+}
188+
189+func (c *copilotClient) convertMessages(messages []message.Message) (copilotMessages []openai.ChatCompletionMessageParamUnion) {
190+ // Add system message first
191+ copilotMessages = append(copilotMessages, openai.SystemMessage(c.providerOptions.systemMessage))
192+
193+ for _, msg := range messages {
194+ switch msg.Role {
195+ case message.User:
196+ var content []openai.ChatCompletionContentPartUnionParam
197+ textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
198+ content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
199+
200+ for _, binaryContent := range msg.BinaryContent() {
201+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderCopilot)}
202+ imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
203+ content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
204+ }
205+
206+ copilotMessages = append(copilotMessages, openai.UserMessage(content))
207+
208+ case message.Assistant:
209+ assistantMsg := openai.ChatCompletionAssistantMessageParam{
210+ Role: "assistant",
211+ }
212+
213+ if msg.Content().String() != "" {
214+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
215+ OfString: openai.String(msg.Content().String()),
216+ }
217+ }
218+
219+ if len(msg.ToolCalls()) > 0 {
220+ assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
221+ for i, call := range msg.ToolCalls() {
222+ assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
223+ ID: call.ID,
224+ Type: "function",
225+ Function: openai.ChatCompletionMessageToolCallFunctionParam{
226+ Name: call.Name,
227+ Arguments: call.Input,
228+ },
229+ }
230+ }
231+ }
232+
233+ copilotMessages = append(copilotMessages, openai.ChatCompletionMessageParamUnion{
234+ OfAssistant: &assistantMsg,
235+ })
236+
237+ case message.Tool:
238+ for _, result := range msg.ToolResults() {
239+ copilotMessages = append(copilotMessages,
240+ openai.ToolMessage(result.Content, result.ToolCallID),
241+ )
242+ }
243+ }
244+ }
245+
246+ return
247+}
248+
249+func (c *copilotClient) convertTools(tools []toolsPkg.BaseTool) []openai.ChatCompletionToolParam {
250+ copilotTools := make([]openai.ChatCompletionToolParam, len(tools))
251+
252+ for i, tool := range tools {
253+ info := tool.Info()
254+ copilotTools[i] = openai.ChatCompletionToolParam{
255+ Function: openai.FunctionDefinitionParam{
256+ Name: info.Name,
257+ Description: openai.String(info.Description),
258+ Parameters: openai.FunctionParameters{
259+ "type": "object",
260+ "properties": info.Parameters,
261+ "required": info.Required,
262+ },
263+ },
264+ }
265+ }
266+
267+ return copilotTools
268+}
269+
270+func (c *copilotClient) finishReason(reason string) message.FinishReason {
271+ switch reason {
272+ case "stop":
273+ return message.FinishReasonEndTurn
274+ case "length":
275+ return message.FinishReasonMaxTokens
276+ case "tool_calls":
277+ return message.FinishReasonToolUse
278+ default:
279+ return message.FinishReasonUnknown
280+ }
281+}
282+
283+func (c *copilotClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
284+ params := openai.ChatCompletionNewParams{
285+ Model: openai.ChatModel(c.providerOptions.model.APIModel),
286+ Messages: messages,
287+ Tools: tools,
288+ }
289+
290+ if c.providerOptions.model.CanReason == true {
291+ params.MaxCompletionTokens = openai.Int(c.providerOptions.maxTokens)
292+ switch c.options.reasoningEffort {
293+ case "low":
294+ params.ReasoningEffort = shared.ReasoningEffortLow
295+ case "medium":
296+ params.ReasoningEffort = shared.ReasoningEffortMedium
297+ case "high":
298+ params.ReasoningEffort = shared.ReasoningEffortHigh
299+ default:
300+ params.ReasoningEffort = shared.ReasoningEffortMedium
301+ }
302+ } else {
303+ params.MaxTokens = openai.Int(c.providerOptions.maxTokens)
304+ }
305+
306+ return params
307+}
308+
309+func (c *copilotClient) send(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) (response *ProviderResponse, err error) {
310+ params := c.preparedParams(c.convertMessages(messages), c.convertTools(tools))
311+ cfg := config.Get()
312+ var sessionId string
313+ requestSeqId := (len(messages) + 1) / 2
314+ if cfg.Debug {
315+ // jsonData, _ := json.Marshal(params)
316+ // logging.Debug("Prepared messages", "messages", string(jsonData))
317+ if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
318+ sessionId = sid
319+ }
320+ jsonData, _ := json.Marshal(params)
321+ if sessionId != "" {
322+ filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, params)
323+ logging.Debug("Prepared messages", "filepath", filepath)
324+ } else {
325+ logging.Debug("Prepared messages", "messages", string(jsonData))
326+ }
327+ }
328+
329+ attempts := 0
330+ for {
331+ attempts++
332+ copilotResponse, err := c.client.Chat.Completions.New(
333+ ctx,
334+ params,
335+ )
336+
337+ // If there is an error we are going to see if we can retry the call
338+ if err != nil {
339+ retry, after, retryErr := c.shouldRetry(attempts, err)
340+ if retryErr != nil {
341+ return nil, retryErr
342+ }
343+ if retry {
344+ logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
345+ select {
346+ case <-ctx.Done():
347+ return nil, ctx.Err()
348+ case <-time.After(time.Duration(after) * time.Millisecond):
349+ continue
350+ }
351+ }
352+ return nil, retryErr
353+ }
354+
355+ content := ""
356+ if copilotResponse.Choices[0].Message.Content != "" {
357+ content = copilotResponse.Choices[0].Message.Content
358+ }
359+
360+ toolCalls := c.toolCalls(*copilotResponse)
361+ finishReason := c.finishReason(string(copilotResponse.Choices[0].FinishReason))
362+
363+ if len(toolCalls) > 0 {
364+ finishReason = message.FinishReasonToolUse
365+ }
366+
367+ return &ProviderResponse{
368+ Content: content,
369+ ToolCalls: toolCalls,
370+ Usage: c.usage(*copilotResponse),
371+ FinishReason: finishReason,
372+ }, nil
373+ }
374+}
375+
376+func (c *copilotClient) stream(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) <-chan ProviderEvent {
377+ params := c.preparedParams(c.convertMessages(messages), c.convertTools(tools))
378+ params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
379+ IncludeUsage: openai.Bool(true),
380+ }
381+
382+ cfg := config.Get()
383+ var sessionId string
384+ requestSeqId := (len(messages) + 1) / 2
385+ if cfg.Debug {
386+ if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
387+ sessionId = sid
388+ }
389+ jsonData, _ := json.Marshal(params)
390+ if sessionId != "" {
391+ filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, params)
392+ logging.Debug("Prepared messages", "filepath", filepath)
393+ } else {
394+ logging.Debug("Prepared messages", "messages", string(jsonData))
395+ }
396+
397+ }
398+
399+ attempts := 0
400+ eventChan := make(chan ProviderEvent)
401+
402+ go func() {
403+ for {
404+ attempts++
405+ copilotStream := c.client.Chat.Completions.NewStreaming(
406+ ctx,
407+ params,
408+ )
409+
410+ acc := openai.ChatCompletionAccumulator{}
411+ currentContent := ""
412+ toolCalls := make([]message.ToolCall, 0)
413+
414+ var currentToolCallId string
415+ var currentToolCall openai.ChatCompletionMessageToolCall
416+ var msgToolCalls []openai.ChatCompletionMessageToolCall
417+ for copilotStream.Next() {
418+ chunk := copilotStream.Current()
419+ acc.AddChunk(chunk)
420+
421+ if cfg.Debug {
422+ logging.AppendToStreamSessionLogJson(sessionId, requestSeqId, chunk)
423+ }
424+
425+ for _, choice := range chunk.Choices {
426+ if choice.Delta.Content != "" {
427+ eventChan <- ProviderEvent{
428+ Type: EventContentDelta,
429+ Content: choice.Delta.Content,
430+ }
431+ currentContent += choice.Delta.Content
432+ }
433+ }
434+
435+ if c.isAnthropicModel() {
436+ // Monkeypatch adapter for Sonnet-4 multi-tool use
437+ for _, choice := range chunk.Choices {
438+ if choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0 {
439+ toolCall := choice.Delta.ToolCalls[0]
440+ // Detect tool use start
441+ if currentToolCallId == "" {
442+ if toolCall.ID != "" {
443+ currentToolCallId = toolCall.ID
444+ currentToolCall = openai.ChatCompletionMessageToolCall{
445+ ID: toolCall.ID,
446+ Type: "function",
447+ Function: openai.ChatCompletionMessageToolCallFunction{
448+ Name: toolCall.Function.Name,
449+ Arguments: toolCall.Function.Arguments,
450+ },
451+ }
452+ }
453+ } else {
454+ // Delta tool use
455+ if toolCall.ID == "" {
456+ currentToolCall.Function.Arguments += toolCall.Function.Arguments
457+ } else {
458+ // Detect new tool use
459+ if toolCall.ID != currentToolCallId {
460+ msgToolCalls = append(msgToolCalls, currentToolCall)
461+ currentToolCallId = toolCall.ID
462+ currentToolCall = openai.ChatCompletionMessageToolCall{
463+ ID: toolCall.ID,
464+ Type: "function",
465+ Function: openai.ChatCompletionMessageToolCallFunction{
466+ Name: toolCall.Function.Name,
467+ Arguments: toolCall.Function.Arguments,
468+ },
469+ }
470+ }
471+ }
472+ }
473+ }
474+ if choice.FinishReason == "tool_calls" {
475+ msgToolCalls = append(msgToolCalls, currentToolCall)
476+ acc.ChatCompletion.Choices[0].Message.ToolCalls = msgToolCalls
477+ }
478+ }
479+ }
480+ }
481+
482+ err := copilotStream.Err()
483+ if err == nil || errors.Is(err, io.EOF) {
484+ if cfg.Debug {
485+ respFilepath := logging.WriteChatResponseJson(sessionId, requestSeqId, acc.ChatCompletion)
486+ logging.Debug("Chat completion response", "filepath", respFilepath)
487+ }
488+ // Stream completed successfully
489+ finishReason := c.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
490+ if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
491+ toolCalls = append(toolCalls, c.toolCalls(acc.ChatCompletion)...)
492+ }
493+ if len(toolCalls) > 0 {
494+ finishReason = message.FinishReasonToolUse
495+ }
496+
497+ eventChan <- ProviderEvent{
498+ Type: EventComplete,
499+ Response: &ProviderResponse{
500+ Content: currentContent,
501+ ToolCalls: toolCalls,
502+ Usage: c.usage(acc.ChatCompletion),
503+ FinishReason: finishReason,
504+ },
505+ }
506+ close(eventChan)
507+ return
508+ }
509+
510+ // If there is an error we are going to see if we can retry the call
511+ retry, after, retryErr := c.shouldRetry(attempts, err)
512+ if retryErr != nil {
513+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
514+ close(eventChan)
515+ return
516+ }
517+ // shouldRetry is not catching the max retries...
518+ // TODO: Figure out why
519+ if attempts > maxRetries {
520+ logging.Warn("Maximum retry attempts reached for rate limit", "attempts", attempts, "max_retries", maxRetries)
521+ retry = false
522+ }
523+ if retry {
524+ logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d (paused for %d ms)", attempts, maxRetries, after), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
525+ select {
526+ case <-ctx.Done():
527+ // context cancelled
528+ if ctx.Err() == nil {
529+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
530+ }
531+ close(eventChan)
532+ return
533+ case <-time.After(time.Duration(after) * time.Millisecond):
534+ continue
535+ }
536+ }
537+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
538+ close(eventChan)
539+ return
540+ }
541+ }()
542+
543+ return eventChan
544+}
545+
546+func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error) {
547+ var apierr *openai.Error
548+ if !errors.As(err, &apierr) {
549+ return false, 0, err
550+ }
551+
552+ // Check for token expiration (401 Unauthorized)
553+ if apierr.StatusCode == 401 {
554+ // Try to refresh the bearer token
555+ var githubToken string
556+
557+ // 1. Environment variable
558+ githubToken = os.Getenv("GITHUB_TOKEN")
559+
560+ // 2. API key from options
561+ if githubToken == "" {
562+ githubToken = c.providerOptions.apiKey
563+ }
564+
565+ // 3. Standard GitHub CLI/Copilot locations
566+ if githubToken == "" {
567+ var err error
568+ githubToken, err = config.LoadGitHubToken()
569+ if err != nil {
570+ logging.Debug("Failed to load GitHub token from standard locations during retry", "error", err)
571+ }
572+ }
573+
574+ if githubToken != "" {
575+ newBearerToken, tokenErr := c.exchangeGitHubToken(githubToken)
576+ if tokenErr == nil {
577+ c.options.bearerToken = newBearerToken
578+ // Update the client with the new token
579+ // Note: This is a simplified approach. In a production system,
580+ // you might want to recreate the entire client with the new token
581+ logging.Info("Refreshed Copilot bearer token")
582+ return true, 1000, nil // Retry immediately with new token
583+ }
584+ logging.Error("Failed to refresh Copilot bearer token", "error", tokenErr)
585+ }
586+ return false, 0, fmt.Errorf("authentication failed: %w", err)
587+ }
588+ logging.Debug("Copilot API Error", "status", apierr.StatusCode, "headers", apierr.Response.Header, "body", apierr.RawJSON())
589+
590+ if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
591+ return false, 0, err
592+ }
593+
594+ if apierr.StatusCode == 500 {
595+ logging.Warn("Copilot API returned 500 error, retrying", "error", err)
596+ }
597+
598+ if attempts > maxRetries {
599+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
600+ }
601+
602+ retryMs := 0
603+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
604+
605+ backoffMs := 2000 * (1 << (attempts - 1))
606+ jitterMs := int(float64(backoffMs) * 0.2)
607+ retryMs = backoffMs + jitterMs
608+ if len(retryAfterValues) > 0 {
609+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
610+ retryMs = retryMs * 1000
611+ }
612+ }
613+ return true, int64(retryMs), nil
614+}
615+
616+func (c *copilotClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
617+ var toolCalls []message.ToolCall
618+
619+ if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
620+ for _, call := range completion.Choices[0].Message.ToolCalls {
621+ toolCall := message.ToolCall{
622+ ID: call.ID,
623+ Name: call.Function.Name,
624+ Input: call.Function.Arguments,
625+ Type: "function",
626+ Finished: true,
627+ }
628+ toolCalls = append(toolCalls, toolCall)
629+ }
630+ }
631+
632+ return toolCalls
633+}
634+
635+func (c *copilotClient) usage(completion openai.ChatCompletion) TokenUsage {
636+ cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
637+ inputTokens := completion.Usage.PromptTokens - cachedTokens
638+
639+ return TokenUsage{
640+ InputTokens: inputTokens,
641+ OutputTokens: completion.Usage.CompletionTokens,
642+ CacheCreationTokens: 0, // GitHub Copilot doesn't provide this directly
643+ CacheReadTokens: cachedTokens,
644+ }
645+}
646+
647+func WithCopilotReasoningEffort(effort string) CopilotOption {
648+ return func(options *copilotOptions) {
649+ defaultReasoningEffort := "medium"
650+ switch effort {
651+ case "low", "medium", "high":
652+ defaultReasoningEffort = effort
653+ default:
654+ logging.Warn("Invalid reasoning effort, using default: medium")
655+ }
656+ options.reasoningEffort = defaultReasoningEffort
657+ }
658+}
659+
660+func WithCopilotExtraHeaders(headers map[string]string) CopilotOption {
661+ return func(options *copilotOptions) {
662+ options.extraHeaders = headers
663+ }
664+}
665+
666+func WithCopilotBearerToken(bearerToken string) CopilotOption {
667+ return func(options *copilotOptions) {
668+ options.bearerToken = bearerToken
669+ }
670+}
671+
internal/llm/provider/provider.go+12
@@ -68,6 +68,7 @@ type providerClientOptions struct {
68 openaiOptions []OpenAIOption
68 openaiOptions []OpenAIOption
69 geminiOptions []GeminiOption
69 geminiOptions []GeminiOption
70 bedrockOptions []BedrockOption
70 bedrockOptions []BedrockOption
71+ copilotOptions []CopilotOption
71}
72}
72
73
73type ProviderClientOption func(*providerClientOptions)
74type ProviderClientOption func(*providerClientOptions)
@@ -88,6 +89,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
88 o(&clientOptions)
89 o(&clientOptions)
89 }
90 }
90 switch providerName {
91 switch providerName {
92+ case models.ProviderCopilot:
93+ return &baseProvider[CopilotClient]{
94+ options: clientOptions,
95+ client: newCopilotClient(clientOptions),
96+ }, nil
91 case models.ProviderAnthropic:
97 case models.ProviderAnthropic:
92 return &baseProvider[AnthropicClient]{
98 return &baseProvider[AnthropicClient]{
93 options: clientOptions,
99 options: clientOptions,
@@ -233,3 +239,9 @@ func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
233 options.bedrockOptions = bedrockOptions
239 options.bedrockOptions = bedrockOptions
234 }
240 }
235}
241}
242+
243+func WithCopilotOptions(copilotOptions ...CopilotOption) ProviderClientOption {
244+ return func(options *providerClientOptions) {
245+ options.copilotOptions = copilotOptions
246+ }
247+}
internal/llm/tools/view.go+2
@@ -11,6 +11,7 @@ import (
11 "strings"
11 "strings"
12
12
13 "github.com/opencode-ai/opencode/internal/config"
13 "github.com/opencode-ai/opencode/internal/config"
14+ "github.com/opencode-ai/opencode/internal/logging"
14 "github.com/opencode-ai/opencode/internal/lsp"
15 "github.com/opencode-ai/opencode/internal/lsp"
15)
16)
16
17
@@ -97,6 +98,7 @@ func (v *viewTool) Info() ToolInfo {
97// Run implements Tool.
98// Run implements Tool.
98func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
99func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
99 var params ViewParams
100 var params ViewParams
101+ logging.Debug("view tool params", "params", call.Input)
100 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
102 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
101 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
103 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
102 }
104 }
internal/logging/logger.go+131−2
@@ -4,16 +4,33 @@ import (
4 "fmt"
4 "fmt"
5 "log/slog"
5 "log/slog"
6 "os"
6 "os"
7+ // "path/filepath"
8+ "encoding/json"
9+ "runtime"
7 "runtime/debug"
10 "runtime/debug"
11+ "sync"
8 "time"
12 "time"
9)
13)
10
14
15+func getCaller() string {
16+ var caller string
17+ if _, file, line, ok := runtime.Caller(2); ok {
18+ // caller = fmt.Sprintf("%s:%d", filepath.Base(file), line)
19+ caller = fmt.Sprintf("%s:%d", file, line)
20+ } else {
21+ caller = "unknown"
22+ }
23+ return caller
24+}
11func Info(msg string, args ...any) {
25func Info(msg string, args ...any) {
26+ source := getCaller()
12− slog.Info(msg, args...)
27+ slog.Info(msg, append([]any{"source", source}, args...)...)
13}
28}
14
29
15func Debug(msg string, args ...any) {
30func Debug(msg string, args ...any) {
31+ // slog.Debug(msg, args...)
32+ source := getCaller()
16− slog.Debug(msg, args...)
33+ slog.Debug(msg, append([]any{"source", source}, args...)...)
17}
34}
18
35
19func Warn(msg string, args ...any) {
36func Warn(msg string, args ...any) {
@@ -76,3 +93,115 @@ func RecoverPanic(name string, cleanup func()) {
76 }
93 }
77 }
94 }
78}
95}
96+
97+// Message Logging for Debug
98+var MessageDir string
99+
100+func GetSessionPrefix(sessionId string) string {
101+ return sessionId[:8]
102+}
103+
104+var sessionLogMutex sync.Mutex
105+
106+func AppendToSessionLogFile(sessionId string, filename string, content string) string {
107+ if MessageDir == "" || sessionId == "" {
108+ return ""
109+ }
110+ sessionPrefix := GetSessionPrefix(sessionId)
111+
112+ sessionLogMutex.Lock()
113+ defer sessionLogMutex.Unlock()
114+
115+ sessionPath := fmt.Sprintf("%s/%s", MessageDir, sessionPrefix)
116+ if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
117+ if err := os.MkdirAll(sessionPath, 0o766); err != nil {
118+ Error("Failed to create session directory", "dirpath", sessionPath, "error", err)
119+ return ""
120+ }
121+ }
122+
123+ filePath := fmt.Sprintf("%s/%s", sessionPath, filename)
124+
125+ f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
126+ if err != nil {
127+ Error("Failed to open session log file", "filepath", filePath, "error", err)
128+ return ""
129+ }
130+ defer f.Close()
131+
132+ // Append chunk to file
133+ _, err = f.WriteString(content)
134+ if err != nil {
135+ Error("Failed to write chunk to session log file", "filepath", filePath, "error", err)
136+ return ""
137+ }
138+ return filePath
139+}
140+
141+func WriteRequestMessageJson(sessionId string, requestSeqId int, message any) string {
142+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
143+ return ""
144+ }
145+ msgJson, err := json.Marshal(message)
146+ if err != nil {
147+ Error("Failed to marshal message", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
148+ return ""
149+ }
150+ return WriteRequestMessage(sessionId, requestSeqId, string(msgJson))
151+}
152+
153+func WriteRequestMessage(sessionId string, requestSeqId int, message string) string {
154+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
155+ return ""
156+ }
157+ filename := fmt.Sprintf("%d_request.json", requestSeqId)
158+
159+ return AppendToSessionLogFile(sessionId, filename, message)
160+}
161+
162+func AppendToStreamSessionLogJson(sessionId string, requestSeqId int, jsonableChunk any) string {
163+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
164+ return ""
165+ }
166+ chunkJson, err := json.Marshal(jsonableChunk)
167+ if err != nil {
168+ Error("Failed to marshal message", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
169+ return ""
170+ }
171+ return AppendToStreamSessionLog(sessionId, requestSeqId, string(chunkJson))
172+}
173+
174+func AppendToStreamSessionLog(sessionId string, requestSeqId int, chunk string) string {
175+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
176+ return ""
177+ }
178+ filename := fmt.Sprintf("%d_response_stream.log", requestSeqId)
179+ return AppendToSessionLogFile(sessionId, filename, chunk)
180+}
181+
182+func WriteChatResponseJson(sessionId string, requestSeqId int, response any) string {
183+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
184+ return ""
185+ }
186+ responseJson, err := json.Marshal(response)
187+ if err != nil {
188+ Error("Failed to marshal response", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
189+ return ""
190+ }
191+ filename := fmt.Sprintf("%d_response.json", requestSeqId)
192+
193+ return AppendToSessionLogFile(sessionId, filename, string(responseJson))
194+}
195+
196+func WriteToolResultsJson(sessionId string, requestSeqId int, toolResults any) string {
197+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
198+ return ""
199+ }
200+ toolResultsJson, err := json.Marshal(toolResults)
201+ if err != nil {
202+ Error("Failed to marshal tool results", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
203+ return ""
204+ }
205+ filename := fmt.Sprintf("%d_tool_results.json", requestSeqId)
206+ return AppendToSessionLogFile(sessionId, filename, string(toolResultsJson))
207+}
internal/logging/writer.go+1
@@ -45,6 +45,7 @@ type writer struct{}
45
45
46func (w *writer) Write(p []byte) (int, error) {
46func (w *writer) Write(p []byte) (int, error) {
47 d := logfmt.NewDecoder(bytes.NewReader(p))
47 d := logfmt.NewDecoder(bytes.NewReader(p))
48+
48 for d.ScanRecord() {
49 for d.ScanRecord() {
49 msg := LogMessage{
50 msg := LogMessage{
50 ID: fmt.Sprintf("%d", time.Now().UnixNano()),
51 ID: fmt.Sprintf("%d", time.Now().UnixNano()),
opencode-schema.json+26−3
@@ -77,7 +77,18 @@
77 "openrouter.o4-mini",
77 "openrouter.o4-mini",
78 "openrouter.claude-3.5-haiku",
78 "openrouter.claude-3.5-haiku",
79 "claude-4-opus",
79 "claude-4-opus",
80− "openrouter.o1-pro"
80+ "openrouter.o1-pro",
81+ "copilot.gpt-4o",
82+ "copilot.gpt-4o-mini",
83+ "copilot.gpt-4.1",
84+ "copilot.claude-3.5-sonnet",
85+ "copilot.claude-3.7-sonnet",
86+ "copilot.claude-sonnet-4",
87+ "copilot.o1",
88+ "copilot.o3-mini",
89+ "copilot.o4-mini",
90+ "copilot.gemini-2.0-flash",
91+ "copilot.gemini-2.5-pro"
81 ],
92 ],
82 "type": "string"
93 "type": "string"
83 },
94 },
@@ -176,7 +187,18 @@
176 "openrouter.o4-mini",
187 "openrouter.o4-mini",
177 "openrouter.claude-3.5-haiku",
188 "openrouter.claude-3.5-haiku",
178 "claude-4-opus",
189 "claude-4-opus",
179− "openrouter.o1-pro"
190+ "openrouter.o1-pro",
191+ "copilot.gpt-4o",
192+ "copilot.gpt-4o-mini",
193+ "copilot.gpt-4.1",
194+ "copilot.claude-3.5-sonnet",
195+ "copilot.claude-3.7-sonnet",
196+ "copilot.claude-sonnet-4",
197+ "copilot.o1",
198+ "copilot.o3-mini",
199+ "copilot.o4-mini",
200+ "copilot.gemini-2.0-flash",
201+ "copilot.gemini-2.5-pro"
180 ],
202 ],
181 "type": "string"
203 "type": "string"
182 },
204 },
@@ -360,7 +382,8 @@
360 "openrouter",
382 "openrouter",
361 "bedrock",
383 "bedrock",
362 "azure",
384 "azure",
363− "vertexai"
385+ "vertexai",
386+ "copilot"
364 ],
387 ],
365 "type": "string"
388 "type": "string"
366 }
389 }