feat(web): add credits-fallback variant for API Key priority with available credits

When API Key is selected but unavailable/unconfigured and credits are
available, the card now shows "AI credits in use" with a warning icon
instead of "API key required". When both credits are exhausted and no
API key exists, it shows "No available usage" (destructive).

New deriveVariant logic for priority=apiKey:
- !exhausted + !authorized → credits-fallback (was api-required-*)
- exhausted + no credential → no-usage (was api-required-add)
- exhausted + named unauthorized → api-unavailable (unchanged)
This commit is contained in:
yyh 2026-03-05 13:02:40 +08:00
parent ad9ac6978e
commit ce34937a1c
No known key found for this signature in database
4 changed files with 80 additions and 17 deletions

View File

@ -147,7 +147,7 @@ describe('CredentialPanel', () => {
expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument()
})
it('should show "API key required" for api-required-add variant (custom priority, no credentials)', () => {
it('should show "AI credits in use" with warning for credits-fallback (custom priority, no credentials, credits available)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -155,10 +155,10 @@ describe('CredentialPanel', () => {
available_credentials: [],
},
}))
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show "API key required" for api-required-configure variant (custom priority, credential exists but name missing)', () => {
it('should show "AI credits in use" with warning for credits-fallback (custom priority, credential unauthorized, credits available)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -168,7 +168,18 @@ describe('CredentialPanel', () => {
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show warning icon for credits-fallback variant', () => {
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(container.querySelector('.i-ri-error-warning-fill')).toBeTruthy()
})
})
@ -201,7 +212,8 @@ describe('CredentialPanel', () => {
expect(container.querySelector('.i-ri-error-warning-fill')).toBeNull()
})
it('should show red indicator and "Unavailable" for api-unavailable', () => {
it('should show red indicator and "Unavailable" for api-unavailable (exhausted + named unauthorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -243,6 +255,7 @@ describe('CredentialPanel', () => {
})
it('should apply destructive container for api-unavailable variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -356,7 +369,7 @@ describe('CredentialPanel', () => {
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
it('should pass api-required-add variant for custom priority with no credentials', () => {
it('should pass credits-fallback variant for custom priority with no credentials and credits available', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -364,10 +377,10 @@ describe('CredentialPanel', () => {
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add')
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
})
it('should pass api-unavailable variant for custom priority with named but unauthorized key', () => {
it('should pass credits-fallback variant for custom priority with named unauthorized key and credits available', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -377,7 +390,7 @@ describe('CredentialPanel', () => {
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-unavailable')
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
})
it('should pass no-usage variant when exhausted + credential but unauthorized', () => {

View File

@ -25,6 +25,7 @@ type CredentialPanelProps = {
const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
'credits-active',
'credits-fallback',
'credits-exhausted',
'no-usage',
'api-required-add',
@ -70,10 +71,11 @@ const CredentialPanel = ({
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
const needsGap = !isTextLabel || variant === 'credits-fallback'
return (
<SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}>
<SystemQuotaCard.Label className={isTextLabel ? undefined : 'gap-1'}>
<SystemQuotaCard.Label className={needsGap ? 'gap-1' : undefined}>
{isTextLabel
? <TextLabel variant={variant} />
: <StatusLabel variant={variant} credentialName={credentialName} />}
@ -92,6 +94,7 @@ const CredentialPanel = ({
const TEXT_LABEL_KEYS = {
'credits-active': 'modelProvider.card.aiCreditsInUse',
'credits-fallback': 'modelProvider.card.aiCreditsInUse',
'credits-exhausted': 'modelProvider.card.quotaExhausted',
'no-usage': 'modelProvider.card.noAvailableUsage',
'api-required-add': 'modelProvider.card.apiKeyRequired',
@ -104,9 +107,14 @@ function TextLabel({ variant }: { variant: CardVariant }) {
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>
<>
<span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}>
{t(labelKey, { ns: 'common' })}
</span>
{variant === 'credits-fallback' && (
<span className="i-ri-error-warning-fill h-3 w-3 shrink-0 text-text-warning" />
)}
</>
)
}

View File

@ -104,7 +104,7 @@ describe('useCredentialPanelState', () => {
expect(result.current.priority).toBe('apiKey')
})
it('should return api-unavailable when API key unauthorized', () => {
it('should return credits-fallback when API key unauthorized and credits available', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -117,10 +117,10 @@ describe('useCredentialPanelState', () => {
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-configure')
expect(result.current.variant).toBe('credits-fallback')
})
it('should return api-required-add when no credentials exist', () => {
it('should return credits-fallback when no credentials and credits available', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -131,7 +131,41 @@ describe('useCredentialPanelState', () => {
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-add')
expect(result.current.variant).toBe('credits-fallback')
})
it('should return no-usage when no credentials and credits exhausted', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
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('no-usage')
})
it('should return api-unavailable when credential with name unauthorized and credits exhausted', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = 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' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-unavailable')
})
})

View File

@ -9,6 +9,7 @@ export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
export type CardVariant
= | 'credits-active'
| 'credits-fallback'
| 'credits-exhausted'
| 'no-usage'
| 'api-fallback'
@ -56,6 +57,13 @@ function deriveVariant(
if (hasCredential && authorized)
return 'api-active'
if (priority === 'apiKey' && !isExhausted)
return 'credits-fallback'
if (priority === 'apiKey' && !hasCredential)
return 'no-usage'
if (hasCredential && !authorized)
return credentialName ? 'api-unavailable' : 'api-required-configure'
return 'api-required-add'