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) {