on main help sign in
feat: add github copilot provider (#230)

* feat: add github copilot * fix: add support for claude4

commitb9bedbae80046a5ae03be38e897bba96661b28d2 authorBryan Vaz <9157498+bryanvaz@users.noreply.github.com> dateWed 25 Jun 2025, 15:23 (1 year ago) parent73729ef
13 files changed+1276−48
.gitignore+1 README.md+56−16 internal/config/config.go+108−8 internal/llm/agent/agent.go+21−5 internal/llm/models/copilot.go+219 internal/llm/models/models.go+10−8 internal/llm/provider/anthropic.go+18−6 internal/llm/provider/copilot.go+671 internal/llm/provider/provider.go+12 internal/llm/tools/view.go+2 internal/logging/logger.go+131−2 internal/logging/writer.go+1 opencode-schema.json+26−3
.gitignore+1
@@ -44,3 +44,4 @@ Thumbs.db
4444.opencode/
4545
4646opencode
47+opencode.md
README.md+56−16
@@ -96,22 +96,23 @@ You can enable or disable this feature in your configuration file:
9696
9797You can configure OpenCode using environment variables:
9898
99−| Environment Variable | Purpose |
100−| -------------------------- | ------------------------------------------------------ |
101−| `ANTHROPIC_API_KEY` | For Claude models |
102−| `OPENAI_API_KEY` | For OpenAI models |
103−| `GEMINI_API_KEY` | For Google Gemini models |
104−| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
105−| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
106−| `GROQ_API_KEY` | For Groq models |
107−| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
108−| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
109−| `AWS_REGION` | For AWS Bedrock (Claude) |
110−| `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_VERSION` | For Azure OpenAI models |
113−| `LOCAL_ENDPOINT` | For self-hosted models |
114−| `SHELL` | Default shell to use (if not specified in config) |
99+| Environment Variable | Purpose |
100+| -------------------------- | -------------------------------------------------------------------------------- |
101+| `ANTHROPIC_API_KEY` | For Claude models |
102+| `OPENAI_API_KEY` | For OpenAI models |
103+| `GEMINI_API_KEY` | For Google Gemini models |
104+| `GITHUB_TOKEN` | For Github Copilot models (see [Using Github Copilot](#using-github-copilot)) |
105+| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
106+| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
107+| `GROQ_API_KEY` | For Groq models |
108+| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
109+| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
110+| `AWS_REGION` | For AWS Bedrock (Claude) |
111+| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
112+| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
113+| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
114+| `LOCAL_ENDPOINT` | For self-hosted models |
115+| `SHELL` | Default shell to use (if not specified in config) |
115116
116117### Shell Configuration
117118
@@ -146,6 +147,9 @@ This is useful if you want to use a different shell than your default system she
146147 "apiKey": "your-api-key",
147148 "disabled": false
148149 },
150+ "copilot": {
151+ "disabled": false
152+ },
149153 "groq": {
150154 "apiKey": "your-api-key",
151155 "disabled": false
@@ -216,6 +220,23 @@ OpenCode supports a variety of AI models from different providers:
216220- Claude 3 Haiku
217221- Claude 3 Opus
218222
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+
219240### Google
220241
221242- Gemini 2.5
@@ -579,6 +600,25 @@ The AI assistant can access LSP features through the `diagnostics` tool, allowin
579600
580601While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
581602
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+
582622## Using a self-hosted model provider
583623
584624OpenCode can also load and use models from a self-hosted (OpenAI-like) provider.
internal/config/config.go+108−8
@@ -7,6 +7,7 @@ import (
77 "log/slog"
88 "os"
99 "path/filepath"
10+ "runtime"
1011 "strings"
1112
1213 "github.com/opencode-ai/opencode/internal/llm/models"
@@ -161,6 +162,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
161162 }
162163 if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
163164 loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
165+ messagesPath := fmt.Sprintf("%s/%s", cfg.Data.Directory, "messages")
164166
165167 // if file does not exist create it
166168 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
@@ -172,6 +174,13 @@ func Load(workingDir string, debug bool) (*Config, error) {
172174 }
173175 }
174176
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+
175184 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
176185 if err != nil {
177186 return cfg, fmt.Errorf("failed to open log file: %w", err)
@@ -245,6 +254,7 @@ func setDefaults(debug bool) {
245254// environment variables and configuration file.
246255func setProviderDefaults() {
247256 // Set all API keys we can find in the environment
257+ // Note: Viper does not default if the json apiKey is ""
248258 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
249259 viper.SetDefault("providers.anthropic.apiKey", apiKey)
250260 }
@@ -267,16 +277,32 @@ func setProviderDefaults() {
267277 // api-key may be empty when using Entra ID credentials – that's okay
268278 viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
269279 }
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+ }
270286
271287 // Use this order to set the default models
272− // 1. Anthropic
273− // 2. OpenAI
274− // 3. Google Gemini
275− // 4. Groq
276− // 5. OpenRouter
277− // 6. AWS Bedrock
278− // 7. Azure
279− // 8. Google Cloud VertexAI
288+ // 1. Copilot
289+ // 2. Anthropic
290+ // 3. OpenAI
291+ // 4. Google Gemini
292+ // 5. Groq
293+ // 6. OpenRouter
294+ // 7. AWS Bedrock
295+ // 8. Azure
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+ }
280306
281307 // Anthropic configuration
282308 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
@@ -399,6 +425,14 @@ func hasVertexAICredentials() bool {
399425 return false
400426}
401427
428+func hasCopilotCredentials() bool {
429+ // Check for explicit Copilot parameters
430+ if token, _ := LoadGitHubToken(); token != "" {
431+ return true
432+ }
433+ return false
434+}
435+
402436// readConfig handles the result of reading a configuration file.
403437func readConfig(err error) error {
404438 if err == nil {
@@ -440,6 +474,9 @@ func applyDefaultValues() {
440474// It validates model IDs and providers, ensuring they are supported.
441475func validateAgent(cfg *Config, name AgentName, agent Agent) error {
442476 // 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.
443480 model, modelExists := models.SupportedModels[agent.Model]
444481 if !modelExists {
445482 logging.Warn("unsupported model configured, reverting to default",
@@ -584,6 +621,7 @@ func Validate() error {
584621 // Validate providers
585622 for provider, providerCfg := range cfg.Providers {
586623 if providerCfg.APIKey == "" && !providerCfg.Disabled {
624+ fmt.Printf("provider has no API key, marking as disabled %s", provider)
587625 logging.Warn("provider has no API key, marking as disabled", "provider", provider)
588626 providerCfg.Disabled = true
589627 cfg.Providers[provider] = providerCfg
@@ -631,6 +669,18 @@ func getProviderAPIKey(provider models.ModelProvider) string {
631669
632670// setDefaultModelForAgent sets a default model for an agent based on available providers
633671func 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+ }
634684 // Check providers in order of preference
635685 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
636686 maxTokens := int64(5000)
@@ -878,3 +928,53 @@ func UpdateTheme(themeName string) error {
878928 config.TUI.Theme = themeName
879929 })
880930}
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
162162 if err != nil {
163163 return err
164164 }
165+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
165166 parts := []message.ContentPart{message.TextContent{Text: content}}
166167 response, err := a.titleProvider.SendMessages(
167168 ctx,
@@ -230,6 +231,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
230231}
231232
232233func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
234+ cfg := config.Get()
233235 // List existing messages; if none, start title generation asynchronously.
234236 msgs, err := a.messages.List(ctx, sessionID)
235237 if err != nil {
@@ -288,7 +290,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
288290 }
289291 return a.err(fmt.Errorf("failed to process events: %w", err))
290292 }
291− logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
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 {
298+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
299+ }
292300 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
293301 // We are not done, we need to respond with the tool response
294302 msgHistory = append(msgHistory, agentMessage, *toolResults)
@@ -312,6 +320,7 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
312320}
313321
314322func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
323+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
315324 eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
316325
317326 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
@@ -325,7 +334,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
325334
326335 // Add the session and message ID into the context if needed by tools.
327336 ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
328− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
329337
330338 // Process each event in the stream.
331339 for event := range eventChan {
@@ -357,10 +365,17 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
357365 default:
358366 // Continue processing
359367 var tool tools.BaseTool
360− for _, availableTools := range a.tools {
361− if availableTools.Info().Name == toolCall.Name {
362− tool = availableTools
368+ for _, availableTool := range a.tools {
369+ if availableTool.Info().Name == toolCall.Name {
370+ tool = availableTool
371+ break
363372 }
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+ // }
364379 }
365380
366381 // Tool not found
@@ -553,6 +568,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
553568 a.Publish(pubsub.CreatedEvent, event)
554569 return
555570 }
571+ summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
556572
557573 if len(msgs) == 0 {
558574 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 (
3636
3737// Providers in order of popularity
3838var ProviderPopularity = map[ModelProvider]int{
39− ProviderAnthropic: 1,
40− ProviderOpenAI: 2,
41− ProviderGemini: 3,
42− ProviderGROQ: 4,
43− ProviderOpenRouter: 5,
44− ProviderBedrock: 6,
45− ProviderAzure: 7,
46− ProviderVertexAI: 8,
39+ ProviderCopilot: 1,
40+ ProviderAnthropic: 2,
41+ ProviderOpenAI: 3,
42+ ProviderGemini: 4,
43+ ProviderGROQ: 5,
44+ ProviderOpenRouter: 6,
45+ ProviderBedrock: 7,
46+ ProviderAzure: 8,
47+ ProviderVertexAI: 9,
4748}
4849
4950var SupportedModels = map[ModelID]Model{
@@ -93,4 +94,5 @@ func init() {
9394 maps.Copy(SupportedModels, OpenRouterModels)
9495 maps.Copy(SupportedModels, XAIModels)
9596 maps.Copy(SupportedModels, VertexAIGeminiModels)
97+ maps.Copy(SupportedModels, CopilotModels)
9698}
internal/llm/provider/anthropic.go+18−6
@@ -14,7 +14,7 @@ import (
1414 "github.com/anthropics/anthropic-sdk-go/option"
1515 "github.com/opencode-ai/opencode/internal/config"
1616 "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"
1818 "github.com/opencode-ai/opencode/internal/logging"
1919 "github.com/opencode-ai/opencode/internal/message"
2020)
@@ -118,7 +118,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
118118 return
119119}
120120
121−func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
121+func (a *anthropicClient) convertTools(tools []toolsPkg.BaseTool) []anthropic.ToolUnionParam {
122122 anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
123123
124124 for i, tool := range tools {
@@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
195195 }
196196}
197197
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) {
199199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200200 cfg := config.Get()
201201 if cfg.Debug {
@@ -244,12 +244,24 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
244244 }
245245}
246246
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 {
248248 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
249249 cfg := config.Get()
250+
251+ var sessionId string
252+ requestSeqId := (len(messages) + 1) / 2
250253 if cfg.Debug {
251− // jsonData, _ := json.Marshal(preparedMessages)
252− // logging.Debug("Prepared messages", "messages", string(jsonData))
254+ if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
255+ sessionId = sid
256+ }
257+ jsonData, _ := json.Marshal(preparedMessages)
258+ if sessionId != "" {
259+ filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, preparedMessages)
260+ logging.Debug("Prepared messages", "filepath", filepath)
261+ } else {
262+ logging.Debug("Prepared messages", "messages", string(jsonData))
263+ }
264+
253265 }
254266 attempts := 0
255267 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 {
6868 openaiOptions []OpenAIOption
6969 geminiOptions []GeminiOption
7070 bedrockOptions []BedrockOption
71+ copilotOptions []CopilotOption
7172}
7273
7374type ProviderClientOption func(*providerClientOptions)
@@ -88,6 +89,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
8889 o(&clientOptions)
8990 }
9091 switch providerName {
92+ case models.ProviderCopilot:
93+ return &baseProvider[CopilotClient]{
94+ options: clientOptions,
95+ client: newCopilotClient(clientOptions),
96+ }, nil
9197 case models.ProviderAnthropic:
9298 return &baseProvider[AnthropicClient]{
9399 options: clientOptions,
@@ -233,3 +239,9 @@ func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
233239 options.bedrockOptions = bedrockOptions
234240 }
235241}
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 (
1111 "strings"
1212
1313 "github.com/opencode-ai/opencode/internal/config"
14+ "github.com/opencode-ai/opencode/internal/logging"
1415 "github.com/opencode-ai/opencode/internal/lsp"
1516)
1617
@@ -97,6 +98,7 @@ func (v *viewTool) Info() ToolInfo {
9798// Run implements Tool.
9899func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
99100 var params ViewParams
101+ logging.Debug("view tool params", "params", call.Input)
100102 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
101103 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
102104 }
internal/logging/logger.go+131−2
@@ -4,16 +4,33 @@ import (
44 "fmt"
55 "log/slog"
66 "os"
7+ // "path/filepath"
8+ "encoding/json"
9+ "runtime"
710 "runtime/debug"
11+ "sync"
812 "time"
913)
1014
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+}
1125func Info(msg string, args ...any) {
12− slog.Info(msg, args...)
26+ source := getCaller()
27+ slog.Info(msg, append([]any{"source", source}, args...)...)
1328}
1429
1530func Debug(msg string, args ...any) {
16− slog.Debug(msg, args...)
31+ // slog.Debug(msg, args...)
32+ source := getCaller()
33+ slog.Debug(msg, append([]any{"source", source}, args...)...)
1734}
1835
1936func Warn(msg string, args ...any) {
@@ -76,3 +93,115 @@ func RecoverPanic(name string, cleanup func()) {
7693 }
7794 }
7895}
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{}
4545
4646func (w *writer) Write(p []byte) (int, error) {
4747 d := logfmt.NewDecoder(bytes.NewReader(p))
48+
4849 for d.ScanRecord() {
4950 msg := LogMessage{
5051 ID: fmt.Sprintf("%d", time.Now().UnixNano()),
opencode-schema.json+26−3
@@ -77,7 +77,18 @@
7777 "openrouter.o4-mini",
7878 "openrouter.claude-3.5-haiku",
7979 "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"
8192 ],
8293 "type": "string"
8394 },
@@ -176,7 +187,18 @@
176187 "openrouter.o4-mini",
177188 "openrouter.claude-3.5-haiku",
178189 "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"
180202 ],
181203 "type": "string"
182204 },
@@ -360,7 +382,8 @@
360382 "openrouter",
361383 "bedrock",
362384 "azure",
363− "vertexai"
385+ "vertexai",
386+ "copilot"
364387 ],
365388 "type": "string"
366389 }