diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 2269147df..160c4d257 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -15,6 +15,17 @@ import ( const modelProbeTimeout = 800 * time.Millisecond +const ( + modelStatusAvailable = "available" + modelStatusUnconfigured = "unconfigured" + modelStatusUnreachable = "unreachable" +) + +type modelConfigurationSummary struct { + Available bool + Status string +} + var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel @@ -43,16 +54,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } -// isModelConfigured reports whether a model is currently available to use. -// Local models must be reachable; remote/API-key models only need saved config. -func isModelConfigured(m *config.ModelConfig) bool { +func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { if !hasModelConfiguration(m) { - return false + return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} } if requiresRuntimeProbe(m) { - return probeLocalModelAvailability(m) + if probeLocalModelAvailability(m) { + return modelConfigurationSummary{Available: true, Status: modelStatusAvailable} + } + return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable} } - return true + return modelConfigurationSummary{Available: true, Status: modelStatusAvailable} } func requiresRuntimeProbe(m *config.ModelConfig) bool { diff --git a/web/backend/api/models.go b/web/backend/api/models.go index fd3cd85b7..e6749b56e 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -40,10 +40,11 @@ type modelResponse struct { ThinkingLevel string `json:"thinking_level,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` // Meta - Enabled bool `json:"enabled"` - Configured bool `json:"configured"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` } // handleListModels returns all model_list entries with masked API keys. @@ -57,14 +58,14 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { } defaultModel := cfg.Agents.Defaults.GetModelName() - configured := make([]bool, len(cfg.ModelList)) + modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) var wg sync.WaitGroup wg.Add(len(cfg.ModelList)) for i, m := range cfg.ModelList { go func(i int, m *config.ModelConfig) { defer wg.Done() - configured[i] = isModelConfigured(m) + modelStatuses[i] = modelConfigurationStatus(m) }(i, m) } wg.Wait() @@ -87,7 +88,8 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, Enabled: m.Enabled, - Configured: configured[i], + Available: modelStatuses[i].Available, + Status: modelStatuses[i].Status, IsDefault: m.ModelName == defaultModel, IsVirtual: m.IsVirtual(), }) diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 97f153a80..e78de1606 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -27,7 +27,7 @@ func resetModelProbeHooks(t *testing.T) { }) } -func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { +func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) @@ -113,25 +113,42 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes t.Fatalf("Unmarshal() error = %v", err) } - got := make(map[string]bool, len(resp.Models)) + gotAvailable := make(map[string]bool, len(resp.Models)) + gotStatus := make(map[string]string, len(resp.Models)) for _, model := range resp.Models { - got[model.ModelName] = model.Configured + gotAvailable[model.ModelName] = model.Available + gotStatus[model.ModelName] = model.Status } - if got["openai-oauth"] { - t.Fatalf("openai oauth model configured = true, want false without stored credential") + if gotAvailable["openai-oauth"] { + t.Fatalf("openai oauth model available = true, want false without stored credential") } - if !got["vllm-local"] { - t.Fatalf("vllm local model configured = false, want true when local probe succeeds") + if !gotAvailable["vllm-local"] { + t.Fatalf("vllm local model available = false, want true when local probe succeeds") } - if !got["ollama-default"] { - t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") + if !gotAvailable["ollama-default"] { + t.Fatalf("ollama default model available = false, want true when default local probe succeeds") } - if !got["vllm-remote"] { - t.Fatalf("remote vllm model configured = false, want true with api_key") + if !gotAvailable["vllm-remote"] { + t.Fatalf("remote vllm model available = false, want true with api_key") } - if !got["copilot-gpt-5.4"] { - t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") + if !gotAvailable["copilot-gpt-5.4"] { + t.Fatalf("copilot model available = false, want true when local bridge probe succeeds") + } + if gotStatus["openai-oauth"] != modelStatusUnconfigured { + t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured) + } + if gotStatus["vllm-local"] != modelStatusAvailable { + t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable) + } + if gotStatus["ollama-default"] != modelStatusAvailable { + t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable) + } + if gotStatus["vllm-remote"] != modelStatusAvailable { + t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable) + } + if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable { + t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable) } if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) @@ -144,7 +161,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes } } -func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { +func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) @@ -193,8 +210,8 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } - if !resp.Models[0].Configured { - t.Fatalf("oauth model configured = false, want true with stored credential") + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") } } @@ -306,14 +323,71 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } - if !resp.Models[0].Configured { - t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") + if !resp.Models[0].Available { + t.Fatal("wildcard-bound local model available = false, want true after probe host normalization") } if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") } } +func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local-down", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + + if resp.Models[0].Available { + t.Fatal("unreachable local model available = true, want false") + } + if resp.Models[0].Status != modelStatusUnreachable { + t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable) + } + if resp.Models[0].APIKey == "" { + t.Fatal("masked API key preview should still be returned when API key is configured") + } +} + func TestHandleAddModel_PersistsAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index d75b3ec3c..eb8d287dd 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -20,7 +20,8 @@ export interface ModelInfo { thinking_level?: string extra_body?: Record // Meta - configured: boolean + available: boolean + status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean } diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 0574c44d1..7e1abca17 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -10,19 +10,19 @@ import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" interface ChatEmptyStateProps { - hasConfiguredModels: boolean + hasAvailableModels: boolean defaultModelName: string isConnected: boolean } export function ChatEmptyState({ - hasConfiguredModels, + hasAvailableModels, defaultModelName, isConnected, }: ChatEmptyStateProps) { const { t } = useTranslation() - if (!hasConfiguredModels) { + if (!hasAvailableModels) { return (
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index ebcde8981..ae705ff1b 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -39,7 +39,7 @@ export function ChatPage() { const { defaultModelName, - hasConfiguredModels, + hasAvailableModels, apiKeyModels, oauthModels, localModels, @@ -94,7 +94,7 @@ export function ChatPage() { hasScrolled ? "shadow-sm" : "shadow-none" }`} titleExtra={ - hasConfiguredModels && ( + hasAvailableModels && ( {messages.length === 0 && !isTyping && ( diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index d1cba6719..52e2d8d9d 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -133,9 +133,10 @@ export function EditModelSheet({ } const isOAuth = model?.auth_method === "oauth" - const apiKeyPlaceholder = model?.configured + const hasSavedAPIKey = Boolean(model?.api_key) + const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( - model.api_key, + model?.api_key ?? "", t("models.field.apiKeyPlaceholderSet"), ) : t("models.field.apiKeyPlaceholder") @@ -161,7 +162,7 @@ export function EditModelSheet({ {model.model_name} @@ -127,14 +127,14 @@ export function ModelCard({ OAuth - ) : model.configured && model.api_key ? ( + ) : status === "available" && model.api_key ? ( {model.api_key} ) : ( - {t("models.status.unconfigured")} + {statusLabel} )}
diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index a6747c5e0..c08b3bdd6 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -40,7 +40,7 @@ interface ProviderGroup { label: string models: ModelInfo[] hasDefault: boolean - configuredCount: number + availableCount: number } export function ModelsPage() { @@ -62,8 +62,8 @@ export function ModelsPage() { const sorted = [...data.models].sort((a, b) => { if (a.is_default && !b.is_default) return -1 if (!a.is_default && b.is_default) return 1 - if (a.configured && !b.configured) return -1 - if (!a.configured && b.configured) return 1 + if (a.available && !b.available) return -1 + if (!a.available && b.available) return 1 return a.model_name.localeCompare(b.model_name) }) setModels(sorted) @@ -107,23 +107,23 @@ export function ModelsPage() { const providerGroups: ProviderGroup[] = Object.entries(grouped) .map(([key, group]) => { - const configuredCount = group.models.filter( - (model) => model.configured, + const availableCount = group.models.filter( + (model) => model.available, ).length return { key, label: group.label, models: group.models, hasDefault: group.models.some((model) => model.is_default), - configuredCount, + availableCount, } }) .sort((a, b) => { if (a.hasDefault && !b.hasDefault) return -1 if (!a.hasDefault && b.hasDefault) return 1 - if (a.configuredCount !== b.configuredCount) { - return b.configuredCount - a.configuredCount + if (a.availableCount !== b.availableCount) { + return b.availableCount - a.availableCount } const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 9afa882db..17cfba00f 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -65,32 +65,32 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { [defaultModelName], ) - const hasConfiguredModels = useMemo( - () => modelList.some((m) => m.configured), + const hasAvailableModels = useMemo( + () => modelList.some((m) => m.available), [modelList], ) const oauthModels = useMemo( - () => modelList.filter((m) => m.configured && m.auth_method === "oauth"), + () => modelList.filter((m) => m.available && m.auth_method === "oauth"), [modelList], ) const localModels = useMemo( - () => modelList.filter((m) => m.configured && isLocalModel(m)), + () => modelList.filter((m) => m.available && isLocalModel(m)), [modelList], ) const apiKeyModels = useMemo( () => modelList.filter( - (m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m), + (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m), ), [modelList], ) return { defaultModelName, - hasConfiguredModels, + hasAvailableModels, apiKeyModels, oauthModels, localModels, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index c512eafbe..d79e90ef7 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -170,8 +170,9 @@ "noDefaultHintPrefix": "No default model set yet. Click", "noDefaultHintSuffix": "to set one.", "status": { - "configured": "Configured", - "unconfigured": "Not configured" + "available": "Available", + "unconfigured": "Not configured", + "unreachable": "Service unreachable" }, "badge": { "default": "Default", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 54d3fe1b3..e5aa71a44 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -170,8 +170,9 @@ "noDefaultHintPrefix": "尚未设置默认模型,点击", "noDefaultHintSuffix": "设为默认。", "status": { - "configured": "已配置", - "unconfigured": "未配置" + "available": "可用", + "unconfigured": "未配置", + "unreachable": "服务不可达" }, "badge": { "default": "默认",