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
This commit is contained in:
SiYue-ZO 2026-05-07 14:10:54 +08:00
parent 249fa93e95
commit 9a0edf0ae3
6 changed files with 93 additions and 22 deletions

View file

@ -17,6 +17,18 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "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. // registerModelRoutes binds model list management endpoints to the ServeMux.
func (h *Handler) registerModelRoutes(mux *http.ServeMux) { func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/models", h.handleListModels) 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"` APIBase string `json:"api_base"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
AuthMethod string `json:"auth_method"` AuthMethod string `json:"auth_method"`
ModelIndex *int `json:"model_index"`
} }
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest) 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) 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 // Check if configuration exists
if !hasModelConfiguration(m) { if !hasModelConfiguration(m) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -784,6 +812,11 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) {
return 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) apiBase := strings.TrimSpace(req.APIBase)
if apiBase == "" { if apiBase == "" {
apiBase = providers.DefaultAPIBaseForProtocol(req.Provider) apiBase = providers.DefaultAPIBaseForProtocol(req.Provider)

View file

@ -133,6 +133,7 @@ export interface TestModelInlineRequest {
api_base?: string api_base?: string
api_key?: string api_key?: string
auth_method?: string auth_method?: string
model_index?: number
} }
export async function testModelInline( export async function testModelInline(

View file

@ -34,7 +34,7 @@ import { FetchModelsDialog } from "./fetch-models-dialog"
import { type FieldValidation, validateModelField } from "./model-validation" import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox" import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label" 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" import { TestModelDialog } from "./test-model-dialog"
interface AddForm { interface AddForm {
@ -484,16 +484,17 @@ export function AddModelSheet({
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setFetchOpen(true)} onClick={() => setFetchOpen(true)}
disabled={!form.provider}
> >
<IconDownload className="size-3" /> <IconDownload className="size-3" />
{t("models.fetch.title")} {t("models.fetch.title")}
</Button> </Button>
)}
{!form.provider && ( {!form.provider && (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")} {t("models.field.selectProviderFirst")}

View file

@ -39,7 +39,7 @@ import { FetchModelsDialog } from "./fetch-models-dialog"
import { type FieldValidation, validateModelField } from "./model-validation" import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox" import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label" 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" import { TestModelDialog } from "./test-model-dialog"
interface EditForm { interface EditForm {
@ -441,16 +441,17 @@ export function EditModelSheet({
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setFetchOpen(true)} onClick={() => setFetchOpen(true)}
disabled={!form.provider}
> >
<IconDownload className="size-3" /> <IconDownload className="size-3" />
{t("models.fetch.title")} {t("models.fetch.title")}
</Button> </Button>
)}
</div> </div>
</Field> </Field>
@ -671,6 +672,7 @@ export function EditModelSheet({
apiBase: form.apiBase, apiBase: form.apiBase,
apiKey: form.apiKey, apiKey: form.apiKey,
authMethod: form.authMethod, authMethod: form.authMethod,
modelIndex: model?.index,
}} }}
/> />

View file

@ -16,6 +16,8 @@ export interface ProviderDefinition {
priority: number priority: number
commonModels?: string[] commonModels?: string[]
aliases?: string[] aliases?: string[]
/** Whether this provider supports the OpenAI-compatible /models listing endpoint. */
supportsFetch?: boolean
} }
export const PROVIDERS: ProviderDefinition[] = [ export const PROVIDERS: ProviderDefinition[] = [
@ -30,6 +32,7 @@ export const PROVIDERS: ProviderDefinition[] = [
priority: 100, priority: 100,
commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"], commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"],
aliases: ["gpt"], aliases: ["gpt"],
supportsFetch: true,
}, },
{ {
key: "anthropic", key: "anthropic",
@ -69,6 +72,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 85, priority: 85,
commonModels: ["deepseek-chat", "deepseek-reasoner"], commonModels: ["deepseek-chat", "deepseek-reasoner"],
supportsFetch: true,
}, },
{ {
key: "openrouter", key: "openrouter",
@ -84,6 +88,7 @@ export const PROVIDERS: ProviderDefinition[] = [
"anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4",
"google/gemini-2.0-flash", "google/gemini-2.0-flash",
], ],
supportsFetch: true,
}, },
{ {
key: "qwen-portal", key: "qwen-portal",
@ -97,6 +102,7 @@ export const PROVIDERS: ProviderDefinition[] = [
priority: 75, priority: 75,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"], commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen"], aliases: ["qwen"],
supportsFetch: true,
}, },
{ {
key: "qwen-intl", key: "qwen-intl",
@ -109,6 +115,7 @@ export const PROVIDERS: ProviderDefinition[] = [
priority: 74, priority: 74,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"], commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen-international", "dashscope-intl"], aliases: ["qwen-international", "dashscope-intl"],
supportsFetch: true,
}, },
{ {
key: "moonshot", key: "moonshot",
@ -120,6 +127,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 70, priority: 70,
commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"], commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"],
supportsFetch: true,
}, },
{ {
key: "volcengine", key: "volcengine",
@ -132,6 +140,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 69, priority: 69,
commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"], commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"],
supportsFetch: true,
}, },
{ {
key: "zhipu", key: "zhipu",
@ -144,6 +153,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 68, priority: 68,
commonModels: ["glm-4-plus", "glm-4-flash"], commonModels: ["glm-4-plus", "glm-4-flash"],
supportsFetch: true,
}, },
{ {
key: "groq", key: "groq",
@ -155,6 +165,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 65, priority: 65,
commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"], commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
supportsFetch: true,
}, },
{ {
key: "mistral", key: "mistral",
@ -166,6 +177,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 64, priority: 64,
commonModels: ["mistral-large-latest", "mistral-small-latest"], commonModels: ["mistral-large-latest", "mistral-small-latest"],
supportsFetch: true,
}, },
{ {
key: "nvidia", key: "nvidia",
@ -177,6 +189,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 63, priority: 63,
commonModels: ["meta/llama-3.1-405b-instruct"], commonModels: ["meta/llama-3.1-405b-instruct"],
supportsFetch: true,
}, },
{ {
key: "cerebras", key: "cerebras",
@ -188,6 +201,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 62, priority: 62,
commonModels: ["llama3.1-8b", "llama3.1-70b"], commonModels: ["llama3.1-8b", "llama3.1-70b"],
supportsFetch: true,
}, },
{ {
key: "azure", key: "azure",
@ -227,6 +241,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: true, isLocal: true,
priority: 50, priority: 50,
commonModels: ["llama3", "mistral", "codellama", "qwen2.5"], commonModels: ["llama3", "mistral", "codellama", "qwen2.5"],
supportsFetch: true,
}, },
{ {
key: "vllm", key: "vllm",
@ -237,6 +252,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: false, requiresApiKey: false,
isLocal: true, isLocal: true,
priority: 49, priority: 49,
supportsFetch: true,
}, },
{ {
key: "lmstudio", key: "lmstudio",
@ -247,6 +263,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: false, requiresApiKey: false,
isLocal: true, isLocal: true,
priority: 48, priority: 48,
supportsFetch: true,
}, },
{ {
key: "venice", key: "venice",
@ -257,6 +274,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 45, priority: 45,
supportsFetch: true,
}, },
{ {
key: "shengsuanyun", key: "shengsuanyun",
@ -267,6 +285,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 44, priority: 44,
supportsFetch: true,
}, },
{ {
key: "vivgrid", key: "vivgrid",
@ -276,6 +295,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 43, priority: 43,
supportsFetch: true,
}, },
{ {
key: "minimax", key: "minimax",
@ -285,6 +305,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 42, priority: 42,
supportsFetch: true,
}, },
{ {
key: "longcat", key: "longcat",
@ -294,6 +315,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 41, priority: 41,
supportsFetch: true,
}, },
{ {
key: "modelscope", key: "modelscope",
@ -304,6 +326,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 40, priority: 40,
supportsFetch: true,
}, },
{ {
key: "mimo", key: "mimo",
@ -314,6 +337,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 39, priority: 39,
supportsFetch: true,
}, },
{ {
key: "avian", key: "avian",
@ -323,6 +347,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 38, priority: 38,
supportsFetch: true,
}, },
{ {
key: "zai", key: "zai",
@ -333,6 +358,7 @@ export const PROVIDERS: ProviderDefinition[] = [
isLocal: false, isLocal: false,
priority: 37, priority: 37,
aliases: ["z.ai", "z-ai"], aliases: ["z.ai", "z-ai"],
supportsFetch: true,
}, },
{ {
key: "novita", key: "novita",
@ -342,6 +368,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 36, priority: 36,
supportsFetch: true,
}, },
{ {
key: "litellm", key: "litellm",
@ -351,6 +378,7 @@ export const PROVIDERS: ProviderDefinition[] = [
requiresApiKey: true, requiresApiKey: true,
isLocal: false, isLocal: false,
priority: 35, priority: 35,
supportsFetch: true,
}, },
] ]
@ -368,6 +396,10 @@ export const PROVIDER_ALIASES: Record<string, string> = Object.fromEntries(
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key)) 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<string, string> = Object.fromEntries( export const PROVIDER_ICON_SLUGS: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]), PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
) )

View file

@ -24,6 +24,7 @@ export interface TestInlineParams {
apiBase: string apiBase: string
apiKey: string apiKey: string
authMethod: string authMethod: string
modelIndex?: number
} }
interface TestModelDialogProps { interface TestModelDialogProps {
@ -62,6 +63,7 @@ export function TestModelDialog({
api_base: inlineParams.apiBase || undefined, api_base: inlineParams.apiBase || undefined,
api_key: inlineParams.apiKey || undefined, api_key: inlineParams.apiKey || undefined,
auth_method: inlineParams.authMethod || undefined, auth_method: inlineParams.authMethod || undefined,
model_index: inlineParams.modelIndex,
} }
res = await testModelInline(req) res = await testModelInline(req)
} else if (model) { } else if (model) {