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"
)
// 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)

View file

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

View file

@ -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({
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
disabled={!form.provider}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
{!form.provider && (
<span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")}

View file

@ -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({
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
disabled={!form.provider}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
</div>
</Field>
@ -671,6 +672,7 @@ export function EditModelSheet({
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
modelIndex: model?.index,
}}
/>

View file

@ -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<string, string> = 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<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
)

View file

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