feat: opencode dynamic model loader (#99)
This commit is contained in:
@@ -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
-2
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user