Merge branch 'feat/model-provider-refactor' into deploy/dev

This commit is contained in:
yyh
2026-03-05 10:20:02 +08:00
47 changed files with 2265 additions and 292 deletions

View File

@ -5,6 +5,7 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { useDebounce } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useCheckInstalled } from '@/service/use-plugins'
@ -153,7 +154,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
<QuotaPanel providers={providers} />
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
{!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

@ -1,52 +1,58 @@
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 { ConfigurationMethodEnum } from '../declarations'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} 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 mockCredentialStatus = {
hasCredential: true,
authorized: true,
authRemoved: false,
current_credential_name: 'test-credential',
notAllowedToUse: false,
}
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, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined },
mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
return { ...actual, IS_CLOUD_EDITION: true }
})
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
default: { notify: mockToastNotify },
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/common', () => ({
changeModelProviderPriority: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
ConfigProvider: () => <div data-testid="config-provider" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({
useCredentialStatus: () => mockCredentialStatus,
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: { key: () => ['console', 'modelProviders', 'models'] },
changePreferredProviderType: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {
mockChangePriorityFn(...args)
return Promise.resolve({ result: 'success' })
},
...opts,
}),
},
},
},
}))
vi.mock('../hooks', () => ({
@ -54,30 +60,47 @@ vi.mock('../hooks', () => ({
useUpdateModelProviders: () => mockUpdateModelProviders,
}))
vi.mock('./priority-selector', () => ({
default: ({ value, onSelect }: { value: string, onSelect: (key: string) => void }) => (
<button data-testid="priority-selector" onClick={() => onSelect('custom')}>
Priority Selector
{' '}
{value}
</button>
vi.mock('./use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('./model-auth-dropdown', () => ({
default: ({ state, onChangePriority }: { state: { variant: string, hasCredentials: boolean }, onChangePriority: (key: string) => void }) => (
<div data-testid="model-auth-dropdown" data-variant={state.variant}>
<button data-testid="change-priority-btn" onClick={() => onChangePriority('custom')}>
Change Priority
</button>
</div>
),
}))
vi.mock('./priority-use-tip', () => ({
default: () => <div data-testid="priority-use-tip">Priority Tip</div>,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid="indicator">{color}</div>,
default: ({ color }: { color: string }) => <div data-testid="indicator" data-color={color} />,
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
provider_credential_schema: { credential_form_schemas: [] },
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'test-credential',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'test-credential' }],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
const renderWithQueryClient = (provider: ModelProvider) => {
const queryClient = createTestQueryClient()
return render(
@ -88,74 +111,308 @@ const renderWithQueryClient = (provider: ModelProvider) => {
}
describe('CredentialPanel', () => {
const mockProvider: ModelProvider = {
provider: 'test-provider',
provider_credential_schema: true,
custom_configuration: { status: 'active' },
system_configuration: { enabled: true },
preferred_provider_type: 'system',
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['gpt-4'],
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockCredentialStatus, {
hasCredential: true,
authorized: true,
authRemoved: false,
current_credential_name: 'test-credential',
notAllowedToUse: false,
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
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 (no credentials)', () => {
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 (exhausted + credential unauthorized)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument()
})
it('should show "API key required" for api-required-add variant (custom priority, no credentials)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
})
it('should show "API key required" for api-required-configure variant (custom priority, credential exists but name missing)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
})
})
it('should show credential name and configuration actions', () => {
renderWithQueryClient(mockProvider)
describe('Status label variants', () => {
it('should show green indicator and credential name for api-fallback (exhausted + authorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
expect(screen.getByText('test-credential')).toBeInTheDocument()
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
})
it('should show warning icon for api-fallback variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('.i-ri-error-warning-fill')).toBeTruthy()
})
it('should show unauthorized status label when credential is missing', () => {
mockCredentialStatus.hasCredential = false
renderWithQueryClient(mockProvider)
it('should show green indicator for api-active (custom priority + authorized)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
})
it('should NOT show warning icon for api-active variant', () => {
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(container.querySelector('.i-ri-error-warning-fill')).toBeNull()
})
it('should show removed credential label and priority tip for custom preference', () => {
mockCredentialStatus.authorized = false
mockCredentialStatus.authRemoved = true
renderWithQueryClient({ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider)
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
})
it('should change priority and refresh related data after success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
renderWithQueryClient(mockProvider)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('gpt-4')
expect(mockEventEmitter.emit).toHaveBeenCalled()
it('should show red indicator and "Unavailable" for api-unavailable', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
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()
expect(screen.getByText('Bad Key')).toBeInTheDocument()
})
})
it('should render standalone priority selector without provider schema', () => {
const providerNoSchema = {
...mockProvider,
provider_credential_schema: null,
} as unknown as ModelProvider
renderWithQueryClient(providerNoSchema)
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
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: [],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply destructive container for no-usage variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply destructive container for api-unavailable variant', () => {
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply default container for credits-active', () => {
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
it('should apply default container for api-active', () => {
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
it('should apply default container for api-fallback', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
})
describe('Text color', () => {
it('should use destructive text color for credits-exhausted label', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(container.querySelector('.text-text-destructive')).toBeTruthy()
})
it('should use secondary text color for credits-active label', () => {
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('.text-text-secondary')).toBeTruthy()
})
})
describe('Priority change', () => {
it('should call mutation with correct params on priority change', async () => {
renderWithQueryClient(createProvider())
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockChangePriorityFn.mock.calls[0]?.[0]).toEqual({
params: { provider: 'test-provider' },
body: { preferred_provider_type: 'custom' },
})
})
})
it('should show success toast and refresh data after successful mutation', async () => {
renderWithQueryClient(createProvider())
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('llm')
expect(mockEventEmitter.emit).toHaveBeenCalled()
})
})
})
describe('ModelAuthDropdown integration', () => {
it('should pass credits-active variant to dropdown when credits available', () => {
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active')
})
it('should pass api-fallback variant to dropdown when exhausted with valid key', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-fallback')
})
it('should pass credits-exhausted variant when exhausted with no credentials', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-exhausted')
})
it('should pass api-active variant for custom priority with authorized key', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
it('should pass api-required-add variant for custom priority with no credentials', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add')
})
it('should pass api-unavailable variant for custom priority with named but unauthorized key', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-unavailable')
})
it('should pass no-usage variant when exhausted + credential but unauthorized', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'no-usage')
})
})
describe('apiKeyOnly priority (system disabled)', () => {
it('should derive api-required-add when system config disabled and no credentials', () => {
renderWithQueryClient(createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add')
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
})
it('should derive api-active when system config disabled but has authorized key', () => {
renderWithQueryClient(createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
})
})

View File

@ -1,175 +1,147 @@
import type {
ModelProvider,
PreferredProviderTypeEnum,
} from '../declarations'
import { useQueryClient } from '@tanstack/react-query'
import type { CardVariant } from './use-credential-panel-state'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
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 Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import { IS_CLOUD_EDITION } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { consoleQuery } from '@/service/client'
import { changeModelProviderPriority } from '@/service/common'
import { cn } from '@/utils/classnames'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import ModelAuthDropdown from './model-auth-dropdown'
import SystemQuotaCard from './system-quota-card'
import { useTrialCredits } from './use-trial-credits'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
type CredentialPanelProps = {
provider: ModelProvider
}
const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
'credits-active',
'credits-exhausted',
'no-usage',
'api-required-add',
'api-required-configure',
])
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const customConfig = provider.custom_configuration
const systemConfig = provider.system_configuration
const priorityUseType = provider.preferred_provider_type
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const configurateMethods = provider.configurate_methods
const {
hasCredential,
authorized,
authRemoved,
current_credential_name,
notAllowedToUse,
} = useCredentialStatus(provider)
const state = useCredentialPanelState(provider)
const showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
const isUsingSystemQuota = systemConfig.enabled && priorityUseType === PreferredProviderTypeEnum.system && IS_CLOUD_EDITION
const { isExhausted } = useTrialCredits()
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
body: {
preferred_provider_type: key,
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()
configurateMethods.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 any)
}
}
const credentialLabel = !hasCredential
? t('modelProvider.auth.unAuthorized', { ns: 'common' })
: authorized
? current_credential_name
: authRemoved
? t('modelProvider.auth.authRemoved', { ns: 'common' })
: ''
const color = (authRemoved || !hasCredential)
? 'red'
: notAllowedToUse
? 'gray'
: 'green'
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
if (isUsingSystemQuota) {
return (
<SystemQuotaCard variant={isExhausted ? 'destructive' : 'default'}>
<SystemQuotaCard.Label>
{isExhausted
? t('modelProvider.card.quotaExhausted', { ns: 'common' })
: t('modelProvider.card.aiCreditsInUse', { ns: 'common' })}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ConfigProvider provider={provider} />
{showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)}
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
return (
<SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}>
<SystemQuotaCard.Label className={isTextLabel ? undefined : 'gap-1'}>
{isTextLabel
? <TextLabel variant={variant} />
: <StatusLabel variant={variant} credentialName={credentialName} />}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ModelAuthDropdown
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
/>
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
const TEXT_LABEL_KEYS = {
'credits-active': 'modelProvider.card.aiCreditsInUse',
'credits-exhausted': 'modelProvider.card.quotaExhausted',
'no-usage': 'modelProvider.card.noAvailableUsage',
'api-required-add': 'modelProvider.card.apiKeyRequired',
'api-required-configure': 'modelProvider.card.apiKeyRequired',
} as const satisfies Partial<Record<CardVariant, string>>
function TextLabel({ variant }: { variant: CardVariant }) {
const { t } = useTranslation()
const isDestructive = isDestructiveVariant(variant)
const labelKey = TEXT_LABEL_KEYS[variant as keyof typeof TEXT_LABEL_KEYS]
return (
<span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}>
{t(labelKey, { ns: 'common' })}
</span>
)
}
function StatusLabel({ variant, credentialName }: {
variant: CardVariant
credentialName: string | undefined
}) {
const { t } = useTranslation()
const dotColor = variant === 'api-unavailable' ? 'red' : 'green'
const showWarning = variant === 'api-fallback'
return (
<>
{
provider.provider_credential_schema && (
<div className={cn(
'relative ml-1 w-[120px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] p-1',
authRemoved && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary system-xs-medium">
<div
className={cn(
'grow truncate',
authRemoved && 'text-text-destructive',
)}
title={credentialLabel}
>
{credentialLabel}
</div>
<Indicator className="shrink-0" color={color} />
</div>
<div className="flex items-center gap-0.5">
<ConfigProvider
provider={provider}
/>
{
showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)
}
</div>
{
priorityUseType === PreferredProviderTypeEnum.custom && systemConfig.enabled && (
<PriorityUseTip />
)
}
</div>
)
}
{
showPrioritySelector && !provider.provider_credential_schema && (
<div className="ml-1">
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
</div>
)
}
<Indicator className="shrink-0" color={dotColor} />
<span
className="truncate text-text-secondary"
title={credentialName}
>
{credentialName}
</span>
{showWarning && (
<span className="i-ri-error-warning-fill h-3 w-3 shrink-0 text-text-warning" />
)}
{variant === 'api-unavailable' && (
<span className="shrink-0 text-text-destructive system-2xs-medium">
{t('modelProvider.card.unavailable', { ns: 'common' })}
</span>
)}
</>
)
}

View File

@ -0,0 +1,142 @@
import type { Credential, ModelProvider } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import ApiKeySection from './api-key-section'
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
credential_id: 'cred-1',
credential_name: 'Test API Key',
...overrides,
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
allow_custom_token: true,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
...overrides,
} as unknown as ModelProvider)
describe('ApiKeySection', () => {
const handlers = {
onItemClick: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onAdd: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Empty state
describe('Empty state (no credentials)', () => {
it('should show empty state message', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument()
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should show Add API Key button', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should call onAdd when Add API Key is clicked', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
expect(handlers.onAdd).toHaveBeenCalledTimes(1)
})
it('should hide Add API Key button when allow_custom_token is false', () => {
render(
<ApiKeySection
provider={createProvider({ allow_custom_token: false })}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
})
})
// With credentials
describe('With credentials', () => {
const credentials = [
createCredential({ credential_id: 'cred-1', credential_name: 'Key Alpha' }),
createCredential({ credential_id: 'cred-2', credential_name: 'Key Beta' }),
]
it('should render credential list with header', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.getByText(/apiKeys/)).toBeInTheDocument()
expect(screen.getByText('Key Alpha')).toBeInTheDocument()
expect(screen.getByText('Key Beta')).toBeInTheDocument()
})
it('should show Add API Key button in footer', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should hide Add API Key when allow_custom_token is false', () => {
render(
<ApiKeySection
provider={createProvider({ allow_custom_token: false })}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,88 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CredentialItem from '../../model-auth/authorized/credential-item'
type ApiKeySectionProps = {
provider: ModelProvider
credentials: Credential[]
selectedCredentialId: string | undefined
onItemClick: (credential: Credential, model?: CustomModel) => void
onEdit: (credential?: Credential) => void
onDelete: (credential?: Credential) => void
onAdd: () => void
}
function ApiKeySection({
provider,
credentials,
selectedCredentialId,
onItemClick,
onEdit,
onDelete,
onAdd,
}: ApiKeySectionProps) {
const { t } = useTranslation()
const notAllowCustomCredential = provider.allow_custom_token === false
if (!credentials.length) {
return (
<div className="flex flex-col gap-2 p-2">
<div className="rounded-[10px] bg-gradient-to-r from-state-base-hover to-transparent p-4">
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-sm-medium">
{t('modelProvider.card.noApiKeysTitle', { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.noApiKeysDescription', { ns: 'common' })}
</div>
</div>
</div>
{!notAllowCustomCredential && (
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
)}
</div>
)
}
return (
<div>
<div className="px-1">
<div className="pb-1 pl-7 pr-2 pt-3 text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.auth.apiKeys', { ns: 'common' })}
</div>
<div className="max-h-[200px] overflow-y-auto">
{credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
showSelectedIcon
selectedCredentialId={selectedCredentialId}
onItemClick={onItemClick}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
{!notAllowCustomCredential && (
<div className="p-2">
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
</div>
)}
</div>
)
}
export default memo(ApiKeySection)

View File

@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
describe('CreditsExhaustedAlert', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 0 })
})
// Without API key fallback
describe('Without API key fallback', () => {
it('should show exhausted message', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument()
})
it('should show description with upgrade link', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/creditsExhaustedDescription/)).toBeInTheDocument()
})
})
// With API key fallback
describe('With API key fallback', () => {
it('should show fallback message', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback />)
expect(screen.getByText(/creditsExhaustedFallback(?!Description)/)).toBeInTheDocument()
})
it('should show fallback description', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback />)
expect(screen.getByText(/creditsExhaustedFallbackDescription/)).toBeInTheDocument()
})
})
// Usage display
describe('Usage display', () => {
it('should show usage label', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/usageLabel/)).toBeInTheDocument()
})
it('should show usage amounts', () => {
mockTrialCredits.credits = 200
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/9,800/)).toBeInTheDocument()
expect(screen.getByText(/10,000/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next'
import { formatNumber } from '@/utils/format'
import { useTrialCredits } from '../use-trial-credits'
type CreditsExhaustedAlertProps = {
hasApiKeyFallback: boolean
}
export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExhaustedAlertProps) {
const { t } = useTranslation()
const { credits, totalCredits } = useTrialCredits()
const titleKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallback'
: 'modelProvider.card.creditsExhaustedMessage'
const descriptionKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallbackDescription'
: 'modelProvider.card.creditsExhaustedDescription'
const usedCredits = totalCredits - credits
const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100
return (
<div className="mx-1 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
{t(descriptionKey, {
ns: 'common',
upgradeLink: `<a class="text-text-accent cursor-pointer system-xs-medium">${t('modelProvider.card.upgradePlan', { ns: 'common' })}</a>`,
interpolation: { escapeValue: false },
})}
</div>
</div>
<div className="mt-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-text-tertiary system-xs-medium">
{t('modelProvider.card.usageLabel', { ns: 'common' })}
</span>
<div className="flex items-center gap-0.5 text-text-tertiary system-xs-regular">
<span className="i-ri-coin-line h-3 w-3" />
<span>
{formatNumber(usedCredits)}
/
{formatNumber(totalCredits)}
</span>
</div>
</div>
<div className="h-1 overflow-hidden rounded-[6px] bg-components-progress-error-bg">
<div
className="h-full rounded-l-[6px] bg-components-progress-error-progress"
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
type CreditsFallbackAlertProps = {
hasCredentials: boolean
}
export default function CreditsFallbackAlert({ hasCredentials }: CreditsFallbackAlertProps) {
const { t } = useTranslation()
const titleKey = hasCredentials
? 'modelProvider.card.apiKeyUnavailableFallback'
: 'modelProvider.card.noApiKeysFallback'
return (
<div className="mx-1 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
{hasCredentials && (
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.apiKeyUnavailableFallbackDescription', { ns: 'common' })}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,428 @@
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'
const mockHandleOpenModal = vi.fn()
const mockHandleActiveCredential = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
let mockDeleteCredentialId: string | null = null
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleActiveCredential: mockHandleActiveCredential,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
handleOpenModal: mockHandleOpenModal,
}),
}))
vi.mock('../../model-auth/authorized/credential-item', () => ({
default: ({ credential, onItemClick, onEdit, onDelete }: {
credential: { credential_id: string, credential_name: string }
onItemClick?: (c: unknown) => void
onEdit?: (c: unknown) => void
onDelete?: (c: unknown) => void
}) => (
<div data-testid={`credential-${credential.credential_id}`}>
<span>{credential.credential_name}</span>
<button data-testid={`click-${credential.credential_id}`} onClick={() => onItemClick?.(credential)}>select</button>
<button data-testid={`edit-${credential.credential_id}`} onClick={() => onEdit?.(credential)}>edit</button>
<button data-testid={`delete-${credential.credential_id}`} onClick={() => onDelete?.(credential)}>delete</button>
</div>
),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'My Key',
available_credentials: [
{ credential_id: 'cred-1', credential_name: 'My Key' },
{ credential_id: 'cred-2', credential_name: 'Other Key' },
],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'My Key',
credits: 100,
...overrides,
})
describe('DropdownContent', () => {
const onChangePriority = vi.fn()
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockDeleteCredentialId = null
})
describe('UsagePrioritySection visibility', () => {
it('should show when showPrioritySwitcher is true', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/usagePriority/)).toBeInTheDocument()
})
it('should hide when showPrioritySwitcher is false', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/usagePriority/)).not.toBeInTheDocument()
})
})
describe('CreditsExhaustedAlert', () => {
it('should show when credits exhausted and supports credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/creditsExhausted/).length).toBeGreaterThan(0)
})
it('should hide when credits not exhausted', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument()
})
it('should hide when credits exhausted but supportsCredits is false', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument()
})
it('should show fallback message when api-fallback variant with exhausted credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-fallback',
isCreditsExhausted: true,
supportsCredits: true,
priority: 'credits',
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/creditsExhaustedFallback/).length).toBeGreaterThan(0)
})
it('should show non-fallback message when credits-exhausted variant', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'credits-exhausted',
isCreditsExhausted: true,
supportsCredits: true,
hasCredentials: false,
priority: 'credits',
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument()
})
})
describe('CreditsFallbackAlert', () => {
it('should show when priority is apiKey, supports credits, not exhausted, and variant is not api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-required-add',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
hasCredentials: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/noApiKeysFallback/)).toBeInTheDocument()
})
it('should show unavailable message when priority is apiKey with credentials but not api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-unavailable',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
hasCredentials: true,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/apiKeyUnavailableFallback/).length).toBeGreaterThan(0)
})
it('should NOT show when variant is api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument()
expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument()
})
it('should NOT show when priority is credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
isCreditsExhausted: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument()
expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument()
})
})
describe('API key section', () => {
it('should render all credential items', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText('My Key')).toBeInTheDocument()
expect(screen.getByText('Other Key')).toBeInTheDocument()
})
it('should show empty state when no credentials', () => {
render(
<DropdownContent
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument()
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should call handleActiveCredential and close on credential item click', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('click-cred-1'))
expect(mockHandleActiveCredential).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-1' }),
)
expect(onClose).toHaveBeenCalled()
})
it('should call handleOpenModal and close on edit credential', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('edit-cred-2'))
expect(mockHandleOpenModal).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
expect(onClose).toHaveBeenCalled()
})
it('should call openConfirmDelete on delete credential', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('delete-cred-2'))
expect(mockOpenConfirmDelete).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
})
})
describe('Add API Key', () => {
it('should call handleOpenModal with no args and close on add', () => {
render(
<DropdownContent
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
expect(mockHandleOpenModal).toHaveBeenCalledWith()
expect(onClose).toHaveBeenCalled()
})
})
describe('AlertDialog for delete confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/confirmDelete/)).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
mockDeleteCredentialId = null
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/confirmDelete/)).not.toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have 320px width container', () => {
const { container } = render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(container.querySelector('.w-\\[320px\\]')).toBeTruthy()
})
})
})

View File

@ -0,0 +1,136 @@
import type { Credential, ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { ConfigurationMethodEnum } from '../../declarations'
import { useAuth } from '../../model-auth/hooks'
import ApiKeySection from './api-key-section'
import CreditsExhaustedAlert from './credits-exhausted-alert'
import CreditsFallbackAlert from './credits-fallback-alert'
import UsagePrioritySection from './usage-priority-section'
const EMPTY_CREDENTIALS: Credential[] = []
type DropdownContentProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
onClose: () => void
}
function DropdownContent({
provider,
state,
isChangingPriority,
onChangePriority,
onClose,
}: DropdownContentProps) {
const { t } = useTranslation()
const {
current_credential_id,
available_credentials,
} = provider.custom_configuration
const {
openConfirmDelete,
closeConfirmDelete,
doingAction,
handleActiveCredential,
handleConfirmDelete,
deleteCredentialId,
handleOpenModal,
} = useAuth(provider, ConfigurationMethodEnum.predefinedModel)
const handleItemClick = useCallback((credential: Credential) => {
handleActiveCredential(credential)
onClose()
}, [handleActiveCredential, onClose])
const handleEdit = useCallback((credential?: Credential) => {
handleOpenModal(credential)
onClose()
}, [handleOpenModal, onClose])
const handleDelete = useCallback((credential?: Credential) => {
if (credential)
openConfirmDelete(credential)
}, [openConfirmDelete])
const handleAdd = useCallback(() => {
handleOpenModal()
onClose()
}, [handleOpenModal, onClose])
const showCreditsExhaustedAlert = state.isCreditsExhausted && state.supportsCredits
const hasApiKeyFallback = state.variant === 'api-fallback'
|| (state.variant === 'api-active' && state.priority === 'apiKey')
const showCreditsFallbackAlert = state.priority === 'apiKey'
&& state.supportsCredits
&& !state.isCreditsExhausted
&& state.variant !== 'api-active'
return (
<>
<div className="w-[320px]">
{state.showPrioritySwitcher && (
<UsagePrioritySection
value={state.priority}
disabled={isChangingPriority}
onSelect={onChangePriority}
/>
)}
{showCreditsFallbackAlert && (
<CreditsFallbackAlert hasCredentials={state.hasCredentials} />
)}
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ApiKeySection
provider={provider}
credentials={available_credentials ?? EMPTY_CREDENTIALS}
selectedCredentialId={current_credential_id}
onItemClick={handleItemClick}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
/>
</div>
<AlertDialog
open={!!deleteCredentialId}
onOpenChange={(open) => {
if (!open)
closeConfirmDelete()
}}
>
<AlertDialogContent>
<div className="p-6 pb-0">
<AlertDialogTitle className="text-text-primary system-xl-semibold">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-1 text-text-secondary system-sm-regular" />
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={doingAction}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}>
{t('operation.delete', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default memo(DropdownContent)

View File

@ -0,0 +1,204 @@
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import ModelAuthDropdown from './index'
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
doingAction: false,
handleActiveCredential: vi.fn(),
handleConfirmDelete: vi.fn(),
deleteCredentialId: null,
handleOpenModal: vi.fn(),
}),
}))
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 100,
...overrides,
})
describe('ModelAuthDropdown', () => {
const onChangePriority = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Button text', () => {
it('should show "Add API Key" when no credentials for credits-active', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: false, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" when has credentials for api-active', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Add API Key" for api-required-add variant', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" for api-required-configure variant', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Configure" for credits-active when has credentials', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Add API Key" for credits-exhausted (no credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'credits-exhausted', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" for api-unavailable (has credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-unavailable', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Configure" for api-fallback (has credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-fallback', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
})
describe('Button variant styling', () => {
it('should use secondary-accent for api-required-add', () => {
const { container } = render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const button = container.querySelector('button')
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/)
})
it('should use secondary-accent for api-required-configure', () => {
const { container } = render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const button = container.querySelector('button')
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/)
})
})
describe('Popover behavior', () => {
it('should open popover on button click and show dropdown content', async () => {
render(
<ModelAuthDropdown
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
})}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /config/i }))
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,80 @@
import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CardVariant, CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import DropdownContent from './dropdown-content'
type ModelAuthDropdownProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
}
const ACCENT_VARIANTS = new Set<CardVariant>([
'api-required-add',
'api-required-configure',
])
function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: string, opts?: Record<string, string>) => string) {
if (ACCENT_VARIANTS.has(variant)) {
return {
text: variant === 'api-required-add'
? t('modelProvider.auth.addApiKey', { ns: 'common' })
: t('operation.config', { ns: 'common' }),
variant: 'secondary-accent' as const,
}
}
const text = hasCredentials
? t('operation.config', { ns: 'common' })
: t('modelProvider.auth.addApiKey', { ns: 'common' })
return { text, variant: 'secondary' as const }
}
function ModelAuthDropdown({ provider, state, isChangingPriority, onChangePriority }: ModelAuthDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleClose = useCallback(() => setOpen(false), [])
const buttonConfig = getButtonConfig(state.variant, state.hasCredentials, t)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
className="flex grow"
size="small"
variant={buttonConfig.variant}
title={buttonConfig.text}
>
<span className="i-ri-equalizer-2-line mr-1 h-3.5 w-3.5 shrink-0" />
<span className="w-0 grow truncate text-left">
{buttonConfig.text}
</span>
</Button>
)}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={onChangePriority}
onClose={handleClose}
/>
</PopoverContent>
</Popover>
)
}
export default memo(ModelAuthDropdown)

View File

@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { PreferredProviderTypeEnum } from '../../declarations'
import UsagePrioritySection from './usage-priority-section'
describe('UsagePrioritySection', () => {
const onSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering
describe('Rendering', () => {
it('should render title and both option buttons', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
expect(screen.getByText(/usagePriority/)).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(2)
})
})
// Selection state
describe('Selection state', () => {
it('should highlight AI credits option when value is credits', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0].className).toContain('border-components-option-card-option-selected-border')
expect(buttons[1].className).not.toContain('border-components-option-card-option-selected-border')
})
it('should highlight API key option when value is apiKey', () => {
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0].className).not.toContain('border-components-option-card-option-selected-border')
expect(buttons[1].className).toContain('border-components-option-card-option-selected-border')
})
it('should highlight API key option when value is apiKeyOnly', () => {
render(<UsagePrioritySection value="apiKeyOnly" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[1].className).toContain('border-components-option-card-option-selected-border')
})
})
// User interactions
describe('User interactions', () => {
it('should call onSelect with system when clicking AI credits option', () => {
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
fireEvent.click(screen.getAllByRole('button')[0])
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.system)
})
it('should call onSelect with custom when clicking API key option', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
fireEvent.click(screen.getAllByRole('button')[1])
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.custom)
})
})
})

View File

@ -0,0 +1,56 @@
import type { UsagePriority } from '../use-credential-panel-state'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
value: UsagePriority
disabled?: boolean
onSelect: (key: PreferredProviderTypeEnum) => void
}
const options = [
{ key: PreferredProviderTypeEnum.system, labelKey: 'modelProvider.card.aiCreditsOption' },
{ key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' },
] as const
export default function UsagePrioritySection({ value, disabled, onSelect }: UsagePrioritySectionProps) {
const { t } = useTranslation()
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
: PreferredProviderTypeEnum.custom
return (
<div className="border-b border-b-divider-subtle p-1">
<div className="flex items-center gap-1 rounded-lg p-1">
<div className="shrink-0 px-0.5 py-1">
<span className="i-ri-arrow-up-double-line block h-4 w-4 text-text-tertiary" />
</div>
<div className="flex min-w-0 flex-1 items-center gap-0.5 py-0.5">
<span className="truncate text-text-secondary system-sm-medium">
{t('modelProvider.card.usagePriority', { ns: 'common' })}
</span>
<span className="i-ri-question-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
</div>
<div className="flex shrink-0 items-center gap-1">
{options.map(option => (
<button
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 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' })}
</button>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import SystemQuotaCard from './system-quota-card'
describe('SystemQuotaCard', () => {
// Renders container with children
describe('Rendering', () => {
it('should render children', () => {
render(
<SystemQuotaCard>
<span>content</span>
</SystemQuotaCard>,
)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should apply default variant styles', () => {
const { container } = render(
<SystemQuotaCard>
<span>test</span>
</SystemQuotaCard>,
)
const card = container.firstElementChild!
expect(card.className).toContain('bg-white')
})
it('should apply destructive variant styles', () => {
const { container } = render(
<SystemQuotaCard variant="destructive">
<span>test</span>
</SystemQuotaCard>,
)
const card = container.firstElementChild!
expect(card.className).toContain('border-state-destructive-border')
})
})
// Label sub-component
describe('Label', () => {
it('should apply default variant text color when no className provided', () => {
render(
<SystemQuotaCard>
<SystemQuotaCard.Label>Default label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
expect(screen.getByText('Default label').className).toContain('text-text-secondary')
})
it('should apply destructive variant text color when no className provided', () => {
render(
<SystemQuotaCard variant="destructive">
<SystemQuotaCard.Label>Error label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
expect(screen.getByText('Error label').className).toContain('text-text-destructive')
})
it('should override variant color with custom className', () => {
render(
<SystemQuotaCard variant="destructive">
<SystemQuotaCard.Label className="gap-1">Custom label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
const label = screen.getByText('Custom label')
expect(label.className).toContain('gap-1')
expect(label.className).not.toContain('text-text-destructive')
})
})
// Actions sub-component
describe('Actions', () => {
it('should render action children', () => {
render(
<SystemQuotaCard>
<SystemQuotaCard.Actions>
<button>Click me</button>
</SystemQuotaCard.Actions>
</SystemQuotaCard>,
)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
})
})

View File

@ -40,12 +40,12 @@ const SystemQuotaCard = ({
)
}
const Label = ({ children }: { children: ReactNode }) => {
const Label = ({ children, className }: { children: ReactNode, className?: string }) => {
const variant = useContext(VariantContext)
return (
<div className={cn(
'relative z-[1] truncate px-1.5 pt-1 system-xs-medium',
labelVariants[variant],
'relative z-[1] flex items-center gap-1 truncate px-1.5 pt-1 system-xs-medium',
className ?? labelVariants[variant],
)}
>
{children}

View File

@ -0,0 +1,201 @@
import type { ModelProvider } from '../declarations'
import { renderHook } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
const mockTrialCredits = { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
vi.mock('./use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
provider_credential_schema: { credential_form_schemas: [] },
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'My Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
describe('useCredentialPanelState', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
// Credits priority variants
describe('Credits priority variants', () => {
it('should return credits-active when credits available', () => {
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.variant).toBe('credits-active')
expect(result.current.priority).toBe('credits')
expect(result.current.supportsCredits).toBe(true)
})
it('should return api-fallback when credits exhausted but API key authorized', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.variant).toBe('api-fallback')
})
it('should return no-usage when credits exhausted and API key unauthorized', () => {
mockTrialCredits.isExhausted = true
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('no-usage')
})
it('should return credits-exhausted when credits exhausted and no credentials', () => {
mockTrialCredits.isExhausted = true
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-exhausted')
})
})
// API key priority variants
describe('API key priority variants', () => {
it('should return api-active when API key authorized', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-active')
expect(result.current.priority).toBe('apiKey')
})
it('should return api-unavailable when API key unauthorized', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-configure')
})
it('should return api-required-add when no credentials exist', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-add')
})
})
// apiKeyOnly priority
describe('apiKeyOnly priority (non-cloud / system disabled)', () => {
it('should return apiKeyOnly when system config disabled', () => {
const provider = createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
})
})
// Derived metadata
describe('Derived metadata', () => {
it('should show priority switcher when credits supported and custom config active', () => {
const provider = createProvider()
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(true)
})
it('should hide priority switcher when system config disabled', () => {
const provider = createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(false)
})
it('should expose credential name from provider', () => {
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.credentialName).toBe('My Key')
})
it('should expose credits amount', () => {
mockTrialCredits.credits = 500
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.credits).toBe(500)
})
})
})
describe('isDestructiveVariant', () => {
it.each([
['credits-exhausted', true],
['no-usage', true],
['api-unavailable', true],
['credits-active', false],
['api-fallback', false],
['api-active', false],
['api-required-add', false],
['api-required-configure', false],
] as const)('should return %s for variant %s', (variant, expected) => {
expect(isDestructiveVariant(variant)).toBe(expected)
})
})

View File

@ -0,0 +1,97 @@
import type { ModelProvider } from '../declarations'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import {
PreferredProviderTypeEnum,
} from '../declarations'
import { useTrialCredits } from './use-trial-credits'
export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
export type CardVariant
= | 'credits-active'
| 'credits-exhausted'
| 'no-usage'
| 'api-fallback'
| 'api-active'
| 'api-required-add'
| 'api-required-configure'
| 'api-unavailable'
export type CredentialPanelState = {
variant: CardVariant
priority: UsagePriority
supportsCredits: boolean
showPrioritySwitcher: boolean
hasCredentials: boolean
isCreditsExhausted: boolean
credentialName: string | undefined
credits: number
}
const DESTRUCTIVE_VARIANTS = new Set<CardVariant>([
'credits-exhausted',
'no-usage',
'api-unavailable',
])
export const isDestructiveVariant = (variant: CardVariant) =>
DESTRUCTIVE_VARIANTS.has(variant)
function deriveVariant(
priority: UsagePriority,
isExhausted: boolean,
hasCredential: boolean,
authorized: boolean | undefined,
credentialName: string | undefined,
): CardVariant {
if (priority === 'credits') {
if (!isExhausted)
return 'credits-active'
if (hasCredential && authorized)
return 'api-fallback'
if (hasCredential && !authorized)
return 'no-usage'
return 'credits-exhausted'
}
if (hasCredential && authorized)
return 'api-active'
if (hasCredential && !authorized)
return credentialName ? 'api-unavailable' : 'api-required-configure'
return 'api-required-add'
}
export function useCredentialPanelState(provider: ModelProvider): CredentialPanelState {
const { isExhausted, credits } = useTrialCredits()
const {
hasCredential,
authorized,
current_credential_name,
} = useCredentialStatus(provider)
const systemConfig = provider.system_configuration
const preferredType = provider.preferred_provider_type
const supportsCredits = systemConfig.enabled
const priority: UsagePriority = !supportsCredits
? 'apiKeyOnly'
: preferredType === PreferredProviderTypeEnum.system
? 'credits'
: 'apiKey'
const showPrioritySwitcher = supportsCredits
const variant = deriveVariant(priority, isExhausted, hasCredential, !!authorized, current_credential_name)
return {
variant,
priority,
supportsCredits,
showPrioritySwitcher,
hasCredentials: hasCredential,
isCreditsExhausted: isExhausted,
credentialName: current_credential_name,
credits,
}
}

View File

@ -2,10 +2,12 @@ import { useCurrentWorkspace } from '@/service/use-common'
export const useTrialCredits = () => {
const { data: currentWorkspace, isPending } = useCurrentWorkspace()
const credits = Math.max(((currentWorkspace?.trial_credits ?? 0) - (currentWorkspace?.trial_credits_used ?? 0)) || 0, 0)
const totalCredits = currentWorkspace?.trial_credits ?? 0
const credits = Math.max(totalCredits - (currentWorkspace?.trial_credits_used ?? 0), 0)
return {
credits,
totalCredits,
isExhausted: credits <= 0,
isLoading: isPending && !currentWorkspace,
nextCreditResetDate: currentWorkspace?.next_credit_reset_date,

View File

@ -18,7 +18,6 @@ vi.mock('react-i18next', async () => {
'modelProvider.speechToTextModel.tip': 'Speech to text model tip',
'modelProvider.ttsModel.key': 'TTS Model',
'modelProvider.ttsModel.tip': 'TTS model tip',
'modelProvider.systemModelSettingsLink': 'Description text here',
'operation.cancel': 'Cancel',
'operation.save': 'Save',
'actionMsg.modifiedSuccessfully': 'Modified successfully',

View File

@ -11,7 +11,6 @@ import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/app/components/base/ui/dialog'
import {
@ -184,9 +183,6 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('modelProvider.systemModelSettings', { ns: 'common' })}
</DialogTitle>
<DialogDescription className="mt-1 text-text-tertiary system-xs-regular">
{t('modelProvider.systemModelSettingsLink', { ns: 'common' })}
</DialogDescription>
</div>
<div className="flex flex-col gap-4 px-6 py-3">
<div className="flex flex-col gap-1">

View File

@ -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<CommonResponse>())

View File

@ -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,

View File

@ -4857,11 +4857,6 @@
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx": {
"no-restricted-imports": {
"count": 1
@ -6344,11 +6339,6 @@
"count": 2
}
},
"app/components/workflow/__tests__/trigger-status-sync.test.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/block-selector/all-start-blocks.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -8360,11 +8350,6 @@
"count": 5
}
},
"app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow/nodes/tool/components/copy-id.tsx": {
"no-restricted-imports": {
"count": 1
@ -8489,11 +8474,6 @@
"count": 1
}
},
"app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts": {
"ts/no-explicit-any": {
"count": 7

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "نموذج تحويل الكلام إلى نص",
"modelProvider.speechToTextModel.tip": "تعيين النموذج الافتراضي لإدخال تحويل الكلام إلى نص في المحادثة.",
"modelProvider.systemModelSettings": "إعدادات نموذج النظام",
"modelProvider.systemModelSettingsLink": "لماذا من الضروري إعداد نموذج النظام؟",
"modelProvider.systemReasoningModel.key": "نموذج التفكير النظامي",
"modelProvider.systemReasoningModel.tip": "تعيين نموذج الاستنتاج الافتراضي لاستخدامه لإنشاء التطبيقات، بالإضافة إلى ميزات مثل إنشاء اسم الحوار واقتراح السؤال التالي ستستخدم أيضًا نموذج الاستنتاج الافتراضي.",
"modelProvider.toBeConfigured": "ليتم تكوينه",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Sprach-zu-Text-Modell",
"modelProvider.speechToTextModel.tip": "Legen Sie das Standardmodell für die Spracheingabe in Konversationen fest.",
"modelProvider.systemModelSettings": "Systemmodell-Einstellungen",
"modelProvider.systemModelSettingsLink": "Warum ist es notwendig, ein Systemmodell einzurichten?",
"modelProvider.systemReasoningModel.key": "System-Reasoning-Modell",
"modelProvider.systemReasoningModel.tip": "Legen Sie das Standardinferenzmodell fest, das für die Erstellung von Anwendungen verwendet wird, sowie Funktionen wie die Generierung von Dialognamen und die Vorschlagserstellung für die nächste Frage, die auch das Standardinferenzmodell verwenden.",
"modelProvider.toBeConfigured": "Zu konfigurieren",

View File

@ -341,11 +341,24 @@
"modelProvider.buyQuota": "Buy Quota",
"modelProvider.callTimes": "Call times",
"modelProvider.card.aiCreditsInUse": "AI credits in use",
"modelProvider.card.aiCreditsOption": "AI credits",
"modelProvider.card.apiKeyOption": "API Key",
"modelProvider.card.apiKeyRequired": "API key required",
"modelProvider.card.apiKeyUnavailableFallback": "API Key unavailable, now using AI credits",
"modelProvider.card.apiKeyUnavailableFallbackDescription": "Check your API key configuration to switch back",
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "Call times",
"modelProvider.card.creditsExhaustedDescription": "Please {{upgradeLink}} or configure an API key",
"modelProvider.card.creditsExhaustedFallback": "AI credits exhausted, now using API key",
"modelProvider.card.creditsExhaustedFallbackDescription": "{{upgradeLink}} to resume AI credit priority.",
"modelProvider.card.creditsExhaustedMessage": "AI credits have been exhausted",
"modelProvider.card.modelAPI": "{{modelName}} models are using the API Key.",
"modelProvider.card.modelNotSupported": "{{modelName}} not installed",
"modelProvider.card.modelSupported": "{{modelName}} models are using these credits.",
"modelProvider.card.noApiKeysDescription": "Add an API key to start using your own model credentials.",
"modelProvider.card.noApiKeysFallback": "No API keys, using AI credits instead",
"modelProvider.card.noApiKeysTitle": "No API keys configured yet",
"modelProvider.card.noAvailableUsage": "No available usage",
"modelProvider.card.onTrial": "On Trial",
"modelProvider.card.paid": "Paid",
"modelProvider.card.priorityUse": "Priority use",
@ -354,6 +367,10 @@
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "AI Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The Trial quota will be used after the paid quota is exhausted.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.card.unavailable": "Unavailable",
"modelProvider.card.upgradePlan": "upgrade your plan",
"modelProvider.card.usageLabel": "Usage",
"modelProvider.card.usagePriority": "Usage Priority",
"modelProvider.collapse": "Collapse",
"modelProvider.config": "Config",
"modelProvider.configLoadBalancing": "Config Load Balancing",
@ -416,7 +433,6 @@
"modelProvider.speechToTextModel.key": "Speech-to-Text Model",
"modelProvider.speechToTextModel.tip": "Set the default model for speech-to-text input in conversation.",
"modelProvider.systemModelSettings": "Default Model Settings",
"modelProvider.systemModelSettingsLink": "Why is it necessary to set up a system model?",
"modelProvider.systemReasoningModel.key": "System Reasoning Model",
"modelProvider.systemReasoningModel.tip": "Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.",
"modelProvider.toBeConfigured": "To be configured",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Modelo de Voz a Texto",
"modelProvider.speechToTextModel.tip": "Establece el modelo predeterminado para la entrada de voz a texto en la conversación.",
"modelProvider.systemModelSettings": "Configuraciones del Modelo del Sistema",
"modelProvider.systemModelSettingsLink": "¿Por qué es necesario configurar un modelo del sistema?",
"modelProvider.systemReasoningModel.key": "Modelo de Razonamiento del Sistema",
"modelProvider.systemReasoningModel.tip": "Establece el modelo de inferencia predeterminado para ser usado en la creación de aplicaciones, así como características como la generación de nombres de diálogo y sugerencias de la próxima pregunta también usarán el modelo de inferencia predeterminado.",
"modelProvider.toBeConfigured": "A configurar",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "مدل تبدیل گفتار به متن",
"modelProvider.speechToTextModel.tip": "مدل پیش‌فرض را برای ورودی گفتار به متن در مکالمه تنظیم کنید.",
"modelProvider.systemModelSettings": "تنظیمات مدل سیستم",
"modelProvider.systemModelSettingsLink": "چرا تنظیم مدل سیستم ضروری است؟",
"modelProvider.systemReasoningModel.key": "مدل استدلال سیستم",
"modelProvider.systemReasoningModel.tip": "مدل استنتاج پیش‌فرض را برای ایجاد برنامه‌ها تنظیم کنید. ویژگی‌هایی مانند تولید نام گفتگو و پیشنهاد سوال بعدی نیز از مدل استنتاج پیش‌فرض استفاده خواهند کرد.",
"modelProvider.toBeConfigured": "پیکربندی شود",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Modèle de Texte-à-Parole",
"modelProvider.speechToTextModel.tip": "Définissez le modèle par défaut pour l'entrée de texte par la parole dans la conversation.",
"modelProvider.systemModelSettings": "Paramètres du Modèle Système",
"modelProvider.systemModelSettingsLink": "Pourquoi est-il nécessaire de mettre en place un modèle de système ?",
"modelProvider.systemReasoningModel.key": "Modèle de Raisonnement du Système",
"modelProvider.systemReasoningModel.tip": "Définissez le modèle d'inférence par défaut à utiliser pour la création d'applications, ainsi que des fonctionnalités telles que la génération de noms de dialogue et la suggestion de la prochaine question utiliseront également le modèle d'inférence par défaut.",
"modelProvider.toBeConfigured": "À configurer",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "भाषण-से-पाठ मॉडल",
"modelProvider.speechToTextModel.tip": "संवाद में भाषण-से-पाठ इनपुट के लिए डिफ़ॉल्ट मॉडल सेट करें।",
"modelProvider.systemModelSettings": "सिस्टम मॉडल सेटिंग्स",
"modelProvider.systemModelSettingsLink": "सिस्टम मॉडल सेट करना क्यों आवश्यक है?",
"modelProvider.systemReasoningModel.key": "सिस्टम तर्क मॉडल",
"modelProvider.systemReasoningModel.tip": "ऐप्लिकेशन बनाने के लिए उपयोग किए जाने वाले डिफ़ॉल्ट अनुमान मॉडल को सेट करें, साथ ही संवाद नाम पीढ़ी और अगले प्रश्न सुझाव जैसी सुविधाएँ भी डिफ़ॉल्ट अनुमान मॉडल का उपयोग करेंगी।",
"modelProvider.toBeConfigured": "कॉन्फ़िगर किया जाना है",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Model Ucapan-ke-Teks",
"modelProvider.speechToTextModel.tip": "Atur model default untuk input ucapan-ke-teks dalam percakapan.",
"modelProvider.systemModelSettings": "Pengaturan Model Sistem",
"modelProvider.systemModelSettingsLink": "Mengapa perlu menyiapkan model sistem?",
"modelProvider.systemReasoningModel.key": "Model Penalaran Sistem",
"modelProvider.systemReasoningModel.tip": "Atur model inferensi default yang akan digunakan untuk membuat aplikasi, serta fitur seperti pembuatan nama dialog dan saran pertanyaan berikutnya juga akan menggunakan model inferensi default.",
"modelProvider.toBeConfigured": "Untuk dikonfigurasi",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Modello da Voce a Testo",
"modelProvider.speechToTextModel.tip": "Imposta il modello predefinito per l'input da voce a testo nella conversazione.",
"modelProvider.systemModelSettings": "Impostazioni Modello di Sistema",
"modelProvider.systemModelSettingsLink": "Perché è necessario configurare un modello di sistema?",
"modelProvider.systemReasoningModel.key": "Modello di Ragionamento di Sistema",
"modelProvider.systemReasoningModel.tip": "Imposta il modello di inferenza predefinito da utilizzare per creare applicazioni, così come funzionalità come la generazione del nome del dialogo e il suggerimento della domanda successiva utilizzeranno anche il modello di inferenza predefinito.",
"modelProvider.toBeConfigured": "Da configurare",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "音声-to-テキストモデル",
"modelProvider.speechToTextModel.tip": "会話での音声-to-テキスト入力に使用するデフォルトモデルを設定します。",
"modelProvider.systemModelSettings": "システムモデル設定",
"modelProvider.systemModelSettingsLink": "システムモデルの設定が必要な理由は何ですか?",
"modelProvider.systemReasoningModel.key": "システム推論モデル",
"modelProvider.systemReasoningModel.tip": "アプリの作成に使用されるデフォルトの推論モデルを設定します。また、対話名の生成や次の質問の提案などの機能もデフォルトの推論モデルを使用します。",
"modelProvider.toBeConfigured": "設定中",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "음성-to-텍스트 모델",
"modelProvider.speechToTextModel.tip": "대화에서의 음성-to-텍스트 입력에 사용되는 기본 모델을 설정합니다.",
"modelProvider.systemModelSettings": "시스템 모델 설정",
"modelProvider.systemModelSettingsLink": "시스템 모델 설정이 필요한 이유는 무엇입니까?",
"modelProvider.systemReasoningModel.key": "시스템 추론 모델",
"modelProvider.systemReasoningModel.tip": "앱 구축에 사용되는 기본 추론 모델을 설정합니다. 또한 대화 이름 생성 및 다음 질문 제안과 같은 기능도 기본 추론 모델을 사용합니다.",
"modelProvider.toBeConfigured": "구성 예정",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Speech-to-Text Model",
"modelProvider.speechToTextModel.tip": "Set the default model for speech-to-text input in conversation.",
"modelProvider.systemModelSettings": "System Model Settings",
"modelProvider.systemModelSettingsLink": "Why is it necessary to set up a system model?",
"modelProvider.systemReasoningModel.key": "System Reasoning Model",
"modelProvider.systemReasoningModel.tip": "Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.",
"modelProvider.toBeConfigured": "To be configured",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Model mowy na tekst",
"modelProvider.speechToTextModel.tip": "Ustaw domyślny model do przetwarzania mowy na tekst w rozmowach.",
"modelProvider.systemModelSettings": "Ustawienia modelu systemowego",
"modelProvider.systemModelSettingsLink": "Dlaczego konieczne jest skonfigurowanie modelu systemowego?",
"modelProvider.systemReasoningModel.key": "Model wnioskowania systemowego",
"modelProvider.systemReasoningModel.tip": "Ustaw domyślny model wnioskowania do użytku przy tworzeniu aplikacji, a także cechy takie jak generowanie nazw dialogów i sugestie następnego pytania będą również korzystać z domyślnego modelu wnioskowania.",
"modelProvider.toBeConfigured": "Do skonfigurowania",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Modelo de Fala para Texto",
"modelProvider.speechToTextModel.tip": "Defina o modelo padrão para entrada de fala para texto na conversa.",
"modelProvider.systemModelSettings": "Configurações do Modelo do Sistema",
"modelProvider.systemModelSettingsLink": "Por que é necessário configurar um modelo do sistema?",
"modelProvider.systemReasoningModel.key": "Modelo de Raciocínio do Sistema",
"modelProvider.systemReasoningModel.tip": "Defina o modelo de inferência padrão a ser usado para criar aplicativos, bem como recursos como geração de nomes de diálogo e sugestão de próxima pergunta também usarão o modelo de inferência padrão.",
"modelProvider.toBeConfigured": "A ser configurado",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Model de conversie text-la-vorbire",
"modelProvider.speechToTextModel.tip": "Setați modelul implicit pentru intrarea de conversie text-la-vorbire în conversație.",
"modelProvider.systemModelSettings": "Setări model de sistem",
"modelProvider.systemModelSettingsLink": "De ce este necesar să se configureze un model de sistem?",
"modelProvider.systemReasoningModel.key": "Model de raționament de sistem",
"modelProvider.systemReasoningModel.tip": "Setați modelul de inferență implicit care va fi utilizat pentru crearea aplicațiilor, precum și caracteristici precum generarea de nume pentru dialog și sugestia următoarei întrebări vor utiliza, de asemenea, modelul de inferență implicit.",
"modelProvider.toBeConfigured": "De configurat",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Модель преобразования речи в текст",
"modelProvider.speechToTextModel.tip": "Установите модель по умолчанию для ввода речи в текст в разговоре.",
"modelProvider.systemModelSettings": "Настройки системной модели",
"modelProvider.systemModelSettingsLink": "Зачем нужно настраивать системную модель?",
"modelProvider.systemReasoningModel.key": "Модель системного мышления",
"modelProvider.systemReasoningModel.tip": "Установите модель вывода по умолчанию, которая будет использоваться для создания приложений, а также такие функции, как генерация имени диалога и предложение следующего вопроса, также будут использовать модель вывода по умолчанию.",
"modelProvider.toBeConfigured": "Подлежит настройке",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Model za pretvorbo govora v besedilo",
"modelProvider.speechToTextModel.tip": "Nastavite privzeti model za vnos govora v besedilo v pogovoru.",
"modelProvider.systemModelSettings": "Nastavitve sistemskega modela",
"modelProvider.systemModelSettingsLink": "Zakaj je potrebno nastaviti sistemski model?",
"modelProvider.systemReasoningModel.key": "Sistemski model za sklepanja",
"modelProvider.systemReasoningModel.tip": "Nastavite privzeti model za sklepanja, ki se bo uporabljal za ustvarjanje aplikacij, kot tudi funkcije, kot so generiranje imen dialogov in predlaganje naslednjih vprašanj.",
"modelProvider.toBeConfigured": "Za konfiguracijo",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "โมเดลคําพูดเป็นข้อความ",
"modelProvider.speechToTextModel.tip": "ตั้งค่าโมเดลเริ่มต้นสําหรับการป้อนข้อมูลคําพูดเป็นข้อความในการสนทนา",
"modelProvider.systemModelSettings": "การตั้งค่ารุ่นระบบ",
"modelProvider.systemModelSettingsLink": "เหตุใดจึงจําเป็นต้องตั้งค่าโมเดลระบบ",
"modelProvider.systemReasoningModel.key": "แบบจําลองการให้เหตุผลของระบบ",
"modelProvider.systemReasoningModel.tip": "ตั้งค่าโมเดลการอนุมานเริ่มต้นที่จะใช้สําหรับการสร้างแอปพลิเคชัน ตลอดจนคุณลักษณะต่างๆ เช่น การสร้างชื่อบทสนทนาและคําแนะนําคําถามถัดไปจะใช้โมเดลการอนุมานเริ่มต้นด้วย",
"modelProvider.toBeConfigured": "ต้องกําหนดค่า",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Konuşmadan Metne Modeli",
"modelProvider.speechToTextModel.tip": "Konuşmada konuşmadan metne giriş için varsayılan modeli ayarlayın.",
"modelProvider.systemModelSettings": "Sistem Model Ayarları",
"modelProvider.systemModelSettingsLink": "Sistem modelini ayarlamak neden gereklidir?",
"modelProvider.systemReasoningModel.key": "Sistem Çıkarım Modeli",
"modelProvider.systemReasoningModel.tip": "Uygulamalar oluşturmak ve diyalog adı oluşturma ve sonraki soru önerisi gibi özelliklerin otomatikleştirilmesi için kullanılacak varsayılan çıkarım modelini ayarlayın.",
"modelProvider.toBeConfigured": "Yapılandırılacak",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Модель перетворення мовлення в текст",
"modelProvider.speechToTextModel.tip": "Встановіть модель за замовчуванням для введення мовлення в текст під час розмови.",
"modelProvider.systemModelSettings": "Налаштування системної моделі",
"modelProvider.systemModelSettingsLink": "Чому необхідно налаштовувати системну модель?",
"modelProvider.systemReasoningModel.key": "Системна модель міркування",
"modelProvider.systemReasoningModel.tip": "Встановіть модель висновку за замовчуванням, яка буде використовуватися для створення програм, а також для таких функцій, як генерація імені діалогу та пропозиція наступного питання також використовуватимуть модель висновку за замовчуванням.",
"modelProvider.toBeConfigured": "Підлягає налаштуванню",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "Mô hình Chuyển đổi Văn bản thành Tiếng nói",
"modelProvider.speechToTextModel.tip": "Thiết lập mô hình mặc định cho đầu vào chuyển đổi tiếng nói thành văn bản trong cuộc trò chuyện.",
"modelProvider.systemModelSettings": "Cài đặt Mô hình Hệ thống",
"modelProvider.systemModelSettingsLink": "Tại sao cần thiết phải thiết lập mô hình hệ thống?",
"modelProvider.systemReasoningModel.key": "Mô hình lập luận hệ thống",
"modelProvider.systemReasoningModel.tip": "Thiết lập mô hình suy luận mặc định sẽ được sử dụng để tạo ứng dụng. Các tính năng như tạo tên cuộc trò chuyện và đề xuất câu hỏi tiếp theo cũng sẽ sử dụng mô hình suy luận mặc định này.",
"modelProvider.toBeConfigured": "Được cấu hình",

View File

@ -341,11 +341,24 @@
"modelProvider.buyQuota": "购买额度",
"modelProvider.callTimes": "调用次数",
"modelProvider.card.aiCreditsInUse": "AI 额度使用中",
"modelProvider.card.aiCreditsOption": "AI 额度",
"modelProvider.card.apiKeyOption": "API Key",
"modelProvider.card.apiKeyRequired": "需要配置 API Key",
"modelProvider.card.apiKeyUnavailableFallback": "API Key 不可用,正在使用 AI 额度",
"modelProvider.card.apiKeyUnavailableFallbackDescription": "检查你的 API Key 配置以切换回来",
"modelProvider.card.buyQuota": "购买额度",
"modelProvider.card.callTimes": "调用次数",
"modelProvider.card.creditsExhaustedDescription": "请{{upgradeLink}}或配置 API Key",
"modelProvider.card.creditsExhaustedFallback": "AI 额度已用尽,正在使用 API Key",
"modelProvider.card.creditsExhaustedFallbackDescription": "{{upgradeLink}}以恢复 AI 额度优先使用。",
"modelProvider.card.creditsExhaustedMessage": "AI 额度已用尽",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API Key。",
"modelProvider.card.modelNotSupported": "{{modelName}} 未安装",
"modelProvider.card.modelSupported": "{{modelName}} 模型正在使用此额度。",
"modelProvider.card.noApiKeysDescription": "添加 API Key 以使用自有模型凭证。",
"modelProvider.card.noApiKeysFallback": "未配置 API Key正在使用 AI 额度",
"modelProvider.card.noApiKeysTitle": "尚未配置 API Key",
"modelProvider.card.noAvailableUsage": "无可用额度",
"modelProvider.card.onTrial": "试用中",
"modelProvider.card.paid": "已购买",
"modelProvider.card.priorityUse": "优先使用",
@ -354,6 +367,10 @@
"modelProvider.card.removeKey": "删除 API 密钥",
"modelProvider.card.tip": "AI Credits 支持使用 {{modelNames}} 的模型;试用额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tokens": "Tokens",
"modelProvider.card.unavailable": "不可用",
"modelProvider.card.upgradePlan": "升级套餐",
"modelProvider.card.usageLabel": "用量",
"modelProvider.card.usagePriority": "使用优先级",
"modelProvider.collapse": "收起",
"modelProvider.config": "配置",
"modelProvider.configLoadBalancing": "设置负载均衡",
@ -416,7 +433,6 @@
"modelProvider.speechToTextModel.key": "语音转文本模型",
"modelProvider.speechToTextModel.tip": "设置对话中语音转文字输入的默认使用模型。",
"modelProvider.systemModelSettings": "默认模型设置",
"modelProvider.systemModelSettingsLink": "为什么需要设置系统模型?",
"modelProvider.systemReasoningModel.key": "系统推理模型",
"modelProvider.systemReasoningModel.tip": "设置创建应用使用的默认推理模型,以及对话名称生成、下一步问题建议等功能也会使用该默认推理模型。",
"modelProvider.toBeConfigured": "待配置",

View File

@ -414,7 +414,6 @@
"modelProvider.speechToTextModel.key": "語音轉文字模型",
"modelProvider.speechToTextModel.tip": "設定對話中語音轉文字輸入的預設使用模型。",
"modelProvider.systemModelSettings": "系統模型設定",
"modelProvider.systemModelSettingsLink": "為什麼需要設定系統模型?",
"modelProvider.systemReasoningModel.key": "系統推理模型",
"modelProvider.systemReasoningModel.tip": "設定建立應用使用的預設推理模型,以及對話名稱生成、下一步問題建議等功能也會使用該預設推理模型。",
"modelProvider.toBeConfigured": "待配置",