diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx index 219f0e8a4e..6044d24f1d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx @@ -1,7 +1,6 @@ import type { ModelProvider } from '../declarations' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { changeModelProviderPriority } from '@/service/common' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ConfigurationMethodEnum, CustomConfigurationStatusEnum, @@ -9,11 +8,21 @@ import { } from '../declarations' import CredentialPanel from './credential-panel' -const mockEventEmitter = { emit: vi.fn() } -const mockNotify = vi.fn() -const mockUpdateModelList = vi.fn() -const mockUpdateModelProviders = vi.fn() -const mockTrialCredits = { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined } +const { + mockEventEmitter, + mockToastNotify, + mockUpdateModelList, + mockUpdateModelProviders, + mockTrialCredits, + mockChangePriorityFn, +} = vi.hoisted(() => ({ + mockEventEmitter: { emit: vi.fn() }, + mockToastNotify: vi.fn(), + mockUpdateModelList: vi.fn(), + mockUpdateModelProviders: vi.fn(), + mockTrialCredits: { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }, + mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }), +})) vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() @@ -21,15 +30,28 @@ vi.mock('@/config', async (importOriginal) => { }) vi.mock('@/app/components/base/toast', () => ({ - useToastContext: () => ({ notify: mockNotify }), + default: { notify: mockToastNotify }, })) vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) -vi.mock('@/service/common', () => ({ - changeModelProviderPriority: vi.fn(), +vi.mock('@/service/client', () => ({ + consoleQuery: { + modelProviders: { + models: { key: () => ['console', 'modelProviders', 'models'] }, + changePreferredProviderType: { + mutationOptions: (opts: Record) => ({ + mutationFn: (...args: unknown[]) => { + mockChangePriorityFn(...args) + return Promise.resolve({ result: 'success' }) + }, + ...opts, + }), + }, + }, + }, })) vi.mock('../hooks', () => ({ @@ -58,6 +80,7 @@ vi.mock('@/app/components/header/indicator', () => ({ const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, }, }) @@ -92,31 +115,26 @@ describe('CredentialPanel', () => { Object.assign(mockTrialCredits, { credits: 100, isExhausted: false, isLoading: false }) }) - // Text label variants describe('Text label variants', () => { it('should show "AI credits in use" for credits-active variant', () => { renderWithQueryClient(createProvider()) - expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument() }) it('should show "Credits exhausted" for credits-exhausted variant', () => { mockTrialCredits.isExhausted = true mockTrialCredits.credits = 0 - renderWithQueryClient(createProvider({ custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure, available_credentials: [], }, })) - expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument() }) it('should show "No available usage" for no-usage variant', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider({ custom_configuration: { status: CustomConfigurationStatusEnum.active, @@ -125,7 +143,6 @@ describe('CredentialPanel', () => { available_credentials: [{ credential_id: 'cred-1' }], }, })) - expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument() }) @@ -137,18 +154,14 @@ describe('CredentialPanel', () => { available_credentials: [], }, })) - expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument() }) }) - // Status label variants (dot + credential name) describe('Status label variants', () => { it('should show green indicator and credential name for api-fallback', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider()) - expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') expect(screen.getByText('test-credential')).toBeInTheDocument() }) @@ -157,7 +170,6 @@ describe('CredentialPanel', () => { renderWithQueryClient(createProvider({ preferred_provider_type: PreferredProviderTypeEnum.custom, })) - expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') }) @@ -167,53 +179,52 @@ describe('CredentialPanel', () => { custom_configuration: { status: CustomConfigurationStatusEnum.active, current_credential_id: undefined, - current_credential_name: undefined, - available_credentials: [{ credential_id: 'cred-1' }], + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], }, })) - expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red') expect(screen.getByText(/unavailable/i)).toBeInTheDocument() }) }) - // Destructive styling describe('Destructive styling', () => { it('should apply destructive container for credits-exhausted', () => { mockTrialCredits.isExhausted = true - const { container } = renderWithQueryClient(createProvider({ custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure, available_credentials: [], }, })) - - const card = container.querySelector('[class*="border-state-destructive"]') - expect(card).toBeTruthy() + expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() }) it('should apply default container for credits-active', () => { const { container } = renderWithQueryClient(createProvider()) - - const card = container.querySelector('[class*="bg-white"]') - expect(card).toBeTruthy() + expect(container.querySelector('[class*="bg-white"]')).toBeTruthy() }) }) - // Priority change describe('Priority change', () => { - it('should change priority and refresh data after success', async () => { - const mockChangePriority = changeModelProviderPriority as ReturnType - mockChangePriority.mockResolvedValue({ result: 'success' }) - + it('should call mutation and trigger side effects on success', async () => { renderWithQueryClient(createProvider()) - fireEvent.click(screen.getByTestId('change-priority-btn')) + await act(async () => { + fireEvent.click(screen.getByTestId('change-priority-btn')) + }) await waitFor(() => { - expect(mockChangePriority).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalled() + expect(mockChangePriorityFn.mock.calls[0]?.[0]).toEqual({ + params: { provider: 'test-provider' }, + body: { preferred_provider_type: 'custom' }, + }) + }) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }), + ) expect(mockUpdateModelProviders).toHaveBeenCalled() expect(mockUpdateModelList).toHaveBeenCalledWith('llm') expect(mockEventEmitter.emit).toHaveBeenCalled() @@ -221,11 +232,9 @@ describe('CredentialPanel', () => { }) }) - // ModelAuthDropdown integration describe('ModelAuthDropdown integration', () => { it('should pass state variant to ModelAuthDropdown', () => { renderWithQueryClient(createProvider()) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active') }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx index c6655b976b..269553a2f2 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx @@ -3,14 +3,12 @@ import type { PreferredProviderTypeEnum, } from '../declarations' import type { CardVariant } from './use-credential-panel-state' -import { useQueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useToastContext } from '@/app/components/base/toast' +import Toast from '@/app/components/base/toast' import Indicator from '@/app/components/header/indicator' import { useEventEmitterContextContext } from '@/context/event-emitter' import { consoleQuery } from '@/service/client' -import { changeModelProviderPriority } from '@/service/common' import { ConfigurationMethodEnum, } from '../declarations' @@ -39,35 +37,42 @@ const CredentialPanel = ({ provider, }: CredentialPanelProps) => { const { t } = useTranslation() - const { notify } = useToastContext() const { eventEmitter } = useEventEmitterContextContext() const queryClient = useQueryClient() const updateModelList = useUpdateModelList() const updateModelProviders = useUpdateModelProviders() const state = useCredentialPanelState(provider) - const handleChangePriority = useCallback(async (key: PreferredProviderTypeEnum) => { - const res = await changeModelProviderPriority({ - url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`, + const { mutate: changePriority, isPending: isChangingPriority } = useMutation( + consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({ + onSuccess: () => { + Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.modelProviders.models.key(), + refetchType: 'none', + }) + updateModelProviders() + provider.configurate_methods.forEach((method) => { + if (method === ConfigurationMethodEnum.predefinedModel) + provider.supported_model_types.forEach(modelType => updateModelList(modelType)) + }) + eventEmitter?.emit({ + type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST, + payload: provider.provider, + } as { type: string, payload: string }) + }, + onError: () => { + Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) + }, + }), + ) + + const handleChangePriority = (key: PreferredProviderTypeEnum) => { + changePriority({ + params: { provider: provider.provider }, body: { preferred_provider_type: key }, }) - if (res.result === 'success') { - notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) - queryClient.invalidateQueries({ - queryKey: consoleQuery.modelProviders.models.key(), - refetchType: 'none', - }) - updateModelProviders() - provider.configurate_methods.forEach((method) => { - if (method === ConfigurationMethodEnum.predefinedModel) - provider.supported_model_types.forEach(modelType => updateModelList(modelType)) - }) - eventEmitter?.emit({ - type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST, - payload: provider.provider, - } as { type: string, payload: string }) - } - }, [provider, notify, t, queryClient, updateModelProviders, updateModelList, eventEmitter]) + } const { variant, credentialName } = state const isDestructive = isDestructiveVariant(variant) @@ -84,6 +89,7 @@ const CredentialPanel = ({ diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx index 43a27a6849..80922f0c9f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx @@ -1,5 +1,5 @@ -import type { CredentialPanelState } from '../use-credential-panel-state' import type { ModelProvider } from '../../declarations' +import type { CredentialPanelState } from '../use-credential-panel-state' import { fireEvent, render, screen } from '@testing-library/react' import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations' import DropdownContent from './dropdown-content' @@ -73,6 +73,7 @@ describe('DropdownContent', () => { , @@ -86,6 +87,7 @@ describe('DropdownContent', () => { , @@ -99,6 +101,7 @@ describe('DropdownContent', () => { , @@ -112,6 +115,7 @@ describe('DropdownContent', () => { , @@ -128,6 +132,7 @@ describe('DropdownContent', () => { , @@ -147,6 +152,7 @@ describe('DropdownContent', () => { }, })} state={createState({ hasCredentials: false })} + isChangingPriority={false} onChangePriority={onChangePriority} onClose={onClose} />, @@ -168,6 +174,7 @@ describe('DropdownContent', () => { }, })} state={createState({ hasCredentials: false })} + isChangingPriority={false} onChangePriority={onChangePriority} onClose={onClose} />, @@ -187,6 +194,7 @@ describe('DropdownContent', () => { , diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx index 433f956c96..8be2924bb0 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx @@ -21,6 +21,7 @@ import UsagePrioritySection from './usage-priority-section' type DropdownContentProps = { provider: ModelProvider state: CredentialPanelState + isChangingPriority: boolean onChangePriority: (key: PreferredProviderTypeEnum) => void onClose: () => void } @@ -28,6 +29,7 @@ type DropdownContentProps = { function DropdownContent({ provider, state, + isChangingPriority, onChangePriority, onClose, }: DropdownContentProps) { @@ -81,6 +83,7 @@ function DropdownContent({ {state.showPrioritySwitcher && ( )} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx index 266e1417e7..07affed653 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx @@ -52,6 +52,7 @@ describe('ModelAuthDropdown', () => { , ) @@ -64,6 +65,7 @@ describe('ModelAuthDropdown', () => { , ) @@ -76,6 +78,7 @@ describe('ModelAuthDropdown', () => { , ) @@ -89,6 +92,7 @@ describe('ModelAuthDropdown', () => { , ) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx index da0fe3909f..392f8ece3f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx @@ -13,6 +13,7 @@ import DropdownContent from './dropdown-content' type ModelAuthDropdownProps = { provider: ModelProvider state: CredentialPanelState + isChangingPriority: boolean onChangePriority: (key: PreferredProviderTypeEnum) => void } @@ -38,7 +39,7 @@ function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: return { text, variant: 'secondary' as const } } -function ModelAuthDropdown({ provider, state, onChangePriority }: ModelAuthDropdownProps) { +function ModelAuthDropdown({ provider, state, isChangingPriority, onChangePriority }: ModelAuthDropdownProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -67,6 +68,7 @@ function ModelAuthDropdown({ provider, state, onChangePriority }: ModelAuthDropd diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx index 3c2c782a41..a923068e0c 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx @@ -5,6 +5,7 @@ import { PreferredProviderTypeEnum } from '../../declarations' type UsagePrioritySectionProps = { value: UsagePriority + disabled?: boolean onSelect: (key: PreferredProviderTypeEnum) => void } @@ -13,7 +14,7 @@ const options = [ { key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' }, ] as const -export default function UsagePrioritySection({ value, onSelect }: UsagePrioritySectionProps) { +export default function UsagePrioritySection({ value, disabled, onSelect }: UsagePrioritySectionProps) { const { t } = useTranslation() const selectedKey = value === 'credits' ? PreferredProviderTypeEnum.system @@ -37,11 +38,12 @@ export default function UsagePrioritySection({ value, onSelect }: UsagePriorityS key={option.key} type="button" className={cn( - 'shrink-0 whitespace-nowrap rounded-md px-2 py-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-button-primary-border', + 'shrink-0 whitespace-nowrap rounded-md px-2 py-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-button-primary-border disabled:opacity-50', selectedKey === option.key ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg text-text-primary shadow-xs system-xs-medium' : 'border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary system-xs-regular hover:bg-components-option-card-option-bg-hover', )} + disabled={disabled} onClick={() => onSelect(option.key)} > {t(option.labelKey, { ns: 'common' })} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts index d810ef635c..8d272834b9 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts @@ -117,7 +117,7 @@ describe('useCredentialPanelState', () => { const { result } = renderHook(() => useCredentialPanelState(provider)) - expect(result.current.variant).toBe('api-unavailable') + expect(result.current.variant).toBe('api-required-configure') }) it('should return api-required-add when no credentials exist', () => { @@ -159,12 +159,9 @@ describe('useCredentialPanelState', () => { expect(result.current.showPrioritySwitcher).toBe(true) }) - it('should hide priority switcher when custom config not active', () => { + it('should hide priority switcher when system config disabled', () => { const provider = createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, + system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, }) const { result } = renderHook(() => useCredentialPanelState(provider)) diff --git a/web/contract/console/model-providers.ts b/web/contract/console/model-providers.ts index 39cbb64914..2feab369ab 100644 --- a/web/contract/console/model-providers.ts +++ b/web/contract/console/model-providers.ts @@ -1,4 +1,5 @@ -import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +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' @@ -15,3 +16,18 @@ export const modelProvidersModelsContract = base .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()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 560284bc3f..e1ac293d20 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -12,7 +12,7 @@ import { exploreInstalledAppsContract, exploreInstalledAppUninstallContract, } from './console/explore' -import { modelProvidersModelsContract } from './console/model-providers' +import { changePreferredProviderTypeContract, modelProvidersModelsContract } from './console/model-providers' import { systemFeaturesContract } from './console/system' import { triggerOAuthConfigContract, @@ -66,6 +66,7 @@ export const consoleRouterContract = { }, modelProviders: { models: modelProvidersModelsContract, + changePreferredProviderType: changePreferredProviderTypeContract, }, billing: { invoices: invoicesContract,