feat(web): clarify model availability and status display

- Rename model availability field from configured to available across backend API and frontend usage

- Keep status as reason classification (configured/unconfigured/unreachable) and show unreachable in UI

- Preserve API key preview even when local service is unreachable

- Update backend tests to assert both availability and status semantics
This commit is contained in:
lc6464 2026-03-31 00:04:54 +08:00
parent dd54601f2d
commit 134779b266
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
12 changed files with 159 additions and 68 deletions

View file

@ -15,6 +15,17 @@ import (
const modelProbeTimeout = 800 * time.Millisecond const modelProbeTimeout = 800 * time.Millisecond
const (
modelStatusConfigured = "configured"
modelStatusUnconfigured = "unconfigured"
modelStatusUnreachable = "unreachable"
)
type modelConfigurationSummary struct {
Available bool
Status string
}
var ( var (
probeTCPServiceFunc = probeTCPService probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel probeOllamaModelFunc = probeOllamaModel
@ -43,16 +54,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
return apiKey != "" return apiKey != ""
} }
// isModelConfigured reports whether a model is currently available to use. func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
// Local models must be reachable; remote/API-key models only need saved config.
func isModelConfigured(m *config.ModelConfig) bool {
if !hasModelConfiguration(m) { if !hasModelConfiguration(m) {
return false return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
} }
if requiresRuntimeProbe(m) { if requiresRuntimeProbe(m) {
return probeLocalModelAvailability(m) if probeLocalModelAvailability(m) {
return modelConfigurationSummary{Available: true, Status: modelStatusConfigured}
} }
return true return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
}
return modelConfigurationSummary{Available: true, Status: modelStatusConfigured}
} }
func requiresRuntimeProbe(m *config.ModelConfig) bool { func requiresRuntimeProbe(m *config.ModelConfig) bool {

View file

@ -41,7 +41,8 @@ type modelResponse struct {
ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"`
// Meta // Meta
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Configured bool `json:"configured"` Available bool `json:"available"`
Status string `json:"status"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
IsVirtual bool `json:"is_virtual"` IsVirtual bool `json:"is_virtual"`
} }
@ -57,14 +58,14 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
} }
defaultModel := cfg.Agents.Defaults.GetModelName() defaultModel := cfg.Agents.Defaults.GetModelName()
configured := make([]bool, len(cfg.ModelList)) modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(cfg.ModelList)) wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
go func(i int, m *config.ModelConfig) { go func(i int, m *config.ModelConfig) {
defer wg.Done() defer wg.Done()
configured[i] = isModelConfigured(m) modelStatuses[i] = modelConfigurationStatus(m)
}(i, m) }(i, m)
} }
wg.Wait() wg.Wait()
@ -87,7 +88,8 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ExtraBody: m.ExtraBody,
Enabled: m.Enabled, Enabled: m.Enabled,
Configured: configured[i], Available: modelStatuses[i].Available,
Status: modelStatuses[i].Status,
IsDefault: m.ModelName == defaultModel, IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(), IsVirtual: m.IsVirtual(),
}) })

View file

@ -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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
resetOAuthHooks(t) resetOAuthHooks(t)
@ -113,25 +113,42 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
t.Fatalf("Unmarshal() error = %v", err) 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 { for _, model := range resp.Models {
got[model.ModelName] = model.Configured gotAvailable[model.ModelName] = model.Available
gotStatus[model.ModelName] = model.Status
} }
if got["openai-oauth"] { if gotAvailable["openai-oauth"] {
t.Fatalf("openai oauth model configured = true, want false without stored credential") t.Fatalf("openai oauth model available = true, want false without stored credential")
} }
if !got["vllm-local"] { if !gotAvailable["vllm-local"] {
t.Fatalf("vllm local model configured = false, want true when local probe succeeds") t.Fatalf("vllm local model available = false, want true when local probe succeeds")
} }
if !got["ollama-default"] { if !gotAvailable["ollama-default"] {
t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") t.Fatalf("ollama default model available = false, want true when default local probe succeeds")
} }
if !got["vllm-remote"] { if !gotAvailable["vllm-remote"] {
t.Fatalf("remote vllm model configured = false, want true with api_key") t.Fatalf("remote vllm model available = false, want true with api_key")
} }
if !got["copilot-gpt-5.4"] { if !gotAvailable["copilot-gpt-5.4"] {
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") 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"] != modelStatusConfigured {
t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusConfigured)
}
if gotStatus["ollama-default"] != modelStatusConfigured {
t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusConfigured)
}
if gotStatus["vllm-remote"] != modelStatusConfigured {
t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusConfigured)
}
if gotStatus["copilot-gpt-5.4"] != modelStatusConfigured {
t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusConfigured)
} }
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" { 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) 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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
resetOAuthHooks(t) resetOAuthHooks(t)
@ -193,8 +210,8 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
if len(resp.Models) != 1 { if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models)) t.Fatalf("len(models) = %d, want 1", len(resp.Models))
} }
if !resp.Models[0].Configured { if !resp.Models[0].Available {
t.Fatalf("oauth model configured = false, want true with stored credential") 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 { if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models)) t.Fatalf("len(models) = %d, want 1", len(resp.Models))
} }
if !resp.Models[0].Configured { if !resp.Models[0].Available {
t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") 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|" { 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|") 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) { func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -20,7 +20,8 @@ export interface ModelInfo {
thinking_level?: string thinking_level?: string
extra_body?: Record<string, unknown> extra_body?: Record<string, unknown>
// Meta // Meta
configured: boolean available: boolean
status?: "configured" | "unconfigured" | "unreachable"
is_default: boolean is_default: boolean
is_virtual: boolean is_virtual: boolean
} }

View file

@ -10,19 +10,19 @@ import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
interface ChatEmptyStateProps { interface ChatEmptyStateProps {
hasConfiguredModels: boolean hasAvailableModels: boolean
defaultModelName: string defaultModelName: string
isConnected: boolean isConnected: boolean
} }
export function ChatEmptyState({ export function ChatEmptyState({
hasConfiguredModels, hasAvailableModels,
defaultModelName, defaultModelName,
isConnected, isConnected,
}: ChatEmptyStateProps) { }: ChatEmptyStateProps) {
const { t } = useTranslation() const { t } = useTranslation()
if (!hasConfiguredModels) { if (!hasAvailableModels) {
return ( return (
<div className="flex flex-col items-center justify-center py-20 opacity-70"> <div className="flex flex-col items-center justify-center py-20 opacity-70">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500"> <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">

View file

@ -39,7 +39,7 @@ export function ChatPage() {
const { const {
defaultModelName, defaultModelName,
hasConfiguredModels, hasAvailableModels,
apiKeyModels, apiKeyModels,
oauthModels, oauthModels,
localModels, localModels,
@ -94,7 +94,7 @@ export function ChatPage() {
hasScrolled ? "shadow-sm" : "shadow-none" hasScrolled ? "shadow-sm" : "shadow-none"
}`} }`}
titleExtra={ titleExtra={
hasConfiguredModels && ( hasAvailableModels && (
<ModelSelector <ModelSelector
defaultModelName={defaultModelName} defaultModelName={defaultModelName}
apiKeyModels={apiKeyModels} apiKeyModels={apiKeyModels}
@ -140,7 +140,7 @@ export function ChatPage() {
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8"> <div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && ( {messages.length === 0 && !isTyping && (
<ChatEmptyState <ChatEmptyState
hasConfiguredModels={hasConfiguredModels} hasAvailableModels={hasAvailableModels}
defaultModelName={defaultModelName} defaultModelName={defaultModelName}
isConnected={isGatewayRunning} isConnected={isGatewayRunning}
/> />

View file

@ -133,7 +133,7 @@ export function EditModelSheet({
} }
const isOAuth = model?.auth_method === "oauth" const isOAuth = model?.auth_method === "oauth"
const apiKeyPlaceholder = model?.configured const apiKeyPlaceholder = model?.available
? maskedSecretPlaceholder( ? maskedSecretPlaceholder(
model.api_key, model.api_key,
t("models.field.apiKeyPlaceholderSet"), t("models.field.apiKeyPlaceholderSet"),
@ -161,7 +161,7 @@ export function EditModelSheet({
<Field <Field
label={t("models.field.apiKey")} label={t("models.field.apiKey")}
hint={ hint={
model?.configured ? t("models.edit.apiKeyHint") : undefined model?.available ? t("models.edit.apiKeyHint") : undefined
} }
> >
<KeyInput <KeyInput

View file

@ -28,14 +28,16 @@ export function ModelCard({
}: ModelCardProps) { }: ModelCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth" const isOAuth = model.auth_method === "oauth"
const status = model.status ?? (model.available ? "configured" : "unconfigured")
const statusLabel = t(`models.status.${status}`)
const canSetDefault = const canSetDefault =
model.configured && !model.is_default && !model.is_virtual model.available && !model.is_default && !model.is_virtual
return ( return (
<div <div
className={[ className={[
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs", "group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
model.configured model.available
? "border-border/60 bg-card" ? "border-border/60 bg-card"
: "border-border/50 bg-card/60", : "border-border/50 bg-card/60",
].join(" ")} ].join(" ")}
@ -47,15 +49,13 @@ export function ModelCard({
"mt-0.5 h-2 w-2 shrink-0 rounded-full", "mt-0.5 h-2 w-2 shrink-0 rounded-full",
model.is_default model.is_default
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]" ? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
: model.configured : status === "configured"
? "bg-green-500" ? "bg-green-500"
: status === "unreachable"
? "bg-amber-500"
: "bg-muted-foreground/25", : "bg-muted-foreground/25",
].join(" ")} ].join(" ")}
title={ title={statusLabel}
model.configured
? t("models.status.configured")
: t("models.status.unconfigured")
}
/> />
<span className="text-foreground truncate text-sm font-semibold"> <span className="text-foreground truncate text-sm font-semibold">
{model.model_name} {model.model_name}
@ -127,14 +127,14 @@ export function ModelCard({
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium"> <span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
OAuth OAuth
</span> </span>
) : model.configured && model.api_key ? ( ) : model.api_key ? (
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]"> <span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
<IconKey className="size-3" /> <IconKey className="size-3" />
{model.api_key} {model.api_key}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground/50 text-[11px]"> <span className="text-muted-foreground/50 text-[11px]">
{t("models.status.unconfigured")} {statusLabel}
</span> </span>
)} )}
</div> </div>

View file

@ -40,7 +40,7 @@ interface ProviderGroup {
label: string label: string
models: ModelInfo[] models: ModelInfo[]
hasDefault: boolean hasDefault: boolean
configuredCount: number availableCount: number
} }
export function ModelsPage() { export function ModelsPage() {
@ -62,8 +62,8 @@ export function ModelsPage() {
const sorted = [...data.models].sort((a, b) => { 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.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.available && !b.available) return -1
if (!a.configured && b.configured) return 1 if (!a.available && b.available) return 1
return a.model_name.localeCompare(b.model_name) return a.model_name.localeCompare(b.model_name)
}) })
setModels(sorted) setModels(sorted)
@ -107,23 +107,23 @@ export function ModelsPage() {
const providerGroups: ProviderGroup[] = Object.entries(grouped) const providerGroups: ProviderGroup[] = Object.entries(grouped)
.map(([key, group]) => { .map(([key, group]) => {
const configuredCount = group.models.filter( const availableCount = group.models.filter(
(model) => model.configured, (model) => model.available,
).length ).length
return { return {
key, key,
label: group.label, label: group.label,
models: group.models, models: group.models,
hasDefault: group.models.some((model) => model.is_default), hasDefault: group.models.some((model) => model.is_default),
configuredCount, availableCount,
} }
}) })
.sort((a, b) => { .sort((a, b) => {
if (a.hasDefault && !b.hasDefault) return -1 if (a.hasDefault && !b.hasDefault) return -1
if (!a.hasDefault && b.hasDefault) return 1 if (!a.hasDefault && b.hasDefault) return 1
if (a.configuredCount !== b.configuredCount) { if (a.availableCount !== b.availableCount) {
return b.configuredCount - a.configuredCount return b.availableCount - a.availableCount
} }
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER

View file

@ -65,32 +65,32 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
[defaultModelName], [defaultModelName],
) )
const hasConfiguredModels = useMemo( const hasAvailableModels = useMemo(
() => modelList.some((m) => m.configured), () => modelList.some((m) => m.available),
[modelList], [modelList],
) )
const oauthModels = useMemo( const oauthModels = useMemo(
() => modelList.filter((m) => m.configured && m.auth_method === "oauth"), () => modelList.filter((m) => m.available && m.auth_method === "oauth"),
[modelList], [modelList],
) )
const localModels = useMemo( const localModels = useMemo(
() => modelList.filter((m) => m.configured && isLocalModel(m)), () => modelList.filter((m) => m.available && isLocalModel(m)),
[modelList], [modelList],
) )
const apiKeyModels = useMemo( const apiKeyModels = useMemo(
() => () =>
modelList.filter( modelList.filter(
(m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m), (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
), ),
[modelList], [modelList],
) )
return { return {
defaultModelName, defaultModelName,
hasConfiguredModels, hasAvailableModels,
apiKeyModels, apiKeyModels,
oauthModels, oauthModels,
localModels, localModels,

View file

@ -171,7 +171,8 @@
"noDefaultHintSuffix": "to set one.", "noDefaultHintSuffix": "to set one.",
"status": { "status": {
"configured": "Configured", "configured": "Configured",
"unconfigured": "Not configured" "unconfigured": "Not configured",
"unreachable": "Service unreachable"
}, },
"badge": { "badge": {
"default": "Default", "default": "Default",

View file

@ -171,7 +171,8 @@
"noDefaultHintSuffix": "设为默认。", "noDefaultHintSuffix": "设为默认。",
"status": { "status": {
"configured": "已配置", "configured": "已配置",
"unconfigured": "未配置" "unconfigured": "未配置",
"unreachable": "服务不可达"
}, },
"badge": { "badge": {
"default": "默认", "default": "默认",