Skip to content
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
61 changes: 61 additions & 0 deletions internal/opencode/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)

// DefaultCachePath returns the default path to the OpenCode models cache file.
Expand Down Expand Up @@ -117,6 +119,32 @@ var envLookup = os.Getenv
// authPath is a package-level variable for testing.
var authPath = DefaultAuthPath

// listModelsForProvider returns runtime-visible model IDs for a provider using
// the installed `opencode` binary. The CLI is the source of truth for custom
// models that may not appear in ~/.cache/opencode/models.json yet.
var listModelsForProvider = func(providerID string) ([]string, error) {
cmd := exec.Command("opencode", "models", providerID)
output, err := cmd.Output()
if err != nil {
return nil, err
}
Comment on lines +125 to +130
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listModelsForProvider runs opencode models <provider> with exec.Command(...).Output() and no timeout/context. Because this is invoked during TUI initialization, a slow/hung opencode process can freeze the UI. Consider using exec.CommandContext with a short timeout (and optionally exec.LookPath upfront) and capturing stderr (e.g., via CombinedOutput) so failures return quickly and are diagnosable.

Copilot uses AI. Check for mistakes.

var models []string
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "/", 2)
if len(parts) != 2 || parts[0] != providerID || parts[1] == "" {
continue
}
models = append(models, parts[1])
}

return models, nil
}

Comment on lines +122 to +147
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing of opencode models output is embedded in listModelsForProvider and currently isn’t covered by tests (the added test stubs listModelsForProvider, so it doesn’t validate the output format assumptions). To prevent regressions if the CLI output changes (headers, extra columns, missing provider prefix, etc.), consider factoring the parsing into a small helper and adding table-driven tests for representative outputs.

Suggested change
// listModelsForProvider returns runtime-visible model IDs for a provider using
// the installed `opencode` binary. The CLI is the source of truth for custom
// models that may not appear in ~/.cache/opencode/models.json yet.
var listModelsForProvider = func(providerID string) ([]string, error) {
cmd := exec.Command("opencode", "models", providerID)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var models []string
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "/", 2)
if len(parts) != 2 || parts[0] != providerID || parts[1] == "" {
continue
}
models = append(models, parts[1])
}
return models, nil
}
// parseModelsForProviderOutput extracts model IDs for a provider from
// `opencode models` CLI output. Lines that are empty, malformed, for a
// different provider, or have an empty model suffix are ignored.
func parseModelsForProviderOutput(providerID, output string) []string {
var models []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "/", 2)
if len(parts) != 2 || parts[0] != providerID || parts[1] == "" {
continue
}
models = append(models, parts[1])
}
return models
}
// listModelsForProvider returns runtime-visible model IDs for a provider using
// the installed `opencode` binary. The CLI is the source of truth for custom
// models that may not appear in ~/.cache/opencode/models.json yet.
var listModelsForProvider = func(providerID string) ([]string, error) {
cmd := exec.Command("opencode", "models", providerID)
output, err := cmd.Output()
if err != nil {
return nil, err
}
return parseModelsForProviderOutput(providerID, string(output)), nil
}

Copilot uses AI. Check for mistakes.
// DetectAvailableProviders returns provider IDs that the user has access to and
// that have at least one model with tool_call support. Detection sources:
// 1. OAuth credentials in ~/.local/share/opencode/auth.json
Expand Down Expand Up @@ -191,6 +219,39 @@ func FilterModelsForSDD(provider Provider) []Model {
return models
}

// EnrichProvidersWithRuntimeModels merges the runtime-visible `opencode models`
// output into the cached provider catalog. This preserves cache metadata where
// present while making custom models selectable in the TUI.
func EnrichProvidersWithRuntimeModels(providers map[string]Provider, providerIDs []string) {
for _, providerID := range providerIDs {
runtimeIDs, err := listModelsForProvider(providerID)
if err != nil || len(runtimeIDs) == 0 {
continue
}

provider, ok := providers[providerID]
if !ok {
provider = Provider{ID: providerID, Name: providerID, Models: map[string]Model{}}
}
if provider.Models == nil {
provider.Models = make(map[string]Model)
}

for _, modelID := range runtimeIDs {
if _, exists := provider.Models[modelID]; exists {
continue
}
provider.Models[modelID] = Model{
ID: modelID,
Name: modelID,
ToolCall: true,
}
}

providers[providerID] = provider
}
}

// SDDPhases returns the ordered list of SDD phase sub-agent names.
func SDDPhases() []string {
return []string{
Expand Down
49 changes: 49 additions & 0 deletions internal/opencode/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,55 @@ func TestFilterModelsForSDD(t *testing.T) {
}
}

func TestEnrichProvidersWithRuntimeModels_MergesMissingCustomModels(t *testing.T) {
path := writeFixture(t)
providers, err := LoadModels(path)
if err != nil {
t.Fatalf("LoadModels() error = %v", err)
}

original := listModelsForProvider
defer func() { listModelsForProvider = original }()

listModelsForProvider = func(providerID string) ([]string, error) {
switch providerID {
case "google":
return []string{
"gemini-2.5-pro",
"antigravity-claude-opus-4-6-thinking",
"antigravity-claude-sonnet-4-6",
}, nil
default:
return nil, nil
}
}

EnrichProvidersWithRuntimeModels(providers, []string{"google"})

google, ok := providers["google"]
if !ok {
t.Fatal("missing google provider after enrichment")
}

for _, modelID := range []string{"gemini-2.5-pro", "antigravity-claude-opus-4-6-thinking", "antigravity-claude-sonnet-4-6"} {
model, ok := google.Models[modelID]
if !ok {
t.Fatalf("google model %q missing after enrichment", modelID)
}
if !model.ToolCall {
t.Fatalf("google model %q ToolCall = false, want true", modelID)
}
}

filtered := FilterModelsForSDD(google)
if len(filtered) != 3 {
t.Fatalf("FilterModelsForSDD(google) len = %d, want 3", len(filtered))
}
if filtered[0].ID != "antigravity-claude-opus-4-6-thinking" {
t.Fatalf("first filtered model = %q, want antigravity-claude-opus-4-6-thinking", filtered[0].ID)
}
}

func TestLoadAuthProviders(t *testing.T) {
authPath := writeAuthFixture(t, map[string]bool{
"anthropic": true,
Expand Down
1 change: 1 addition & 0 deletions internal/tui/screens/model_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func NewModelPickerState(cachePath string) ModelPickerState {
}

available := opencode.DetectAvailableProviders(providers)
opencode.EnrichProvidersWithRuntimeModels(providers, available)

Comment on lines 63 to 65
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewModelPickerState now enriches providers by running the opencode CLI during state construction. This codepath is triggered synchronously on key navigation (see callers in internal/tui/model.go), so it can introduce noticeable latency. Consider making runtime enrichment lazy (only when entering a provider) or performing it asynchronously with a loading state/spinner, especially if multiple providers are available.

Copilot uses AI. Check for mistakes.
sddModels := make(map[string][]opencode.Model, len(available))
for _, id := range available {
Expand Down
Loading