diff --git a/models/models.go b/models/models.go index 503cbab9475af9ae407f4ef1e5556f2a03257d95..9eb04ccb0fed442554c541cd76816e8cef75be32 100644 --- a/models/models.go +++ b/models/models.go @@ -28,6 +28,15 @@ const ( ProviderBuiltIn Provider = "builtin" ) +// ModelSource describes where a model's configuration comes from +type ModelSource string + +const ( + SourceGateway ModelSource = "exe.dev gateway" + SourceEnvVar ModelSource = "env" // Will be combined with env var name + SourceCustom ModelSource = "custom" // User-configured custom model +) + // Model represents a configured LLM model in Shelley type Model struct { // ID is the user-facing identifier for this model @@ -46,6 +55,48 @@ type Model struct { Factory func(config *Config, httpc *http.Client) (llm.Service, error) } +// Source returns a human-readable description of where this model's configuration comes from. +// For example: "exe.dev gateway", "$ANTHROPIC_API_KEY", etc. +func (m Model) Source(cfg *Config) string { + // Predictable model has no source + if m.ID == "predictable" { + return "" + } + + // Check if using gateway with implicit keys + if cfg.Gateway != "" { + // Gateway is configured - check if this model is using gateway (implicit key) + switch m.Provider { + case ProviderAnthropic: + if cfg.AnthropicAPIKey == "implicit" { + return string(SourceGateway) + } + return "$ANTHROPIC_API_KEY" + case ProviderOpenAI: + if cfg.OpenAIAPIKey == "implicit" { + return string(SourceGateway) + } + return "$OPENAI_API_KEY" + case ProviderFireworks: + if cfg.FireworksAPIKey == "implicit" { + return string(SourceGateway) + } + return "$FIREWORKS_API_KEY" + case ProviderGemini: + if cfg.GeminiAPIKey == "implicit" { + return string(SourceGateway) + } + return "$GEMINI_API_KEY" + } + } + + // No gateway - use env var names based on RequiredEnvVars + if len(m.RequiredEnvVars) > 0 { + return "$" + m.RequiredEnvVars[0] + } + return "" +} + // Config holds the configuration needed to create LLM services type Config struct { // API keys for each provider @@ -266,16 +317,21 @@ func Default() Model { // Manager manages LLM services for all configured models type Manager struct { - services map[string]serviceEntry - logger *slog.Logger - db *db.DB // for custom models and LLM request recording - httpc *http.Client // HTTP client with recording middleware + services map[string]serviceEntry + modelOrder []string // ordered list of model IDs (built-in first, then custom) + logger *slog.Logger + db *db.DB // for custom models and LLM request recording + httpc *http.Client // HTTP client with recording middleware + cfg *Config // retained for refreshing custom models } type serviceEntry struct { - service llm.Service - provider Provider - modelID string + service llm.Service + provider Provider + modelID string + source string // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY") + displayName string // For custom models, the user-provided display name + tags string // For custom models, user-provided tags } // ConfigInfo is an optional interface that services can implement to provide configuration details for logging @@ -439,125 +495,148 @@ func NewManager(cfg *Config) (*Manager, error) { httpc = llmhttp.NewClient(nil, nil) } - // Store the HTTP client for use with custom models + // Store the HTTP client and config for use with custom models manager.httpc = httpc + manager.cfg = cfg + // Load built-in models first for _, model := range All() { svc, err := model.Factory(cfg, httpc) if err != nil { // Model not available (e.g., missing API key) - skip it continue } + manager.services[model.ID] = serviceEntry{ - service: svc, - provider: model.Provider, - modelID: model.ID, + service: svc, + provider: model.Provider, + modelID: model.ID, + source: model.Source(cfg), + displayName: model.ID, // built-in models use ID as display name } + manager.modelOrder = append(manager.modelOrder, model.ID) + } + + // Load custom models from database + if err := manager.loadCustomModels(); err != nil && cfg.Logger != nil { + cfg.Logger.Warn("Failed to load custom models", "error", err) } return manager, nil } -// GetService returns the LLM service for the given model ID, wrapped with logging -func (m *Manager) GetService(modelID string) (llm.Service, error) { - // Check custom models first if we have a database - if m.db != nil { - dbModels, err := m.db.GetModels(context.Background()) - if err == nil && len(dbModels) > 0 { - // Custom models exist - only serve custom models, not built-in ones - for _, model := range dbModels { - if model.ModelID == modelID { - svc := m.createServiceFromModel(&model) - if svc != nil { - if m.logger != nil { - return &loggingService{ - service: svc, - logger: m.logger, - modelID: modelID, - provider: Provider(model.ProviderType), - db: m.db, - }, nil - } - return svc, nil - } - } - } - // Custom models exist but this model ID wasn't found among them - return nil, fmt.Errorf("unsupported model: %s", modelID) - } +// loadCustomModels loads custom models from the database into the manager. +// It adds them after built-in models in the order. +func (m *Manager) loadCustomModels() error { + if m.db == nil { + return nil } - // No custom models - fall back to built-in models - if entry, ok := m.services[modelID]; ok { - // Wrap with logging if we have a logger - if m.logger != nil { - return &loggingService{ - service: entry.service, - logger: m.logger, - modelID: entry.modelID, - provider: entry.provider, - db: m.db, - }, nil + dbModels, err := m.db.GetModels(context.Background()) + if err != nil { + return err + } + + for _, model := range dbModels { + // Skip if this model ID is already registered (built-in takes precedence) + if _, exists := m.services[model.ModelID]; exists { + continue + } + + svc := m.createServiceFromModel(&model) + if svc == nil { + continue + } + + m.services[model.ModelID] = serviceEntry{ + service: svc, + provider: Provider(model.ProviderType), + modelID: model.ModelID, + source: string(SourceCustom), + displayName: model.DisplayName, + tags: model.Tags, } - return entry.service, nil + m.modelOrder = append(m.modelOrder, model.ModelID) } - return nil, fmt.Errorf("unsupported model: %s", modelID) + + return nil } -// GetAvailableModels returns a list of available model IDs in the same order as All() -func (m *Manager) GetAvailableModels() []string { - var ids []string +// RefreshCustomModels reloads custom models from the database. +// Call this after adding or removing custom models via the UI. +func (m *Manager) RefreshCustomModels() error { + if m.db == nil { + return nil + } - // If we have custom models in the database, use ONLY those - if m.db != nil { - if dbModels, err := m.db.GetModels(context.Background()); err == nil && len(dbModels) > 0 { - for _, model := range dbModels { - ids = append(ids, model.ModelID) - } - return ids + // Remove existing custom models from services and modelOrder + newOrder := make([]string, 0, len(m.modelOrder)) + for _, id := range m.modelOrder { + entry, ok := m.services[id] + if ok && entry.source != string(SourceCustom) { + newOrder = append(newOrder, id) + } else { + delete(m.services, id) } } + m.modelOrder = newOrder - // No custom models - fall back to built-in models in the same order as All() - all := All() - for _, model := range all { - if _, ok := m.services[model.ID]; ok { - ids = append(ids, model.ID) - } + // Reload custom models + return m.loadCustomModels() +} + +// GetService returns the LLM service for the given model ID, wrapped with logging +func (m *Manager) GetService(modelID string) (llm.Service, error) { + entry, ok := m.services[modelID] + if !ok { + return nil, fmt.Errorf("unsupported model: %s", modelID) } - return ids + + // Wrap with logging if we have a logger + if m.logger != nil { + return &loggingService{ + service: entry.service, + logger: m.logger, + modelID: entry.modelID, + provider: entry.provider, + db: m.db, + }, nil + } + return entry.service, nil +} + +// GetAvailableModels returns a list of available model IDs. +// Returns union of built-in models (in order) followed by custom models. +func (m *Manager) GetAvailableModels() []string { + // Return a copy to prevent external modification + result := make([]string, len(m.modelOrder)) + copy(result, m.modelOrder) + return result } // HasModel reports whether the manager has a service for the given model ID func (m *Manager) HasModel(modelID string) bool { - // Check custom models first - if m.db != nil { - if model, err := m.db.GetModel(context.Background(), modelID); err == nil && model != nil { - return true - } - } _, ok := m.services[modelID] return ok } -// ModelInfo contains display name and tags for a model +// ModelInfo contains display name, tags, and source for a model type ModelInfo struct { DisplayName string Tags string + Source string // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY", "custom") } -// GetModelInfo returns the display name and tags for a model +// GetModelInfo returns the display name, tags, and source for a model func (m *Manager) GetModelInfo(modelID string) *ModelInfo { - if m.db == nil { - return nil - } - model, err := m.db.GetModel(context.Background(), modelID) - if err != nil { + entry, ok := m.services[modelID] + if !ok { return nil } return &ModelInfo{ - DisplayName: model.DisplayName, - Tags: model.Tags, + DisplayName: entry.displayName, + Tags: entry.tags, + Source: entry.source, } } diff --git a/models/models_test.go b/models/models_test.go index 5baf3b2f5ccea33444d7d62f2a1dc4ed802b2831..236aa30d241f224eb240a32ac30a7cdc08a9da50 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -404,3 +404,110 @@ func TestHTTPClientPassedToFactory(t *testing.T) { t.Fatal("Factory returned nil service") } } + +func TestGetModelSource(t *testing.T) { + tests := []struct { + name string + cfg *Config + modelID string + want string + }{ + { + name: "anthropic with env var only", + cfg: &Config{AnthropicAPIKey: "test-key"}, + modelID: "claude-opus-4.5", + want: "$ANTHROPIC_API_KEY", + }, + { + name: "anthropic with gateway implicit key", + cfg: &Config{Gateway: "https://gateway.example.com", AnthropicAPIKey: "implicit"}, + modelID: "claude-opus-4.5", + want: "exe.dev gateway", + }, + { + name: "anthropic with gateway but explicit key", + cfg: &Config{Gateway: "https://gateway.example.com", AnthropicAPIKey: "actual-key"}, + modelID: "claude-opus-4.5", + want: "$ANTHROPIC_API_KEY", + }, + { + name: "fireworks with env var only", + cfg: &Config{FireworksAPIKey: "test-key"}, + modelID: "qwen3-coder-fireworks", + want: "$FIREWORKS_API_KEY", + }, + { + name: "fireworks with gateway implicit key", + cfg: &Config{Gateway: "https://gateway.example.com", FireworksAPIKey: "implicit"}, + modelID: "qwen3-coder-fireworks", + want: "exe.dev gateway", + }, + { + name: "openai with env var only", + cfg: &Config{OpenAIAPIKey: "test-key"}, + modelID: "gpt-5.2-codex", + want: "$OPENAI_API_KEY", + }, + { + name: "gemini with env var only", + cfg: &Config{GeminiAPIKey: "test-key"}, + modelID: "gemini-3-pro", + want: "$GEMINI_API_KEY", + }, + { + name: "predictable has no source", + cfg: &Config{}, + modelID: "predictable", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager, err := NewManager(tt.cfg) + if err != nil { + t.Fatalf("NewManager failed: %v", err) + } + + info := manager.GetModelInfo(tt.modelID) + if info == nil { + t.Fatalf("GetModelInfo(%q) returned nil", tt.modelID) + } + if info.Source != tt.want { + t.Errorf("GetModelInfo(%q).Source = %q, want %q", tt.modelID, info.Source, tt.want) + } + }) + } +} + +func TestGetAvailableModelsUnion(t *testing.T) { + // Test that GetAvailableModels returns both built-in and custom models + // This test just verifies the union behavior with built-in models only + // (testing with custom models requires a database) + cfg := &Config{ + AnthropicAPIKey: "test-key", + FireworksAPIKey: "test-key", + } + + manager, err := NewManager(cfg) + if err != nil { + t.Fatalf("NewManager failed: %v", err) + } + + models := manager.GetAvailableModels() + + // Should have anthropic models and fireworks models, plus predictable + expectedModels := []string{"claude-opus-4.5", "qwen3-coder-fireworks", "glm-4p6-fireworks", "claude-sonnet-4.5", "claude-haiku-4.5", "predictable"} + for _, expected := range expectedModels { + found := false + for _, m := range models { + if m == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected model %q not found in available models: %v", expected, models) + } + } +} diff --git a/server/cancel_claude_test.go b/server/cancel_claude_test.go index 4736e10d8fd72190c5c06882b34ef42599c6a2f2..cc1f2a2e5a52e356a4191130feb15a6527833489 100644 --- a/server/cancel_claude_test.go +++ b/server/cancel_claude_test.go @@ -552,6 +552,10 @@ func (m *claudeLLMManager) GetModelInfo(modelID string) *models.ModelInfo { return nil } +func (m *claudeLLMManager) RefreshCustomModels() error { + return nil +} + // TestClaudeCancelDuringToolCall tests cancellation during tool execution with Claude func TestClaudeCancelDuringToolCall(t *testing.T) { h := NewClaudeTestHarness(t) diff --git a/server/cancel_test.go b/server/cancel_test.go index d25f03f4bec33f98aa7c30d87319679f9c75af00..a475a3a0f567a4673434be76481091db56c385c7 100644 --- a/server/cancel_test.go +++ b/server/cancel_test.go @@ -411,3 +411,7 @@ func (m *testLLMManager) HasModel(modelID string) bool { func (m *testLLMManager) GetModelInfo(modelID string) *models.ModelInfo { return nil } + +func (m *testLLMManager) RefreshCustomModels() error { + return nil +} diff --git a/server/custom_models.go b/server/custom_models.go index 03116c6b9fbf2226462a7ce36949ba57cfe93c50..e4ec24bbd0c0e20a3504ada40cb7b99931d0e77d 100644 --- a/server/custom_models.go +++ b/server/custom_models.go @@ -141,6 +141,11 @@ func (s *Server) handleCreateModel(w http.ResponseWriter, r *http.Request) { return } + // Refresh the model manager's cache + if err := s.llmManager.RefreshCustomModels(); err != nil { + s.logger.Warn("Failed to refresh custom models cache", "error", err) + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(toModelAPI(*model)) @@ -234,6 +239,11 @@ func (s *Server) handleUpdateModel(w http.ResponseWriter, r *http.Request, model return } + // Refresh the model manager's cache + if err := s.llmManager.RefreshCustomModels(); err != nil { + s.logger.Warn("Failed to refresh custom models cache", "error", err) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(toModelAPI(*model)) } @@ -245,6 +255,11 @@ func (s *Server) handleDeleteModel(w http.ResponseWriter, r *http.Request, model return } + // Refresh the model manager's cache + if err := s.llmManager.RefreshCustomModels(); err != nil { + s.logger.Warn("Failed to refresh custom models cache", "error", err) + } + w.WriteHeader(http.StatusNoContent) } @@ -292,6 +307,11 @@ func (s *Server) handleDuplicateModel(w http.ResponseWriter, r *http.Request, mo return } + // Refresh the model manager's cache + if err := s.llmManager.RefreshCustomModels(); err != nil { + s.logger.Warn("Failed to refresh custom models cache", "error", err) + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(toModelAPI(*model)) diff --git a/server/handlers.go b/server/handlers.go index 7b298c1aed617770da7d1fa3c424487b325c0240..d43e6e780e6242a418ba438cc541d2ae5258db21 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -361,25 +361,29 @@ func (s *Server) serveIndexWithInit(w http.ResponseWriter, r *http.Request, fs h modelList := s.getModelList() // Select default model - use configured default if available, otherwise first ready model - defaultModel := s.defaultModel - if defaultModel == "" { - defaultModel = models.Default().ID - } - defaultModelAvailable := false - for _, m := range modelList { - if m.ID == defaultModel && m.Ready { - defaultModelAvailable = true - break + // If no models are available, default_model should be empty + defaultModel := "" + if len(modelList) > 0 { + defaultModel = s.defaultModel + if defaultModel == "" { + defaultModel = models.Default().ID } - } - if !defaultModelAvailable { - // Fall back to first ready model + defaultModelAvailable := false for _, m := range modelList { - if m.Ready { - defaultModel = m.ID + if m.ID == defaultModel && m.Ready { + defaultModelAvailable = true break } } + if !defaultModelAvailable { + // Fall back to first ready model + for _, m := range modelList { + if m.Ready { + defaultModel = m.ID + break + } + } + } } // Get hostname (add .exe.xyz suffix if no dots, matching system_prompt.go) @@ -1082,6 +1086,7 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { type ModelInfo struct { ID string `json:"id"` DisplayName string `json:"display_name,omitempty"` + Source string `json:"source,omitempty"` // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY") Ready bool `json:"ready"` MaxContextTokens int `json:"max_context_tokens,omitempty"` } @@ -1104,9 +1109,10 @@ func (s *Server) getModelList() []ModelInfo { maxCtx = svc.TokenContextWindow() } info := ModelInfo{ID: id, Ready: err == nil, MaxContextTokens: maxCtx} - // Add display name from model info + // Add display name and source from model info if modelInfo := s.llmManager.GetModelInfo(id); modelInfo != nil { info.DisplayName = modelInfo.DisplayName + info.Source = modelInfo.Source } modelList = append(modelList, info) } diff --git a/server/server.go b/server/server.go index 4a27754bba6b9a24b82f827d6db6484afaa3b97f..cd61516ef9147911e4f56419f076c4df331644c1 100644 --- a/server/server.go +++ b/server/server.go @@ -71,6 +71,7 @@ type LLMProvider interface { GetAvailableModels() []string HasModel(modelID string) bool GetModelInfo(modelID string) *models.ModelInfo + RefreshCustomModels() error } // NewLLMServiceManager creates a new LLM service manager from config diff --git a/test/server_test.go b/test/server_test.go index 223f555df507cedecd8602abe8da89b685272f0c..b9cf3ad635d1abe660648e8be45758ccf200fe2c 100644 --- a/test/server_test.go +++ b/test/server_test.go @@ -912,6 +912,10 @@ func (m *inspectableLLMManager) GetModelInfo(modelID string) *models.ModelInfo { return nil } +func (m *inspectableLLMManager) RefreshCustomModels() error { + return nil +} + func TestVersionEndpoint(t *testing.T) { // Create temp DB-backed server ctx := context.Background() diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index db66806500ab5fe46987ee8d248b0148d93ad367..a4193b9eb856c01e54d381b861bdde14da238920 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -489,7 +489,13 @@ function ChatInterface({ const [sending, setSending] = useState(false); const [error, setError] = useState(null); const [models, setModels] = useState< - Array<{ id: string; display_name?: string; ready: boolean; max_context_tokens?: number }> + Array<{ + id: string; + display_name?: string; + source?: string; + ready: boolean; + max_context_tokens?: number; + }> >(window.__SHELLEY_INIT__?.models || []); const [selectedModel, setSelectedModelState] = useState(() => { // First check localStorage for a sticky model preference @@ -1208,7 +1214,11 @@ function ChatInterface({ {models.length === 0 ? (

- No AI models configured. Press Ctrl+K or +K to add a model. + No AI models configured. Press Ctrl + + + K or + + + K to add a model.

) : ( diff --git a/ui/src/components/ModelPicker.tsx b/ui/src/components/ModelPicker.tsx index 93091ab7e88d340b0661fed64709d2ac43153439..b4a96abb1561b8685574f3d19407dd10b9d118dc 100644 --- a/ui/src/components/ModelPicker.tsx +++ b/ui/src/components/ModelPicker.tsx @@ -61,6 +61,10 @@ function ModelPicker({ const selectedModelObj = models.find((m) => m.id === selectedModel); const displayName = selectedModelObj?.display_name || selectedModel; + const displayWithSource = + selectedModelObj?.source && selectedModelObj.source !== "custom" + ? `${displayName} (${selectedModelObj.source})` + : displayName; const handleSelect = (modelId: string) => { onSelectModel(modelId); @@ -80,7 +84,7 @@ function ModelPicker({ disabled={disabled} type="button" > - {displayName} + {displayWithSource} - {model.display_name || model.id} +
+ {model.display_name || model.id} + {model.source && ( + {model.source} + )} +
{!model.ready && not ready} {model.id === selectedModel && ( ([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [builtInModels, setBuiltInModels] = useState([]); + const [builtInModels, setBuiltInModels] = useState([]); // Form state const [showForm, setShowForm] = useState(false); @@ -99,10 +107,13 @@ function ModelsModal({ isOpen, onClose, onModelsChanged }: ModelsModalProps) { useEffect(() => { if (isOpen) { loadModels(); - // Get built-in models from init data + // Get built-in models from init data (those with non-custom source) const initData = window.__SHELLEY_INIT__; if (initData?.models) { - setBuiltInModels(initData.models.map((m) => m.id)); + const builtIn = initData.models.filter( + (m: BuiltInModel) => m.source && m.source !== "custom", + ); + setBuiltInModels(builtIn); } } }, [isOpen, loadModels]); @@ -485,104 +496,114 @@ function ModelsModal({ isOpen, onClose, onModelsChanged }: ModelsModalProps) { ) : ( // Model List <> - {models.length === 0 && - builtInModels.length > 0 && - builtInModels[0] !== "predictable" && ( -
-

Built-in models available:

-
    - {builtInModels - .filter((m) => m !== "predictable") - .map((model) => ( -
  • {model}
  • - ))} -
-
- )} - - {models.length > 0 && ( -
- {models.map((model) => ( -
+
+ {/* Built-in models (from env vars or gateway) - read only */} + {builtInModels + .filter((m) => m.id !== "predictable") + .map((model) => ( +
- {model.display_name} - - {PROVIDER_LABELS[model.provider_type]} - - {model.tags && ( - - {model.tags.split(",")[0]} - - )} -
-
- - - + {model.display_name || model.id} + {model.source}
- {model.model_name} - {model.endpoint} + {model.id}
))} -
- )} + + {/* Custom models - editable */} + {models.map((model) => ( +
+
+
+ {model.display_name} + {PROVIDER_LABELS[model.provider_type]} + {model.tags && ( + + {model.tags.split(",")[0]} + + )} +
+
+ + + +
+
+
+ {model.model_name} + {model.endpoint} +
+
+ ))} + + {/* Empty state when no models at all */} + {builtInModels.length === 0 && models.length === 0 && ( +
+

No models configured.

+

+ Set environment variables like ANTHROPIC_API_KEY, or use the -gateway flag, or + add a custom model below. +

+
+ )} +
)}
diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index ca8f8e5bb6b1149c2f045f1235bd303d3430dcd6..9b11789991294c8604c3a150d74c409f72f563c4 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -28,7 +28,13 @@ class ApiService { } async getModels(): Promise< - Array<{ id: string; display_name?: string; ready: boolean; max_context_tokens?: number }> + Array<{ + id: string; + display_name?: string; + source?: string; + ready: boolean; + max_context_tokens?: number; + }> > { const response = await fetch(`${this.baseUrl}/models`); if (!response.ok) { diff --git a/ui/src/styles.css b/ui/src/styles.css index bf26199f2307cfff7daeb0f24c3258bdf29d35cd..3ce6c20cabab6c8efc2ae72d46dd98ab42962be4 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2848,8 +2848,23 @@ svg { cursor: not-allowed; } -.model-picker-option-name { +.model-picker-option-content { flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.model-picker-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-picker-option-source { + font-size: 0.65rem; + color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -4273,6 +4288,31 @@ svg { color: var(--text-secondary); } +.model-source { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 0.25rem; + color: var(--text-secondary); +} + +.model-card-builtin { + opacity: 0.8; + background: var(--bg-secondary); +} + +.models-empty { + padding: 2rem; + text-align: center; + color: var(--text-secondary); +} + +.models-empty-hint { + font-size: 0.875rem; + margin-top: 0.5rem; + color: var(--text-tertiary); +} + .model-badge { font-size: 0.625rem; text-transform: uppercase; diff --git a/ui/src/types.ts b/ui/src/types.ts index 7010aa70e3aae865c6706cca719fcbcb88519eab..721a9ce2d65ac96bb4db68b2d580c5f7afa23e59 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -50,6 +50,7 @@ export interface LLMContent { export interface Model { id: string; display_name?: string; + source?: string; // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY") ready: boolean; max_context_tokens?: number; }