From 9a0edf0ae399b395bf0dce31df99fcd8e4e83c1c Mon Sep 17 00:00:00 2001
From: SiYue-ZO <2835601846@qq.com>
Date: Thu, 7 May 2026 14:10:54 +0800
Subject: [PATCH] fix(web,api): fix edit test connection false negative and
gate fetch for unsupported providers
- handleTestInlineModel now accepts optional model_index to fall back to stored credentials when api_key is empty, fixing false negatives when testing edited models
- Add supportsFetch to provider registry and FETCHABLE_PROVIDER_KEYS derived set
- Gate Fetch Models button to only show for OpenAI-compatible and Ollama providers
- Add backend guard in handleFetchModels to reject unsupported providers with clear error
---
web/backend/api/models.go | 33 +++++++++++++++++++
web/frontend/src/api/models.ts | 1 +
.../src/components/models/add-model-sheet.tsx | 23 ++++++-------
.../components/models/edit-model-sheet.tsx | 24 +++++++-------
.../components/models/provider-registry.ts | 32 ++++++++++++++++++
.../components/models/test-model-dialog.tsx | 2 ++
6 files changed, 93 insertions(+), 22 deletions(-)
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index c3640b030..95ec47a6b 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -17,6 +17,18 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
+// fetchableProviders lists providers that support OpenAI-compatible /models listing.
+var fetchableProviders = map[string]bool{
+ "openai": true, "deepseek": true, "openrouter": true,
+ "qwen-portal": true, "qwen-intl": true, "moonshot": true,
+ "volcengine": true, "zhipu": true, "groq": true,
+ "mistral": true, "nvidia": true, "cerebras": true,
+ "venice": true, "shengsuanyun": true, "vivgrid": true,
+ "minimax": true, "longcat": true, "modelscope": true,
+ "mimo": true, "avian": true, "zai": true, "novita": true,
+ "litellm": true, "vllm": true, "lmstudio": true, "ollama": true,
+}
+
// registerModelRoutes binds model list management endpoints to the ServeMux.
func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/models", h.handleListModels)
@@ -684,6 +696,7 @@ func (h *Handler) handleTestInlineModel(w http.ResponseWriter, r *http.Request)
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
AuthMethod string `json:"auth_method"`
+ ModelIndex *int `json:"model_index"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
@@ -700,6 +713,21 @@ func (h *Handler) handleTestInlineModel(w http.ResponseWriter, r *http.Request)
m.SetAPIKey(req.APIKey)
}
+ // When api_key is empty and model_index is provided, fall back to stored credentials.
+ // This lets the edit form test unsaved field changes while using the saved key.
+ if req.APIKey == "" && req.ModelIndex != nil {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err == nil && *req.ModelIndex >= 0 && *req.ModelIndex < len(cfg.ModelList) {
+ stored := cfg.ModelList[*req.ModelIndex]
+ if stored.APIKey() != "" {
+ m.SetAPIKey(stored.APIKey())
+ }
+ if m.APIBase == "" && stored.APIBase != "" {
+ m.APIBase = stored.APIBase
+ }
+ }
+ }
+
// Check if configuration exists
if !hasModelConfiguration(m) {
w.Header().Set("Content-Type", "application/json")
@@ -784,6 +812,11 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) {
return
}
+ if !fetchableProviders[strings.ToLower(req.Provider)] {
+ http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), http.StatusBadRequest)
+ return
+ }
+
apiBase := strings.TrimSpace(req.APIBase)
if apiBase == "" {
apiBase = providers.DefaultAPIBaseForProtocol(req.Provider)
diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts
index 1ed9c29e0..9fd29e0fd 100644
--- a/web/frontend/src/api/models.ts
+++ b/web/frontend/src/api/models.ts
@@ -133,6 +133,7 @@ export interface TestModelInlineRequest {
api_base?: string
api_key?: string
auth_method?: string
+ model_index?: number
}
export async function testModelInline(
diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx
index e20443601..dbd41b534 100644
--- a/web/frontend/src/components/models/add-model-sheet.tsx
+++ b/web/frontend/src/components/models/add-model-sheet.tsx
@@ -34,7 +34,7 @@ import { FetchModelsDialog } from "./fetch-models-dialog"
import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label"
-import { PROVIDER_MAP } from "./provider-registry"
+import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface AddForm {
@@ -484,16 +484,17 @@ export function AddModelSheet({
)}
-
+ {form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
+
+ )}
{!form.provider && (
{t("models.field.selectProviderFirst")}
diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx
index 755ea6678..d76bfc9fe 100644
--- a/web/frontend/src/components/models/edit-model-sheet.tsx
+++ b/web/frontend/src/components/models/edit-model-sheet.tsx
@@ -39,7 +39,7 @@ import { FetchModelsDialog } from "./fetch-models-dialog"
import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label"
-import { PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry"
+import { FETCHABLE_PROVIDER_KEYS, PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface EditForm {
@@ -441,16 +441,17 @@ export function EditModelSheet({
)}
-
+ {form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
+
+ )}
@@ -671,6 +672,7 @@ export function EditModelSheet({
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
+ modelIndex: model?.index,
}}
/>
diff --git a/web/frontend/src/components/models/provider-registry.ts b/web/frontend/src/components/models/provider-registry.ts
index 884a0af45..f24c094e4 100644
--- a/web/frontend/src/components/models/provider-registry.ts
+++ b/web/frontend/src/components/models/provider-registry.ts
@@ -16,6 +16,8 @@ export interface ProviderDefinition {
priority: number
commonModels?: string[]
aliases?: string[]
+ /** Whether this provider supports the OpenAI-compatible /models listing endpoint. */
+ supportsFetch?: boolean
}
export const PROVIDERS: ProviderDefinition[] = [
@@ -30,6 +32,7 @@ export const PROVIDERS: ProviderDefinition[] = [
priority: 100,
commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"],
aliases: ["gpt"],
+ supportsFetch: true,
},
{
key: "anthropic",
@@ -69,6 +72,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 85,
commonModels: ["deepseek-chat", "deepseek-reasoner"],
+ supportsFetch: true,
},
{
key: "openrouter",
@@ -84,6 +88,7 @@ export const PROVIDERS: ProviderDefinition[] = [
"anthropic/claude-sonnet-4",
"google/gemini-2.0-flash",
],
+ supportsFetch: true,
},
{
key: "qwen-portal",
@@ -97,6 +102,7 @@ export const PROVIDERS: ProviderDefinition[] = [
priority: 75,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen"],
+ supportsFetch: true,
},
{
key: "qwen-intl",
@@ -109,6 +115,7 @@ export const PROVIDERS: ProviderDefinition[] = [
priority: 74,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen-international", "dashscope-intl"],
+ supportsFetch: true,
},
{
key: "moonshot",
@@ -120,6 +127,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 70,
commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"],
+ supportsFetch: true,
},
{
key: "volcengine",
@@ -132,6 +140,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 69,
commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"],
+ supportsFetch: true,
},
{
key: "zhipu",
@@ -144,6 +153,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 68,
commonModels: ["glm-4-plus", "glm-4-flash"],
+ supportsFetch: true,
},
{
key: "groq",
@@ -155,6 +165,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 65,
commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
+ supportsFetch: true,
},
{
key: "mistral",
@@ -166,6 +177,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 64,
commonModels: ["mistral-large-latest", "mistral-small-latest"],
+ supportsFetch: true,
},
{
key: "nvidia",
@@ -177,6 +189,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 63,
commonModels: ["meta/llama-3.1-405b-instruct"],
+ supportsFetch: true,
},
{
key: "cerebras",
@@ -188,6 +201,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 62,
commonModels: ["llama3.1-8b", "llama3.1-70b"],
+ supportsFetch: true,
},
{
key: "azure",
@@ -227,6 +241,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: true,
priority: 50,
commonModels: ["llama3", "mistral", "codellama", "qwen2.5"],
+ supportsFetch: true,
},
{
key: "vllm",
@@ -237,6 +252,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: false,
isLocal: true,
priority: 49,
+ supportsFetch: true,
},
{
key: "lmstudio",
@@ -247,6 +263,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: false,
isLocal: true,
priority: 48,
+ supportsFetch: true,
},
{
key: "venice",
@@ -257,6 +274,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 45,
+ supportsFetch: true,
},
{
key: "shengsuanyun",
@@ -267,6 +285,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 44,
+ supportsFetch: true,
},
{
key: "vivgrid",
@@ -276,6 +295,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 43,
+ supportsFetch: true,
},
{
key: "minimax",
@@ -285,6 +305,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 42,
+ supportsFetch: true,
},
{
key: "longcat",
@@ -294,6 +315,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 41,
+ supportsFetch: true,
},
{
key: "modelscope",
@@ -304,6 +326,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 40,
+ supportsFetch: true,
},
{
key: "mimo",
@@ -314,6 +337,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 39,
+ supportsFetch: true,
},
{
key: "avian",
@@ -323,6 +347,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 38,
+ supportsFetch: true,
},
{
key: "zai",
@@ -333,6 +358,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false,
priority: 37,
aliases: ["z.ai", "z-ai"],
+ supportsFetch: true,
},
{
key: "novita",
@@ -342,6 +368,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 36,
+ supportsFetch: true,
},
{
key: "litellm",
@@ -351,6 +378,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true,
isLocal: false,
priority: 35,
+ supportsFetch: true,
},
]
@@ -368,6 +396,10 @@ export const PROVIDER_ALIASES: Record = Object.fromEntries(
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key))
+export const FETCHABLE_PROVIDER_KEYS = new Set(
+ PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key),
+)
+
export const PROVIDER_ICON_SLUGS: Record = Object.fromEntries(
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
)
diff --git a/web/frontend/src/components/models/test-model-dialog.tsx b/web/frontend/src/components/models/test-model-dialog.tsx
index 51b322356..d756a1cf1 100644
--- a/web/frontend/src/components/models/test-model-dialog.tsx
+++ b/web/frontend/src/components/models/test-model-dialog.tsx
@@ -24,6 +24,7 @@ export interface TestInlineParams {
apiBase: string
apiKey: string
authMethod: string
+ modelIndex?: number
}
interface TestModelDialogProps {
@@ -62,6 +63,7 @@ export function TestModelDialog({
api_base: inlineParams.apiBase || undefined,
api_key: inlineParams.apiKey || undefined,
auth_method: inlineParams.authMethod || undefined,
+ model_index: inlineParams.modelIndex,
}
res = await testModelInline(req)
} else if (model) {