feat: add github copilot provider (#230)

* feat: add github copilot

* fix: add support for claude4
This commit is contained in:
Bryan Vaz 2025-06-25 15:23:40 -04:00 • committed by GitHub
parent 73729ef526
commit b9bedbae80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1276 additions and 48 deletions

1
.gitignore vendored
View file

@ -44,3 +44,4 @@ Thumbs.db
.opencode/ .opencode/
opencode opencode
opencode.md

View file

@ -97,10 +97,11 @@ You can enable or disable this feature in your configuration file:
You can configure OpenCode using environment variables: You can configure OpenCode using environment variables:
| Environment Variable | Purpose | | Environment Variable | Purpose |
| -------------------------- | ------------------------------------------------------ | | -------------------------- | -------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY` | For Claude models | | `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models | | `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models | | `GEMINI_API_KEY` | For Google Gemini models |
| `GITHUB_TOKEN` | For Github Copilot models (see [Using Github Copilot](#using-github-copilot)) |
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) | | `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) | | `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
| `GROQ_API_KEY` | For Groq models | | `GROQ_API_KEY` | For Groq models |
@ -146,6 +147,9 @@ This is useful if you want to use a different shell than your default system she
"apiKey": "your-api-key", "apiKey": "your-api-key",
"disabled": false "disabled": false
}, },
"copilot": {
"disabled": false
},
"groq": { "groq": {
"apiKey": "your-api-key", "apiKey": "your-api-key",
"disabled": false "disabled": false
@ -216,6 +220,23 @@ OpenCode supports a variety of AI models from different providers:
- Claude 3 Haiku - Claude 3 Haiku
- Claude 3 Opus - Claude 3 Opus
### GitHub Copilot
- GPT-3.5 Turbo
- GPT-4
- GPT-4o
- GPT-4o Mini
- GPT-4.1
- Claude 3.5 Sonnet
- Claude 3.7 Sonnet
- Claude 3.7 Sonnet Thinking
- Claude Sonnet 4
- O1
- O3 Mini
- O4 Mini
- Gemini 2.0 Flash
- Gemini 2.5 Pro
### Google ### Google
- Gemini 2.5 - Gemini 2.5
@ -579,6 +600,25 @@ The AI assistant can access LSP features through the `diagnostics` tool, allowin
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant. While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
## Using Github Copilot
_Copilot support is currently experimental._
### Requirements
- [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings
- One of:
- VSCode Github Copilot chat extension
- Github `gh` CLI
- Neovim Github Copilot plugin (`copilot.vim` or `copilot.lua`)
- Github token with copilot permissions
If using one of the above plugins or cli tools, make sure you use the authenticate
the tool with your github account. This should create a github token at one of the following locations:
- ~/.config/github-copilot/[hosts,apps].json
- $XDG_CONFIG_HOME/github-copilot/[hosts,apps].json
If using an explicit github token, you may either set the $GITHUB_TOKEN environment variable or add it to the opencode.json config file at `providers.copilot.apiKey`.
## Using a self-hosted model provider ## Using a self-hosted model provider
OpenCode can also load and use models from a self-hosted (OpenAI-like) provider. OpenCode can also load and use models from a self-hosted (OpenAI-like) provider.

View file

@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"github.com/opencode-ai/opencode/internal/llm/models" "github.com/opencode-ai/opencode/internal/llm/models"
@ -161,6 +162,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
} }
if os.Getenv("OPENCODE_DEV_DEBUG") == "true" { if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log") loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
messagesPath := fmt.Sprintf("%s/%s", cfg.Data.Directory, "messages")
// if file does not exist create it // if file does not exist create it
if _, err := os.Stat(loggingFile); os.IsNotExist(err) { if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
@ -172,6 +174,13 @@ func Load(workingDir string, debug bool) (*Config, error) {
} }
} }
if _, err := os.Stat(messagesPath); os.IsNotExist(err) {
if err := os.MkdirAll(messagesPath, 0o756); err != nil {
return cfg, fmt.Errorf("failed to create directory: %w", err)
}
}
logging.MessageDir = messagesPath
sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil { if err != nil {
return cfg, fmt.Errorf("failed to open log file: %w", err) return cfg, fmt.Errorf("failed to open log file: %w", err)
@ -245,6 +254,7 @@ func setDefaults(debug bool) {
// environment variables and configuration file. // environment variables and configuration file.
func setProviderDefaults() { func setProviderDefaults() {
// Set all API keys we can find in the environment // Set all API keys we can find in the environment
// Note: Viper does not default if the json apiKey is ""
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" { if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
viper.SetDefault("providers.anthropic.apiKey", apiKey) viper.SetDefault("providers.anthropic.apiKey", apiKey)
} }
@ -267,16 +277,32 @@ func setProviderDefaults() {
// api-key may be empty when using Entra ID credentials – that's okay // api-key may be empty when using Entra ID credentials – that's okay
viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY")) viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
} }
if apiKey, err := LoadGitHubToken(); err == nil && apiKey != "" {
viper.SetDefault("providers.copilot.apiKey", apiKey)
if viper.GetString("providers.copilot.apiKey") == "" {
viper.Set("providers.copilot.apiKey", apiKey)
}
}
// Use this order to set the default models // Use this order to set the default models
// 1. Anthropic // 1. Copilot
// 2. OpenAI // 2. Anthropic
// 3. Google Gemini // 3. OpenAI
// 4. Groq // 4. Google Gemini
// 5. OpenRouter // 5. Groq
// 6. AWS Bedrock // 6. OpenRouter
// 7. Azure // 7. AWS Bedrock
// 8. Google Cloud VertexAI // 8. Azure
// 9. Google Cloud VertexAI
// copilot configuration
if key := viper.GetString("providers.copilot.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.CopilotGPT4o)
viper.SetDefault("agents.summarizer.model", models.CopilotGPT4o)
viper.SetDefault("agents.task.model", models.CopilotGPT4o)
viper.SetDefault("agents.title.model", models.CopilotGPT4o)
return
}
// Anthropic configuration // Anthropic configuration
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
@ -399,6 +425,14 @@ func hasVertexAICredentials() bool {
return false return false
} }
func hasCopilotCredentials() bool {
// Check for explicit Copilot parameters
if token, _ := LoadGitHubToken(); token != "" {
return true
}
return false
}
// readConfig handles the result of reading a configuration file. // readConfig handles the result of reading a configuration file.
func readConfig(err error) error { func readConfig(err error) error {
if err == nil { if err == nil {
@ -440,6 +474,9 @@ func applyDefaultValues() {
// It validates model IDs and providers, ensuring they are supported. // It validates model IDs and providers, ensuring they are supported.
func validateAgent(cfg *Config, name AgentName, agent Agent) error { func validateAgent(cfg *Config, name AgentName, agent Agent) error {
// Check if model exists // Check if model exists
// TODO: If a copilot model is specified, but model is not found,
// it might be new model. The https://api.githubcopilot.com/models
// endpoint should be queried to validate if the model is supported.
model, modelExists := models.SupportedModels[agent.Model] model, modelExists := models.SupportedModels[agent.Model]
if !modelExists { if !modelExists {
logging.Warn("unsupported model configured, reverting to default", logging.Warn("unsupported model configured, reverting to default",
@ -584,6 +621,7 @@ func Validate() error {
// Validate providers // Validate providers
for provider, providerCfg := range cfg.Providers { for provider, providerCfg := range cfg.Providers {
if providerCfg.APIKey == "" && !providerCfg.Disabled { if providerCfg.APIKey == "" && !providerCfg.Disabled {
fmt.Printf("provider has no API key, marking as disabled %s", provider)
logging.Warn("provider has no API key, marking as disabled", "provider", provider) logging.Warn("provider has no API key, marking as disabled", "provider", provider)
providerCfg.Disabled = true providerCfg.Disabled = true
cfg.Providers[provider] = providerCfg cfg.Providers[provider] = providerCfg
@ -631,6 +669,18 @@ func getProviderAPIKey(provider models.ModelProvider) string {
// setDefaultModelForAgent sets a default model for an agent based on available providers // setDefaultModelForAgent sets a default model for an agent based on available providers
func setDefaultModelForAgent(agent AgentName) bool { func setDefaultModelForAgent(agent AgentName) bool {
if hasCopilotCredentials() {
maxTokens := int64(5000)
if agent == AgentTitle {
maxTokens = 80
}
cfg.Agents[agent] = Agent{
Model: models.CopilotGPT4o,
MaxTokens: maxTokens,
}
return true
}
// Check providers in order of preference // Check providers in order of preference
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" { if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
maxTokens := int64(5000) maxTokens := int64(5000)
@ -878,3 +928,53 @@ func UpdateTheme(themeName string) error {
config.TUI.Theme = themeName config.TUI.Theme = themeName
}) })
} }
// Tries to load Github token from all possible locations
func LoadGitHubToken() (string, error) {
// First check environment variable
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
return token, nil
}
// Get config directory
var configDir string
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
configDir = xdgConfig
} else if runtime.GOOS == "windows" {
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
configDir = localAppData
} else {
configDir = filepath.Join(os.Getenv("HOME"), "AppData", "Local")
}
} else {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
}
// Try both hosts.json and apps.json files
filePaths := []string{
filepath.Join(configDir, "github-copilot", "hosts.json"),
filepath.Join(configDir, "github-copilot", "apps.json"),
}
for _, filePath := range filePaths {
data, err := os.ReadFile(filePath)
if err != nil {
continue
}
var config map[string]map[string]interface{}
if err := json.Unmarshal(data, &config); err != nil {
continue
}
for key, value := range config {
if strings.Contains(key, "github.com") {
if oauthToken, ok := value["oauth_token"].(string); ok {
return oauthToken, nil
}
}
}
}
return "", fmt.Errorf("GitHub token not found in standard locations")
}

View file

@ -162,6 +162,7 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
if err != nil { if err != nil {
return err return err
} }
ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
parts := []message.ContentPart{message.TextContent{Text: content}} parts := []message.ContentPart{message.TextContent{Text: content}}
response, err := a.titleProvider.SendMessages( response, err := a.titleProvider.SendMessages(
ctx, ctx,
@ -230,6 +231,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
} }
func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent { func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
cfg := config.Get()
// List existing messages; if none, start title generation asynchronously. // List existing messages; if none, start title generation asynchronously.
msgs, err := a.messages.List(ctx, sessionID) msgs, err := a.messages.List(ctx, sessionID)
if err != nil { if err != nil {
@ -288,7 +290,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
} }
return a.err(fmt.Errorf("failed to process events: %w", err)) return a.err(fmt.Errorf("failed to process events: %w", err))
} }
if cfg.Debug {
seqId := (len(msgHistory) + 1) / 2
toolResultFilepath := logging.WriteToolResultsJson(sessionID, seqId, toolResults)
logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", "{}", "filepath", toolResultFilepath)
} else {
logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults) logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
}
if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil { if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
// We are not done, we need to respond with the tool response // We are not done, we need to respond with the tool response
msgHistory = append(msgHistory, agentMessage, *toolResults) msgHistory = append(msgHistory, agentMessage, *toolResults)
@ -312,6 +320,7 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
} }
func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) { func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools) eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
@ -325,7 +334,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
// Add the session and message ID into the context if needed by tools. // Add the session and message ID into the context if needed by tools.
ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID) ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
// Process each event in the stream. // Process each event in the stream.
for event := range eventChan { for event := range eventChan {
@ -357,10 +365,17 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
default: default:
// Continue processing // Continue processing
var tool tools.BaseTool var tool tools.BaseTool
for _, availableTools := range a.tools { for _, availableTool := range a.tools {
if availableTools.Info().Name == toolCall.Name { if availableTool.Info().Name == toolCall.Name {
tool = availableTools tool = availableTool
break
} }
// Monkey patch for Copilot Sonnet-4 tool repetition obfuscation
// if strings.HasPrefix(toolCall.Name, availableTool.Info().Name) &&
// strings.HasPrefix(toolCall.Name, availableTool.Info().Name+availableTool.Info().Name) {
// tool = availableTool
// break
// }
} }
// Tool not found // Tool not found
@ -553,6 +568,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
a.Publish(pubsub.CreatedEvent, event) a.Publish(pubsub.CreatedEvent, event)
return return
} }
summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
if len(msgs) == 0 { if len(msgs) == 0 {
event = AgentEvent{ event = AgentEvent{

View file

@ -0,0 +1,219 @@
package models
const (
ProviderCopilot ModelProvider = "copilot"
// GitHub Copilot models
CopilotGTP35Turbo ModelID = "copilot.gpt-3.5-turbo"
CopilotGPT4o ModelID = "copilot.gpt-4o"
CopilotGPT4oMini ModelID = "copilot.gpt-4o-mini"
CopilotGPT41 ModelID = "copilot.gpt-4.1"
CopilotClaude35 ModelID = "copilot.claude-3.5-sonnet"
CopilotClaude37 ModelID = "copilot.claude-3.7-sonnet"
CopilotClaude4 ModelID = "copilot.claude-sonnet-4"
CopilotO1 ModelID = "copilot.o1"
CopilotO3Mini ModelID = "copilot.o3-mini"
CopilotO4Mini ModelID = "copilot.o4-mini"
CopilotGemini20 ModelID = "copilot.gemini-2.0-flash"
CopilotGemini25 ModelID = "copilot.gemini-2.5-pro"
CopilotGPT4 ModelID = "copilot.gpt-4"
CopilotClaude37Thought ModelID = "copilot.claude-3.7-sonnet-thought"
)
var CopilotAnthropicModels = []ModelID{
CopilotClaude35,
CopilotClaude37,
CopilotClaude37Thought,
CopilotClaude4,
}
// GitHub Copilot models available through GitHub's API
var CopilotModels = map[ModelID]Model{
CopilotGTP35Turbo: {
ID: CopilotGTP35Turbo,
Name: "GitHub Copilot GPT-3.5-turbo",
Provider: ProviderCopilot,
APIModel: "gpt-3.5-turbo",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 16_384,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
CopilotGPT4o: {
ID: CopilotGPT4o,
Name: "GitHub Copilot GPT-4o",
Provider: ProviderCopilot,
APIModel: "gpt-4o",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 128_000,
DefaultMaxTokens: 16384,
SupportsAttachments: true,
},
CopilotGPT4oMini: {
ID: CopilotGPT4oMini,
Name: "GitHub Copilot GPT-4o Mini",
Provider: ProviderCopilot,
APIModel: "gpt-4o-mini",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 128_000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
CopilotGPT41: {
ID: CopilotGPT41,
Name: "GitHub Copilot GPT-4.1",
Provider: ProviderCopilot,
APIModel: "gpt-4.1",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 128_000,
DefaultMaxTokens: 16384,
CanReason: true,
SupportsAttachments: true,
},
CopilotClaude35: {
ID: CopilotClaude35,
Name: "GitHub Copilot Claude 3.5 Sonnet",
Provider: ProviderCopilot,
APIModel: "claude-3.5-sonnet",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 90_000,
DefaultMaxTokens: 8192,
SupportsAttachments: true,
},
CopilotClaude37: {
ID: CopilotClaude37,
Name: "GitHub Copilot Claude 3.7 Sonnet",
Provider: ProviderCopilot,
APIModel: "claude-3.7-sonnet",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 200_000,
DefaultMaxTokens: 16384,
SupportsAttachments: true,
},
CopilotClaude4: {
ID: CopilotClaude4,
Name: "GitHub Copilot Claude Sonnet 4",
Provider: ProviderCopilot,
APIModel: "claude-sonnet-4",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 128_000,
DefaultMaxTokens: 16000,
SupportsAttachments: true,
},
CopilotO1: {
ID: CopilotO1,
Name: "GitHub Copilot o1",
Provider: ProviderCopilot,
APIModel: "o1",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 200_000,
DefaultMaxTokens: 100_000,
CanReason: true,
SupportsAttachments: false,
},
CopilotO3Mini: {
ID: CopilotO3Mini,
Name: "GitHub Copilot o3-mini",
Provider: ProviderCopilot,
APIModel: "o3-mini",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 200_000,
DefaultMaxTokens: 100_000,
CanReason: true,
SupportsAttachments: false,
},
CopilotO4Mini: {
ID: CopilotO4Mini,
Name: "GitHub Copilot o4-mini",
Provider: ProviderCopilot,
APIModel: "o4-mini",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 128_000,
DefaultMaxTokens: 16_384,
CanReason: true,
SupportsAttachments: true,
},
CopilotGemini20: {
ID: CopilotGemini20,
Name: "GitHub Copilot Gemini 2.0 Flash",
Provider: ProviderCopilot,
APIModel: "gemini-2.0-flash-001",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 1_000_000,
DefaultMaxTokens: 8192,
SupportsAttachments: true,
},
CopilotGemini25: {
ID: CopilotGemini25,
Name: "GitHub Copilot Gemini 2.5 Pro",
Provider: ProviderCopilot,
APIModel: "gemini-2.5-pro",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 128_000,
DefaultMaxTokens: 64000,
SupportsAttachments: true,
},
CopilotGPT4: {
ID: CopilotGPT4,
Name: "GitHub Copilot GPT-4",
Provider: ProviderCopilot,
APIModel: "gpt-4",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 32_768,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
CopilotClaude37Thought: {
ID: CopilotClaude37Thought,
Name: "GitHub Copilot Claude 3.7 Sonnet Thinking",
Provider: ProviderCopilot,
APIModel: "claude-3.7-sonnet-thought",
CostPer1MIn: 0.0, // Included in GitHub Copilot subscription
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.0,
ContextWindow: 200_000,
DefaultMaxTokens: 16384,
CanReason: true,
SupportsAttachments: true,
},
}

View file

@ -36,14 +36,15 @@ const (
// Providers in order of popularity // Providers in order of popularity
var ProviderPopularity = map[ModelProvider]int{ var ProviderPopularity = map[ModelProvider]int{
ProviderAnthropic: 1, ProviderCopilot: 1,
ProviderOpenAI: 2, ProviderAnthropic: 2,
ProviderGemini: 3, ProviderOpenAI: 3,
ProviderGROQ: 4, ProviderGemini: 4,
ProviderOpenRouter: 5, ProviderGROQ: 5,
ProviderBedrock: 6, ProviderOpenRouter: 6,
ProviderAzure: 7, ProviderBedrock: 7,
ProviderVertexAI: 8, ProviderAzure: 8,
ProviderVertexAI: 9,
} }
var SupportedModels = map[ModelID]Model{ var SupportedModels = map[ModelID]Model{
@ -93,4 +94,5 @@ func init() {
maps.Copy(SupportedModels, OpenRouterModels) maps.Copy(SupportedModels, OpenRouterModels)
maps.Copy(SupportedModels, XAIModels) maps.Copy(SupportedModels, XAIModels)
maps.Copy(SupportedModels, VertexAIGeminiModels) maps.Copy(SupportedModels, VertexAIGeminiModels)
maps.Copy(SupportedModels, CopilotModels)
} }

View file

@ -14,7 +14,7 @@ import (
"github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/option"
"github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models" "github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools" toolsPkg "github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/message"
) )
@ -118,7 +118,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
return return
} }
func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam { func (a *anthropicClient) convertTools(tools []toolsPkg.BaseTool) []anthropic.ToolUnionParam {
anthropicTools := make([]anthropic.ToolUnionParam, len(tools)) anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
for i, tool := range tools { for i, tool := range tools {
@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
} }
} }
func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) { func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) (resposne *ProviderResponse, err error) {
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
cfg := config.Get() cfg := config.Get()
if cfg.Debug { if cfg.Debug {
@ -244,12 +244,24 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
} }
} }
func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) <-chan ProviderEvent {
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
cfg := config.Get() cfg := config.Get()
var sessionId string
requestSeqId := (len(messages) + 1) / 2
if cfg.Debug { if cfg.Debug {
// jsonData, _ := json.Marshal(preparedMessages) if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
// logging.Debug("Prepared messages", "messages", string(jsonData)) sessionId = sid
}
jsonData, _ := json.Marshal(preparedMessages)
if sessionId != "" {
filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, preparedMessages)
logging.Debug("Prepared messages", "filepath", filepath)
} else {
logging.Debug("Prepared messages", "messages", string(jsonData))
}
} }
attempts := 0 attempts := 0
eventChan := make(chan ProviderEvent) eventChan := make(chan ProviderEvent)

View file

@ -0,0 +1,671 @@
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
toolsPkg "github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
)
type copilotOptions struct {
reasoningEffort string
extraHeaders map[string]string
bearerToken string
}
type CopilotOption func(*copilotOptions)
type copilotClient struct {
providerOptions providerClientOptions
options copilotOptions
client openai.Client
httpClient *http.Client
}
type CopilotClient ProviderClient
// CopilotTokenResponse represents the response from GitHub's token exchange endpoint
type CopilotTokenResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
}
func (c *copilotClient) isAnthropicModel() bool {
for _, modelId := range models.CopilotAnthropicModels {
if c.providerOptions.model.ID == modelId {
return true
}
}
return false
}
// loadGitHubToken loads the GitHub OAuth token from the standard GitHub CLI/Copilot locations
// exchangeGitHubToken exchanges a GitHub token for a Copilot bearer token
func (c *copilotClient) exchangeGitHubToken(githubToken string) (string, error) {
req, err := http.NewRequest("GET", "https://api.github.com/copilot_internal/v2/token", nil)
if err != nil {
return "", fmt.Errorf("failed to create token exchange request: %w", err)
}
req.Header.Set("Authorization", "Token "+githubToken)
req.Header.Set("User-Agent", "OpenCode/1.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to exchange GitHub token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp CopilotTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
}
return tokenResp.Token, nil
}
func newCopilotClient(opts providerClientOptions) CopilotClient {
copilotOpts := copilotOptions{
reasoningEffort: "medium",
}
// Apply copilot-specific options
for _, o := range opts.copilotOptions {
o(&copilotOpts)
}
// Create HTTP client for token exchange
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
var bearerToken string
// If bearer token is already provided, use it
if copilotOpts.bearerToken != "" {
bearerToken = copilotOpts.bearerToken
} else {
// Try to get GitHub token from multiple sources
var githubToken string
// 1. Environment variable
githubToken = os.Getenv("GITHUB_TOKEN")
// 2. API key from options
if githubToken == "" {
githubToken = opts.apiKey
}
// 3. Standard GitHub CLI/Copilot locations
if githubToken == "" {
var err error
githubToken, err = config.LoadGitHubToken()
if err != nil {
logging.Debug("Failed to load GitHub token from standard locations", "error", err)
}
}
if githubToken == "" {
logging.Error("GitHub token is required for Copilot provider. Set GITHUB_TOKEN environment variable, configure it in opencode.json, or ensure GitHub CLI/Copilot is properly authenticated.")
return &copilotClient{
providerOptions: opts,
options: copilotOpts,
httpClient: httpClient,
}
}
// Create a temporary client for token exchange
tempClient := &copilotClient{
providerOptions: opts,
options: copilotOpts,
httpClient: httpClient,
}
// Exchange GitHub token for bearer token
var err error
bearerToken, err = tempClient.exchangeGitHubToken(githubToken)
if err != nil {
logging.Error("Failed to exchange GitHub token for Copilot bearer token", "error", err)
return &copilotClient{
providerOptions: opts,
options: copilotOpts,
httpClient: httpClient,
}
}
}
copilotOpts.bearerToken = bearerToken
// GitHub Copilot API base URL
baseURL := "https://api.githubcopilot.com"
openaiClientOptions := []option.RequestOption{
option.WithBaseURL(baseURL),
option.WithAPIKey(bearerToken), // Use bearer token as API key
}
// Add GitHub Copilot specific headers
openaiClientOptions = append(openaiClientOptions,
option.WithHeader("Editor-Version", "OpenCode/1.0"),
option.WithHeader("Editor-Plugin-Version", "OpenCode/1.0"),
option.WithHeader("Copilot-Integration-Id", "vscode-chat"),
)
// Add any extra headers
if copilotOpts.extraHeaders != nil {
for key, value := range copilotOpts.extraHeaders {
openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
}
}
client := openai.NewClient(openaiClientOptions...)
// logging.Debug("Copilot client created", "opts", opts, "copilotOpts", copilotOpts, "model", opts.model)
return &copilotClient{
providerOptions: opts,
options: copilotOpts,
client: client,
httpClient: httpClient,
}
}
func (c *copilotClient) convertMessages(messages []message.Message) (copilotMessages []openai.ChatCompletionMessageParamUnion) {
// Add system message first
copilotMessages = append(copilotMessages, openai.SystemMessage(c.providerOptions.systemMessage))
for _, msg := range messages {
switch msg.Role {
case message.User:
var content []openai.ChatCompletionContentPartUnionParam
textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
for _, binaryContent := range msg.BinaryContent() {
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderCopilot)}
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
}
copilotMessages = append(copilotMessages, openai.UserMessage(content))
case message.Assistant:
assistantMsg := openai.ChatCompletionAssistantMessageParam{
Role: "assistant",
}
if msg.Content().String() != "" {
assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
OfString: openai.String(msg.Content().String()),
}
}
if len(msg.ToolCalls()) > 0 {
assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
for i, call := range msg.ToolCalls() {
assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
ID: call.ID,
Type: "function",
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: call.Name,
Arguments: call.Input,
},
}
}
}
copilotMessages = append(copilotMessages, openai.ChatCompletionMessageParamUnion{
OfAssistant: &assistantMsg,
})
case message.Tool:
for _, result := range msg.ToolResults() {
copilotMessages = append(copilotMessages,
openai.ToolMessage(result.Content, result.ToolCallID),
)
}
}
}
return
}
func (c *copilotClient) convertTools(tools []toolsPkg.BaseTool) []openai.ChatCompletionToolParam {
copilotTools := make([]openai.ChatCompletionToolParam, len(tools))
for i, tool := range tools {
info := tool.Info()
copilotTools[i] = openai.ChatCompletionToolParam{
Function: openai.FunctionDefinitionParam{
Name: info.Name,
Description: openai.String(info.Description),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": info.Parameters,
"required": info.Required,
},
},
}
}
return copilotTools
}
func (c *copilotClient) finishReason(reason string) message.FinishReason {
switch reason {
case "stop":
return message.FinishReasonEndTurn
case "length":
return message.FinishReasonMaxTokens
case "tool_calls":
return message.FinishReasonToolUse
default:
return message.FinishReasonUnknown
}
}
func (c *copilotClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(c.providerOptions.model.APIModel),
Messages: messages,
Tools: tools,
}
if c.providerOptions.model.CanReason == true {
params.MaxCompletionTokens = openai.Int(c.providerOptions.maxTokens)
switch c.options.reasoningEffort {
case "low":
params.ReasoningEffort = shared.ReasoningEffortLow
case "medium":
params.ReasoningEffort = shared.ReasoningEffortMedium
case "high":
params.ReasoningEffort = shared.ReasoningEffortHigh
default:
params.ReasoningEffort = shared.ReasoningEffortMedium
}
} else {
params.MaxTokens = openai.Int(c.providerOptions.maxTokens)
}
return params
}
func (c *copilotClient) send(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) (response *ProviderResponse, err error) {
params := c.preparedParams(c.convertMessages(messages), c.convertTools(tools))
cfg := config.Get()
var sessionId string
requestSeqId := (len(messages) + 1) / 2
if cfg.Debug {
// jsonData, _ := json.Marshal(params)
// logging.Debug("Prepared messages", "messages", string(jsonData))
if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
sessionId = sid
}
jsonData, _ := json.Marshal(params)
if sessionId != "" {
filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, params)
logging.Debug("Prepared messages", "filepath", filepath)
} else {
logging.Debug("Prepared messages", "messages", string(jsonData))
}
}
attempts := 0
for {
attempts++
copilotResponse, err := c.client.Chat.Completions.New(
ctx,
params,
)
// If there is an error we are going to see if we can retry the call
if err != nil {
retry, after, retryErr := c.shouldRetry(attempts, err)
if retryErr != nil {
return nil, retryErr
}
if retry {
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(after) * time.Millisecond):
continue
}
}
return nil, retryErr
}
content := ""
if copilotResponse.Choices[0].Message.Content != "" {
content = copilotResponse.Choices[0].Message.Content
}
toolCalls := c.toolCalls(*copilotResponse)
finishReason := c.finishReason(string(copilotResponse.Choices[0].FinishReason))
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
return &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: c.usage(*copilotResponse),
FinishReason: finishReason,
}, nil
}
}
func (c *copilotClient) stream(ctx context.Context, messages []message.Message, tools []toolsPkg.BaseTool) <-chan ProviderEvent {
params := c.preparedParams(c.convertMessages(messages), c.convertTools(tools))
params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
IncludeUsage: openai.Bool(true),
}
cfg := config.Get()
var sessionId string
requestSeqId := (len(messages) + 1) / 2
if cfg.Debug {
if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok {
sessionId = sid
}
jsonData, _ := json.Marshal(params)
if sessionId != "" {
filepath := logging.WriteRequestMessageJson(sessionId, requestSeqId, params)
logging.Debug("Prepared messages", "filepath", filepath)
} else {
logging.Debug("Prepared messages", "messages", string(jsonData))
}
}
attempts := 0
eventChan := make(chan ProviderEvent)
go func() {
for {
attempts++
copilotStream := c.client.Chat.Completions.NewStreaming(
ctx,
params,
)
acc := openai.ChatCompletionAccumulator{}
currentContent := ""
toolCalls := make([]message.ToolCall, 0)
var currentToolCallId string
var currentToolCall openai.ChatCompletionMessageToolCall
var msgToolCalls []openai.ChatCompletionMessageToolCall
for copilotStream.Next() {
chunk := copilotStream.Current()
acc.AddChunk(chunk)
if cfg.Debug {
logging.AppendToStreamSessionLogJson(sessionId, requestSeqId, chunk)
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: choice.Delta.Content,
}
currentContent += choice.Delta.Content
}
}
if c.isAnthropicModel() {
// Monkeypatch adapter for Sonnet-4 multi-tool use
for _, choice := range chunk.Choices {
if choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0 {
toolCall := choice.Delta.ToolCalls[0]
// Detect tool use start
if currentToolCallId == "" {
if toolCall.ID != "" {
currentToolCallId = toolCall.ID
currentToolCall = openai.ChatCompletionMessageToolCall{
ID: toolCall.ID,
Type: "function",
Function: openai.ChatCompletionMessageToolCallFunction{
Name: toolCall.Function.Name,
Arguments: toolCall.Function.Arguments,
},
}
}
} else {
// Delta tool use
if toolCall.ID == "" {
currentToolCall.Function.Arguments += toolCall.Function.Arguments
} else {
// Detect new tool use
if toolCall.ID != currentToolCallId {
msgToolCalls = append(msgToolCalls, currentToolCall)
currentToolCallId = toolCall.ID
currentToolCall = openai.ChatCompletionMessageToolCall{
ID: toolCall.ID,
Type: "function",
Function: openai.ChatCompletionMessageToolCallFunction{
Name: toolCall.Function.Name,
Arguments: toolCall.Function.Arguments,
},
}
}
}
}
}
if choice.FinishReason == "tool_calls" {
msgToolCalls = append(msgToolCalls, currentToolCall)
acc.ChatCompletion.Choices[0].Message.ToolCalls = msgToolCalls
}
}
}
}
err := copilotStream.Err()
if err == nil || errors.Is(err, io.EOF) {
if cfg.Debug {
respFilepath := logging.WriteChatResponseJson(sessionId, requestSeqId, acc.ChatCompletion)
logging.Debug("Chat completion response", "filepath", respFilepath)
}
// Stream completed successfully
finishReason := c.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
toolCalls = append(toolCalls, c.toolCalls(acc.ChatCompletion)...)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: currentContent,
ToolCalls: toolCalls,
Usage: c.usage(acc.ChatCompletion),
FinishReason: finishReason,
},
}
close(eventChan)
return
}
// If there is an error we are going to see if we can retry the call
retry, after, retryErr := c.shouldRetry(attempts, err)
if retryErr != nil {
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
close(eventChan)
return
}
// shouldRetry is not catching the max retries...
// TODO: Figure out why
if attempts > maxRetries {
logging.Warn("Maximum retry attempts reached for rate limit", "attempts", attempts, "max_retries", maxRetries)
retry = false
}
if retry {
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))
select {
case <-ctx.Done():
// context cancelled
if ctx.Err() == nil {
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
}
close(eventChan)
return
case <-time.After(time.Duration(after) * time.Millisecond):
continue
}
}
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
close(eventChan)
return
}
}()
return eventChan
}
func (c *copilotClient) shouldRetry(attempts int, err error) (bool, int64, error) {
var apierr *openai.Error
if !errors.As(err, &apierr) {
return false, 0, err
}
// Check for token expiration (401 Unauthorized)
if apierr.StatusCode == 401 {
// Try to refresh the bearer token
var githubToken string
// 1. Environment variable
githubToken = os.Getenv("GITHUB_TOKEN")
// 2. API key from options
if githubToken == "" {
githubToken = c.providerOptions.apiKey
}
// 3. Standard GitHub CLI/Copilot locations
if githubToken == "" {
var err error
githubToken, err = config.LoadGitHubToken()
if err != nil {
logging.Debug("Failed to load GitHub token from standard locations during retry", "error", err)
}
}
if githubToken != "" {
newBearerToken, tokenErr := c.exchangeGitHubToken(githubToken)
if tokenErr == nil {
c.options.bearerToken = newBearerToken
// Update the client with the new token
// Note: This is a simplified approach. In a production system,
// you might want to recreate the entire client with the new token
logging.Info("Refreshed Copilot bearer token")
return true, 1000, nil // Retry immediately with new token
}
logging.Error("Failed to refresh Copilot bearer token", "error", tokenErr)
}
return false, 0, fmt.Errorf("authentication failed: %w", err)
}
logging.Debug("Copilot API Error", "status", apierr.StatusCode, "headers", apierr.Response.Header, "body", apierr.RawJSON())
if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
return false, 0, err
}
if apierr.StatusCode == 500 {
logging.Warn("Copilot API returned 500 error, retrying", "error", err)
}
if attempts > maxRetries {
return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
}
retryMs := 0
retryAfterValues := apierr.Response.Header.Values("Retry-After")
backoffMs := 2000 * (1 << (attempts - 1))
jitterMs := int(float64(backoffMs) * 0.2)
retryMs = backoffMs + jitterMs
if len(retryAfterValues) > 0 {
if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
retryMs = retryMs * 1000
}
}
return true, int64(retryMs), nil
}
func (c *copilotClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
var toolCalls []message.ToolCall
if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
for _, call := range completion.Choices[0].Message.ToolCalls {
toolCall := message.ToolCall{
ID: call.ID,
Name: call.Function.Name,
Input: call.Function.Arguments,
Type: "function",
Finished: true,
}
toolCalls = append(toolCalls, toolCall)
}
}
return toolCalls
}
func (c *copilotClient) usage(completion openai.ChatCompletion) TokenUsage {
cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
inputTokens := completion.Usage.PromptTokens - cachedTokens
return TokenUsage{
InputTokens: inputTokens,
OutputTokens: completion.Usage.CompletionTokens,
CacheCreationTokens: 0, // GitHub Copilot doesn't provide this directly
CacheReadTokens: cachedTokens,
}
}
func WithCopilotReasoningEffort(effort string) CopilotOption {
return func(options *copilotOptions) {
defaultReasoningEffort := "medium"
switch effort {
case "low", "medium", "high":
defaultReasoningEffort = effort
default:
logging.Warn("Invalid reasoning effort, using default: medium")
}
options.reasoningEffort = defaultReasoningEffort
}
}
func WithCopilotExtraHeaders(headers map[string]string) CopilotOption {
return func(options *copilotOptions) {
options.extraHeaders = headers
}
}
func WithCopilotBearerToken(bearerToken string) CopilotOption {
return func(options *copilotOptions) {
options.bearerToken = bearerToken
}
}

View file

@ -68,6 +68,7 @@ type providerClientOptions struct {
openaiOptions []OpenAIOption openaiOptions []OpenAIOption
geminiOptions []GeminiOption geminiOptions []GeminiOption
bedrockOptions []BedrockOption bedrockOptions []BedrockOption
copilotOptions []CopilotOption
} }
type ProviderClientOption func(*providerClientOptions) type ProviderClientOption func(*providerClientOptions)
@ -88,6 +89,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
o(&clientOptions) o(&clientOptions)
} }
switch providerName { switch providerName {
case models.ProviderCopilot:
return &baseProvider[CopilotClient]{
options: clientOptions,
client: newCopilotClient(clientOptions),
}, nil
case models.ProviderAnthropic: case models.ProviderAnthropic:
return &baseProvider[AnthropicClient]{ return &baseProvider[AnthropicClient]{
options: clientOptions, options: clientOptions,
@ -233,3 +239,9 @@ func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
options.bedrockOptions = bedrockOptions options.bedrockOptions = bedrockOptions
} }
} }
func WithCopilotOptions(copilotOptions ...CopilotOption) ProviderClientOption {
return func(options *providerClientOptions) {
options.copilotOptions = copilotOptions
}
}

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp" "github.com/opencode-ai/opencode/internal/lsp"
) )
@ -97,6 +98,7 @@ func (v *viewTool) Info() ToolInfo {
// Run implements Tool. // Run implements Tool.
func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params ViewParams var params ViewParams
logging.Debug("view tool params", "params", call.Input)
if err := json.Unmarshal([]byte(call.Input), &params); err != nil { if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
} }

View file

@ -4,16 +4,33 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
// "path/filepath"
"encoding/json"
"runtime"
"runtime/debug" "runtime/debug"
"sync"
"time" "time"
) )
func getCaller() string {
var caller string
if _, file, line, ok := runtime.Caller(2); ok {
// caller = fmt.Sprintf("%s:%d", filepath.Base(file), line)
caller = fmt.Sprintf("%s:%d", file, line)
} else {
caller = "unknown"
}
return caller
}
func Info(msg string, args ...any) { func Info(msg string, args ...any) {
slog.Info(msg, args...) source := getCaller()
slog.Info(msg, append([]any{"source", source}, args...)...)
} }
func Debug(msg string, args ...any) { func Debug(msg string, args ...any) {
slog.Debug(msg, args...) // slog.Debug(msg, args...)
source := getCaller()
slog.Debug(msg, append([]any{"source", source}, args...)...)
} }
func Warn(msg string, args ...any) { func Warn(msg string, args ...any) {
@ -76,3 +93,115 @@ func RecoverPanic(name string, cleanup func()) {
} }
} }
} }
// Message Logging for Debug
var MessageDir string
func GetSessionPrefix(sessionId string) string {
return sessionId[:8]
}
var sessionLogMutex sync.Mutex
func AppendToSessionLogFile(sessionId string, filename string, content string) string {
if MessageDir == "" || sessionId == "" {
return ""
}
sessionPrefix := GetSessionPrefix(sessionId)
sessionLogMutex.Lock()
defer sessionLogMutex.Unlock()
sessionPath := fmt.Sprintf("%s/%s", MessageDir, sessionPrefix)
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
if err := os.MkdirAll(sessionPath, 0o766); err != nil {
Error("Failed to create session directory", "dirpath", sessionPath, "error", err)
return ""
}
}
filePath := fmt.Sprintf("%s/%s", sessionPath, filename)
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
Error("Failed to open session log file", "filepath", filePath, "error", err)
return ""
}
defer f.Close()
// Append chunk to file
_, err = f.WriteString(content)
if err != nil {
Error("Failed to write chunk to session log file", "filepath", filePath, "error", err)
return ""
}
return filePath
}
func WriteRequestMessageJson(sessionId string, requestSeqId int, message any) string {
if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
return ""
}
msgJson, err := json.Marshal(message)
if err != nil {
Error("Failed to marshal message", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
return ""
}
return WriteRequestMessage(sessionId, requestSeqId, string(msgJson))
}
func WriteRequestMessage(sessionId string, requestSeqId int, message string) string {
if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
return ""
}
filename := fmt.Sprintf("%d_request.json", requestSeqId)
return AppendToSessionLogFile(sessionId, filename, message)
}
func AppendToStreamSessionLogJson(sessionId string, requestSeqId int, jsonableChunk any) string {
if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
return ""
}
chunkJson, err := json.Marshal(jsonableChunk)
if err != nil {
Error("Failed to marshal message", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
return ""
}
return AppendToStreamSessionLog(sessionId, requestSeqId, string(chunkJson))
}
func AppendToStreamSessionLog(sessionId string, requestSeqId int, chunk string) string {
if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
return ""
}
filename := fmt.Sprintf("%d_response_stream.log", requestSeqId)
return AppendToSessionLogFile(sessionId, filename, chunk)
}
func WriteChatResponseJson(sessionId string, requestSeqId int, response any) string {
if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
return ""
}
responseJson, err := json.Marshal(response)
if err != nil {
Error("Failed to marshal response", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
return ""
}
filename := fmt.Sprintf("%d_response.json", requestSeqId)
return AppendToSessionLogFile(sessionId, filename, string(responseJson))
}
func WriteToolResultsJson(sessionId string, requestSeqId int, toolResults any) string {
if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
return ""
}
toolResultsJson, err := json.Marshal(toolResults)
if err != nil {
Error("Failed to marshal tool results", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
return ""
}
filename := fmt.Sprintf("%d_tool_results.json", requestSeqId)
return AppendToSessionLogFile(sessionId, filename, string(toolResultsJson))
}

View file

@ -45,6 +45,7 @@ type writer struct{}
func (w *writer) Write(p []byte) (int, error) { func (w *writer) Write(p []byte) (int, error) {
d := logfmt.NewDecoder(bytes.NewReader(p)) d := logfmt.NewDecoder(bytes.NewReader(p))
for d.ScanRecord() { for d.ScanRecord() {
msg := LogMessage{ msg := LogMessage{
ID: fmt.Sprintf("%d", time.Now().UnixNano()), ID: fmt.Sprintf("%d", time.Now().UnixNano()),

View file

@ -77,7 +77,18 @@
"openrouter.o4-mini", "openrouter.o4-mini",
"openrouter.claude-3.5-haiku", "openrouter.claude-3.5-haiku",
"claude-4-opus", "claude-4-opus",
"openrouter.o1-pro" "openrouter.o1-pro",
"copilot.gpt-4o",
"copilot.gpt-4o-mini",
"copilot.gpt-4.1",
"copilot.claude-3.5-sonnet",
"copilot.claude-3.7-sonnet",
"copilot.claude-sonnet-4",
"copilot.o1",
"copilot.o3-mini",
"copilot.o4-mini",
"copilot.gemini-2.0-flash",
"copilot.gemini-2.5-pro"
], ],
"type": "string" "type": "string"
}, },
@ -176,7 +187,18 @@
"openrouter.o4-mini", "openrouter.o4-mini",
"openrouter.claude-3.5-haiku", "openrouter.claude-3.5-haiku",
"claude-4-opus", "claude-4-opus",
"openrouter.o1-pro" "openrouter.o1-pro",
"copilot.gpt-4o",
"copilot.gpt-4o-mini",
"copilot.gpt-4.1",
"copilot.claude-3.5-sonnet",
"copilot.claude-3.7-sonnet",
"copilot.claude-sonnet-4",
"copilot.o1",
"copilot.o3-mini",
"copilot.o4-mini",
"copilot.gemini-2.0-flash",
"copilot.gemini-2.5-pro"
], ],
"type": "string" "type": "string"
}, },
@ -360,7 +382,8 @@
"openrouter", "openrouter",
"bedrock", "bedrock",
"azure", "azure",
"vertexai" "vertexai",
"copilot"
], ],
"type": "string" "type": "string"
} }