mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 19:15:59 +08:00
fix(billing): align zero and unlimited quota semantics (#41927)
This commit is contained in:
parent
8980506597
commit
4f463b1c46
@ -536,7 +536,7 @@ class AppAnnotationService:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True)
|
||||
annotation_quota_limit = features.annotation_quota_limit
|
||||
if annotation_quota_limit.limit < len(result) + annotation_quota_limit.size:
|
||||
if 0 < annotation_quota_limit.limit < len(result) + annotation_quota_limit.size:
|
||||
raise ValueError("The number of annotations exceeds the limit of your subscription.")
|
||||
# async job
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
@ -719,6 +719,29 @@ class TestAppAnnotationServiceBatchImport:
|
||||
"uuid-3", [{"question": "q1", "answer": "a1"}], app.id, TENANT_ID, current_user.id
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, 2], ids=["unlimited", "exactly-at-limit"])
|
||||
@config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
def test_import_with_available_quota_enqueues_job(
|
||||
self, sqlite_session: Session, current_user: Account, limit: int
|
||||
) -> None:
|
||||
app = _persist_app(sqlite_session)
|
||||
features = SimpleNamespace(annotation_quota_limit=SimpleNamespace(limit=limit, size=1))
|
||||
with (
|
||||
patch.object(annotation_service_module.FeatureService, "get_features", return_value=features),
|
||||
patch.object(annotation_service_module, "batch_import_annotations_task") as task,
|
||||
patch.object(annotation_service_module, "redis_client"),
|
||||
config_overrides_context(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
||||
):
|
||||
result = AppAnnotationService.batch_import_app_annotations(
|
||||
app.id, _file(b"question,answer\nq,a\n"), sqlite_session
|
||||
)
|
||||
|
||||
assert result["job_status"] == "waiting"
|
||||
assert result["record_count"] == 1
|
||||
task.delay.assert_called_once_with(
|
||||
result["job_id"], [{"question": "q", "answer": "a"}], app.id, TENANT_ID, current_user.id
|
||||
)
|
||||
|
||||
@config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
|
||||
def test_unexpected_error_cleans_active_job(
|
||||
self, sqlite_session: Session, current_user: Account, caplog: pytest.LogCaptureFixture
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
createConsoleQueryWrapper,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import AnnotationUsage from '../annotation-full/usage'
|
||||
import Billing from '../billing-page'
|
||||
|
||||
@ -75,3 +78,73 @@ it('renders annotation usage only from returned data and preserves zero as an un
|
||||
expect(within(annotation).getByText('4')).toBeInTheDocument()
|
||||
expect(within(annotation).getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ limit: 0, usage: 0, percent: 100 },
|
||||
{ limit: -1, usage: 25, percent: 0 },
|
||||
{ limit: 100, usage: 25, percent: 25 },
|
||||
{ limit: 100, usage: 100, percent: 100 },
|
||||
])(
|
||||
'renders event and API quotas with limit $limit and usage $usage',
|
||||
async ({ limit, usage, percent }) => {
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: {
|
||||
trigger_event: { limit, usage },
|
||||
api_rate_limit: { limit, usage },
|
||||
apps: { size: 4, limit: 0 },
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryKey(), {
|
||||
size: 256,
|
||||
limit: 900,
|
||||
usage_unknown: false,
|
||||
})
|
||||
render(<Billing />, { wrapper })
|
||||
|
||||
for (const name of ['billing.usagePage.triggerEvents', 'billing.plansCommon.apiRateLimit']) {
|
||||
const quota = within(await screen.findByRole('group', { name }))
|
||||
expect(quota.getByTestId('billing-quota-value')).toHaveTextContent(
|
||||
`${usage}/${limit === -1 ? 'billing.plansCommon.unlimited' : limit}`,
|
||||
)
|
||||
expect(quota.getByRole('meter', { name })).toHaveAttribute('aria-valuenow', String(percent))
|
||||
if (limit !== -1)
|
||||
expect(quota.queryByText('billing.plansCommon.unlimited')).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
const apps = within(screen.getByRole('group', { name: 'billing.usagePage.buildApps' }))
|
||||
expect(apps.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('shows a dismissible limit dialog when the workspace receives a zero event quota', async () => {
|
||||
localStorage.clear()
|
||||
const user = userEvent.setup()
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
currentWorkspace: { id: 'workspace-zero-quota' },
|
||||
})
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
|
||||
render(
|
||||
<NuqsWrapper>
|
||||
<ModalContextProvider>
|
||||
<span>Workspace</span>
|
||||
</ModalContextProvider>
|
||||
</NuqsWrapper>,
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
seedFeatures(queryClient, {
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
trigger_event: { limit: 0, usage: 0, reset_date: -1 },
|
||||
})
|
||||
})
|
||||
const dialog = await screen.findByRole('dialog', { name: 'billing.triggerLimitModal.title' })
|
||||
expect(within(dialog).getByTestId('billing-quota-value')).toHaveTextContent('0/0')
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@ -19,7 +19,7 @@ import { NUM_INFINITE } from '../config'
|
||||
import { useEducationDiscount } from '../hooks/use-education-discount'
|
||||
import UpgradeBtn from '../upgrade-btn'
|
||||
import VectorSpaceInfo from '../usage-info/vector-space-info'
|
||||
import { getResetInDaysFromDate, parseLimit, parseRateLimit } from '../utils'
|
||||
import { getResetInDaysFromDate, parseLimit } from '../utils'
|
||||
import { Professional, Sandbox, Team } from './assets'
|
||||
|
||||
type Props = Readonly<{
|
||||
@ -49,8 +49,8 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
)
|
||||
const { isAboutToExpire = false, isEducationAccount = false } = educationStatus ?? {}
|
||||
const type = features.billing.subscription.plan
|
||||
const triggerEventsLimit = parseRateLimit(features.trigger_event.limit)
|
||||
const apiRateLimit = parseRateLimit(features.api_rate_limit.limit)
|
||||
const triggerEventsLimit = features.trigger_event.limit
|
||||
const apiRateLimit = features.api_rate_limit.limit
|
||||
const apiRateLimitReset = getResetInDaysFromDate(features.api_rate_limit.reset_date)
|
||||
const triggerEventsResetInDays =
|
||||
type === 'professional' && triggerEventsLimit !== NUM_INFINITE
|
||||
|
||||
@ -52,11 +52,10 @@ const UsageInfo: FC<Props> = ({
|
||||
const isBelowThreshold = !usageUnknown && storageMode && usage < storageThreshold
|
||||
const isSandboxFull = !usageUnknown && storageMode && isSandboxPlan && usage >= storageThreshold
|
||||
|
||||
// Single source of truth: sandbox full is visually clamped to 100%; all other
|
||||
// determinate cases show the real percent capped at 100. Tone derives from
|
||||
// this, so we never need a separate tone override.
|
||||
// Zero count quotas have no remaining capacity; storage keeps its separate limit convention.
|
||||
const isZeroQuota = !storageMode && total === 0
|
||||
const rawPercent = total > 0 ? (usage / total) * 100 : 0
|
||||
const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100)
|
||||
const effectivePercent = isSandboxFull || isZeroQuota ? 100 : Math.min(rawPercent, 100)
|
||||
const tone: MeterTone =
|
||||
effectivePercent >= 100 ? 'error' : effectivePercent >= 80 ? 'warning' : 'neutral'
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ import {
|
||||
getPlanVectorSpaceLimitMB,
|
||||
getResetInDaysFromDate,
|
||||
parseLimit,
|
||||
parseRateLimit,
|
||||
parseVectorSpaceToMB,
|
||||
} from '../index'
|
||||
|
||||
@ -71,10 +70,5 @@ describe('billing utils', () => {
|
||||
expect(parseLimit(0)).toBe(-1)
|
||||
expect(parseLimit(10)).toBe(10)
|
||||
})
|
||||
it('preserves unlimited rate limits in either API representation', () => {
|
||||
expect(parseRateLimit(0)).toBe(-1)
|
||||
expect(parseRateLimit(-1)).toBe(-1)
|
||||
expect(parseRateLimit(5000)).toBe(5000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,19 +23,14 @@ export const getPlanVectorSpaceLimitMB = (planType: CloudPlan): number => {
|
||||
return parseVectorSpaceToMB(ALL_PLANS[planType].vectorSpace)
|
||||
}
|
||||
|
||||
// The API uses 0 for unlimited count quotas.
|
||||
// App, member, document upload, and annotation quotas use 0 for unlimited.
|
||||
// Event and API quotas use -1 for unlimited and must preserve 0 as zero capacity.
|
||||
export const parseLimit = (limit: number) => {
|
||||
if (limit === 0) return NUM_INFINITE
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
export const parseRateLimit = (limit: number) => {
|
||||
if (limit === 0 || limit === -1) return NUM_INFINITE
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
const normalizeResetDate = (resetDate: number) => {
|
||||
if (resetDate <= 0) return null
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ export const useTriggerEventsLimitModal = (): UseTriggerEventsLimitModalResult =
|
||||
currentWorkspaceId &&
|
||||
quota &&
|
||||
quota.plan !== 'team' &&
|
||||
quota.limit > 0 &&
|
||||
quota.limit >= 0 &&
|
||||
quota.usage >= quota.limit
|
||||
? `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${quota.plan}-${quota.limit}-${cycleTag}`
|
||||
: null
|
||||
|
||||
Loading…
Reference in New Issue
Block a user