feat: model total credits (#30727)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
This commit is contained in:
Coding On Star
2026-01-08 14:11:44 +08:00
committed by GitHub
parent fe0802262c
commit cd1af04dee
46 changed files with 1892 additions and 77 deletions

View File

@ -6,8 +6,10 @@ import {
RiBrainLine,
} from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -20,6 +22,7 @@ import {
} from './hooks'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
import QuotaPanel from './provider-added-card/quota-panel'
import SystemModelSelector from './system-model-selector'
type Props = {
@ -31,6 +34,7 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({ searchText }: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
@ -88,6 +92,10 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
useEffect(() => {
mutateCurrentWorkspace()
}, [mutateCurrentWorkspace])
return (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>
@ -115,6 +123,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
{!filteredConfiguredProviders?.length && (
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">

View File

@ -7,6 +7,7 @@ import { useToastContext } from '@/app/components/base/toast'
import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import Indicator from '@/app/components/header/indicator'
import { IS_CLOUD_EDITION } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { changeModelProviderPriority } from '@/service/common'
import { cn } from '@/utils/classnames'
@ -26,6 +27,7 @@ import PriorityUseTip from './priority-use-tip'
type CredentialPanelProps = {
provider: ModelProvider
}
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
@ -47,6 +49,8 @@ const CredentialPanel = ({
notAllowedToUse,
} = useCredentialStatus(provider)
const showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
@ -114,7 +118,7 @@ const CredentialPanel = ({
provider={provider}
/>
{
systemConfig.enabled && isCustomConfigured && (
showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
@ -131,7 +135,7 @@ const CredentialPanel = ({
)
}
{
systemConfig.enabled && isCustomConfigured && !provider.provider_credential_schema && (
showPrioritySelector && !provider.provider_credential_schema && (
<div className="ml-1">
<PrioritySelector
value={priorityUseType}

View File

@ -3,6 +3,7 @@ import type {
ModelItem,
ModelProvider,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '../utils'
import {
RiArrowRightSLine,
RiInformation2Fill,
@ -28,7 +29,6 @@ import {
} from '../utils'
import CredentialPanel from './credential-panel'
import ModelList from './model-list'
import QuotaPanel from './quota-panel'
export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
type ProviderAddedCardProps = {
@ -49,7 +49,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
const systemConfig = provider.system_configuration
const hasModelList = fetched && !!modelList.length
const { isCurrentWorkspaceManager } = useAppContext()
const showQuota = systemConfig.enabled && [...MODEL_PROVIDER_QUOTA_GET_PAID].includes(provider.provider) && !IS_CE_EDITION
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
const showCredential = configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && isCurrentWorkspaceManager
const getModelList = async (providerName: string) => {
@ -104,13 +104,6 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
}
</div>
</div>
{
showQuota && (
<QuotaPanel
provider={provider}
/>
)
}
{
showCredential && (
<CredentialPanel
@ -122,7 +115,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
{
collapsed && (
<div className="system-xs-medium group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary">
{(showQuota || !notConfigured) && (
{(showModelProvider || !notConfigured) && (
<>
<div className="flex h-6 items-center pl-1 pr-1.5 leading-6 group-hover:hidden">
{
@ -150,7 +143,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
</div>
</>
)}
{!showQuota && notConfigured && (
{!showModelProvider && notConfigured && (
<div className="flex h-6 items-center pl-1 pr-1.5">
<RiInformation2Fill className="mr-1 h-4 w-4 text-text-accent" />
<span className="system-xs-medium text-text-secondary">{t('modelProvider.configureTip', { ns: 'common' })}</span>

View File

@ -1,66 +1,163 @@
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useAppContext } from '@/context/app-context'
import useTimestamp from '@/hooks/use-timestamp'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import {
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
} from '../utils'
import PriorityUseTip from './priority-use-tip'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { modelNameMap, ModelProviderQuotaGetPaid } from '../utils'
const allProviders = [
{ key: ModelProviderQuotaGetPaid.OPENAI, Icon: OpenaiSmall },
{ key: ModelProviderQuotaGetPaid.ANTHROPIC, Icon: AnthropicShortLight },
{ key: ModelProviderQuotaGetPaid.GEMINI, Icon: Gemini },
{ key: ModelProviderQuotaGetPaid.X, Icon: Grok },
{ key: ModelProviderQuotaGetPaid.DEEPSEEK, Icon: Deepseek },
{ key: ModelProviderQuotaGetPaid.TONGYI, Icon: Tongyi },
] as const
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<string, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini',
[ModelProviderQuotaGetPaid.X]: 'langgenius/x',
[ModelProviderQuotaGetPaid.DEEPSEEK]: 'langgenius/deepseek',
[ModelProviderQuotaGetPaid.TONGYI]: 'langgenius/tongyi',
}
type QuotaPanelProps = {
provider: ModelProvider
providers: ModelProvider[]
isLoading?: boolean
}
const QuotaPanel: FC<QuotaPanelProps> = ({
provider,
providers,
isLoading = false,
}) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
), [providers])
const { formatTime } = useTimestamp()
const {
plugins: allPlugins,
} = useMarketplaceAllPlugins(providers, '')
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null)
const [isShowInstallModal, {
setTrue: showInstallFromMarketplace,
setFalse: hideInstallFromMarketplace,
}] = useBoolean(false)
const selectedPluginIdRef = useRef<string | null>(null)
const customConfig = provider.custom_configuration
const priorityUseType = provider.preferred_provider_type
const systemConfig = provider.system_configuration
const currentQuota = systemConfig.enabled && systemConfig.quota_configurations.find(item => item.quota_type === systemConfig.current_quota_type)
const openaiOrAnthropic = MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider)
const handleIconClick = useCallback((key: string) => {
const providerType = providerMap.get(key)
if (!providerType && allPlugins) {
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
if (plugin) {
setSelectedPlugin(plugin)
selectedPluginIdRef.current = pluginId
showInstallFromMarketplace()
}
}
}, [allPlugins, providerMap, showInstallFromMarketplace])
useEffect(() => {
if (isShowInstallModal && selectedPluginIdRef.current) {
const isInstalled = providers.some(p => p.provider.startsWith(selectedPluginIdRef.current!))
if (isInstalled) {
hideInstallFromMarketplace()
selectedPluginIdRef.current = null
}
}
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
if (isLoading) {
return (
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
<Loading />
</div>
)
}
return (
<div className="group relative min-w-[112px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] px-3 py-2 shadow-xs">
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={
openaiOrAnthropic
? t('modelProvider.card.tip', { ns: 'common' })
: t('modelProvider.quotaTip', { ns: 'common' })
}
/>
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common' })} />
</div>
{
currentQuota && (
<div className="flex h-4 items-center text-xs text-text-tertiary">
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(Math.max((currentQuota?.quota_limit || 0) - (currentQuota?.quota_used || 0), 0))}</span>
{
currentQuota?.quota_unit === QuotaUnitEnum.tokens && 'Tokens'
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
{currentWorkspace.next_credit_reset_date
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const usingQuota = providerType === PreferredProviderTypeEnum.system
const getTooltipKey = () => {
if (usingQuota)
return 'modelProvider.card.modelSupported'
if (providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelNotSupported'
}
{
currentQuota?.quota_unit === QuotaUnitEnum.times && t('modelProvider.callTimes', { ns: 'common' })
}
{
currentQuota?.quota_unit === QuotaUnitEnum.credits && t('modelProvider.credits', { ns: 'common' })
}
</div>
)
}
{
priorityUseType === PreferredProviderTypeEnum.system && customConfig.status === CustomConfigurationStatusEnum.active && (
<PriorityUseTip />
)
}
return (
<Tooltip
key={key}
popupContent={t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })}
>
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!usingQuota && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
</Tooltip>
)
})}
</div>
</div>
{isShowInstallModal && selectedPlugin && (
<InstallFromMarketplace
manifest={selectedPlugin}
uniqueIdentifier={selectedPlugin.latest_package_identifier}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)}
</div>
)
}
export default QuotaPanel
export default React.memo(QuotaPanel)

View File

@ -17,7 +17,25 @@ import {
ModelTypeEnum,
} from './declarations'
export const MODEL_PROVIDER_QUOTA_GET_PAID = ['langgenius/anthropic/anthropic', 'langgenius/openai/openai', 'langgenius/azure_openai/azure_openai']
export enum ModelProviderQuotaGetPaid {
ANTHROPIC = 'langgenius/anthropic/anthropic',
OPENAI = 'langgenius/openai/openai',
// AZURE_OPENAI = 'langgenius/azure_openai/azure_openai',
GEMINI = 'langgenius/gemini/google',
X = 'langgenius/x/x',
DEEPSEEK = 'langgenius/deepseek/deepseek',
TONGYI = 'langgenius/tongyi/tongyi',
}
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const modelNameMap = {
[ModelProviderQuotaGetPaid.OPENAI]: 'OpenAI',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'Anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'Gemini',
[ModelProviderQuotaGetPaid.X]: 'xAI',
[ModelProviderQuotaGetPaid.DEEPSEEK]: 'DeepSeek',
[ModelProviderQuotaGetPaid.TONGYI]: 'Tongyi',
}
export const isNullOrUndefined = (value: any) => {
return value === undefined || value === null