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)" - /> - ) : ( - + + + + + {models.map((model) => ( + + {model.name} {model.id === defaultModel && ( - (default) + (default) )} - - ))} - - - )} + + + ))} + + + ); + }; -
- + + +
+ ); + } + + 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 = {