From b9bedbae80046a5ae03be38e897bba96661b28d2 Mon Sep 17 00:00:00 2001 From: Bryan Vaz <9157498+bryanvaz@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:23:40 -0400 Subject: [PATCH 1/4] feat: add github copilot provider (#230) * feat: add github copilot * fix: add support for claude4 --- .gitignore | 1 + README.md | 72 +++- internal/config/config.go | 116 ++++- internal/llm/agent/agent.go | 26 +- internal/llm/models/copilot.go | 219 ++++++++++ internal/llm/models/models.go | 18 +- internal/llm/provider/anthropic.go | 24 +- internal/llm/provider/copilot.go | 671 +++++++++++++++++++++++++++++ internal/llm/provider/provider.go | 12 + internal/llm/tools/view.go | 2 + internal/logging/logger.go | 133 +++++- internal/logging/writer.go | 1 + opencode-schema.json | 29 +- 13 files changed, 1276 insertions(+), 48 deletions(-) create mode 100644 internal/llm/models/copilot.go create mode 100644 internal/llm/provider/copilot.go diff --git a/.gitignore b/.gitignore index 36ff9c73267bcc5c7b8ece367108972dad21c1e2..3a206a7f28440a2632f13402e74d0567613e0419 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ Thumbs.db .opencode/ opencode +opencode.md diff --git a/README.md b/README.md index 0d3e299daeafa9cf4e2a340fd5f1d77f99fb0efb..eee06acd9aa1cc12b2a829ade101adbbffc63185 100644 --- a/README.md +++ b/README.md @@ -96,22 +96,23 @@ You can enable or disable this feature in your configuration file: You can configure OpenCode using environment variables: -| Environment Variable | Purpose | -| -------------------------- | ------------------------------------------------------ | -| `ANTHROPIC_API_KEY` | For Claude models | -| `OPENAI_API_KEY` | For OpenAI models | -| `GEMINI_API_KEY` | For Google Gemini models | -| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) | -| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) | -| `GROQ_API_KEY` | For Groq models | -| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) | -| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) | -| `AWS_REGION` | For AWS Bedrock (Claude) | -| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models | -| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) | -| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models | -| `LOCAL_ENDPOINT` | For self-hosted models | -| `SHELL` | Default shell to use (if not specified in config) | +| Environment Variable | Purpose | +| -------------------------- | -------------------------------------------------------------------------------- | +| `ANTHROPIC_API_KEY` | For Claude models | +| `OPENAI_API_KEY` | For OpenAI models | +| `GEMINI_API_KEY` | For Google Gemini models | +| `GITHUB_TOKEN` | For Github Copilot models (see [Using Github Copilot](#using-github-copilot)) | +| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) | +| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) | +| `GROQ_API_KEY` | For Groq models | +| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) | +| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) | +| `AWS_REGION` | For AWS Bedrock (Claude) | +| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models | +| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) | +| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models | +| `LOCAL_ENDPOINT` | For self-hosted models | +| `SHELL` | Default shell to use (if not specified in config) | ### Shell Configuration @@ -146,6 +147,9 @@ This is useful if you want to use a different shell than your default system she "apiKey": "your-api-key", "disabled": false }, + "copilot": { + "disabled": false + }, "groq": { "apiKey": "your-api-key", "disabled": false @@ -216,6 +220,23 @@ OpenCode supports a variety of AI models from different providers: - Claude 3 Haiku - 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 - 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. +## 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 OpenCode can also load and use models from a self-hosted (OpenAI-like) provider. diff --git a/internal/config/config.go b/internal/config/config.go index 5a0905bba239c0d7c79f669801ef9b3a5caa9cf9..630fac9b6e9375a6dd192ca9df6582bd1e4a92c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strings" "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" { 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 _, 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) if err != nil { return cfg, fmt.Errorf("failed to open log file: %w", err) @@ -245,6 +254,7 @@ func setDefaults(debug bool) { // environment variables and configuration file. func setProviderDefaults() { // 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 != "" { 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 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 - // 1. Anthropic - // 2. OpenAI - // 3. Google Gemini - // 4. Groq - // 5. OpenRouter - // 6. AWS Bedrock - // 7. Azure - // 8. Google Cloud VertexAI + // 1. Copilot + // 2. Anthropic + // 3. OpenAI + // 4. Google Gemini + // 5. Groq + // 6. OpenRouter + // 7. AWS Bedrock + // 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 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" { @@ -399,6 +425,14 @@ func hasVertexAICredentials() bool { 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. func readConfig(err error) error { if err == nil { @@ -440,6 +474,9 @@ func applyDefaultValues() { // It validates model IDs and providers, ensuring they are supported. func validateAgent(cfg *Config, name AgentName, agent Agent) error { // 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] if !modelExists { logging.Warn("unsupported model configured, reverting to default", @@ -584,6 +621,7 @@ func Validate() error { // Validate providers for provider, providerCfg := range cfg.Providers { if providerCfg.APIKey == "" && !providerCfg.Disabled { + fmt.Printf("provider has no API key, marking as disabled %s", provider) logging.Warn("provider has no API key, marking as disabled", "provider", provider) providerCfg.Disabled = true 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 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 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" { maxTokens := int64(5000) @@ -878,3 +928,53 @@ func UpdateTheme(themeName string) error { 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") +} diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 4f31fe75d688aa2c4fdd80a4f633fe35d45125cc..20b10fd374fdf67867e0b626058bc68b8d7496d5 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -162,6 +162,7 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str if err != nil { return err } + ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID) parts := []message.ContentPart{message.TextContent{Text: content}} response, err := a.titleProvider.SendMessages( 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 { + cfg := config.Get() // List existing messages; if none, start title generation asynchronously. msgs, err := a.messages.List(ctx, sessionID) 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)) } - logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults) + 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) + } if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil { // We are not done, we need to respond with the tool response 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) { + ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID) eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools) 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. ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID) - ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID) // Process each event in the stream. for event := range eventChan { @@ -357,10 +365,17 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg default: // Continue processing var tool tools.BaseTool - for _, availableTools := range a.tools { - if availableTools.Info().Name == toolCall.Name { - tool = availableTools + for _, availableTool := range a.tools { + if availableTool.Info().Name == toolCall.Name { + 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 @@ -553,6 +568,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error { a.Publish(pubsub.CreatedEvent, event) return } + summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID) if len(msgs) == 0 { event = AgentEvent{ diff --git a/internal/llm/models/copilot.go b/internal/llm/models/copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..f6ec91cddf6c8537e7b1ff66ff4240071dc62f3c --- /dev/null +++ b/internal/llm/models/copilot.go @@ -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, + }, +} diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 47d217184de54f7e2937286cd2c64c9e98c4a02b..2bcb508e9928722a1bd2cc4151bf43529645dc74 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -36,14 +36,15 @@ const ( // Providers in order of popularity var ProviderPopularity = map[ModelProvider]int{ - ProviderAnthropic: 1, - ProviderOpenAI: 2, - ProviderGemini: 3, - ProviderGROQ: 4, - ProviderOpenRouter: 5, - ProviderBedrock: 6, - ProviderAzure: 7, - ProviderVertexAI: 8, + ProviderCopilot: 1, + ProviderAnthropic: 2, + ProviderOpenAI: 3, + ProviderGemini: 4, + ProviderGROQ: 5, + ProviderOpenRouter: 6, + ProviderBedrock: 7, + ProviderAzure: 8, + ProviderVertexAI: 9, } var SupportedModels = map[ModelID]Model{ @@ -93,4 +94,5 @@ func init() { maps.Copy(SupportedModels, OpenRouterModels) maps.Copy(SupportedModels, XAIModels) maps.Copy(SupportedModels, VertexAIGeminiModels) + maps.Copy(SupportedModels, CopilotModels) } diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index badf6a3a07df27f6494bdbf9692f174e0a17a1ce..213d4b94a34beb4f858ecf2fed99da040a870f32 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -14,7 +14,7 @@ import ( "github.com/anthropics/anthropic-sdk-go/option" "github.com/opencode-ai/opencode/internal/config" "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/message" ) @@ -118,7 +118,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic 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)) 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)) cfg := config.Get() 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)) cfg := config.Get() + + var sessionId string + requestSeqId := (len(messages) + 1) / 2 if cfg.Debug { - // jsonData, _ := json.Marshal(preparedMessages) - // logging.Debug("Prepared messages", "messages", string(jsonData)) + if sid, ok := ctx.Value(toolsPkg.SessionIDContextKey).(string); ok { + 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 eventChan := make(chan ProviderEvent) diff --git a/internal/llm/provider/copilot.go b/internal/llm/provider/copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..5d70e718ae781d086711884b550a3995d720edff --- /dev/null +++ b/internal/llm/provider/copilot.go @@ -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 + } +} + diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 08175450a6d85953e996c08f436982a1981053b6..d5be0ba0e8b1a88165c8dddfa84a1acda2e1a0dc 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -68,6 +68,7 @@ type providerClientOptions struct { openaiOptions []OpenAIOption geminiOptions []GeminiOption bedrockOptions []BedrockOption + copilotOptions []CopilotOption } type ProviderClientOption func(*providerClientOptions) @@ -88,6 +89,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption o(&clientOptions) } switch providerName { + case models.ProviderCopilot: + return &baseProvider[CopilotClient]{ + options: clientOptions, + client: newCopilotClient(clientOptions), + }, nil case models.ProviderAnthropic: return &baseProvider[AnthropicClient]{ options: clientOptions, @@ -233,3 +239,9 @@ func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption { options.bedrockOptions = bedrockOptions } } + +func WithCopilotOptions(copilotOptions ...CopilotOption) ProviderClientOption { + return func(options *providerClientOptions) { + options.copilotOptions = copilotOptions + } +} diff --git a/internal/llm/tools/view.go b/internal/llm/tools/view.go index 6d800ce6ee27902a5c99767b9954e91f2c650428..7802817226d728ac20e63ecf38d9d827dd182fa2 100644 --- a/internal/llm/tools/view.go +++ b/internal/llm/tools/view.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" ) @@ -97,6 +98,7 @@ func (v *viewTool) Info() ToolInfo { // Run implements Tool. func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { var params ViewParams + logging.Debug("view tool params", "params", call.Input) if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 7ae2e7b87ab7f3f71811c793118c79e2a72a3bbf..51787d00e2c6b4c37e0b1b9c3f23a9fbd6cd6f65 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -4,16 +4,33 @@ import ( "fmt" "log/slog" "os" + // "path/filepath" + "encoding/json" + "runtime" "runtime/debug" + "sync" "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) { - slog.Info(msg, args...) + source := getCaller() + slog.Info(msg, append([]any{"source", source}, args...)...) } 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) { @@ -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)) +} diff --git a/internal/logging/writer.go b/internal/logging/writer.go index 50f3367db015af253869262ce139d4d36c962254..5c0e3c80392cc92830038f2302a0bea1e3c4fdb3 100644 --- a/internal/logging/writer.go +++ b/internal/logging/writer.go @@ -45,6 +45,7 @@ type writer struct{} func (w *writer) Write(p []byte) (int, error) { d := logfmt.NewDecoder(bytes.NewReader(p)) + for d.ScanRecord() { msg := LogMessage{ ID: fmt.Sprintf("%d", time.Now().UnixNano()), diff --git a/opencode-schema.json b/opencode-schema.json index dc139fda374964b1254d5df12c42751c84d29e7a..406c75f8c7945cb1418f17cca9ba1aee9c4b2959 100644 --- a/opencode-schema.json +++ b/opencode-schema.json @@ -77,7 +77,18 @@ "openrouter.o4-mini", "openrouter.claude-3.5-haiku", "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" }, @@ -176,7 +187,18 @@ "openrouter.o4-mini", "openrouter.claude-3.5-haiku", "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" }, @@ -360,7 +382,8 @@ "openrouter", "bedrock", "azure", - "vertexai" + "vertexai", + "copilot" ], "type": "string" } From 4427df587f3c636002b66dba467a338fe948c828 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 26 Jun 2025 23:44:20 -0700 Subject: [PATCH 2/4] fixup early return for ollama (#266) --- internal/llm/models/local.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/llm/models/local.go b/internal/llm/models/local.go index 5d8412c86a0f3f4ccf305763171f6acfdaea6eb1..db0ea11c60b23151fe5f0b724c86683ef431b41c 100644 --- a/internal/llm/models/local.go +++ b/internal/llm/models/local.go @@ -81,6 +81,7 @@ func listLocalModels(modelsEndpoint string) []localModel { "error", err, "endpoint", modelsEndpoint, ) + return []localModel{} } defer res.Body.Close() @@ -89,6 +90,7 @@ func listLocalModels(modelsEndpoint string) []localModel { "status", res.StatusCode, "endpoint", modelsEndpoint, ) + return []localModel{} } var modelList localModelList @@ -97,6 +99,7 @@ func listLocalModels(modelsEndpoint string) []localModel { "error", err, "endpoint", modelsEndpoint, ) + return []localModel{} } var supportedModels []localModel From 1f6eef460ec921c435cba5bd58228dfa8adf6ef3 Mon Sep 17 00:00:00 2001 From: Gedy Palomino <36518098+gedzeppelin@users.noreply.github.com> Date: Tue, 1 Jul 2025 04:50:43 -0500 Subject: [PATCH 3/4] fix(mcp): ensure required field if nil (#278) --- internal/llm/agent/mcp-tools.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 2375606416e144db5ada7b0ab4309c7987aa8080..59a15bdd72ff620c92eb4ee3144a0a4b13276f67 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -33,11 +33,15 @@ type MCPClient interface { } func (b *mcpTool) Info() tools.ToolInfo { + required := b.tool.InputSchema.Required + if required == nil { + required = make([]string, 0) + } return tools.ToolInfo{ Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, Parameters: b.tool.InputSchema.Properties, - Required: b.tool.InputSchema.Required, + Required: required, } } From f0571f5f5adef12eba9ddf6d07223a043d63dca8 Mon Sep 17 00:00:00 2001 From: Aldehir Rojas Date: Tue, 1 Jul 2025 04:52:19 -0500 Subject: [PATCH 4/4] fix(tool/grep): always show file names with rg (#271) --- internal/llm/tools/grep.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index f20d61ef1ed44f50235f4ba19b8ea44ba7043eb6..1d2d008cf3d641f03cebcd97b73956d11e84a2a1 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -211,7 +211,7 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { } // Use -n to show line numbers and include the matched line - args := []string{"-n", pattern} + args := []string{"-H", "-n", pattern} if include != "" { args = append(args, "--glob", include) }