Skip to content

feat(session and litellm): add session deletion flow with Ctrl+P and overlay dialog; support LiteLLM (or any other) proxy; extend vertex models #243

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 11 commits 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ Thumbs.db
.opencode/

opencode
.aider*
opencode.md
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ OpenCode supports a variety of AI models from different providers:

- Gemini 2.5
- Gemini 2.5 Flash
- Anthropic Sonnet 4
- Anthropic Opus 4

## Usage

Expand Down Expand Up @@ -335,6 +337,7 @@ The output format is implemented as a strongly-typed `OutputFormat` in the codeb
| `?` | Toggle help dialog (when not in editing mode) |
| `Ctrl+L` | View logs |
| `Ctrl+A` | Switch session |
| `Ctrl+P` | Prune session |
| `Ctrl+K` | Command dialog |
| `Ctrl+O` | Toggle model selection dialog |
| `Esc` | Close current overlay/dialog or return to previous mode |
Expand Down Expand Up @@ -626,11 +629,30 @@ This is useful for developers who want to experiment with custom models.

### Configuring a self-hosted provider

You can use a self-hosted model by setting the `LOCAL_ENDPOINT` environment variable.
This will cause OpenCode to load and use the models from the specified endpoint.
You can use a self-hosted model by setting the `LOCAL_ENDPOINT` and `LOCAL_ENDPOINT_API_KEY` environment variable.
This will cause OpenCode to load and use the models from the specified endpoint. When it loads model it tries to inherit settings
from predefined ones if possible.

```bash
LOCAL_ENDPOINT=http://localhost:1235/v1
LOCAL_ENDPOINT_API_KEY=secret
```

### Using LiteLLM Proxy
It is possible to use LiteLLM as a passthrough proxy by providing `baseURL` and auth header to provider configuration:
```json
{
"providers": {
"vertexai": {
"apiKey": "litellm-api-key",
"disabled": false,
"baseURL": "https://localhost/vertex_ai"
"headers": {
"x-litellm-api-key": "litellm-api-key"
}
}
}
}
```

### Configuring a self-hosted model
Expand Down
11 changes: 11 additions & 0 deletions cmd/schema/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ func generateSchema() map[string]any {
"description": "Whether the provider is disabled",
"default": false,
},
"baseURL": map[string]any{
"type": "string",
"description": "Base URL for the provider instead of default one",
},
"headers": map[string]any{
"type": "object",
"description": "Extra headers to attach to request",
"additionalProperties": map[string]any{
"type": "string",
},
},
},
},
}
Expand Down
10 changes: 9 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ require (
github.com/stretchr/testify v1.10.0
)

require (
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/api v0.215.0 // indirect
)

require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth v0.13.0
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
Expand Down Expand Up @@ -250,6 +252,8 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
Expand Down Expand Up @@ -289,6 +293,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down Expand Up @@ -329,11 +335,15 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
google.golang.org/genai v1.3.0 h1:tXhPJF30skOjnnDY7ZnjK3q7IKy4PuAlEA0fk7uEaEI=
google.golang.org/genai v1.3.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
Expand Down
6 changes: 4 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,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"`
Disabled bool `json:"disabled"`
BaseURL string `json:"baseURL"`
Headers map[string]string `json:"headers,omitempty"`
}

// Data defines storage configuration.
Expand Down
8 changes: 8 additions & 0 deletions internal/llm/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -725,12 +725,20 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
if agentConfig.MaxTokens > 0 {
maxTokens = agentConfig.MaxTokens
}

opts := []provider.ProviderClientOption{
provider.WithAPIKey(providerCfg.APIKey),
provider.WithModel(model),
provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
provider.WithMaxTokens(maxTokens),
}
if providerCfg.BaseURL != "" {
opts = append(opts, provider.WithBaseURL(providerCfg.BaseURL))
}
if len(providerCfg.Headers) != 0 {
opts = append(opts, provider.WithHeaders(providerCfg.Headers))
}

if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason {
opts = append(
opts,
Expand Down
4 changes: 2 additions & 2 deletions internal/llm/models/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ var AnthropicModels = map[ModelID]Model{
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 50000,
DefaultMaxTokens: 64000,
CanReason: true,
SupportsAttachments: true,
},
Expand All @@ -105,7 +105,7 @@ var AnthropicModels = map[ModelID]Model{
CostPer1MOutCached: 1.50,
CostPer1MOut: 75.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
DefaultMaxTokens: 32000,
SupportsAttachments: true,
},
}
18 changes: 18 additions & 0 deletions internal/llm/models/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package models

import "maps"

func init() {
maps.Copy(SupportedModels, AnthropicModels)
maps.Copy(SupportedModels, OpenAIModels)
maps.Copy(SupportedModels, GeminiModels)
maps.Copy(SupportedModels, GroqModels)
maps.Copy(SupportedModels, AzureModels)
maps.Copy(SupportedModels, OpenRouterModels)
maps.Copy(SupportedModels, XAIModels)
maps.Copy(SupportedModels, VertexAIGeminiModels)
maps.Copy(SupportedModels, VertexAIAnthropicModels)
maps.Copy(SupportedModels, CopilotModels)

initLocalModels()
}
79 changes: 62 additions & 17 deletions internal/llm/models/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package models
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
Expand All @@ -21,7 +22,7 @@ const (
lmStudioBetaModelsPath = "api/v0/models"
)

func init() {
func initLocalModels() {
if endpoint := os.Getenv("LOCAL_ENDPOINT"); endpoint != "" {
localEndpoint, err := url.Parse(endpoint)
if err != nil {
Expand All @@ -33,7 +34,8 @@ func init() {
}

load := func(url *url.URL, path string) []localModel {
url.Path = path
url = url.JoinPath(path)
logging.Debug(fmt.Sprintf("Trying to load models from %s", url))
return listLocalModels(url.String())
}

Expand All @@ -43,16 +45,22 @@ func init() {
models = load(localEndpoint, localModelsPath)
}

if len(models) == 0 {
if c := len(models); c == 0 {
logging.Debug("No local models found",
"endpoint", endpoint,
)
return
} else {
logging.Debug(fmt.Sprintf("%d local models found", c))
}

loadLocalModels(models)

viper.SetDefault("providers.local.apiKey", "dummy")
if token, ok := os.LookupEnv("LOCAL_ENDPOINT_API_KEY"); ok {
viper.SetDefault("providers.local.apiKey", token)
} else {
viper.SetDefault("providers.local.apiKey", "dummy")
}
ProviderPopularity[ProviderLocal] = 0
}
}
Expand All @@ -75,8 +83,26 @@ type localModel struct {
}

func listLocalModels(modelsEndpoint string) []localModel {
res, err := http.Get(modelsEndpoint)
if err != nil {
token := os.Getenv("LOCAL_ENDPOINT_API_KEY")
var (
res *http.Response
err error
)
if token != "" {
req, reqErr := http.NewRequest("GET", modelsEndpoint, nil)
if reqErr != nil {
logging.Debug("Failed to create local models request",
"error", reqErr,
"endpoint", modelsEndpoint,
)
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
res, err = http.DefaultClient.Do(req)
} else {
res, err = http.Get(modelsEndpoint)
}
if err != nil || res == nil {
logging.Debug("Failed to list local models",
"error", err,
"endpoint", modelsEndpoint,
Expand Down Expand Up @@ -125,7 +151,8 @@ func listLocalModels(modelsEndpoint string) []localModel {

func loadLocalModels(models []localModel) {
for i, m := range models {
model := convertLocalModel(m)
source := tryResolveSource(m.ID)
model := convertLocalModel(m, source)
SupportedModels[model.ID] = model

if i == 0 || m.State == "loaded" {
Expand All @@ -137,16 +164,34 @@ func loadLocalModels(models []localModel) {
}
}

func convertLocalModel(model localModel) Model {
return Model{
ID: ModelID("local." + model.ID),
Name: friendlyModelName(model.ID),
Provider: ProviderLocal,
APIModel: model.ID,
ContextWindow: cmp.Or(model.LoadedContextLength, 4096),
DefaultMaxTokens: cmp.Or(model.LoadedContextLength, 4096),
CanReason: true,
SupportsAttachments: true,
func tryResolveSource(localID string) *Model {
for _, model := range SupportedModels {
if strings.Contains(localID, model.APIModel) {
return &model
}
}
return nil
}

func convertLocalModel(model localModel, source *Model) Model {
if source != nil {
m := *source
m.ID = ModelID("local." + model.ID)
m.Name = source.Name
m.APIModel = model.ID
m.Provider = ProviderLocal
return m
} else {
return Model{
ID: ModelID("local." + model.ID),
Name: friendlyModelName(model.ID),
Provider: ProviderLocal,
APIModel: model.ID,
ContextWindow: cmp.Or(model.LoadedContextLength, 4096),
DefaultMaxTokens: cmp.Or(model.LoadedContextLength, 4096),
CanReason: false,
SupportsAttachments: false,
}
}
}

Expand Down
33 changes: 33 additions & 0 deletions internal/llm/models/vertexai.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const (
// Models
VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
VertexAIGemini25 ModelID = "vertexai.gemini-2.5"
VertexAISonnet4 ModelID = "vertexai.claude-sonnet-4"
VertexAIOpus4 ModelID = "vertexai.claude-opus-4"
)

var VertexAIGeminiModels = map[ModelID]Model{
Expand Down Expand Up @@ -36,3 +38,34 @@ var VertexAIGeminiModels = map[ModelID]Model{
SupportsAttachments: true,
},
}

var VertexAIAnthropicModels = map[ModelID]Model{
VertexAISonnet4: {
ID: VertexAISonnet4,
Name: "VertexAI: Claude Sonnet 4",
Provider: ProviderVertexAI,
APIModel: "claude-sonnet-4",
CostPer1MIn: AnthropicModels[Claude4Sonnet].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude4Sonnet].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude4Sonnet].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude4Sonnet].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude4Sonnet].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude4Sonnet].DefaultMaxTokens,
SupportsAttachments: AnthropicModels[Claude4Sonnet].SupportsAttachments,
CanReason: AnthropicModels[Claude4Sonnet].CanReason,
},
VertexAIOpus4: {
ID: VertexAIOpus4,
Name: "VertexAI: Claude Opus 4",
Provider: ProviderVertexAI,
APIModel: "claude-opus-4@20250514",
CostPer1MIn: AnthropicModels[Claude4Opus].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude4Opus].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude4Opus].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude4Opus].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude4Opus].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude4Opus].DefaultMaxTokens,
SupportsAttachments: AnthropicModels[Claude4Opus].SupportsAttachments,
CanReason: AnthropicModels[Claude4Opus].CanReason,
},
}
Loading