feat: opencode dynamic model loader (#99)

This commit is contained in:
Carlos Florêncio
2026-01-25 22:23:03 +00:00
committed by GitHub
parent c9355e526b
commit 323e14c1a6
21 changed files with 1621 additions and 67 deletions
@@ -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
}
@@ -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",
@@ -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",
@@ -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
@@ -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
}
@@ -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"},
@@ -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"`
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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)
}
@@ -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)
}
}
@@ -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{}
}
}
@@ -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)
}
}
}
@@ -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({
</p>
{loading && (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<div className="space-y-2">
<Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-4 w-3/4" />
</div>
)}
@@ -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(() => {
@@ -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}
/>
</div>
+220 -37
View File
@@ -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 (
<div className="space-y-2">
{isCustomMode ? (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter model ID (e.g., gpt-4o)"
/>
) : (
<Select
value={value || defaultModel}
onValueChange={onChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
const handleCustomValueChange = (newValue: string) => {
setCustomValue(newValue);
// Ensure we stay in custom mode when user is typing in the custom input
if (userToggle !== true) {
setUserToggle(true);
}
onChange(newValue);
};
const handleRefresh = async () => {
await refresh();
};
const selectedModel = models.find((m) => m.id === (value || defaultModel));
// Render the dropdown/combobox for model selection
const renderModelSelector = () => {
// Use searchable combobox for dynamic models (typically many options)
if (supportsDynamicModels) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
disabled={isCustomMode}
>
{selectedModel ? (
<span className="flex items-center gap-2 truncate">
{selectedModel.name}
{selectedModel.id === defaultModel && (
<span className="text-muted-foreground">(default)</span>
)}
{selectedModel.provider && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{selectedModel.provider}
</span>
)}
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{models.map((model) => (
<CommandItem
key={model.id}
value={model.id}
onSelect={(currentValue) => {
onChange(currentValue);
setOpen(false);
}}
data-checked={value === model.id || (!value && model.id === defaultModel)}
>
<span className="flex-1 truncate">
{model.name}
{model.id === defaultModel && (
<span className="text-muted-foreground text-xs ml-1">(default)</span>
)}
</span>
{model.provider && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium mr-1">
{model.provider}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// Use simple select for static models (fewer options)
return (
<Select
value={value || defaultModel}
onValueChange={onChange}
disabled={isCustomMode}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<span className="flex items-center gap-2">
{model.name}
{model.id === defaultModel && (
<span className="ml-2 text-muted-foreground">(default)</span>
<span className="text-muted-foreground">(default)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
<div className="flex items-center space-x-2">
<Switch
id="custom-model-mode"
checked={isCustomMode}
onCheckedChange={handleModeChange}
// Show full-row skeleton while loading dynamic models
if (supportsDynamicModels && isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-4 w-32 ml-auto" />
</div>
);
}
return (
<div className="flex items-start gap-2">
{/* Left side: Model dropdown/combobox + refresh button */}
<div className="flex flex-1 items-center gap-2">
<div className="flex-1">
{renderModelSelector()}
</div>
{/* Refresh button for dynamic models */}
{supportsDynamicModels && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={isCustomMode}
>
<IconRefresh className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh models from CLI</p>
</TooltipContent>
</Tooltip>
)}
{/* Error indicator */}
{error && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<IconAlertCircle className="h-4 w-4 text-amber-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
Failed to fetch dynamic models: {error}
<br />
Showing static fallback models.
</p>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Right side: Custom model input + checkbox */}
<div className="flex-1 space-y-2">
<Input
value={customValue}
onChange={(e) => handleCustomValueChange(e.target.value)}
placeholder="Custom model ID"
disabled={!isCustomMode}
/>
<Label htmlFor="custom-model-mode" className="text-xs text-muted-foreground">
Custom model
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="custom-model-mode"
checked={isCustomMode}
onCheckedChange={(checked) => handleModeChange(checked === true)}
/>
<Label htmlFor="custom-model-mode" className="text-xs text-muted-foreground">
Use custom model
</Label>
</div>
</div>
</div>
);
@@ -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<void>;
};
export function useDynamicModels(
agentName: string | undefined,
staticModels: ModelEntry[],
supportsDynamicModels: boolean
): UseDynamicModelsState {
const [models, setModels] = useState<ModelEntry[]>(staticModels);
// Start in loading state if dynamic models are supported to avoid UI flash
const [isLoading, setIsLoading] = useState(supportsDynamicModels && !!agentName);
const [error, setError] = useState<string | null>(null);
const [cached, setCached] = useState(false);
const [cachedAt, setCachedAt] = useState<string | null>(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,
};
}
+10
View File
@@ -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<DynamicModelsResponse> {
const refresh = options?.refresh ?? false;
const url = `/api/v1/agent-models/${agentName}${refresh ? '?refresh=true' : ''}`;
return fetchJson<DynamicModelsResponse>(url, options);
}
// Editors
export async function listEditors(options?: ApiRequestOptions) {
return fetchJson<EditorsResponse>('/api/v1/editors', options);
+10
View File
@@ -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 = {