diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py
index 7efa26803a2..317ada53d89 100644
--- a/api/services/annotation_service.py
+++ b/api/services/annotation_service.py
@@ -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())
diff --git a/api/tests/unit_tests/services/test_annotation_service.py b/api/tests/unit_tests/services/test_annotation_service.py
index 8e5c305fa34..ac2a69b0f40 100644
--- a/api/tests/unit_tests/services/test_annotation_service.py
+++ b/api/tests/unit_tests/services/test_annotation_service.py
@@ -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
diff --git a/web/app/components/billing/__tests__/query-state.spec.tsx b/web/app/components/billing/__tests__/query-state.spec.tsx
index 07b3084caee..1f26eba6520 100644
--- a/web/app/components/billing/__tests__/query-state.spec.tsx
+++ b/web/app/components/billing/__tests__/query-state.spec.tsx
@@ -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(, { 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(
+
+
+ Workspace
+
+ ,
+ { 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())
+})
diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx
index 783693472a4..6a4b83aadc0 100644
--- a/web/app/components/billing/plan/index.tsx
+++ b/web/app/components/billing/plan/index.tsx
@@ -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 = ({ 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
diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx
index d71507ca02c..3aed8589b50 100644
--- a/web/app/components/billing/usage-info/index.tsx
+++ b/web/app/components/billing/usage-info/index.tsx
@@ -52,11 +52,10 @@ const UsageInfo: FC = ({
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'
diff --git a/web/app/components/billing/utils/__tests__/index.spec.ts b/web/app/components/billing/utils/__tests__/index.spec.ts
index e910ac13abd..d728b1bf8a9 100644
--- a/web/app/components/billing/utils/__tests__/index.spec.ts
+++ b/web/app/components/billing/utils/__tests__/index.spec.ts
@@ -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)
- })
})
})
diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts
index ce8791d49ae..740da620893 100644
--- a/web/app/components/billing/utils/index.ts
+++ b/web/app/components/billing/utils/index.ts
@@ -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
diff --git a/web/context/hooks/use-trigger-events-limit-modal.ts b/web/context/hooks/use-trigger-events-limit-modal.ts
index da90ee8ae41..0ea3aa44c1f 100644
--- a/web/context/hooks/use-trigger-events-limit-modal.ts
+++ b/web/context/hooks/use-trigger-events-limit-modal.ts
@@ -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