From 323e14c1a6cb3df647562113fc9ad8129d8d627c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?=
Date: Sun, 25 Jan 2026 22:23:03 +0000
Subject: [PATCH] feat: opencode dynamic model loader (#99)
---
.../internal/agent/lifecycle/manager.go | 12 +-
.../internal/agent/lifecycle/manager_test.go | 3 +-
.../internal/agent/registry/agents.json | 2 +
.../internal/agent/registry/registry.go | 7 +-
.../agent/settings/controller/controller.go | 56 +++-
.../settings/controller/controller_test.go | 35 +-
.../internal/agent/settings/dto/dto.go | 15 +-
.../agent/settings/handlers/handlers.go | 25 ++
.../agent/settings/modelfetcher/cache.go | 90 +++++
.../agent/settings/modelfetcher/cache_test.go | 209 ++++++++++++
.../agent/settings/modelfetcher/fetcher.go | 187 +++++++++++
.../settings/modelfetcher/fetcher_test.go | 309 ++++++++++++++++++
.../agent/settings/modelfetcher/parser.go | 83 +++++
.../settings/modelfetcher/parser_test.go | 284 ++++++++++++++++
.../[profileId]/command-preview-card.tsx | 6 +-
.../[profileId]/use-agent-profile-settings.ts | 2 +-
.../settings/agent-profile-page.tsx | 2 +
.../components/settings/model-combobox.tsx | 257 ++++++++++++---
.../domains/settings/use-dynamic-models.ts | 84 +++++
apps/web/lib/api/domains/settings-api.ts | 10 +
apps/web/lib/types/http.ts | 10 +
21 files changed, 1621 insertions(+), 67 deletions(-)
create mode 100644 apps/backend/internal/agent/settings/modelfetcher/cache.go
create mode 100644 apps/backend/internal/agent/settings/modelfetcher/cache_test.go
create mode 100644 apps/backend/internal/agent/settings/modelfetcher/fetcher.go
create mode 100644 apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go
create mode 100644 apps/backend/internal/agent/settings/modelfetcher/parser.go
create mode 100644 apps/backend/internal/agent/settings/modelfetcher/parser_test.go
create mode 100644 apps/web/hooks/domains/settings/use-dynamic-models.ts
diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go
index a095c06c80..37e223d554 100644
--- a/apps/backend/internal/agent/lifecycle/manager.go
+++ b/apps/backend/internal/agent/lifecycle/manager.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"path/filepath"
+ "sort"
"strings"
"sync"
"time"
@@ -2476,6 +2477,7 @@ func (m *Manager) buildPassthroughResumeCommand(agentConfig *registry.AgentTypeC
}
// Apply permission settings that use CLI flags
+ // Sort keys for deterministic output order
if profileInfo != nil && agentConfig.PermissionSettings != nil {
permissionValues := map[string]bool{
"auto_approve": profileInfo.AutoApprove,
@@ -2483,7 +2485,15 @@ func (m *Manager) buildPassthroughResumeCommand(agentConfig *registry.AgentTypeC
"allow_indexing": profileInfo.AllowIndexing,
}
- for settingName, setting := range agentConfig.PermissionSettings {
+ // Get sorted keys for deterministic iteration
+ settingNames := make([]string, 0, len(agentConfig.PermissionSettings))
+ for name := range agentConfig.PermissionSettings {
+ settingNames = append(settingNames, name)
+ }
+ sort.Strings(settingNames)
+
+ for _, settingName := range settingNames {
+ setting := agentConfig.PermissionSettings[settingName]
if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" {
continue
}
diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go
index 048c989ba3..776c8f55a4 100644
--- a/apps/backend/internal/agent/lifecycle/manager_test.go
+++ b/apps/backend/internal/agent/lifecycle/manager_test.go
@@ -482,7 +482,8 @@ func TestManager_BuildPassthroughResumeCommand(t *testing.T) {
DangerouslySkipPermissions: true,
AutoApprove: true,
},
- wantCmd: []string{"test-cli", "--dangerous", "--yes"},
+ // Order is alphabetical by setting name: auto_approve, dangerously_skip_permissions
+ wantCmd: []string{"test-cli", "--yes", "--dangerous"},
},
{
name: "full command with all options",
diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json
index c10c4d661b..d0dcaccade 100644
--- a/apps/backend/internal/agent/registry/agents.json
+++ b/apps/backend/internal/agent/registry/agents.json
@@ -688,6 +688,8 @@
},
"model_config": {
"default_model": "anthropic/claude-sonnet-4-20250514",
+ "dynamic_models_cmd": ["opencode", "models"],
+ "dynamic_models_timeout_ms": 30000,
"available_models": [
{
"id": "anthropic/claude-sonnet-4-20250514",
diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go
index 4f1daf9606..ce93c07ed9 100644
--- a/apps/backend/internal/agent/registry/registry.go
+++ b/apps/backend/internal/agent/registry/registry.go
@@ -168,8 +168,10 @@ type ResourceLimits struct {
// ModelConfig defines the model configuration for an agent
type ModelConfig struct {
- DefaultModel string `json:"default_model"`
- AvailableModels []ModelEntry `json:"available_models"`
+ DefaultModel string `json:"default_model"`
+ AvailableModels []ModelEntry `json:"available_models"`
+ DynamicModelsCmd []string `json:"dynamic_models_cmd,omitempty"`
+ DynamicModelsTimeoutMs int `json:"dynamic_models_timeout_ms,omitempty"`
}
// ModelEntry defines a single model available for an agent
@@ -179,6 +181,7 @@ type ModelEntry struct {
Provider string `json:"provider"`
ContextWindow int `json:"context_window"`
IsDefault bool `json:"is_default"`
+ Source string `json:"source,omitempty"` // "static" or "dynamic"
}
// OSPaths defines OS-specific paths for discovery
diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go
index e97559da88..2577587cd6 100644
--- a/apps/backend/internal/agent/settings/controller/controller.go
+++ b/apps/backend/internal/agent/settings/controller/controller.go
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/dto"
+ "github.com/kandev/kandev/internal/agent/settings/modelfetcher"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
@@ -29,13 +30,12 @@ var (
const defaultAgentProfileName = "default"
type Controller struct {
- repo store.Repository
- discovery *discovery.Registry
- agentRegistry interface {
- Get(id string) (*registry.AgentTypeConfig, bool)
- }
+ repo store.Repository
+ discovery *discovery.Registry
+ agentRegistry *registry.Registry
sessionChecker SessionChecker
mcpService *mcpconfig.Service
+ modelFetcher *modelfetcher.Fetcher
logger *logger.Logger
}
@@ -43,9 +43,7 @@ type SessionChecker interface {
HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error)
}
-func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry interface {
- Get(id string) (*registry.AgentTypeConfig, bool)
-}, sessionChecker SessionChecker, log *logger.Logger,
+func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry *registry.Registry, sessionChecker SessionChecker, log *logger.Logger,
) *Controller {
return &Controller{
repo: repo,
@@ -53,6 +51,7 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry,
agentRegistry: agentRegistry,
sessionChecker: sessionChecker,
mcpService: mcpconfig.NewService(repo),
+ modelFetcher: modelfetcher.NewFetcher(agentRegistry, log),
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
}
}
@@ -156,8 +155,9 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl
SupportsWorkspaceOnly: def.Capabilities.SupportsWorkspaceOnly,
},
ModelConfig: dto.ModelConfigDTO{
- DefaultModel: def.ModelConfig.DefaultModel,
- AvailableModels: modelEntries,
+ DefaultModel: def.ModelConfig.DefaultModel,
+ AvailableModels: modelEntries,
+ SupportsDynamicModels: len(def.ModelConfig.DynamicModelsCmd) > 0,
},
PermissionSettings: permissionSettings,
PassthroughConfig: passthroughConfig,
@@ -777,3 +777,39 @@ func (c *Controller) buildCommandString(cmd []string) string {
}
return strings.Join(parts, " ")
}
+
+// FetchDynamicModels fetches models for an agent, optionally refreshing the cache
+func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) {
+ result, err := c.modelFetcher.Fetch(ctx, agentName, refresh)
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert to DTOs
+ modelDTOs := make([]dto.ModelEntryDTO, 0, len(result.Models))
+ for _, m := range result.Models {
+ modelDTOs = append(modelDTOs, dto.ModelEntryDTO{
+ ID: m.ID,
+ Name: m.Name,
+ Provider: m.Provider,
+ ContextWindow: m.ContextWindow,
+ IsDefault: m.IsDefault,
+ Source: m.Source,
+ })
+ }
+
+ // Convert error to string pointer
+ var errStr *string
+ if result.Error != nil {
+ s := result.Error.Error()
+ errStr = &s
+ }
+
+ return &dto.DynamicModelsResponse{
+ AgentName: result.AgentName,
+ Models: modelDTOs,
+ Cached: result.Cached,
+ CachedAt: result.CachedAt,
+ Error: errStr,
+ }, nil
+}
diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go
index d9e1a9c2dc..b6caadef15 100644
--- a/apps/backend/internal/agent/settings/controller/controller_test.go
+++ b/apps/backend/internal/agent/settings/controller/controller_test.go
@@ -9,23 +9,18 @@ import (
"github.com/kandev/kandev/internal/common/logger"
)
-// mockAgentRegistry implements the agent registry interface for testing
-type mockAgentRegistry struct {
- agents map[string]*registry.AgentTypeConfig
-}
-
-func (m *mockAgentRegistry) Get(id string) (*registry.AgentTypeConfig, bool) {
- agent, ok := m.agents[id]
- return agent, ok
-}
-
func newTestController(agents map[string]*registry.AgentTypeConfig) *Controller {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
+ // Create a real registry and register the agents
+ reg := registry.NewRegistry(log)
+ for _, agent := range agents {
+ _ = reg.Register(agent)
+ }
return &Controller{
- agentRegistry: &mockAgentRegistry{agents: agents},
+ agentRegistry: reg,
logger: log,
}
}
@@ -33,9 +28,15 @@ func newTestController(agents map[string]*registry.AgentTypeConfig) *Controller
func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) {
agents := map[string]*registry.AgentTypeConfig{
"test-agent": {
+ ID: "test-agent",
Name: "test-agent",
Cmd: []string{"test-cli", "--verbose"},
ModelFlag: "--model {model}",
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
PermissionSettings: map[string]registry.PermissionSetting{
"auto_approve": {
Supported: true,
@@ -85,9 +86,15 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) {
func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) {
agents := map[string]*registry.AgentTypeConfig{
"claude-code": {
+ ID: "claude-code",
Name: "claude-code",
Cmd: []string{"claude"},
ModelFlag: "--model {model}",
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
PassthroughConfig: registry.PassthroughConfig{
Supported: true,
PassthroughCmd: []string{"npx", "-y", "@anthropic-ai/claude-code"},
@@ -171,8 +178,14 @@ func TestController_PreviewAgentCommand_AgentNotFound(t *testing.T) {
func TestController_PreviewAgentCommand_PassthroughDisabled(t *testing.T) {
agents := map[string]*registry.AgentTypeConfig{
"test-agent": {
+ ID: "test-agent",
Name: "test-agent",
Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
PassthroughConfig: registry.PassthroughConfig{
Supported: true,
PassthroughCmd: []string{"passthrough-cli"},
diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go
index 999c785d7e..60dd835c17 100644
--- a/apps/backend/internal/agent/settings/dto/dto.go
+++ b/apps/backend/internal/agent/settings/dto/dto.go
@@ -63,11 +63,13 @@ type ModelEntryDTO struct {
Provider string `json:"provider"`
ContextWindow int `json:"context_window"`
IsDefault bool `json:"is_default"`
+ Source string `json:"source,omitempty"`
}
type ModelConfigDTO struct {
- DefaultModel string `json:"default_model"`
- AvailableModels []ModelEntryDTO `json:"available_models"`
+ DefaultModel string `json:"default_model"`
+ AvailableModels []ModelEntryDTO `json:"available_models"`
+ SupportsDynamicModels bool `json:"supports_dynamic_models"`
}
type PermissionSettingDTO struct {
@@ -126,3 +128,12 @@ type CommandPreviewResponse struct {
Command []string `json:"command"`
CommandString string `json:"command_string"`
}
+
+// DynamicModelsResponse is the response for the dynamic models endpoint
+type DynamicModelsResponse struct {
+ AgentName string `json:"agent_name"`
+ Models []ModelEntryDTO `json:"models"`
+ Cached bool `json:"cached"`
+ CachedAt *time.Time `json:"cached_at,omitempty"`
+ Error *string `json:"error"`
+}
diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go
index 6c1fb4a0a7..69d1146a73 100644
--- a/apps/backend/internal/agent/settings/handlers/handlers.go
+++ b/apps/backend/internal/agent/settings/handlers/handlers.go
@@ -46,6 +46,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
api.PATCH("/agents/:id", h.httpUpdateAgent)
api.DELETE("/agents/:id", h.httpDeleteAgent)
api.POST("/agents/:id/profiles", h.httpCreateProfile)
+ api.GET("/agent-models/:agentName", h.httpGetAgentModels)
api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand)
api.PATCH("/agent-profiles/:id", h.httpUpdateProfile)
api.DELETE("/agent-profiles/:id", h.httpDeleteProfile)
@@ -426,3 +427,27 @@ func (h *Handlers) httpPreviewAgentCommand(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
+
+func (h *Handlers) httpGetAgentModels(c *gin.Context) {
+ agentName := c.Param("agentName")
+ if agentName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"})
+ return
+ }
+
+ // Check for refresh query parameter
+ refresh := c.Query("refresh") == "true"
+
+ resp, err := h.controller.FetchDynamicModels(c.Request.Context(), agentName, refresh)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
+ return
+ }
+ h.logger.Error("failed to fetch agent models", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, resp)
+}
diff --git a/apps/backend/internal/agent/settings/modelfetcher/cache.go b/apps/backend/internal/agent/settings/modelfetcher/cache.go
new file mode 100644
index 0000000000..e73a038942
--- /dev/null
+++ b/apps/backend/internal/agent/settings/modelfetcher/cache.go
@@ -0,0 +1,90 @@
+package modelfetcher
+
+import (
+ "sync"
+ "time"
+
+ "github.com/kandev/kandev/internal/agent/registry"
+)
+
+const (
+ defaultTTL = 10 * time.Second
+ maxCacheAge = 1 * time.Minute
+)
+
+// CacheEntry holds cached model data for an agent
+type CacheEntry struct {
+ Models []registry.ModelEntry
+ CachedAt time.Time
+ ExpiresAt time.Time
+ Error error
+}
+
+// IsValid returns true if the cache entry is still valid
+func (e *CacheEntry) IsValid() bool {
+ return time.Now().Before(e.ExpiresAt) && e.Error == nil
+}
+
+// IsStale returns true if the cache entry is stale but still usable
+func (e *CacheEntry) IsStale() bool {
+ now := time.Now()
+ return now.After(e.ExpiresAt) && now.Before(e.CachedAt.Add(maxCacheAge))
+}
+
+// Cache provides thread-safe caching for dynamic models
+type Cache struct {
+ entries map[string]*CacheEntry
+ mu sync.RWMutex
+ ttl time.Duration
+}
+
+// NewCache creates a new model cache
+func NewCache() *Cache {
+ return &Cache{
+ entries: make(map[string]*CacheEntry),
+ ttl: defaultTTL,
+ }
+}
+
+// Get returns the cached models for an agent if available
+func (c *Cache) Get(agentName string) (*CacheEntry, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ entry, exists := c.entries[agentName]
+ if !exists {
+ return nil, false
+ }
+
+ return entry, true
+}
+
+// Set caches the models for an agent
+func (c *Cache) Set(agentName string, models []registry.ModelEntry, err error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ now := time.Now()
+ c.entries[agentName] = &CacheEntry{
+ Models: models,
+ CachedAt: now,
+ ExpiresAt: now.Add(c.ttl),
+ Error: err,
+ }
+}
+
+// Invalidate removes the cache entry for an agent
+func (c *Cache) Invalidate(agentName string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ delete(c.entries, agentName)
+}
+
+// Clear removes all cache entries
+func (c *Cache) Clear() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.entries = make(map[string]*CacheEntry)
+}
diff --git a/apps/backend/internal/agent/settings/modelfetcher/cache_test.go b/apps/backend/internal/agent/settings/modelfetcher/cache_test.go
new file mode 100644
index 0000000000..74d67ae171
--- /dev/null
+++ b/apps/backend/internal/agent/settings/modelfetcher/cache_test.go
@@ -0,0 +1,209 @@
+package modelfetcher
+
+import (
+ "testing"
+ "time"
+
+ "github.com/kandev/kandev/internal/agent/registry"
+)
+
+func TestCache_SetAndGet(t *testing.T) {
+ cache := NewCache()
+
+ models := []registry.ModelEntry{
+ {ID: "anthropic/claude-sonnet", Name: "Claude Sonnet", Provider: "anthropic"},
+ {ID: "openai/gpt-4", Name: "GPT-4", Provider: "openai"},
+ }
+
+ cache.Set("test-agent", models, nil)
+
+ entry, exists := cache.Get("test-agent")
+ if !exists {
+ t.Fatal("expected cache entry to exist")
+ }
+
+ if len(entry.Models) != 2 {
+ t.Errorf("expected 2 models, got %d", len(entry.Models))
+ }
+
+ if entry.Models[0].ID != "anthropic/claude-sonnet" {
+ t.Errorf("expected first model ID to be 'anthropic/claude-sonnet', got %q", entry.Models[0].ID)
+ }
+
+ if entry.Error != nil {
+ t.Errorf("expected no error, got %v", entry.Error)
+ }
+}
+
+func TestCache_GetNonExistent(t *testing.T) {
+ cache := NewCache()
+
+ _, exists := cache.Get("non-existent")
+ if exists {
+ t.Error("expected cache entry to not exist")
+ }
+}
+
+func TestCache_SetWithError(t *testing.T) {
+ cache := NewCache()
+
+ testErr := &testError{msg: "command failed"}
+ cache.Set("test-agent", nil, testErr)
+
+ entry, exists := cache.Get("test-agent")
+ if !exists {
+ t.Fatal("expected cache entry to exist")
+ }
+
+ if entry.Error == nil {
+ t.Error("expected error to be stored")
+ }
+
+ if entry.Error.Error() != "command failed" {
+ t.Errorf("expected error message 'command failed', got %q", entry.Error.Error())
+ }
+}
+
+func TestCache_Invalidate(t *testing.T) {
+ cache := NewCache()
+
+ models := []registry.ModelEntry{
+ {ID: "test/model", Name: "Test Model", Provider: "test"},
+ }
+
+ cache.Set("test-agent", models, nil)
+
+ // Verify it exists
+ _, exists := cache.Get("test-agent")
+ if !exists {
+ t.Fatal("expected cache entry to exist before invalidation")
+ }
+
+ // Invalidate
+ cache.Invalidate("test-agent")
+
+ // Verify it's gone
+ _, exists = cache.Get("test-agent")
+ if exists {
+ t.Error("expected cache entry to not exist after invalidation")
+ }
+}
+
+func TestCache_Clear(t *testing.T) {
+ cache := NewCache()
+
+ models := []registry.ModelEntry{
+ {ID: "test/model", Name: "Test Model", Provider: "test"},
+ }
+
+ cache.Set("agent1", models, nil)
+ cache.Set("agent2", models, nil)
+
+ cache.Clear()
+
+ _, exists1 := cache.Get("agent1")
+ _, exists2 := cache.Get("agent2")
+
+ if exists1 || exists2 {
+ t.Error("expected all cache entries to be cleared")
+ }
+}
+
+func TestCacheEntry_IsValid(t *testing.T) {
+ tests := []struct {
+ name string
+ entry CacheEntry
+ expected bool
+ }{
+ {
+ name: "valid entry",
+ entry: CacheEntry{
+ Models: []registry.ModelEntry{{ID: "test"}},
+ CachedAt: time.Now(),
+ ExpiresAt: time.Now().Add(5 * time.Second),
+ Error: nil,
+ },
+ expected: true,
+ },
+ {
+ name: "expired entry",
+ entry: CacheEntry{
+ Models: []registry.ModelEntry{{ID: "test"}},
+ CachedAt: time.Now().Add(-1 * time.Minute),
+ ExpiresAt: time.Now().Add(-30 * time.Second),
+ Error: nil,
+ },
+ expected: false,
+ },
+ {
+ name: "entry with error",
+ entry: CacheEntry{
+ Models: nil,
+ CachedAt: time.Now(),
+ ExpiresAt: time.Now().Add(5 * time.Second),
+ Error: &testError{msg: "some error"},
+ },
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.entry.IsValid()
+ if result != tt.expected {
+ t.Errorf("IsValid() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestCacheEntry_IsStale(t *testing.T) {
+ tests := []struct {
+ name string
+ entry CacheEntry
+ expected bool
+ }{
+ {
+ name: "fresh entry - not stale",
+ entry: CacheEntry{
+ CachedAt: time.Now(),
+ ExpiresAt: time.Now().Add(5 * time.Second),
+ },
+ expected: false,
+ },
+ {
+ name: "expired but within max age - stale",
+ entry: CacheEntry{
+ CachedAt: time.Now().Add(-30 * time.Second),
+ ExpiresAt: time.Now().Add(-20 * time.Second),
+ },
+ expected: true,
+ },
+ {
+ name: "beyond max age - not stale (too old)",
+ entry: CacheEntry{
+ CachedAt: time.Now().Add(-2 * time.Hour),
+ ExpiresAt: time.Now().Add(-2 * time.Hour),
+ },
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.entry.IsStale()
+ if result != tt.expected {
+ t.Errorf("IsStale() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+// testError is a simple error type for testing
+type testError struct {
+ msg string
+}
+
+func (e *testError) Error() string {
+ return e.msg
+}
diff --git a/apps/backend/internal/agent/settings/modelfetcher/fetcher.go b/apps/backend/internal/agent/settings/modelfetcher/fetcher.go
new file mode 100644
index 0000000000..23e937dc26
--- /dev/null
+++ b/apps/backend/internal/agent/settings/modelfetcher/fetcher.go
@@ -0,0 +1,187 @@
+package modelfetcher
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "time"
+
+ "github.com/kandev/kandev/internal/agent/registry"
+ "github.com/kandev/kandev/internal/common/logger"
+ "go.uber.org/zap"
+)
+
+const (
+ defaultTimeout = 30 * time.Second
+ maxOutputSize = 1024 * 1024 // 1MB max output
+)
+
+// Fetcher handles dynamic model fetching for agents
+type Fetcher struct {
+ agentRegistry *registry.Registry
+ cache *Cache
+ logger *logger.Logger
+}
+
+// NewFetcher creates a new model fetcher
+func NewFetcher(agentRegistry *registry.Registry, log *logger.Logger) *Fetcher {
+ return &Fetcher{
+ agentRegistry: agentRegistry,
+ cache: NewCache(),
+ logger: log.WithFields(zap.String("component", "model-fetcher")),
+ }
+}
+
+// FetchResult contains the result of fetching models
+type FetchResult struct {
+ AgentName string
+ Models []registry.ModelEntry
+ Cached bool
+ CachedAt *time.Time
+ Error error
+}
+
+// Fetch fetches models for an agent, using cache if available
+func (f *Fetcher) Fetch(ctx context.Context, agentName string, refresh bool) (*FetchResult, error) {
+ // Get agent config
+ agentConfig, ok := f.agentRegistry.Get(agentName)
+ if !ok {
+ return nil, fmt.Errorf("agent %q not found", agentName)
+ }
+
+ // Check if agent supports dynamic models
+ if len(agentConfig.ModelConfig.DynamicModelsCmd) == 0 {
+ // Return static models
+ return &FetchResult{
+ AgentName: agentName,
+ Models: f.markModelsAsStatic(agentConfig.ModelConfig.AvailableModels),
+ Cached: false,
+ Error: nil,
+ }, nil
+ }
+
+ // Check cache unless refresh is requested
+ if !refresh {
+ if entry, exists := f.cache.Get(agentName); exists && entry.IsValid() {
+ cachedAt := entry.CachedAt
+ return &FetchResult{
+ AgentName: agentName,
+ Models: entry.Models,
+ Cached: true,
+ CachedAt: &cachedAt,
+ Error: entry.Error,
+ }, nil
+ }
+ }
+
+ // Fetch dynamic models
+ models, err := f.executeDynamicFetch(ctx, agentConfig)
+ if err != nil {
+ f.logger.Warn("dynamic model fetch failed, using static fallback",
+ zap.String("agent", agentName),
+ zap.Error(err))
+
+ // Cache the error briefly to avoid repeated failures
+ f.cache.Set(agentName, nil, err)
+
+ // Return static models as fallback
+ return &FetchResult{
+ AgentName: agentName,
+ Models: f.markModelsAsStatic(agentConfig.ModelConfig.AvailableModels),
+ Cached: false,
+ Error: err,
+ }, nil
+ }
+
+ // Cache the result
+ f.cache.Set(agentName, models, nil)
+ cachedAt := time.Now()
+
+ return &FetchResult{
+ AgentName: agentName,
+ Models: models,
+ Cached: true,
+ CachedAt: &cachedAt,
+ Error: nil,
+ }, nil
+}
+
+// executeDynamicFetch runs the dynamic models command and parses output
+func (f *Fetcher) executeDynamicFetch(ctx context.Context, agentConfig *registry.AgentTypeConfig) ([]registry.ModelEntry, error) {
+ if len(agentConfig.ModelConfig.DynamicModelsCmd) == 0 {
+ return nil, fmt.Errorf("no dynamic models command configured")
+ }
+
+ // Determine timeout
+ timeout := defaultTimeout
+ if agentConfig.ModelConfig.DynamicModelsTimeoutMs > 0 {
+ timeout = time.Duration(agentConfig.ModelConfig.DynamicModelsTimeoutMs) * time.Millisecond
+ }
+
+ // Create context with timeout
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ // Build command - use exec.Command directly (no shell)
+ cmd := agentConfig.ModelConfig.DynamicModelsCmd
+ execCmd := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
+
+ // Capture output
+ var stdout, stderr bytes.Buffer
+ execCmd.Stdout = &stdout
+ execCmd.Stderr = &stderr
+
+ // Run command
+ err := execCmd.Run()
+ if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ return nil, fmt.Errorf("command timed out after %v", timeout)
+ }
+ return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
+ }
+
+ // Check output size
+ if stdout.Len() > maxOutputSize {
+ return nil, fmt.Errorf("output exceeds maximum size of %d bytes", maxOutputSize)
+ }
+
+ // Get the appropriate parser for this agent
+ parser := GetParser(agentConfig.ID)
+
+ // Parse output using the agent-specific parser
+ models, err := parser.Parse(stdout.String(), agentConfig.ModelConfig.DefaultModel)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse model output: %w", err)
+ }
+
+ if len(models) == 0 {
+ return nil, fmt.Errorf("no models found in output")
+ }
+
+ return models, nil
+}
+
+// markModelsAsStatic sets the Source field to "static" for all models
+func (f *Fetcher) markModelsAsStatic(models []registry.ModelEntry) []registry.ModelEntry {
+ result := make([]registry.ModelEntry, len(models))
+ for i, m := range models {
+ m.Source = "static"
+ result[i] = m
+ }
+ return result
+}
+
+// SupportsynamicModels returns true if the agent supports dynamic model fetching
+func (f *Fetcher) SupportsDynamicModels(agentName string) bool {
+ agentConfig, ok := f.agentRegistry.Get(agentName)
+ if !ok {
+ return false
+ }
+ return len(agentConfig.ModelConfig.DynamicModelsCmd) > 0
+}
+
+// InvalidateCache invalidates the cache for an agent
+func (f *Fetcher) InvalidateCache(agentName string) {
+ f.cache.Invalidate(agentName)
+}
diff --git a/apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go b/apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go
new file mode 100644
index 0000000000..457857463e
--- /dev/null
+++ b/apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go
@@ -0,0 +1,309 @@
+package modelfetcher
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/registry"
+ "github.com/kandev/kandev/internal/common/logger"
+)
+
+func newTestFetcher(t *testing.T, agents map[string]*registry.AgentTypeConfig) *Fetcher {
+ t.Helper()
+
+ log, err := logger.NewLogger(logger.LoggingConfig{
+ Level: "error",
+ Format: "json",
+ })
+ if err != nil {
+ t.Fatalf("failed to create logger: %v", err)
+ }
+
+ reg := registry.NewRegistry(log)
+ for _, agent := range agents {
+ if err := reg.Register(agent); err != nil {
+ t.Fatalf("failed to register agent: %v", err)
+ }
+ }
+
+ return NewFetcher(reg, log)
+}
+
+func TestFetcher_SupportsDynamicModels(t *testing.T) {
+ agents := map[string]*registry.AgentTypeConfig{
+ "with-dynamic": {
+ ID: "with-dynamic",
+ Name: "With Dynamic",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ ModelConfig: registry.ModelConfig{
+ DefaultModel: "test/model",
+ DynamicModelsCmd: []string{"test", "models"},
+ },
+ },
+ "without-dynamic": {
+ ID: "without-dynamic",
+ Name: "Without Dynamic",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ ModelConfig: registry.ModelConfig{
+ DefaultModel: "test/model",
+ },
+ },
+ }
+
+ fetcher := newTestFetcher(t, agents)
+
+ tests := []struct {
+ name string
+ agentName string
+ want bool
+ }{
+ {
+ name: "agent with dynamic models cmd",
+ agentName: "with-dynamic",
+ want: true,
+ },
+ {
+ name: "agent without dynamic models cmd",
+ agentName: "without-dynamic",
+ want: false,
+ },
+ {
+ name: "non-existent agent",
+ agentName: "non-existent",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := fetcher.SupportsDynamicModels(tt.agentName)
+ if got != tt.want {
+ t.Errorf("SupportsDynamicModels(%q) = %v, want %v", tt.agentName, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFetcher_MarkModelsAsStatic(t *testing.T) {
+ agents := map[string]*registry.AgentTypeConfig{
+ "test-agent": {
+ ID: "test-agent",
+ Name: "Test Agent",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ },
+ }
+
+ fetcher := newTestFetcher(t, agents)
+
+ models := []registry.ModelEntry{
+ {ID: "model1", Name: "Model 1", Provider: "test"},
+ {ID: "model2", Name: "Model 2", Provider: "test", Source: "dynamic"},
+ {ID: "model3", Name: "Model 3", Provider: "test", Source: ""},
+ }
+
+ result := fetcher.markModelsAsStatic(models)
+
+ if len(result) != len(models) {
+ t.Fatalf("markModelsAsStatic() returned %d models, want %d", len(result), len(models))
+ }
+
+ for i, m := range result {
+ if m.Source != "static" {
+ t.Errorf("model %d Source = %q, want 'static'", i, m.Source)
+ }
+ }
+
+ // Verify original slice is not modified
+ if models[1].Source != "dynamic" {
+ t.Error("original slice should not be modified")
+ }
+}
+
+func TestFetcher_Fetch_AgentNotFound(t *testing.T) {
+ fetcher := newTestFetcher(t, map[string]*registry.AgentTypeConfig{})
+
+ _, err := fetcher.Fetch(context.Background(), "non-existent", false)
+ if err == nil {
+ t.Error("Fetch() should return error for non-existent agent")
+ }
+}
+
+func TestFetcher_Fetch_StaticModels(t *testing.T) {
+ agents := map[string]*registry.AgentTypeConfig{
+ "static-agent": {
+ ID: "static-agent",
+ Name: "Static Agent",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ ModelConfig: registry.ModelConfig{
+ DefaultModel: "test/default",
+ AvailableModels: []registry.ModelEntry{
+ {ID: "test/model1", Name: "Model 1", Provider: "test"},
+ {ID: "test/model2", Name: "Model 2", Provider: "test"},
+ },
+ // No DynamicModelsCmd - uses static models
+ },
+ },
+ }
+
+ fetcher := newTestFetcher(t, agents)
+
+ result, err := fetcher.Fetch(context.Background(), "static-agent", false)
+ if err != nil {
+ t.Fatalf("Fetch() error = %v", err)
+ }
+
+ if result.Cached {
+ t.Error("Fetch() Cached = true for static models, want false")
+ }
+
+ if len(result.Models) != 2 {
+ t.Errorf("Fetch() returned %d models, want 2", len(result.Models))
+ }
+
+ // Verify all models are marked as static
+ for i, m := range result.Models {
+ if m.Source != "static" {
+ t.Errorf("model %d Source = %q, want 'static'", i, m.Source)
+ }
+ }
+}
+
+func TestFetcher_InvalidateCache(t *testing.T) {
+ agents := map[string]*registry.AgentTypeConfig{
+ "test-agent": {
+ ID: "test-agent",
+ Name: "Test Agent",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ },
+ }
+
+ fetcher := newTestFetcher(t, agents)
+
+ // Manually set cache
+ fetcher.cache.Set("test-agent", []registry.ModelEntry{{ID: "test"}}, nil)
+
+ // Verify cache exists
+ if _, exists := fetcher.cache.Get("test-agent"); !exists {
+ t.Fatal("cache entry should exist before invalidation")
+ }
+
+ // Invalidate
+ fetcher.InvalidateCache("test-agent")
+
+ // Verify cache is cleared
+ if _, exists := fetcher.cache.Get("test-agent"); exists {
+ t.Error("cache entry should not exist after invalidation")
+ }
+}
+
+func TestFetcher_Fetch_UsesCache(t *testing.T) {
+ agents := map[string]*registry.AgentTypeConfig{
+ "cached-agent": {
+ ID: "cached-agent",
+ Name: "Cached Agent",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ ModelConfig: registry.ModelConfig{
+ DefaultModel: "test/default",
+ DynamicModelsCmd: []string{"echo", "test/model"}, // Will fail or return something
+ },
+ },
+ }
+
+ fetcher := newTestFetcher(t, agents)
+
+ // Pre-populate cache
+ cachedModels := []registry.ModelEntry{
+ {ID: "cached/model", Name: "Cached Model", Provider: "cached", Source: "dynamic"},
+ }
+ fetcher.cache.Set("cached-agent", cachedModels, nil)
+
+ // Fetch without refresh - should use cache
+ result, err := fetcher.Fetch(context.Background(), "cached-agent", false)
+ if err != nil {
+ t.Fatalf("Fetch() error = %v", err)
+ }
+
+ if !result.Cached {
+ t.Error("Fetch() Cached = false, want true (should use cache)")
+ }
+
+ if len(result.Models) != 1 || result.Models[0].ID != "cached/model" {
+ t.Error("Fetch() should return cached models")
+ }
+}
+
+func TestFetcher_Fetch_RefreshBypassesCache(t *testing.T) {
+ agents := map[string]*registry.AgentTypeConfig{
+ "refresh-agent": {
+ ID: "refresh-agent",
+ Name: "Refresh Agent",
+ Cmd: []string{"test-cli"},
+ ResourceLimits: registry.ResourceLimits{
+ MemoryMB: 1024,
+ CPUCores: 1.0,
+ TimeoutSeconds: 3600,
+ },
+ ModelConfig: registry.ModelConfig{
+ DefaultModel: "test/default",
+ DynamicModelsCmd: []string{"echo", "fresh/model"}, // Returns fresh model
+ AvailableModels: []registry.ModelEntry{
+ {ID: "static/fallback", Name: "Fallback", Provider: "static"},
+ },
+ },
+ },
+ }
+
+ fetcher := newTestFetcher(t, agents)
+
+ // Pre-populate cache with different data
+ cachedModels := []registry.ModelEntry{
+ {ID: "cached/model", Name: "Cached Model", Provider: "cached", Source: "dynamic"},
+ }
+ fetcher.cache.Set("refresh-agent", cachedModels, nil)
+
+ // Fetch with refresh - should bypass cache and execute command
+ result, err := fetcher.Fetch(context.Background(), "refresh-agent", true)
+ if err != nil {
+ t.Fatalf("Fetch() error = %v", err)
+ }
+
+ // Should have executed the command and got "fresh/model"
+ if len(result.Models) == 0 {
+ t.Fatal("Fetch() returned no models")
+ }
+
+ // The echo command returns "fresh/model", so that's what we should get
+ if result.Models[0].ID != "fresh/model" {
+ t.Errorf("Fetch() first model ID = %q, want 'fresh/model'", result.Models[0].ID)
+ }
+}
diff --git a/apps/backend/internal/agent/settings/modelfetcher/parser.go b/apps/backend/internal/agent/settings/modelfetcher/parser.go
new file mode 100644
index 0000000000..e8669db634
--- /dev/null
+++ b/apps/backend/internal/agent/settings/modelfetcher/parser.go
@@ -0,0 +1,83 @@
+package modelfetcher
+
+import (
+ "strings"
+
+ "github.com/kandev/kandev/internal/agent/registry"
+)
+
+// ModelParser defines the interface for parsing model output from different agents
+type ModelParser interface {
+ // Parse parses the raw output from a model listing command into ModelEntry structs
+ Parse(output string, defaultModel string) ([]registry.ModelEntry, error)
+}
+
+// DefaultParser implements ModelParser with a default parsing strategy
+// Expected format: one model ID per line, optionally in "provider/model" format
+type DefaultParser struct{}
+
+func (p *DefaultParser) Parse(output string, defaultModel string) ([]registry.ModelEntry, error) {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ models := make([]registry.ModelEntry, 0, len(lines))
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ entry := p.parseLine(line, defaultModel)
+ models = append(models, entry)
+ }
+
+ return models, nil
+}
+
+func (p *DefaultParser) parseLine(line string, defaultModel string) registry.ModelEntry {
+ var provider, modelID, name string
+
+ // Check for provider/model format - split on the LAST "/" to get provider and model name
+ // Examples:
+ // "opencode/big-pickle" -> provider: "opencode", name: "big-pickle"
+ // "github-copilot/gemini-3-flash" -> provider: "github-copilot", name: "gemini-3-flash"
+ // "openrouter/qwen/qwen3-30b" -> provider: "openrouter/qwen", name: "qwen3-30b"
+ if idx := strings.LastIndex(line, "/"); idx > 0 {
+ provider = line[:idx]
+ modelID = line
+ name = line[idx+1:] // Use the model part after last "/" as name
+ } else {
+ // No provider prefix - use the whole line
+ provider = ""
+ modelID = line
+ name = line
+ }
+
+ return registry.ModelEntry{
+ ID: modelID,
+ Name: name,
+ Provider: provider,
+ IsDefault: modelID == defaultModel,
+ Source: "dynamic",
+ }
+}
+
+// OpenCodeParser implements ModelParser for OpenCode agent
+// OpenCode outputs models in "provider/model-name" format, one per line
+type OpenCodeParser struct {
+ DefaultParser
+}
+
+// NewOpenCodeParser creates a new OpenCode model parser
+func NewOpenCodeParser() *OpenCodeParser {
+ return &OpenCodeParser{}
+}
+
+// GetParser returns the appropriate parser for an agent
+func GetParser(agentID string) ModelParser {
+ switch agentID {
+ case "opencode":
+ return NewOpenCodeParser()
+ default:
+ return &DefaultParser{}
+ }
+}
diff --git a/apps/backend/internal/agent/settings/modelfetcher/parser_test.go b/apps/backend/internal/agent/settings/modelfetcher/parser_test.go
new file mode 100644
index 0000000000..bdd8131557
--- /dev/null
+++ b/apps/backend/internal/agent/settings/modelfetcher/parser_test.go
@@ -0,0 +1,284 @@
+package modelfetcher
+
+import (
+ "testing"
+)
+
+func TestDefaultParser_Parse(t *testing.T) {
+ parser := &DefaultParser{}
+
+ tests := []struct {
+ name string
+ output string
+ defaultModel string
+ wantCount int
+ wantFirst string
+ wantErr bool
+ }{
+ {
+ name: "single model with provider",
+ output: `anthropic/claude-sonnet-4-20250514`,
+ defaultModel: "",
+ wantCount: 1,
+ wantFirst: "anthropic/claude-sonnet-4-20250514",
+ wantErr: false,
+ },
+ {
+ name: "multiple models with providers",
+ output: `anthropic/claude-sonnet-4-20250514
+openai/gpt-4o
+google/gemini-2.5-pro`,
+ defaultModel: "",
+ wantCount: 3,
+ wantFirst: "anthropic/claude-sonnet-4-20250514",
+ wantErr: false,
+ },
+ {
+ name: "model without provider",
+ output: `gpt-4o`,
+ defaultModel: "",
+ wantCount: 1,
+ wantFirst: "gpt-4o",
+ wantErr: false,
+ },
+ {
+ name: "empty lines ignored",
+ output: `anthropic/claude-sonnet
+
+openai/gpt-4o
+
+`,
+ defaultModel: "",
+ wantCount: 2,
+ wantFirst: "anthropic/claude-sonnet",
+ wantErr: false,
+ },
+ {
+ name: "empty output",
+ output: "",
+ defaultModel: "",
+ wantCount: 0,
+ wantErr: false,
+ },
+ {
+ name: "whitespace only",
+ output: " \n\t\n ",
+ defaultModel: "",
+ wantCount: 0,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ models, err := parser.Parse(tt.output, tt.defaultModel)
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if len(models) != tt.wantCount {
+ t.Errorf("Parse() returned %d models, want %d", len(models), tt.wantCount)
+ return
+ }
+
+ if tt.wantCount > 0 && models[0].ID != tt.wantFirst {
+ t.Errorf("Parse() first model ID = %q, want %q", models[0].ID, tt.wantFirst)
+ }
+ })
+ }
+}
+
+func TestDefaultParser_ParseLine(t *testing.T) {
+ parser := &DefaultParser{}
+
+ tests := []struct {
+ name string
+ line string
+ defaultModel string
+ wantID string
+ wantName string
+ wantProvider string
+ wantDefault bool
+ wantSource string
+ }{
+ {
+ name: "provider/model format",
+ line: "anthropic/claude-sonnet-4-20250514",
+ defaultModel: "",
+ wantID: "anthropic/claude-sonnet-4-20250514",
+ wantName: "claude-sonnet-4-20250514",
+ wantProvider: "anthropic",
+ wantDefault: false,
+ wantSource: "dynamic",
+ },
+ {
+ name: "model only format",
+ line: "gpt-4o",
+ defaultModel: "",
+ wantID: "gpt-4o",
+ wantName: "gpt-4o",
+ wantProvider: "",
+ wantDefault: false,
+ wantSource: "dynamic",
+ },
+ {
+ name: "is default model",
+ line: "anthropic/claude-sonnet-4",
+ defaultModel: "anthropic/claude-sonnet-4",
+ wantID: "anthropic/claude-sonnet-4",
+ wantName: "claude-sonnet-4",
+ wantProvider: "anthropic",
+ wantDefault: true,
+ wantSource: "dynamic",
+ },
+ {
+ name: "simple provider/model",
+ line: "openai/gpt-4",
+ defaultModel: "",
+ wantID: "openai/gpt-4",
+ wantName: "gpt-4",
+ wantProvider: "openai",
+ wantDefault: false,
+ wantSource: "dynamic",
+ },
+ {
+ name: "nested provider path - split on last slash",
+ line: "openrouter/qwen/qwen3-30b-a3b-thinking",
+ defaultModel: "",
+ wantID: "openrouter/qwen/qwen3-30b-a3b-thinking",
+ wantName: "qwen3-30b-a3b-thinking",
+ wantProvider: "openrouter/qwen",
+ wantDefault: false,
+ wantSource: "dynamic",
+ },
+ {
+ name: "github-copilot provider",
+ line: "github-copilot/gemini-3-flash-preview",
+ defaultModel: "",
+ wantID: "github-copilot/gemini-3-flash-preview",
+ wantName: "gemini-3-flash-preview",
+ wantProvider: "github-copilot",
+ wantDefault: false,
+ wantSource: "dynamic",
+ },
+ {
+ name: "opencode provider",
+ line: "opencode/big-pickle",
+ defaultModel: "",
+ wantID: "opencode/big-pickle",
+ wantName: "big-pickle",
+ wantProvider: "opencode",
+ wantDefault: false,
+ wantSource: "dynamic",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ entry := parser.parseLine(tt.line, tt.defaultModel)
+
+ if entry.ID != tt.wantID {
+ t.Errorf("parseLine() ID = %q, want %q", entry.ID, tt.wantID)
+ }
+ if entry.Name != tt.wantName {
+ t.Errorf("parseLine() Name = %q, want %q", entry.Name, tt.wantName)
+ }
+ if entry.Provider != tt.wantProvider {
+ t.Errorf("parseLine() Provider = %q, want %q", entry.Provider, tt.wantProvider)
+ }
+ if entry.IsDefault != tt.wantDefault {
+ t.Errorf("parseLine() IsDefault = %v, want %v", entry.IsDefault, tt.wantDefault)
+ }
+ if entry.Source != tt.wantSource {
+ t.Errorf("parseLine() Source = %q, want %q", entry.Source, tt.wantSource)
+ }
+ })
+ }
+}
+
+func TestGetParser(t *testing.T) {
+ tests := []struct {
+ name string
+ agentID string
+ wantType string
+ }{
+ {
+ name: "opencode agent",
+ agentID: "opencode",
+ wantType: "*modelfetcher.OpenCodeParser",
+ },
+ {
+ name: "unknown agent uses default",
+ agentID: "unknown-agent",
+ wantType: "*modelfetcher.DefaultParser",
+ },
+ {
+ name: "claude-code uses default",
+ agentID: "claude-code",
+ wantType: "*modelfetcher.DefaultParser",
+ },
+ {
+ name: "empty string uses default",
+ agentID: "",
+ wantType: "*modelfetcher.DefaultParser",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ parser := GetParser(tt.agentID)
+
+ // Check type by attempting type assertions
+ switch tt.wantType {
+ case "*modelfetcher.OpenCodeParser":
+ if _, ok := parser.(*OpenCodeParser); !ok {
+ t.Errorf("GetParser(%q) returned %T, want *OpenCodeParser", tt.agentID, parser)
+ }
+ case "*modelfetcher.DefaultParser":
+ if _, ok := parser.(*DefaultParser); !ok {
+ t.Errorf("GetParser(%q) returned %T, want *DefaultParser", tt.agentID, parser)
+ }
+ }
+ })
+ }
+}
+
+func TestOpenCodeParser_Parse(t *testing.T) {
+ parser := NewOpenCodeParser()
+
+ // OpenCode outputs models in provider/model format
+ output := `anthropic/claude-sonnet-4-20250514
+anthropic/claude-opus-4-20250514
+openai/gpt-4.1
+google/gemini-2.5-pro`
+
+ models, err := parser.Parse(output, "anthropic/claude-sonnet-4-20250514")
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+
+ if len(models) != 4 {
+ t.Fatalf("Parse() returned %d models, want 4", len(models))
+ }
+
+ // Check first model is marked as default
+ if !models[0].IsDefault {
+ t.Error("first model should be marked as default")
+ }
+
+ // Check other models are not default
+ for i := 1; i < len(models); i++ {
+ if models[i].IsDefault {
+ t.Errorf("model %d should not be marked as default", i)
+ }
+ }
+
+ // Verify all are marked as dynamic
+ for i, m := range models {
+ if m.Source != "dynamic" {
+ t.Errorf("model %d Source = %q, want 'dynamic'", i, m.Source)
+ }
+ }
+}
diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx
index ea92c6ccd6..b196bf2df3 100644
--- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx
+++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState, useMemo } from 'react';
import { IconCopy, IconCheck, IconTerminal2 } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
+import { Skeleton } from '@kandev/ui/skeleton';
import { previewAgentCommandAction, type CommandPreviewResponse } from '@/app/actions/agents';
type CommandPreviewCardProps = {
@@ -89,8 +90,9 @@ export function CommandPreviewCard({
{loading && (
-
-
+
+
+
)}
diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts
index 9de1ed4b71..1e5a8c6890 100644
--- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts
+++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts
@@ -33,7 +33,7 @@ export function useAgentProfileSettings(
}, [availableAgents, agent?.name]);
const modelConfig = useMemo(() => {
- return availableAgent?.model_config ?? { default_model: '', available_models: [] };
+ return availableAgent?.model_config ?? { default_model: '', available_models: [], supports_dynamic_models: false };
}, [availableAgent]);
const permissionSettings = useMemo(() => {
diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx
index e8684df71e..d1c36e3fe2 100644
--- a/apps/web/components/settings/agent-profile-page.tsx
+++ b/apps/web/components/settings/agent-profile-page.tsx
@@ -187,6 +187,8 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passth
models={modelConfig.available_models}
defaultModel={modelConfig.default_model}
placeholder="Select or enter model..."
+ agentName={agent.name}
+ supportsDynamicModels={modelConfig.supports_dynamic_models}
/>
diff --git a/apps/web/components/settings/model-combobox.tsx b/apps/web/components/settings/model-combobox.tsx
index eee217b06d..a9f8fa7f98 100644
--- a/apps/web/components/settings/model-combobox.tsx
+++ b/apps/web/components/settings/model-combobox.tsx
@@ -1,10 +1,17 @@
'use client';
import { useState } from 'react';
+import { IconRefresh, IconAlertCircle, IconSelector } from '@tabler/icons-react';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
-import { Switch } from '@kandev/ui/switch';
+import { Checkbox } from '@kandev/ui/checkbox';
+import { Button } from '@kandev/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
+import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover';
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@kandev/ui/command';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
+import { Skeleton } from '@kandev/ui/skeleton';
+import { useDynamicModels } from '@/hooks/domains/settings/use-dynamic-models';
import type { ModelEntry } from '@/lib/types/http';
type ModelComboboxProps = {
@@ -13,15 +20,37 @@ type ModelComboboxProps = {
models: ModelEntry[];
defaultModel?: string;
placeholder?: string;
+ agentName?: string;
+ supportsDynamicModels?: boolean;
};
export function ModelCombobox({
value,
onChange,
- models,
+ models: staticModels,
defaultModel,
placeholder = 'Select a model',
+ agentName,
+ supportsDynamicModels = false,
}: ModelComboboxProps) {
+ const {
+ models,
+ isLoading,
+ error,
+ refresh,
+ } = useDynamicModels(agentName, staticModels, supportsDynamicModels);
+
+ const [open, setOpen] = useState(false);
+
+ // Initialize customValue with the current value if it's not in the static models list
+ // (we use staticModels for init since dynamic models haven't loaded yet)
+ const [customValue, setCustomValue] = useState(() => {
+ if (value && !staticModels.some((m) => m.id === value)) {
+ return value;
+ }
+ return '';
+ });
+
// Determine if the current value is a custom model (not in the list)
const isValueCustom = Boolean(value) && !models.some((m) => m.id === value);
// Track user's explicit toggle choice; null means derive from value
@@ -31,50 +60,204 @@ export function ModelCombobox({
const handleModeChange = (custom: boolean) => {
setUserToggle(custom);
- if (!custom && isValueCustom) {
- // Switching to select mode with a custom value - reset to default
+ if (custom) {
+ // Switching to custom mode - use preserved custom value
+ if (customValue) {
+ onChange(customValue);
+ }
+ } else {
+ // Switching to select mode - preserve current value if it's custom
+ if (isValueCustom && value) {
+ setCustomValue(value);
+ }
onChange(defaultModel || '');
}
};
- return (
-
- {isCustomMode ? (
-
onChange(e.target.value)}
- placeholder="Enter model ID (e.g., gpt-4o)"
- />
- ) : (
-
+ );
+ };
-
-
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Left side: Model dropdown/combobox + refresh button */}
+
+
+ {renderModelSelector()}
+
+
+ {/* Refresh button for dynamic models */}
+ {supportsDynamicModels && (
+
+
+
+
+
+ Refresh models from CLI
+
+
+ )}
+
+ {/* Error indicator */}
+ {error && (
+
+
+
+
+
+
+
+
+ Failed to fetch dynamic models: {error}
+
+ Showing static fallback models.
+
+
+
+ )}
+
+
+ {/* Right side: Custom model input + checkbox */}
+
+
handleCustomValueChange(e.target.value)}
+ placeholder="Custom model ID"
+ disabled={!isCustomMode}
/>
-
+
+ handleModeChange(checked === true)}
+ />
+
+
);
diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.ts b/apps/web/hooks/domains/settings/use-dynamic-models.ts
new file mode 100644
index 0000000000..c780d7722f
--- /dev/null
+++ b/apps/web/hooks/domains/settings/use-dynamic-models.ts
@@ -0,0 +1,84 @@
+import { useState, useEffect, useCallback } from 'react';
+import { fetchDynamicModels } from '@/lib/api/domains/settings-api';
+import type { ModelEntry, DynamicModelsResponse } from '@/lib/types/http';
+
+type UseDynamicModelsState = {
+ models: ModelEntry[];
+ isLoading: boolean;
+ error: string | null;
+ cached: boolean;
+ cachedAt: string | null;
+ refresh: () => Promise
;
+};
+
+export function useDynamicModels(
+ agentName: string | undefined,
+ staticModels: ModelEntry[],
+ supportsDynamicModels: boolean
+): UseDynamicModelsState {
+ const [models, setModels] = useState(staticModels);
+ // Start in loading state if dynamic models are supported to avoid UI flash
+ const [isLoading, setIsLoading] = useState(supportsDynamicModels && !!agentName);
+ const [error, setError] = useState(null);
+ const [cached, setCached] = useState(false);
+ const [cachedAt, setCachedAt] = useState(null);
+
+ const fetchModels = useCallback(
+ async (forceRefresh = false) => {
+ if (!agentName || !supportsDynamicModels) {
+ setModels(staticModels);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response: DynamicModelsResponse = await fetchDynamicModels(agentName, {
+ refresh: forceRefresh,
+ });
+
+ if (response.error) {
+ setError(response.error);
+ // Fall back to static models on error
+ setModels(staticModels);
+ } else {
+ setModels(response.models);
+ }
+
+ setCached(response.cached);
+ setCachedAt(response.cached_at ?? null);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch models';
+ setError(errorMessage);
+ // Fall back to static models on error
+ setModels(staticModels);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [agentName, supportsDynamicModels, staticModels]
+ );
+
+ // Fetch on mount if dynamic models are supported
+ useEffect(() => {
+ if (supportsDynamicModels && agentName) {
+ fetchModels(false);
+ } else {
+ setModels(staticModels);
+ }
+ }, [agentName, supportsDynamicModels, fetchModels, staticModels]);
+
+ const refresh = useCallback(async () => {
+ await fetchModels(true);
+ }, [fetchModels]);
+
+ return {
+ models,
+ isLoading,
+ error,
+ cached,
+ cachedAt,
+ refresh,
+ };
+}
diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts
index e419e07257..4fa20f2fe2 100644
--- a/apps/web/lib/api/domains/settings-api.ts
+++ b/apps/web/lib/api/domains/settings-api.ts
@@ -13,6 +13,7 @@ import type {
CustomPrompt,
PromptsResponse,
UserSettingsResponse,
+ DynamicModelsResponse,
} from '@/lib/types/http';
// User settings
@@ -94,6 +95,15 @@ export async function previewAgentCommand(
});
}
+export async function fetchDynamicModels(
+ agentName: string,
+ options?: ApiRequestOptions & { refresh?: boolean }
+): Promise {
+ const refresh = options?.refresh ?? false;
+ const url = `/api/v1/agent-models/${agentName}${refresh ? '?refresh=true' : ''}`;
+ return fetchJson(url, options);
+}
+
// Editors
export async function listEditors(options?: ApiRequestOptions) {
return fetchJson('/api/v1/editors', options);
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index bbd98c7df7..082d8b7bba 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -455,11 +455,21 @@ export type ModelEntry = {
provider: string;
context_window: number;
is_default: boolean;
+ source?: 'static' | 'dynamic';
};
export type ModelConfig = {
default_model: string;
available_models: ModelEntry[];
+ supports_dynamic_models: boolean;
+};
+
+export type DynamicModelsResponse = {
+ agent_name: string;
+ models: ModelEntry[];
+ cached: boolean;
+ cached_at?: string;
+ error: string | null;
};
export type PermissionSetting = {