refactor(web): migrate model provider console contracts (#38253)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-07-01 22:46:17 +08:00 committed by GitHub
parent ee1d33eaa6
commit f98c6cb2a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 214 additions and 90 deletions

View File

@ -22,6 +22,31 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
ConfigurationMethodEnum: {
predefinedModel: 'predefined-model',
customizableModel: 'customizable-model',
fetchFromRemote: 'fetch-from-remote',
},
ModelFeatureEnum: {
toolCall: 'tool-call',
multiToolCall: 'multi-tool-call',
agentThought: 'agent-thought',
streamToolCall: 'stream-tool-call',
vision: 'vision',
video: 'video',
document: 'document',
audio: 'audio',
polling: 'polling',
StructuredOutput: 'structured-output',
},
ModelStatusEnum: {
active: 'active',
noConfigure: 'no-configure',
quotaExceeded: 'quota-exceeded',
noPermission: 'no-permission',
disabled: 'disabled',
credentialRemoved: 'credential-removed',
},
ModelTypeEnum: {
speech2text: 'speech2text',
tts: 'tts',

View File

@ -1011,7 +1011,7 @@ describe('hooks', () => {
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const provider = createMockProvider()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: provider.provider,
@ -1046,7 +1046,7 @@ describe('hooks', () => {
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: provider.provider,
@ -1077,7 +1077,7 @@ describe('hooks', () => {
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
const provider = { ...createMockProvider(), custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure } }
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: provider.provider,
@ -1104,7 +1104,7 @@ describe('hooks', () => {
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const provider = createMockProvider()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: provider.provider,

View File

@ -68,6 +68,7 @@ export enum ModelFeatureEnum {
video = 'video',
document = 'document',
audio = 'audio',
polling = 'polling',
StructuredOutput = 'structured-output',
}

View File

@ -262,7 +262,7 @@ export const useRefreshModel = () => {
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
refreshModelList?: boolean,
) => {
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: provider.provider,

View File

@ -41,28 +41,36 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const mockedModelProviders = {
models: {
queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
},
changePreferredProviderType: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {
mockChangePriorityFn(...args)
return Promise.resolve({ result: 'success' })
byProvider: {
models: {
get: {
queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
},
...opts,
}),
},
preferredProviderType: {
post: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {
mockChangePriorityFn(...args)
return Promise.resolve({ result: 'success' })
},
...opts,
}),
},
},
},
}
return {
...actual,
consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop) {
if (prop === 'modelProviders')
return mockedModelProviders
return Reflect.get(target, prop)
consoleQuery: {
systemFeatures: actual.consoleQuery.systemFeatures,
trialModels: actual.consoleQuery.trialModels,
workspaces: {
current: {
modelProviders: mockedModelProviders,
},
},
}),
},
}
})

View File

@ -18,9 +18,17 @@ const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { prov
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryOptions: (options: { input: { params: { provider: string } }, enabled?: boolean }) => mockQueryOptions(options),
workspaces: {
current: {
modelProviders: {
byProvider: {
models: {
get: {
queryOptions: (options: { input: { params: { provider: string } }, enabled?: boolean }) => mockQueryOptions(options),
},
},
},
},
},
},
},
@ -94,6 +102,25 @@ const ExternalExpandControls = () => {
)
}
const modelProviderModelsResponse = {
data: [{
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: 'llm',
features: [],
fetch_from: 'predefined-model',
status: 'active',
model_properties: {},
load_balancing_enabled: false,
provider: {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
supported_model_types: ['llm'],
tenant_id: 'tenant-id',
},
}],
}
describe('ProviderAddedCard', () => {
const mockProvider = {
provider: 'langgenius/openai/openai',
@ -115,7 +142,7 @@ describe('ProviderAddedCard', () => {
})
it('should open, refresh and collapse model list', async () => {
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
mockFetchModelProviderModels.mockResolvedValue(modelProviderModelsResponse)
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getByRole('button', { name: /modelProvider\.showModels/i })
@ -172,7 +199,7 @@ describe('ProviderAddedCard', () => {
})
it('should only react to external expansion for the matching provider', async () => {
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
mockFetchModelProviderModels.mockResolvedValue(modelProviderModelsResponse)
renderWithQueryClient(
<>
<ProviderAddedCard provider={mockProvider} />

View File

@ -30,12 +30,22 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryKey: (options: { input: { params: { provider: string } } }) => mockQueryKey(options),
},
changePreferredProviderType: {
mutationOptions: (options: Record<string, unknown>) => mockMutationOptions(options),
workspaces: {
current: {
modelProviders: {
byProvider: {
models: {
get: {
queryKey: (options: { input: { params: { provider: string } } }) => mockQueryKey(options),
},
},
preferredProviderType: {
post: {
mutationOptions: (options: Record<string, unknown>) => mockMutationOptions(options),
},
},
},
},
},
},
},

View File

@ -28,6 +28,7 @@ import ProviderIcon from '../provider-icon'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
modelTypeFormat,
normalizeModelProviderModelsResponse,
} from '../utils'
import CredentialPanel from './credential-panel'
import ModelList from './model-list'
@ -59,11 +60,11 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
isFetching: loading,
isSuccess: hasFetchedModelList,
refetch: refetchModelList,
} = useQuery(consoleQuery.modelProviders.models.queryOptions({
} = useQuery(consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
input: { params: { provider: currentProviderName } },
enabled: expanded,
refetchOnWindowFocus: false,
select: response => response.data,
select: normalizeModelProviderModelsResponse,
}))
const hasModelList = hasFetchedModelList && !!modelList.length
const showCollapsedSection = !expanded || !hasFetchedModelList

View File

@ -36,7 +36,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: provider.provider,

View File

@ -12,14 +12,14 @@ export function useChangeProviderPriority(provider: ModelProvider | undefined) {
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const providerName = provider?.provider ?? ''
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({
input: {
params: {
provider: providerName,
},
},
})
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(consoleQuery.workspaces.current.modelProviders.byProvider.preferredProviderType.post.mutationOptions({
onSuccess: () => {
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
queryClient.invalidateQueries({

View File

@ -1,14 +1,26 @@
import type {
FetchFrom,
ModelFeature,
ModelStatus,
ModelType,
ModelWithProviderEntityResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ComponentType } from 'react'
import type {
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
ModelItem,
TypeWithI18N,
} from './declarations'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import {
ConfigurationMethodEnum,
FormTypeEnum,
MODEL_TYPE_TEXT,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from './declarations'
@ -107,3 +119,74 @@ export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInpu
},
}
}
const modelTypeMap: Record<ModelType, ModelTypeEnum> = {
'llm': ModelTypeEnum.textGeneration,
'moderation': ModelTypeEnum.moderation,
'rerank': ModelTypeEnum.rerank,
'speech2text': ModelTypeEnum.speech2text,
'text-embedding': ModelTypeEnum.textEmbedding,
'tts': ModelTypeEnum.tts,
}
const modelFeatureMap: Record<ModelFeature, ModelFeatureEnum> = {
'agent-thought': ModelFeatureEnum.agentThought,
'audio': ModelFeatureEnum.audio,
'document': ModelFeatureEnum.document,
'multi-tool-call': ModelFeatureEnum.multiToolCall,
'polling': ModelFeatureEnum.polling,
'stream-tool-call': ModelFeatureEnum.streamToolCall,
'structured-output': ModelFeatureEnum.StructuredOutput,
'tool-call': ModelFeatureEnum.toolCall,
'video': ModelFeatureEnum.video,
'vision': ModelFeatureEnum.vision,
}
const fetchFromMap: Record<FetchFrom, ConfigurationMethodEnum> = {
'customizable-model': ConfigurationMethodEnum.customizableModel,
'predefined-model': ConfigurationMethodEnum.predefinedModel,
}
const modelStatusMap: Record<ModelStatus, ModelStatusEnum> = {
'active': ModelStatusEnum.active,
'credential-removed': ModelStatusEnum.credentialRemoved,
'disabled': ModelStatusEnum.disabled,
'no-configure': ModelStatusEnum.noConfigure,
'no-permission': ModelStatusEnum.noPermission,
'quota-exceeded': ModelStatusEnum.quotaExceeded,
}
const normalizeModelLabel = (label: ModelWithProviderEntityResponse['label']): TypeWithI18N => ({
en_US: label.en_US,
zh_Hans: label.zh_Hans ?? label.en_US,
})
const normalizeModelProperties = (
modelProperties: ModelWithProviderEntityResponse['model_properties'],
): ModelItem['model_properties'] => {
const normalized: ModelItem['model_properties'] = {}
Object.entries(modelProperties).forEach(([key, value]) => {
if (typeof value === 'string' || typeof value === 'number')
normalized[key] = value
})
return normalized
}
const normalizeModelProviderModel = (model: ModelWithProviderEntityResponse): ModelItem => ({
model: model.model,
label: normalizeModelLabel(model.label),
model_type: modelTypeMap[model.model_type],
features: model.features?.map(feature => modelFeatureMap[feature]),
fetch_from: fetchFromMap[model.fetch_from],
status: modelStatusMap[model.status],
model_properties: normalizeModelProperties(model.model_properties),
load_balancing_enabled: model.load_balancing_enabled ?? false,
deprecated: model.deprecated,
has_invalid_load_balancing_configs: model.has_invalid_load_balancing_configs,
})
export const normalizeModelProviderModelsResponse = (
response: { data: ModelWithProviderEntityResponse[] },
): ModelItem[] => response.data.map(normalizeModelProviderModel)

View File

@ -31,6 +31,7 @@ import { useEdges, useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { normalizeModelProviderModelsResponse } from '@/app/components/header/account-setting/model-provider-page/utils'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { MAX_TREE_DEPTH } from '@/config'
import { useGetLanguage } from '@/context/i18n'
@ -185,11 +186,11 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
}, [nodes])
const knowledgeBaseProviderModelMap = useQueries({
queries: knowledgeBaseEmbeddingProviders.map(provider =>
consoleQuery.modelProviders.models.queryOptions({
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
input: { params: { provider } },
enabled: !!provider,
refetchOnWindowFocus: false,
select: response => response.data,
select: normalizeModelProviderModelsResponse,
}),
),
combine: (results) => {
@ -467,13 +468,13 @@ export const useChecklistBeforePublish = () => {
await Promise.all(knowledgeBaseEmbeddingProviders.map(async (provider) => {
try {
const modelList = await queryClient.fetchQuery(
consoleQuery.modelProviders.models.queryOptions({
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
input: { params: { provider } },
}),
)
if (modelList.data)
modelMap[provider] = modelList.data
modelMap[provider] = normalizeModelProviderModelsResponse(modelList)
}
catch {
}

View File

@ -19,9 +19,17 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryOptions: mockQueryOptions,
workspaces: {
current: {
modelProviders: {
byProvider: {
models: {
get: {
queryOptions: mockQueryOptions,
},
},
},
},
},
},
},

View File

@ -16,6 +16,7 @@ import {
useLanguage,
useModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { normalizeModelProviderModelsResponse } from '@/app/components/header/account-setting/model-provider-page/utils'
import { consoleQuery } from '@/service/client'
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
import { useSettingsDisplay } from './hooks/use-settings-display'
@ -82,11 +83,11 @@ const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => {
const retrievalRerankingEnable = retrievalModel?.reranking_enable
const embeddingModelProvider = data.embedding_model_provider
const { data: embeddingProviderModelList } = useQuery(
consoleQuery.modelProviders.models.queryOptions({
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
input: { params: { provider: embeddingModelProvider || '' } },
enabled: indexingTechnique === IndexMethodEnum.QUALIFIED && !!embeddingModelProvider,
refetchOnWindowFocus: false,
select: response => response.data,
select: normalizeModelProviderModelsResponse,
}),
)

View File

@ -12,6 +12,7 @@ import SummaryIndexSetting from '@/app/components/datasets/settings/summary-inde
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { normalizeModelProviderModelsResponse } from '@/app/components/header/account-setting/model-provider-page/utils'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import {
BoxGroup,
@ -54,11 +55,11 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
const retrievalRerankingEnable = retrievalModel?.reranking_enable
const embeddingModelProvider = data.embedding_model_provider
const { data: embeddingProviderModelList } = useQuery(
consoleQuery.modelProviders.models.queryOptions({
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
input: { params: { provider: embeddingModelProvider || '' } },
enabled: indexingTechnique === IndexMethodEnum.QUALIFIED && !!embeddingModelProvider,
refetchOnWindowFocus: false,
select: response => response.data,
select: normalizeModelProviderModelsResponse,
}),
)

View File

@ -1,38 +0,0 @@
import type { ModelItem, PreferredProviderTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonResponse } from '@/models/common'
import { type } from '@orpc/contract'
import { base } from '../base'
export const modelProvidersModelsContract = base
.route({
path: '/workspaces/current/model-providers/{provider}/models',
method: 'GET',
})
.input(type<{
params: {
provider: string
}
}>())
.output(type<{
data: ModelItem[]
}>())
export const changePreferredProviderTypeContract = base
.route({
path: '/workspaces/current/model-providers/{provider}/preferred-provider-type',
method: 'POST',
})
.input(type<{
params: {
provider: string
}
body: {
preferred_provider_type: PreferredProviderTypeEnum
}
}>())
.output(type<CommonResponse>())
export const modelProvidersRouterContract = {
models: modelProvidersModelsContract,
changePreferredProviderType: changePreferredProviderTypeContract,
}

View File

@ -46,7 +46,6 @@ import { workflow } from '@dify/contracts/api/console/workflow/orpc.gen'
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { exploreRouterContract } from './console/explore'
import { modelProvidersRouterContract } from './console/model-providers'
import { pluginsRouterContract } from './console/plugins'
import { snippetsRouterContract } from './console/snippets'
import { triggersRouterContract } from './console/trigger'
@ -105,7 +104,6 @@ export const consoleRouterContract = {
enterprise: enterpriseContract,
...communityContract,
explore: exploreRouterContract,
modelProviders: modelProvidersRouterContract,
plugins: pluginsRouterContract,
snippets: snippetsRouterContract,
triggers: triggersRouterContract,

View File

@ -14,8 +14,6 @@ async function loadGeneratedConsoleContract(segment: string) {
const customConsoleContractLoaders: Record<string, () => Promise<AnyContractRouter>> = {
enterprise: () => import('@dify/contracts/enterprise/orpc.gen').then(({ contract }) => wrapConsoleContract('enterprise', contract)),
explore: () => import('@/contract/console/explore').then(({ exploreRouterContract }) => wrapConsoleContract('explore', exploreRouterContract)),
modelProviders: () =>
import('@/contract/console/model-providers').then(({ modelProvidersRouterContract }) => wrapConsoleContract('modelProviders', modelProvidersRouterContract)),
plugins: () => import('@/contract/console/plugins').then(({ pluginsRouterContract }) => wrapConsoleContract('plugins', pluginsRouterContract)),
snippets: () => import('@/contract/console/snippets').then(({ snippetsRouterContract }) => wrapConsoleContract('snippets', snippetsRouterContract)),
triggers: () => import('@/contract/console/trigger').then(({ triggersRouterContract }) => wrapConsoleContract('triggers', triggersRouterContract)),