Skip to content

feat: Add support for custom provider endpoints and headers #245

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ This is useful if you want to use a different shell than your default system she
"providers": {
"openai": {
"apiKey": "your-api-key",
"baseURL": "https://custom-openai-endpoint.com/v1",
"headers": {
"X-Custom-Header": "value"
},
"disabled": false
},
"anthropic": {
Expand All @@ -143,6 +147,7 @@ This is useful if you want to use a different shell than your default system she
},
"groq": {
"apiKey": "your-api-key",
"baseURL": "https://custom-groq-proxy.com/openai/v1",
"disabled": false
},
"openrouter": {
Expand Down Expand Up @@ -188,6 +193,49 @@ This is useful if you want to use a different shell than your default system she
}
```

### Provider Configuration

#### Custom Base URLs

You can configure custom base URLs for providers that support it (OpenAI, Gemini, Groq, OpenRouter, XAI, Local). This is useful for:

- Using proxy servers
- Corporate API gateways
- Self-hosted compatible endpoints
- Alternative API endpoints

Example configuration:

```json
{
"providers": {
"openai": {
"apiKey": "your-api-key",
"baseURL": "https://your-openai-proxy.com/v1"
}
}
}
```

#### Custom Headers

You can also add custom headers to provider requests (currently supported for OpenAI-compatible providers and Gemini):

```json
{
"providers": {
"openai": {
"apiKey": "your-api-key",
"baseURL": "https://your-proxy.com/v1",
"headers": {
"X-Auth-Token": "additional-auth-token",
"X-Organization": "your-org"
}
}
}
}
```

## Supported AI Models

OpenCode supports a variety of AI models from different providers:
Expand Down
77 changes: 76 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package cmd
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -45,6 +48,12 @@ to assist developers in writing, debugging, and understanding code directly from

# Run a single non-interactive prompt with JSON output format
opencode -p "Explain the use of context in Go" -f json

# Run prompt from a markdown file
opencode --prompt-file /path/to/prompt.md

# Use custom configuration file
opencode --config /path/to/custom-config.json
`,
RunE: func(cmd *cobra.Command, args []string) error {
// If the help flag is set, show the help message
Expand All @@ -61,14 +70,78 @@ to assist developers in writing, debugging, and understanding code directly from
debug, _ := cmd.Flags().GetBool("debug")
cwd, _ := cmd.Flags().GetString("cwd")
prompt, _ := cmd.Flags().GetString("prompt")
promptFile, _ := cmd.Flags().GetString("prompt-file")
configFile, _ := cmd.Flags().GetString("config")
outputFormat, _ := cmd.Flags().GetString("output-format")
quiet, _ := cmd.Flags().GetBool("quiet")

// Validate that only one prompt option is provided
if prompt != "" && promptFile != "" {
return fmt.Errorf("cannot use both --prompt and --prompt-file options at the same time")
}

// Validate config file if specified
if configFile != "" {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(configFile), ".json") {
return fmt.Errorf("config file must have .json extension")
}

// Check if file exists
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return fmt.Errorf("config file does not exist: %s", configFile)
}

// Convert relative path to absolute
absPath, err := filepath.Abs(configFile)
if err != nil {
return fmt.Errorf("failed to resolve absolute path for config file: %v", err)
}
configFile = absPath
}

// Validate format option
if !format.IsValid(outputFormat) {
return fmt.Errorf("invalid format option: %s\n%s", outputFormat, format.GetHelpText())
}

// Load prompt from file if specified
if promptFile != "" {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(promptFile), ".md") {
return fmt.Errorf("prompt file must have .md extension")
}

// Check if file exists
if _, err := os.Stat(promptFile); os.IsNotExist(err) {
return fmt.Errorf("prompt file does not exist: %s", promptFile)
}

// Convert relative path to absolute
absPath, err := filepath.Abs(promptFile)
if err != nil {
return fmt.Errorf("failed to resolve absolute path for prompt file: %v", err)
}

// Read file content
file, err := os.Open(absPath)
if err != nil {
return fmt.Errorf("failed to open prompt file: %v", err)
}
defer file.Close()

content, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read prompt file: %v", err)
}

if len(content) == 0 {
return fmt.Errorf("prompt file is empty")
}

prompt = string(content)
}

if cwd != "" {
err := os.Chdir(cwd)
if err != nil {
Expand All @@ -82,7 +155,7 @@ to assist developers in writing, debugging, and understanding code directly from
}
cwd = c
}
_, err := config.Load(cwd, debug)
_, err := config.Load(cwd, debug, configFile)
if err != nil {
return err
}
Expand Down Expand Up @@ -294,6 +367,8 @@ func init() {
rootCmd.Flags().BoolP("debug", "d", false, "Debug")
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
rootCmd.Flags().String("prompt-file", "", "Markdown file containing prompt to run in non-interactive mode")
rootCmd.Flags().String("config", "", "Path to custom configuration file (.json)")

// Add format flag with validation logic
rootCmd.Flags().StringP("output-format", "f", format.Text.String(),
Expand Down
30 changes: 20 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ type Agent struct {

// Provider defines configuration for an LLM provider.
type Provider struct {
APIKey string `json:"apiKey"`
Disabled bool `json:"disabled"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseURL,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Disabled bool `json:"disabled"`
}

// Data defines storage configuration.
Expand Down Expand Up @@ -123,8 +125,9 @@ var cfg *Config

// Load initializes the configuration from environment variables and config files.
// If debug is true, debug mode is enabled and log level is set to debug.
// If configFile is provided, it will be used instead of the default config file locations.
// It returns an error if configuration loading fails.
func Load(workingDir string, debug bool) (*Config, error) {
func Load(workingDir string, debug bool, configFile string) (*Config, error) {
if cfg != nil {
return cfg, nil
}
Expand All @@ -136,7 +139,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
LSP: make(map[string]LSPConfig),
}

configureViper()
configureViper(configFile)
setDefaults(debug)

// Read global config
Expand Down Expand Up @@ -207,12 +210,19 @@ func Load(workingDir string, debug bool) (*Config, error) {
}

// configureViper sets up viper's configuration paths and environment variables.
func configureViper() {
viper.SetConfigName(fmt.Sprintf(".%s", appName))
viper.SetConfigType("json")
viper.AddConfigPath("$HOME")
viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
// If configFile is provided, it will be used instead of the default config file locations.
func configureViper(configFile string) {
if configFile != "" {
// Use the specific config file provided
viper.SetConfigFile(configFile)
} else {
// Use default config file locations
viper.SetConfigName(fmt.Sprintf(".%s", appName))
viper.SetConfigType("json")
viper.AddConfigPath("$HOME")
viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
}
viper.SetEnvPrefix(strings.ToUpper(appName))
viper.AutomaticEnv()
}
Expand Down
66 changes: 52 additions & 14 deletions internal/llm/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,20 +715,58 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
provider.WithMaxTokens(maxTokens),
}
if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason {
opts = append(
opts,
provider.WithOpenAIOptions(
provider.WithReasoningEffort(agentConfig.ReasoningEffort),
),
)
} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
opts = append(
opts,
provider.WithAnthropicOptions(
provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn),
),
)

// Apply provider-specific options based on configuration
switch model.Provider {
case models.ProviderOpenAI, models.ProviderLocal, models.ProviderGROQ, models.ProviderOpenRouter, models.ProviderXAI:
openAIOpts := []provider.OpenAIOption{}

// Add BaseURL if configured
if providerCfg.BaseURL != "" {
openAIOpts = append(openAIOpts, provider.WithOpenAIBaseURL(providerCfg.BaseURL))
}

// Add Headers if configured
if len(providerCfg.Headers) > 0 {
openAIOpts = append(openAIOpts, provider.WithOpenAIExtraHeaders(providerCfg.Headers))
}

// Add reasoning effort if applicable
if (model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal) && model.CanReason {
openAIOpts = append(openAIOpts, provider.WithReasoningEffort(agentConfig.ReasoningEffort))
}

if len(openAIOpts) > 0 {
opts = append(opts, provider.WithOpenAIOptions(openAIOpts...))
}

case models.ProviderAnthropic:
if model.CanReason && agentName == config.AgentCoder {
opts = append(
opts,
provider.WithAnthropicOptions(
provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn),
),
)
}
// TODO: Add BaseURL support for Anthropic when available

case models.ProviderGemini:
geminiOpts := []provider.GeminiOption{}

// Add BaseURL if configured
if providerCfg.BaseURL != "" {
geminiOpts = append(geminiOpts, provider.WithGeminiBaseURL(providerCfg.BaseURL))
}

// Add Headers if configured
if len(providerCfg.Headers) > 0 {
geminiOpts = append(geminiOpts, provider.WithGeminiExtraHeaders(providerCfg.Headers))
}

if len(geminiOpts) > 0 {
opts = append(opts, provider.WithGeminiOptions(geminiOpts...))
}
}
agentProvider, err := provider.NewProvider(
model.Provider,
Expand Down
40 changes: 39 additions & 1 deletion internal/llm/provider/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

Expand All @@ -19,6 +20,8 @@ import (

type geminiOptions struct {
disableCache bool
baseURL string
headers map[string]string
}

type GeminiOption func(*geminiOptions)
Expand All @@ -37,7 +40,30 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
o(&geminiOpts)
}

client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
clientConfig := &genai.ClientConfig{
APIKey: opts.apiKey,
Backend: genai.BackendGeminiAPI,
}

// Apply HTTP options if custom base URL or headers are provided
if geminiOpts.baseURL != "" || len(geminiOpts.headers) > 0 {
httpOptions := genai.HTTPOptions{}

if geminiOpts.baseURL != "" {
httpOptions.BaseURL = geminiOpts.baseURL
}

if len(geminiOpts.headers) > 0 {
httpOptions.Headers = make(http.Header)
for key, value := range geminiOpts.headers {
httpOptions.Headers.Set(key, value)
}
}

clientConfig.HTTPOptions = httpOptions
}

client, err := genai.NewClient(context.Background(), clientConfig)
if err != nil {
logging.Error("Failed to create Gemini client", "error", err)
return nil
Expand Down Expand Up @@ -463,6 +489,18 @@ func WithGeminiDisableCache() GeminiOption {
}
}

func WithGeminiBaseURL(baseURL string) GeminiOption {
return func(options *geminiOptions) {
options.baseURL = baseURL
}
}

func WithGeminiExtraHeaders(headers map[string]string) GeminiOption {
return func(options *geminiOptions) {
options.headers = headers
}
}

// Helper functions
func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
var result map[string]interface{}
Expand Down
Loading