Merge branch 'feat/model-plugins-implementing' into deploy/dev

This commit is contained in:
yyh
2026-03-12 15:00:39 +08:00
8 changed files with 259 additions and 27 deletions

View File

@ -36,6 +36,9 @@ const mockMarketplacePlugins = vi.hoisted(() => ({
const mockContextModelProviders = vi.hoisted(() => ({
current: [] as MockContextProvider[],
}))
const mockTrialModels = vi.hoisted(() => ({
current: ['test-openai', 'test-anthropic'] as string[],
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
@ -56,20 +59,38 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
}))
vi.mock('../provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => ({
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined,
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: { trial_models: mockTrialModels.current },
}),
}))
const mockTrialCredits = vi.hoisted(() => ({
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined as number | undefined,
}))
vi.mock('../provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('../provider-added-card/model-auth-dropdown/credits-exhausted-alert', () => ({
default: ({ hasApiKeyFallback }: { hasApiKeyFallback: boolean }) => (
<div data-testid="credits-exhausted-alert" data-has-api-key-fallback={String(hasApiKeyFallback)} />
),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
@ -148,6 +169,14 @@ describe('Popup', () => {
mockMarketplacePlugins.current = []
mockMarketplacePlugins.isLoading = false
mockContextModelProviders.current = []
mockTrialModels.current = ['test-openai', 'test-anthropic']
Object.assign(mockTrialCredits, {
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined,
})
})
it('should filter models by search and allow clearing search', () => {
@ -249,6 +278,89 @@ describe('Popup', () => {
expect(screen.getByText('openai')).toBeInTheDocument()
})
it('should show credits exhausted alert when an exhausted provider supports credits', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute('data-has-api-key-fallback', 'false')
})
it('should not show credits exhausted alert when only non-trial system providers are exhausted', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockTrialModels.current = ['test-anthropic']
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument()
})
it('should not mark api key fallback for non-trial system providers', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockTrialModels.current = ['test-anthropic']
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
} as MockContextProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument()
})
it('should open provider settings when clicking footer link', () => {
const onHide = vi.fn()
render(

View File

@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { IS_CLOUD_EDITION } from '@/config'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
@ -23,6 +23,7 @@ import { CustomConfigurationStatusEnum, ModelFeatureEnum } from '../declarations
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import PopupItem from './popup-item'
@ -55,16 +56,14 @@ const Popup: FC<PopupProps> = ({
const { refreshPluginList } = useRefreshPluginList()
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
const { isExhausted: isCreditsExhausted } = useTrialCredits()
const showCreditsExhaustedAlert = useMemo(() => {
return isCreditsExhausted && modelProviders.some(p => p.system_configuration.enabled && IS_CLOUD_EDITION)
}, [isCreditsExhausted, modelProviders])
const hasApiKeyFallback = useMemo(() => {
return modelProviders.some((p) => {
const isApiKeyActive = p.custom_configuration?.status === CustomConfigurationStatusEnum.active
const supportsCredits = p.system_configuration.enabled && IS_CLOUD_EDITION
return isApiKeyActive && supportsCredits
})
}, [modelProviders])
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models
const showCreditsExhaustedAlert = isCreditsExhausted
&& modelProviders.some(provider => providerSupportsCredits(provider, trialModels))
const hasApiKeyFallback = modelProviders.some((provider) => {
const isApiKeyActive = provider.custom_configuration?.status === CustomConfigurationStatusEnum.active
return isApiKeyActive && providerSupportsCredits(provider, trialModels)
})
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
if (!allPlugins || isMarketplacePluginsLoading || installingProvider)

View File

@ -1,10 +1,10 @@
import type { ModelProvider } from '../declarations'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import { IS_CLOUD_EDITION } from '@/config'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import {
PreferredProviderTypeEnum,
} from '../declarations'
import { providerSupportsCredits } from '../supports-credits'
import { useTrialCredits } from './use-trial-credits'
export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
@ -82,14 +82,9 @@ export function useCredentialPanelState(provider: ModelProvider | undefined): Cr
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models
const systemConfig = provider?.system_configuration
const preferredType = provider?.preferred_provider_type
const providerKey = provider?.provider
const supportsCredits = !!systemConfig?.enabled
&& IS_CLOUD_EDITION
&& !!providerKey
&& (trialModels as string[] ?? []).includes(providerKey)
const supportsCredits = providerSupportsCredits(provider, trialModels)
const priority: UsagePriority = !supportsCredits
? 'apiKeyOnly'

View File

@ -0,0 +1,42 @@
import type { ModelProvider } from './declarations'
import { CurrentSystemQuotaTypeEnum } from './declarations'
import { providerSupportsCredits } from './supports-credits'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const makeProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
...overrides,
} as ModelProvider)
describe('providerSupportsCredits', () => {
it('returns true when the provider is system-enabled and listed in trial_models', () => {
expect(providerSupportsCredits(makeProvider(), ['langgenius/openai/openai'])).toBe(true)
})
it('returns false when the provider is not listed in trial_models', () => {
expect(providerSupportsCredits(makeProvider(), ['langgenius/anthropic/anthropic'])).toBe(false)
})
it('returns false when system hosting is disabled', () => {
expect(providerSupportsCredits(makeProvider({
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
}), ['langgenius/openai/openai'])).toBe(false)
})
it('returns false for an undefined provider', () => {
expect(providerSupportsCredits(undefined, ['langgenius/openai/openai'])).toBe(false)
})
})

View File

@ -0,0 +1,14 @@
import type { ModelProvider } from './declarations'
import { IS_CLOUD_EDITION } from '@/config'
type CreditAwareProvider = Pick<ModelProvider, 'provider' | 'system_configuration'>
export const providerSupportsCredits = (
provider: CreditAwareProvider | undefined,
trialModels: readonly string[] | undefined,
): boolean => {
if (!IS_CLOUD_EDITION || !provider?.system_configuration.enabled)
return false
return !!provider.provider && !!trialModels?.includes(provider.provider)
}