diff --git a/pkg/config/config.go b/pkg/config/config.go index 9da461b7f..0a347e3db 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -747,6 +747,21 @@ func (c *ModelConfig) Validate() error { if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil { return err } + + // Reject whitespace in model identifier + if strings.ContainsAny(c.Model, " \t\n\r") { + return fmt.Errorf("model identifier contains whitespace") + } + + // Reject leading slash + if strings.HasPrefix(c.Model, "/") { + return fmt.Errorf("model identifier must not start with /") + } + + // Reject consecutive slashes + if strings.Contains(c.Model, "//") { + return fmt.Errorf("model identifier must not contain //") + } return nil } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index aa99d6d38..e9e0e6e98 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -15,6 +15,7 @@ import ( anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" "github.com/sipeed/picoclaw/pkg/providers/bedrock" + "github.com/sipeed/picoclaw/pkg/providers/common" ) type protocolMeta struct { @@ -60,6 +61,8 @@ var protocolMetaByName = map[string]protocolMeta{ "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, + "anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"}, + "anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"}, } // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -318,10 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) } // Use API key with HTTP API - apiBase := cfg.APIBase - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } + apiBase := common.NormalizeBaseURL(cfg.APIBase, "https://api.anthropic.com/v1", true) if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 302231d80..6cfda501d 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -434,8 +434,11 @@ func modelProbeAPIBase(m *config.ModelConfig) string { } protocol := modelProtocol(m) - if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { - return providers.DefaultAPIBaseForProtocol(protocol) + + // Resolve the default API base for any known protocol so that probes + // work even when the config stores only a provider without an explicit api_base. + if defaultBase := providers.DefaultAPIBaseForProtocol(protocol); defaultBase != "" { + return normalizeModelProbeAPIBase(defaultBase) } switch protocol { diff --git a/web/frontend/package.json b/web/frontend/package.json index 1b6821c33..8e53fb850 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", + "@radix-ui/react-popover": "^1.1.15", "@tabler/icons-react": "^3.43.0", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.0", @@ -25,6 +26,7 @@ "@tanstack/react-router-devtools": "^1.166.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dayjs": "^1.11.20", "highlight.js": "^11.11.1", "i18next": "^26.0.10", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 8bcd65944..3ff74a4be 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tabler/icons-react': specifier: ^3.43.0 version: 3.43.0(react@19.2.5) @@ -32,6 +35,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) dayjs: specifier: ^1.11.20 version: 1.11.20 @@ -2038,6 +2044,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -5976,6 +5988,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + code-block-writer@13.0.3: {} color-convert@2.0.1: diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 5bb275fde..9fd29e0fd 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -23,6 +23,7 @@ export interface ModelInfo { extra_body?: Record custom_headers?: Record // Meta + enabled: boolean available: boolean status: "available" | "unconfigured" | "unreachable" is_default: boolean @@ -58,7 +59,13 @@ const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { - throw new Error(`API error: ${res.status} ${res.statusText}`) + let detail = "" + try { + detail = await res.text() + } catch { + // ignore + } + throw new Error(detail || `API error: ${res.status} ${res.statusText}`) } return res.json() as Promise } @@ -107,4 +114,97 @@ export async function setDefaultModel( return response } +export interface TestModelResponse { + success: boolean + latency_ms: number + status: string + error?: string +} + +export async function testModel(index: number): Promise { + return request(`/api/models/${index}/test`, { + method: "POST", + }) +} + +export interface TestModelInlineRequest { + provider: string + model: string + api_base?: string + api_key?: string + auth_method?: string + model_index?: number +} + +export async function testModelInline( + params: TestModelInlineRequest, +): Promise { + return request("/api/models/test-inline", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }) +} + +export interface UpstreamModel { + id: string + owned_by?: string +} + +export interface FetchModelsRequest { + provider: string + api_key?: string + api_base?: string +} + +export interface FetchModelsResponse { + models: UpstreamModel[] + total: number +} + +export async function fetchUpstreamModels( + req: FetchModelsRequest, +): Promise { + return request("/api/models/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }) +} + +// --- Model Catalog API --- + +export interface CatalogModel { + id: string + owned_by?: string + extra?: Record +} + +export interface CatalogEntry { + id: string + provider: string + api_base: string + api_key_mask: string + models: CatalogModel[] + fetched_at: string +} + +interface CatalogListResponse { + entries: CatalogEntry[] + total: number +} + +export async function getCatalogs(): Promise { + return request("/api/models/catalog") +} + +export async function deleteCatalog(id: string): Promise { + await request>( + `/api/models/catalog/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ) +} + export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx index f3c8004b5..e8c81408e 100644 --- a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx +++ b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx @@ -66,10 +66,7 @@ export function WebSearchGeneralSettings({
{contextUsage && ( - + )} {canInput ? ( diff --git a/web/frontend/src/components/chat/context-usage-ring.tsx b/web/frontend/src/components/chat/context-usage-ring.tsx index 4a32e617b..037a20cef 100644 --- a/web/frontend/src/components/chat/context-usage-ring.tsx +++ b/web/frontend/src/components/chat/context-usage-ring.tsx @@ -127,7 +127,7 @@ export function ContextUsageRing({ : "pointer-events-none scale-95 opacity-0" }`} > -
+
diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index e0f51596a..06626441d 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,10 +1,15 @@ -import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useMemo, useState } from "react" +import { + IconDownload, + IconLoader2, + IconPlugConnected, +} from "@tabler/icons-react" +import { type ComponentType, useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { type ModelProviderOption, addModel, + getCatalogs, setDefaultModel, } from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" @@ -15,15 +20,9 @@ import { KeyInput, SwitchCardField, } from "@/components/shared-form" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { Sheet, SheetContent, @@ -36,14 +35,10 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" -import { - findProviderOption, - getProviderDefaultAPIBase, - getProviderDefaultAuthMethod, - getProviderLabel, - getSortedProviderOptions, - isProviderAuthMethodLocked, -} from "./provider-label" +import { type FieldValidation, validateModelField } from "./model-validation" +import { ProviderCombobox } from "./provider-combobox" +import { getProviderKey } from "./provider-label" +import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" interface AddForm { modelName: string @@ -66,7 +61,7 @@ interface AddForm { const EMPTY_ADD_FORM: AddForm = { modelName: "", - provider: "openai", + provider: "", model: "", apiBase: "", apiKey: "", @@ -83,12 +78,43 @@ const EMPTY_ADD_FORM: AddForm = { customHeaders: "", } +function normalizeApiBase(value: string): string { + return value.trim().replace(/\/+$/, "") +} + +function getNextApiBaseForProviderChange( + currentApiBase: string, + currentProvider: string, + nextProvider: string, +): string { + const normalizedCurrentApiBase = normalizeApiBase(currentApiBase) + const currentDefaultApiBase = normalizeApiBase( + PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "", + ) + const nextDefaultApiBase = + PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? "" + + if (!normalizedCurrentApiBase) { + return nextDefaultApiBase + } + + if ( + normalizedCurrentApiBase && + currentDefaultApiBase && + normalizedCurrentApiBase === currentDefaultApiBase + ) { + return nextDefaultApiBase + } + + return currentApiBase +} + interface AddModelSheetProps { open: boolean onClose: () => void onSaved: () => void existingModelNames: string[] - providerOptions: ModelProviderOption[] + providerOptions?: ModelProviderOption[] } export function AddModelSheet({ @@ -106,41 +132,33 @@ export function AddModelSheet({ Partial> >({}) const [serverError, setServerError] = useState("") + const [modelValidation, setModelValidation] = + useState(null) + const [fetchOpen, setFetchOpen] = useState(false) + const [testOpen, setTestOpen] = useState(false) + const [fetchedModels, setFetchedModels] = useState([]) + const [catalogModels, setCatalogModels] = useState([]) + const debounceRef = useRef>(undefined) + const scrollContainerRef = useRef(null) + + // Dynamic imports for dialogs added in later PRs + const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState void; onFill: (models: string[]) => void; + provider: string; apiKey: string; apiBase: string; + }> | null>(null) + const [TestModelDialogComp, setTestModelDialogComp] = useState void; + inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string }; + }> | null>(null) + useEffect(() => { + import("./fetch-models-dialog").then((m) => setFetchModelsDialogComp(() => m.FetchModelsDialog)).catch(() => {}) + import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) + }, []) + const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, t("models.field.apiKeyPlaceholder"), ) - const sortedProviderOptions = useMemo( - () => getSortedProviderOptions(providerOptions), - [providerOptions], - ) - const creatableProviderOptions = useMemo( - () => sortedProviderOptions.filter((option) => option.create_allowed), - [sortedProviderOptions], - ) - const selectedProviderOption = findProviderOption( - form.provider, - providerOptions, - ) - const authMethodLocked = isProviderAuthMethodLocked( - form.provider, - providerOptions, - ) - const defaultAuthMethod = getProviderDefaultAuthMethod( - form.provider, - providerOptions, - ) - const effectiveAuthMethod = ( - authMethodLocked ? defaultAuthMethod : form.authMethod - ) - .trim() - .toLowerCase() - const isOAuth = effectiveAuthMethod === "oauth" - const defaultModelAllowed = - selectedProviderOption?.default_model_allowed !== false - const apiBasePlaceholder = - getProviderDefaultAPIBase(form.provider, providerOptions) || - "https://api.example.com/v1" const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault @@ -150,9 +168,39 @@ export function AddModelSheet({ setSetAsDefault(false) setFieldErrors({}) setServerError("") + setModelValidation(null) + setFetchedModels([]) + setCatalogModels([]) } }, [open]) + // Load catalog models when provider or apiBase changes + useEffect(() => { + const providerKey = getProviderKey(form.provider || undefined) + const apiBase = form.apiBase.trim().replace(/\/+$/, "") + if (!form.provider.trim()) { + setCatalogModels([]) + return + } + let cancelled = false + getCatalogs() + .then((res) => { + if (cancelled) return + const matched = (res.entries || []).filter((e) => { + const ep = getProviderKey(e.provider || undefined) + const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") + return ep === providerKey && eb === apiBase + }) + const ids = matched.flatMap((e) => e.models.map((m) => m.id)) + const unique = [...new Set(ids)] + setCatalogModels(unique) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [form.provider, form.apiBase]) + const validate = (): boolean => { const errors: Partial> = {} const modelName = form.modelName.trim() @@ -161,10 +209,13 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } - if (!selectedProviderOption) { - errors.provider = t("models.field.providerInvalid") - } if (!form.model.trim()) errors.model = t("models.add.errorRequired") + if (modelValidation?.level === "error") { + errors.model = t( + modelValidation.messageKey, + modelValidation.messageParams, + ) + } setFieldErrors(errors) return Object.keys(errors).length === 0 } @@ -178,47 +229,125 @@ export function AddModelSheet({ } } - const setProvider = (value: string) => { - setForm((f) => { - const previousOption = findProviderOption(f.provider, providerOptions) - const nextOption = findProviderOption(value, providerOptions) - let authMethod = f.authMethod - if (nextOption?.auth_method_locked) { - authMethod = nextOption.default_auth_method ?? "" - } else if ( - previousOption?.auth_method_locked && - f.authMethod === (previousOption.default_auth_method ?? "") - ) { - authMethod = "" - } - return { ...f, provider: value, authMethod } - }) - const nextOption = findProviderOption(value, providerOptions) - if (nextOption?.default_model_allowed === false) { - setSetAsDefault(false) + const debouncedValidateModel = useCallback( + (value: string, provider: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + const result = validateModelField(value, provider || undefined) + setModelValidation(result) + }, 300) + }, + [], + ) + + const handleModelChange = (e: React.ChangeEvent) => { + const value = e.target.value + setForm((f) => ({ ...f, model: value })) + if (fieldErrors.model) { + setFieldErrors((prev) => ({ ...prev, model: undefined })) } - if (fieldErrors.provider) { - setFieldErrors((prev) => ({ ...prev, provider: undefined })) + debouncedValidateModel(value, form.provider) + } + + const handleProviderChange = (provider: string) => { + setForm((f) => { + return { + ...f, + provider, + apiBase: getNextApiBaseForProviderChange( + f.apiBase, + f.provider, + provider, + ), + } + }) + // Re-validate model with new provider context + if (form.model) { + debouncedValidateModel(form.model, provider) + } + // Clear setAsDefault if the new provider doesn't support being default + const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false + if (!allowed) { + setSetAsDefault(false) } } + const applyFix = () => { + if (modelValidation?.fix) { + setForm((f) => ({ ...f, model: modelValidation.fix! })) + setModelValidation(null) + } + } + + const handleCommonModel = (modelId: string) => { + setForm((f) => ({ ...f, model: modelId })) + setModelValidation(null) + if (fieldErrors.model) { + setFieldErrors((prev) => ({ ...prev, model: undefined })) + } + } + + const handleFetchFill = (models: string[]) => { + setFetchedModels(models) + if (models.length >= 1) { + setForm((f) => ({ ...f, model: models[0] })) + setModelValidation(null) + if (fieldErrors.model) { + setFieldErrors((prev) => ({ ...prev, model: undefined })) + } + } + } + + const providerDef = PROVIDER_MAP.get(form.provider) + const commonModels = providerDef?.commonModels || [] + const defaultModelAllowed = form.provider + ? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false) + : false + const handleSave = async () => { if (!validate()) return + + let extraBody: Record | undefined + let customHeaders: Record | undefined + try { + if (form.extraBody.trim()) { + extraBody = JSON.parse(form.extraBody.trim()) + } else { + extraBody = {} + } + } catch { + setServerError( + t("models.field.extraBody") + ": " + t("models.field.invalidJson"), + ) + return + } + try { + if (form.customHeaders.trim()) { + customHeaders = JSON.parse(form.customHeaders.trim()) + } else { + customHeaders = {} + } + } catch { + setServerError( + t("models.field.customHeaders") + ": " + t("models.field.invalidJson"), + ) + return + } + setSaving(true) setServerError("") try { const modelName = form.modelName.trim() + const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, - provider: form.provider.trim(), + provider: provider || undefined, model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: authMethodLocked - ? defaultAuthMethod || undefined - : form.authMethod.trim() || undefined, + auth_method: form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -228,12 +357,8 @@ export function AddModelSheet({ : undefined, thinking_level: form.thinkingLevel.trim() || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, - extra_body: form.extraBody.trim() - ? JSON.parse(form.extraBody.trim()) - : undefined, - custom_headers: form.customHeaders.trim() - ? JSON.parse(form.customHeaders.trim()) - : undefined, + extra_body: extraBody, + custom_headers: customHeaders, }) if (setAsDefault) { await setDefaultModel(modelName) @@ -255,82 +380,166 @@ export function AddModelSheet({ } return ( - !v && onClose()}> - - - {t("models.add.title")} - - {t("models.add.description")} - - + <> + !v && onClose()}> + + + + {t("models.add.title")} + + + {t("models.add.description")} + + -
-
- - - {fieldErrors.modelName && ( -

- {fieldErrors.modelName} -

- )} -
- - - - + + {fieldErrors.modelName && ( +

+ {fieldErrors.modelName} +

+ )} + - - - {fieldErrors.model && ( -

{fieldErrors.model}

- )} -
+ + + + + + + {modelValidation && modelValidation.messageKey && ( +
+ + {t( + modelValidation.messageKey, + modelValidation.messageParams, + )} + + {modelValidation.fix && ( + + )} +
+ )} + {fieldErrors.model && !modelValidation && ( +

+ {fieldErrors.model} +

+ )} + {commonModels.length > 0 && ( +
+ {commonModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {catalogModels.length > 0 && ( +
+ {catalogModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {fetchedModels.length > 0 && ( +
+ {fetchedModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} +
+ {form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( + + )} + {!form.provider && ( + + {t("models.field.selectProviderFirst")} + + )} +
+
- {!isOAuth && ( - )} - - - - - - - - + - + +
+ + - - + checked={setAsDefault} + onCheckedChange={setSetAsDefault} + disabled={!defaultModelAllowed} + /> - - - + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - -