From c87fd5c42e80ade36276b6fab08489969764421f Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Wed, 25 Mar 2026 21:12:23 +0900 Subject: [PATCH] Adding More Ai Quota Tiers (#32128) * feat: adding tiers for free and standard * feat: updating feature calculation to account for more quota tiers * feat: rename freeTrialQuota to freeQuota * feat: add hasAiFreeTier and hasUnlimitedAi to editor, block free tier from using workbench * fix: updating tests * fix: updating ordering precedence for quota tiers * feat: bump unlimited ai fair usage to 300 uses * fix: update workbench quota usage for unlimited plans * feat: bump features version for ai quota split * feat: popover should only show for relevant users on workbench, and adding upgrade notification to ineligible users GitOrigin-RevId: e3ef38797f267677cad51d7273272623027ca330 --- .../app/src/Features/Project/ProjectController.mjs | 6 ++++++ .../src/Features/Subscription/FeaturesHelper.mjs | 14 ++++++-------- .../rate-limiters/AiFeatureUsageRateLimiter.mjs | 2 +- services/web/app/views/project/editor/_meta.pug | 3 ++- services/web/config/settings.defaults.js | 13 ++++++++++++- services/web/frontend/extracted-translations.json | 2 ++ .../shared/components/ai-paywall-notification.tsx | 6 +++--- .../js/shared/context/user-features-context.tsx | 4 ++-- services/web/frontend/js/utils/meta.ts | 3 ++- services/web/locales/en.json | 2 ++ .../src/Institutions/InstitutionsFeatures.test.mjs | 12 +++++++++++- .../unit/src/Subscription/FeaturesUpdater.test.mjs | 12 +++++++++++- .../web/test/unit/src/User/UserGetter.test.mjs | 12 +++++++++++- .../AiFeatureUsageRateLimiter.test.mjs | 6 +++++- 14 files changed, 76 insertions(+), 21 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 2fe0c388c2..f57963d15b 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -964,6 +964,12 @@ const _ProjectController = { showAiFeatures, onAiFreeTrial: fullFeatureSet?.aiUsageQuota === Settings.aiFeatures?.freeTrialQuota, + // default to free tier if they dont have a quota + hasAiFreeTier: + fullFeatureSet?.aiUsageQuota === Settings.aiFeatures?.freeQuota || + !fullFeatureSet?.aiUsageQuota, + hasUnlimitedAi: + fullFeatureSet?.aiUsageQuota === Settings.aiFeatures?.unlimitedQuota, detachRole, metadata: { viewport: false }, showUpgradePrompt, diff --git a/services/web/app/src/Features/Subscription/FeaturesHelper.mjs b/services/web/app/src/Features/Subscription/FeaturesHelper.mjs index 312d71898c..bd1fb7e8b2 100644 --- a/services/web/app/src/Features/Subscription/FeaturesHelper.mjs +++ b/services/web/app/src/Features/Subscription/FeaturesHelper.mjs @@ -39,14 +39,12 @@ function mergeFeatures(featuresA, featuresB) { featuresB.compileTimeout || 0 ) } else if (key === 'aiUsageQuota') { - if ( - features.aiUsageQuota === Settings.aiFeatures.unlimitedQuota || - featuresB.aiUsageQuota === Settings.aiFeatures.unlimitedQuota - ) { - features.aiUsageQuota = Settings.aiFeatures.unlimitedQuota - } else { - features.aiUsageQuota = Settings.aiFeatures.freeTrialQuota - } + // later entries have higher precedence + const QUOTA_TIER_LIST = ['free', 'basic', 'standard', 'unlimited'] + const quotaA = QUOTA_TIER_LIST.indexOf(features.aiUsageQuota) + const quotaB = QUOTA_TIER_LIST.indexOf(featuresB.aiUsageQuota) + features.aiUsageQuota = + quotaA > quotaB ? features.aiUsageQuota : featuresB.aiUsageQuota } else { // Boolean keys, true is better features[key] = features[key] || featuresB[key] diff --git a/services/web/app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs b/services/web/app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs index b1af6b3ae0..9b8c4e9133 100644 --- a/services/web/app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs +++ b/services/web/app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs @@ -30,7 +30,7 @@ class AiFeatureUsageRateLimiter extends FeatureUsageRateLimiter { if (inQuotaSplitTest) { const wfQuota = user.writefull?.isPremium ? Settings.writefull.quotaTierGranted - : Settings.aiFeatures.freeTrialQuota + : Settings.aiFeatures.freeQuota const mergedFeatures = FeaturesHelper.mergeFeatures(user.features, { aiUsageQuota: wfQuota, }) diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index c6e303b87b..7f93e96ccf 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -25,7 +25,8 @@ meta(name="ol-debugPdfDetach" data-type="boolean" content=debugPdfDetach) meta(name="ol-showSymbolPalette" data-type="boolean" content=showSymbolPalette) meta(name="ol-symbolPaletteAvailable" data-type="boolean" content=symbolPaletteAvailable) meta(name="ol-showAiFeatures" data-type="boolean" content=showAiFeatures) -meta(name="ol-onAiFreeTrial" data-type="boolean" content=onAiFreeTrial) +meta(name="ol-hasUnlimitedAi" data-type="boolean" content=hasUnlimitedAi) +meta(name="ol-hasAiFreeTier" data-type="boolean" content=hasAiFreeTier) meta(name="ol-detachRole" data-type="string" content=detachRole) meta(name="ol-imageNames" data-type="json" content=imageNames) meta(name="ol-languages" data-type="json" content=languages) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index eee3905ca2..7c4039b1e9 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -425,10 +425,21 @@ module.exports = { }, aiFeatures: { - freeTrialQuota: 'basic', + freeQuota: 'free', + standardQuota: 'standard', + basicQuota: 'basic', unlimitedQuota: 'unlimited', }, + quotaGrants: { + ai: { + free: 0, + basic: 0, + standard: 0, + unlimited: 0, + }, + }, + groupPlanModalOptions: { plan_codes: [], currencies: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index f517d0314b..c282135fc7 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -710,6 +710,7 @@ "get_most_subscription_by_checking_overleaf_ai_writefull": "", "get_real_time_track_changes": "", "get_unlimited_ai": "", + "get_your_hands_on_the_ultimate_research_writing_ai_assistant": "", "git": "", "git_authentication_token": "", "git_authentication_token_create_modal_info_1": "", @@ -2139,6 +2140,7 @@ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "", "upgrade_to_add_more_collaborators_and_more": "", "upgrade_to_get_feature": "", + "upgrade_to_get_started": "", "upgrade_to_review": "", "upgrade_your_subscription": "", "upload": "", diff --git a/services/web/frontend/js/shared/components/ai-paywall-notification.tsx b/services/web/frontend/js/shared/components/ai-paywall-notification.tsx index e5a8d0cc5a..f590a3a936 100644 --- a/services/web/frontend/js/shared/components/ai-paywall-notification.tsx +++ b/services/web/frontend/js/shared/components/ai-paywall-notification.tsx @@ -7,7 +7,7 @@ import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time' import { useFeatureFlag } from '@/shared/context/split-test-context' import getMeta from '@/utils/meta' -const onAiFreeTrial = getMeta('ol-onAiFreeTrial') +const hasUnlimitedAi = getMeta('ol-hasUnlimitedAi') type aiFeatureLocations = 'errorAssist' | 'workbench' @@ -33,8 +33,8 @@ function AiPaywallNotification({ return null } - // todo: quota clean-up: remove once we are transitioned off aiErrorAssistant naming and replace with just !onAiFreeTrial, also remove null FF check - const hasAddOn = !onAiFreeTrial || Boolean(features?.aiErrorAssistant) + // todo: quota clean-up: remove once we are transitioned off aiErrorAssistant naming and replace with just hasUnlimitedAi, also remove null FF check + const hasAddOn = hasUnlimitedAi || Boolean(features?.aiErrorAssistant) // error assist only needs usage quota const canUseErrorAssist = hasSuggestionsLeft diff --git a/services/web/frontend/js/shared/context/user-features-context.tsx b/services/web/frontend/js/shared/context/user-features-context.tsx index 151ca3357a..2c7c32ca2f 100644 --- a/services/web/frontend/js/shared/context/user-features-context.tsx +++ b/services/web/frontend/js/shared/context/user-features-context.tsx @@ -15,7 +15,7 @@ import getMeta from '@/utils/meta' export const UserFeaturesContext = createContext(undefined) -const onAiFreeTrial = getMeta('ol-onAiFreeTrial') +const hasUnlimitedAi = getMeta('ol-hasUnlimitedAi') export const UserFeaturesProvider: FC = ({ children, @@ -35,7 +35,7 @@ export const UserFeaturesProvider: FC = ({ useEffect(() => { const listener = async ({ isPremium }: { isPremium: boolean }) => { // todo: quota clean-up: remove once we are transitioned off aiErrorAssistant naming - const hasPremiumQuota = !onAiFreeTrial + const hasPremiumQuota = hasUnlimitedAi const alreadyPremium = features?.aiErrorAssistant === isPremium || hasPremiumQuota === isPremium diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 6b0b077ed3..5b24623f01 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -152,6 +152,7 @@ export interface Meta { 'ol-groupSubscriptionsPendingEnrollment': PendingGroupSubscriptionEnrollment[] 'ol-groupsAndEnterpriseBannerVariant': GroupsAndEnterpriseBannerVariant 'ol-hasAiAssistViaWritefull': boolean + 'ol-hasAiFreeTier': boolean 'ol-hasGroupSSOFeature': boolean 'ol-hasIndividualPaidSubscription': boolean 'ol-hasManagedUsersFeature': boolean @@ -160,6 +161,7 @@ export interface Meta { 'ol-hasSplitTestWriteAccess': boolean 'ol-hasSubscription': boolean 'ol-hasTrackChangesFeature': boolean + 'ol-hasUnlimitedAi': boolean 'ol-hasWriteAccess': boolean 'ol-hideLinkingWidgets': boolean // CI only 'ol-historyBlobStats': { @@ -221,7 +223,6 @@ export interface Meta { 'ol-notificationsInstitution': InstitutionType[] 'ol-oauthProviders': OAuthProviders 'ol-odcData': OnboardingFormData - 'ol-onAiFreeTrial': boolean 'ol-otMigrationStage': number 'ol-overallThemes': OverallThemeMeta[] 'ol-ownerIsManaged': boolean diff --git a/services/web/locales/en.json b/services/web/locales/en.json index af664c184d..8c56b6f412 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -913,6 +913,7 @@ "get_real_time_track_changes": "Get real-time track changes", "get_the_best_overleaf_experience": "Get the best Overleaf experience", "get_unlimited_ai": "Get unlimited use of AI features", + "get_your_hands_on_the_ultimate_research_writing_ai_assistant": "Get your hands on the ultimate research writing AI assistant.", "git": "Git", "git_authentication_token": "Git authentication token", "git_authentication_token_create_modal_info_1": "This is your Git authentication token. You should enter this when prompted for a password.", @@ -2691,6 +2692,7 @@ "upgrade_to_add_more_collaborators_and_more": "Upgrade to add more collaborators and access features like Overleaf AI, track changes, and full project history.", "upgrade_to_get_feature": "Upgrade to get __feature__, plus:", "upgrade_to_get_more_from_overleaf": "Upgrade to get more from Overleaf", + "upgrade_to_get_started": "Upgrade to get started", "upgrade_to_review": "Upgrade to Review", "upgrade_your_subscription": "Upgrade your subscription", "upload": "Upload", diff --git a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs index 5c7264d0ee..4479d71a7b 100644 --- a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs +++ b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs @@ -34,9 +34,19 @@ describe('InstitutionsFeatures', function () { quotaTierGranted: 'unlimited', }, aiFeatures: { - freeTrialQuota: 'basic', + freeQuota: 'free', + standardQuota: 'standard', + basicQuota: 'basic', unlimitedQuota: 'unlimited', }, + quotaGrants: { + ai: { + free: 5, + basic: 5, + standard: 10, + unlimited: 200, + }, + }, }, })) diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs index e126cd58e0..926308eba1 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs @@ -77,9 +77,19 @@ describe('FeaturesUpdater', function () { quotaTierGranted: 'unlimited', }, aiFeatures: { - freeTrialQuota: 'basic', + freeQuota: 'free', + standardQuota: 'standard', + basicQuota: 'basic', unlimitedQuota: 'unlimited', }, + quotaGrants: { + ai: { + free: 5, + basic: 5, + standard: 10, + unlimited: 200, + }, + }, } ctx.ReferalFeatures = { diff --git a/services/web/test/unit/src/User/UserGetter.test.mjs b/services/web/test/unit/src/User/UserGetter.test.mjs index 8e738643f6..8943d90deb 100644 --- a/services/web/test/unit/src/User/UserGetter.test.mjs +++ b/services/web/test/unit/src/User/UserGetter.test.mjs @@ -67,9 +67,19 @@ describe('UserGetter', function () { default: (ctx.settings = { reconfirmNotificationDays: 14, aiFeatures: { - freeTrialQuota: 'basic', + freeQuota: 'free', + standardQuota: 'standard', + basicQuota: 'basic', unlimitedQuota: 'unlimited', }, + quotaGrants: { + ai: { + free: 5, + basic: 5, + standard: 10, + unlimited: 200, + }, + }, }), })) diff --git a/services/web/test/unit/src/infrastructure/AiFeatureUsageRateLimiter.test.mjs b/services/web/test/unit/src/infrastructure/AiFeatureUsageRateLimiter.test.mjs index 27c61113c9..95758b45d2 100644 --- a/services/web/test/unit/src/infrastructure/AiFeatureUsageRateLimiter.test.mjs +++ b/services/web/test/unit/src/infrastructure/AiFeatureUsageRateLimiter.test.mjs @@ -61,12 +61,16 @@ describe('AiFeatureUsageRateLimiter', function () { quotaTierGranted: 'unlimited', }, aiFeatures: { - freeTrialQuota: 'basic', + freeQuota: 'free', + standardQuota: 'standard', + basicQuota: 'basic', unlimitedQuota: 'unlimited', }, quotaGrants: { ai: { + free: 5, basic: 5, + standard: 10, unlimited: 200, }, },