From 5104fe3c5f467fa4d3d291251dad4a6360f23a38 Mon Sep 17 00:00:00 2001 From: Kalo Chin Date: Thu, 19 Jun 2025 23:01:03 +0900 Subject: [PATCH] Validate and filter model completion params on change Introduces a utility to merge and validate completion parameters against model-specific rules when changing models in configuration and workflow nodes. Invalid or unsupported parameters are removed, and users are notified via toast messages. This ensures only valid parameters are retained for the selected model, improving robustness and user feedback. --- .../components/app/configuration/index.tsx | 19 ++++- .../components/workflow/nodes/llm/panel.tsx | 22 +++++- .../nodes/parameter-extractor/use-config.ts | 38 ++++++--- .../nodes/question-classifier/use-config.ts | 30 +++++-- web/utils/completion-params.ts | 78 +++++++++++++++++++ 5 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 web/utils/completion-params.ts diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 2b97a64f5b..dd1faeeaa3 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -80,6 +80,9 @@ import { import PluginDependency from '@/app/components/workflow/plugin-dependency' import { supportFunctionCall } from '@/utils/tool-call' import { MittProvider } from '@/context/mitt-context' +import { mergeValidCompletionParams } from '@/utils/completion-params' +import { fetchModelParameterRules } from '@/service/common' +import Toast from '@/app/components/base/toast' type PublishConfig = { modelConfig: ModelConfig @@ -453,7 +456,21 @@ const Configuration: FC = () => { ...visionConfig, enabled: supportVision, }, true) - setCompletionParams({}) + + // merge and keep only valid completion params for the new model + try { + const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}` + const { data: parameterRules } = await fetchModelParameterRules(url) + const { params: filtered, removedDetails } = mergeValidCompletionParams(completionParams, parameterRules ?? []) + if (Object.keys(removedDetails).length) + Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ` + Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ') }) + setCompletionParams(filtered) + } + catch (e) { + // if fetch fails just clear invalid params + Toast.notify({ type: 'error', message: t('common.error') }) + setCompletionParams({}) + } } const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision) diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 29fb4fb2c3..9e9380afaf 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -23,6 +23,9 @@ import Editor from '@/app/components/workflow/nodes/_base/components/prompt/edit import StructureOutput from './components/structure-output' import Switch from '@/app/components/base/switch' import { RiAlertFill, RiQuestionLine } from '@remixicon/react' +import { mergeValidCompletionParams } from '@/utils/completion-params' +import { fetchModelParameterRules } from '@/service/common' +import Toast from '@/app/components/base/toast' const i18nPrefix = 'workflow.nodes.llm' @@ -137,10 +140,21 @@ const Panel: FC> = ({ modelId: string mode?: string }) => { - handleCompletionParamsChange({}) - handleModelChanged(model) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + (async () => { + try { + const url = `/workspaces/current/model-providers/${model.provider}/models/parameter-rules?model=${model.modelId}` + const { data: parameterRules } = await fetchModelParameterRules(url) + const { params: filtered, removedDetails } = mergeValidCompletionParams(inputs.model.completion_params, parameterRules ?? []) + const keys = Object.keys(removedDetails) + if (keys.length) + Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ` + keys.map(k => `${k} (${removedDetails[k]})`).join(', ') }) + handleCompletionParamsChange(filtered) + } + finally { + handleModelChanged(model) + } + })() + }, [inputs.model.completion_params]) return (
diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts index 045737b230..c7535de38e 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts @@ -17,6 +17,9 @@ import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-cr import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import { supportFunctionCall } from '@/utils/tool-call' +import { mergeValidCompletionParams } from '@/utils/completion-params' +import { fetchModelParameterRules } from '@/service/common' +import Toast from '@/app/components/base/toast' const useConfig = (id: string, payload: ParameterExtractorNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -117,16 +120,31 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) const handleModelChanged = useCallback((model: { provider: string; modelId: string; mode?: string }) => { - const newInputs = produce(inputRef.current, (draft) => { - draft.model.provider = model.provider - draft.model.name = model.modelId - draft.model.mode = model.mode! - const isModeChange = model.mode !== inputRef.current.model?.mode - if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat') - }) - setInputs(newInputs) - setModelChanged(true) + (async () => { + const newInputs = produce(inputRef.current, (draft) => { + draft.model.provider = model.provider + draft.model.name = model.modelId + draft.model.mode = model.mode! + const isModeChange = model.mode !== inputRef.current.model?.mode + if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) + appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat') + }) + + // filter completion params + try { + const url = `/workspaces/current/model-providers/${model.provider}/models/parameter-rules?model=${model.modelId}` + const { data: parameterRules } = await fetchModelParameterRules(url) + const { params: filtered, removedDetails } = mergeValidCompletionParams(inputRef.current.model.completion_params, parameterRules ?? []) + if (Object.keys(removedDetails).length) + Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ` + Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ') }) + newInputs.model.completion_params = filtered + } + catch { + // ignore errors + } + setInputs(newInputs) + setModelChanged(true) + })() }, [setInputs, defaultConfig, appendDefaultPromptConfig]) useEffect(() => { diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index 7df8293b40..056bacd84c 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -15,6 +15,9 @@ import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-s import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' +import { mergeValidCompletionParams } from '@/utils/completion-params' +import { fetchModelParameterRules } from '@/service/common' +import Toast from '@/app/components/base/toast' const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -55,13 +58,26 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { }) const handleModelChanged = useCallback((model: { provider: string; modelId: string; mode?: string }) => { - const newInputs = produce(inputRef.current, (draft) => { - draft.model.provider = model.provider - draft.model.name = model.modelId - draft.model.mode = model.mode! - }) - setInputs(newInputs) - setModelChanged(true) + (async () => { + const newInputs = produce(inputRef.current, (draft) => { + draft.model.provider = model.provider + draft.model.name = model.modelId + draft.model.mode = model.mode! + }) + try { + const url = `/workspaces/current/model-providers/${model.provider}/models/parameter-rules?model=${model.modelId}` + const { data: parameterRules } = await fetchModelParameterRules(url) + const { params: filtered, removedDetails } = mergeValidCompletionParams(inputRef.current.model.completion_params, parameterRules ?? []) + if (Object.keys(removedDetails).length) + Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ` + Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ') }) + newInputs.model.completion_params = filtered + } + catch { + // ignore + } + setInputs(newInputs) + setModelChanged(true) + })() }, [setInputs]) useEffect(() => { diff --git a/web/utils/completion-params.ts b/web/utils/completion-params.ts new file mode 100644 index 0000000000..db191e6ed4 --- /dev/null +++ b/web/utils/completion-params.ts @@ -0,0 +1,78 @@ +import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' + +// Returns a new object that contains only the keys accepted by `rules` and whose +// values conform to the rule's basic validation (range/options). +export const mergeValidCompletionParams = ( + oldParams: FormValue | undefined, + rules: ModelParameterRule[], +): { params: FormValue; removedDetails: Record } => { + if (!oldParams || Object.keys(oldParams).length === 0) + return { params: {}, removedDetails: {} } + + const acceptedKeys = new Set(rules.map(r => r.name)) + const ruleMap: Record = {} + rules.forEach(r => { + ruleMap[r.name] = r + }) + + const nextParams: FormValue = {} + const removedDetails: Record = {} + + Object.entries(oldParams).forEach(([key, value]) => { + if (!acceptedKeys.has(key)) + { removedDetails[key] = 'unsupported'; return } + + const rule = ruleMap[key] + if (!rule) + { removedDetails[key] = 'unsupported'; return } + + switch (rule.type) { + case 'int': + case 'float': { + if (typeof value !== 'number') { removedDetails[key] = 'invalid type'; return } + const min = rule.min ?? Number.NEGATIVE_INFINITY + const max = rule.max ?? Number.POSITIVE_INFINITY + if (value < min || value > max) { removedDetails[key] = `out of range (${min}-${max})`; return } + nextParams[key] = value + return + } + case 'boolean': { + if (typeof value === 'boolean') + nextParams[key] = value + else + removedDetails[key] = 'invalid type' + return + } + case 'string': + case 'text': { + if (typeof value === 'string') { + if (Array.isArray(rule.options) && rule.options.length) { + if (rule.options.some(opt => opt.value === value)) + nextParams[key] = value + else + removedDetails[key] = 'unsupported option' + } + else { + nextParams[key] = value + } + } + else { + removedDetails[key] = 'invalid type' + } + return + } + case 'tag': { + if (Array.isArray(value)) + nextParams[key] = value + else + removedDetails[key] = 'invalid type' + return + } + default: { + nextParams[key] = value + } + } + }) + + return { params: nextParams, removedDetails } +} \ No newline at end of file