Cleanup AiErrorAssistant todos post plans changes (#35067)

* feat: cleaning up ai quota leftovers

fix: removing more straggler invos

* fix: featureUpdater still need analyticsId in the user projection

* feat: updating cypress tests to also check for fair usage text

GitOrigin-RevId: 09c1167106c144084c111beea56867f6aa4a0906
This commit is contained in:
Jimmy Domagala-Tang
2026-09-17 11:41:10 +00:00
committed by Copybot
parent 275d381656
commit ba76fda37b
19 changed files with 40 additions and 91 deletions
@@ -18,10 +18,8 @@ async function _getInstitutionsAddons(userId) {
) )
// todo: seperate quota value depending on source of entitlement if needed // todo: seperate quota value depending on source of entitlement if needed
// todo: quota clean-up: remove aiErrorAssistant once migration finishes
const bundleFeatures = { const bundleFeatures = {
aiUsageQuota: Settings.writefull.quotaTierGranted, aiUsageQuota: Settings.writefull.quotaTierGranted,
aiErrorAssistant: true,
} }
return hasAssistBundle ? bundleFeatures : {} return hasAssistBundle ? bundleFeatures : {}
} }
@@ -492,7 +492,6 @@ const _ProjectController = {
'editor-popup-ux-survey-03-2026', 'editor-popup-ux-survey-03-2026',
'chat-edit-delete', 'chat-edit-delete',
'comment-mentions', 'comment-mentions',
'ai-workbench-release',
'compile-timeout-target-plans', 'compile-timeout-target-plans',
'writefull-figure-generator', 'writefull-figure-generator',
'writefull-toolbar-migration', 'writefull-toolbar-migration',
@@ -181,7 +181,7 @@ async function projectListPage(req, res, next) {
userId, userId,
`email isAdmin emails features alphaProgram betaProgram lastPrimaryEmailCheck lastActive signUpDate ace refProviders${ `email isAdmin emails features alphaProgram betaProgram lastPrimaryEmailCheck lastActive signUpDate ace refProviders${
isSaas isSaas
? ' enrollment writefull completedTutorials aiFeatures aiErrorAssistant labsProgram' ? ' enrollment writefull completedTutorials aiFeatures labsProgram'
: '' : ''
}` }`
) )
@@ -538,8 +538,8 @@ async function projectListPage(req, res, next) {
const aiBlocked = const aiBlocked =
Features.hasFeature('saas') && !(await _canUseAIAssist(user)) Features.hasFeature('saas') && !(await _canUseAIAssist(user))
const hasAiAssist = const hasUnlimitedAi =
Features.hasFeature('saas') && (await _userHasAIAssist(user)) Features.hasFeature('saas') && (await _userHasUnlimitedAiTier(user))
const splitTests = [ const splitTests = [
// Split tests that will be made available to the frontend // Split tests that will be made available to the frontend
@@ -591,7 +591,7 @@ async function projectListPage(req, res, next) {
best_subscription_type: usersBestSubscription.type, best_subscription_type: usersBestSubscription.type,
}), }),
ai_blocked: aiBlocked, ai_blocked: aiBlocked,
has_ai_assist: hasAiAssist, has_ai_assist: hasUnlimitedAi,
...(subjectArea && { subject_area: subjectArea }), ...(subjectArea && { subject_area: subjectArea }),
...(role && { role }), ...(role && { role }),
...(primaryOccupation && { primary_occupation: primaryOccupation }), ...(primaryOccupation && { primary_occupation: primaryOccupation }),
@@ -951,18 +951,19 @@ function _hasActiveFilter(filters) {
/** /**
* @param {any} user * @param {any} user
*/ */
async function _userHasAIAssist(user) { async function _userHasUnlimitedAiTier(user) {
const hasPremiumAiFeatures = const hasPremiumAiFeatures =
user.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota user.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
// Check if the user has a non free trial version of our AI features
// Check if the user has highest tier version of our AI features
if (hasPremiumAiFeatures) { if (hasPremiumAiFeatures) {
return true return true
} }
// Check if the user has AI Assist enabled via Writefull // Check if the user has Unlimited AI quota via Writefull
const { isPremium: hasAiAssistViaWritefull } = const { isPremium: hasUnlimitedAiViaWritefull } =
await UserGetter.promises.getWritefullData(user._id) await UserGetter.promises.getWritefullData(user._id)
if (hasAiAssistViaWritefull) { if (hasUnlimitedAiViaWritefull) {
return true return true
} }
return false return false
@@ -365,10 +365,7 @@ function hasPlanAiEnabled(plan) {
return false return false
} }
return ( return plan.features.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
plan.features.aiUsageQuota === Settings.aiFeatures.unlimitedQuota ||
plan.features.aiErrorAssistant === true
)
} }
/** /**
@@ -20,6 +20,7 @@ import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs'
import { GroupPolicy } from '../../models/GroupPolicy.mjs' import { GroupPolicy } from '../../models/GroupPolicy.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.mjs' import { AI_ADD_ON_CODE } from './AiHelper.mjs'
import { fetchNothing } from '@overleaf/fetch-utils' import { fetchNothing } from '@overleaf/fetch-utils'
import SplitTestUserGetter from '../SplitTests/SplitTestUserGetter.mjs'
/** /**
* Enqueue a job for refreshing features for the given user * Enqueue a job for refreshing features for the given user
@@ -46,8 +47,7 @@ async function refreshFeatures(userId, reason) {
_id: 1, _id: 1,
features: 1, features: 1,
email: 1, email: 1,
analyticsId: 1, ...SplitTestUserGetter.getProjection(),
labsProgram: 1,
}) })
const oldFeatures = _.clone(user.features) const oldFeatures = _.clone(user.features)
const features = await computeFeatures(userId) const features = await computeFeatures(userId)
@@ -104,6 +104,7 @@ async function refreshFeatures(userId, reason) {
json: { json: {
userOverleafId: userId, userOverleafId: userId,
// todo: quota clean-up: collab with writefull to rename this, and check if still needed // todo: quota clean-up: collab with writefull to rename this, and check if still needed
// AiAssist is legacy naming for our old one tier AI subscription, which is now our "Unlimited" quota tier
hasAiAssist: hasPremiumAiFeatures, hasAiAssist: hasPremiumAiFeatures,
aiUsageQuota: newFeatures.aiUsageQuota, aiUsageQuota: newFeatures.aiUsageQuota,
}, },
@@ -271,7 +272,7 @@ async function _getIndividualFeatures(userId) {
featureSets.push(_subscriptionToFeatures(subscription)) featureSets.push(_subscriptionToFeatures(subscription))
} }
// todo: quota clean-up - remove // todo: quota clean-up - remove once we finish transitioning all users to other plans
// if they are in the quota split test, we no longer look at the add-on, since every plan will now have the same quota // if they are in the quota split test, we no longer look at the add-on, since every plan will now have the same quota
// standalone plan will receive correct state since their plan will provide the correct quota // standalone plan will receive correct state since their plan will provide the correct quota
featureSets.push(_aiAddOnFeatures(subscription)) featureSets.push(_aiAddOnFeatures(subscription))
@@ -323,8 +324,6 @@ function _subscriptionToFeatures(subscription) {
function _aiAddOnFeatures(subscription) { function _aiAddOnFeatures(subscription) {
if (subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE)) { if (subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE)) {
return { return {
// allow both naming systems to work
aiErrorAssistant: true,
aiUsageQuota: Settings.aiFeatures.unlimitedQuota, aiUsageQuota: Settings.aiFeatures.unlimitedQuota,
} }
} else { } else {
@@ -9,8 +9,6 @@ import UserAuditLogHandler from '../../Features/User/UserAuditLogHandler.mjs'
const PERIOD = 24 // hours const PERIOD = 24 // hours
const PERIOD_IN_MILLISECONDS = PERIOD * 60 * 60 * 1000 const PERIOD_IN_MILLISECONDS = PERIOD * 60 * 60 * 1000
// todo: quota clean-up: extend this off base RateLimitController and unify behaviour where possible.
export default class TokenUsageRateLimiter { export default class TokenUsageRateLimiter {
/** /**
* @param {string} featureName * @param {string} featureName
@@ -1,12 +1,8 @@
// @ts-check // @ts-check
import SplitTestHandler from '../../Features/SplitTests/SplitTestHandler.mjs'
import UserGetter from '../../Features/User/UserGetter.mjs'
import TokenUsageRateLimiter from './TokenUsageRateLimiter.mjs' import TokenUsageRateLimiter from './TokenUsageRateLimiter.mjs'
/** @typedef {{usage?: number | null, periodStart?: Date | null}} FeatureUsage */ /** @typedef {{usage?: number | null, periodStart?: Date | null}} FeatureUsage */
const DEFAULT_USER_TOKEN_ALLOWANCE = 8_000_000 const DEFAULT_USER_TOKEN_ALLOWANCE = 8_000_000
const ALPHA_USER_TOKEN_ALLOWANCE = 8_000_000
class WorkbenchRateLimiter extends TokenUsageRateLimiter { class WorkbenchRateLimiter extends TokenUsageRateLimiter {
constructor() { constructor() {
super('aiWorkbench') super('aiWorkbench')
@@ -17,23 +13,6 @@ class WorkbenchRateLimiter extends TokenUsageRateLimiter {
* @returns {Promise<number>} * @returns {Promise<number>}
*/ */
async _getAllowance(userId) { async _getAllowance(userId) {
const splitTestAssignment =
await SplitTestHandler.promises.getAssignmentForUser(
userId,
'ai-workbench-release'
)
const inSplitTest = splitTestAssignment.variant === 'enabled'
if (!inSplitTest) {
return 0
}
const user = await UserGetter.promises.getUser(userId, {
alphaProgram: 1,
})
if (user?.alphaProgram) {
return ALPHA_USER_TOKEN_ALLOWANCE
}
// all users have the same token limit (fair usage) // all users have the same token limit (fair usage)
return DEFAULT_USER_TOKEN_ALLOWANCE return DEFAULT_USER_TOKEN_ALLOWANCE
} }
-6
View File
@@ -153,10 +153,6 @@ export const UserSchema = new Schema(
type: Boolean, type: Boolean,
default: Settings.defaultFeatures.symbolPalette, default: Settings.defaultFeatures.symbolPalette,
}, },
aiErrorAssistant: {
type: Boolean,
default: false,
},
aiUsageQuota: { type: String, default: 'basic' }, aiUsageQuota: { type: String, default: 'basic' },
}, },
featuresOverrides: [ featuresOverrides: [
@@ -170,8 +166,6 @@ export const UserSchema = new Schema(
expiresAt: { type: Date }, expiresAt: { type: Date },
note: { type: String }, note: { type: String },
features: { features: {
// todo: quota clean-up: remove aiErrorAssistant
aiErrorAssistant: { type: Boolean },
aiUsageQuota: { type: String }, aiUsageQuota: { type: String },
collaborators: { type: Number }, collaborators: { type: Number },
versioning: { type: Boolean }, versioning: { type: Boolean },
@@ -2,7 +2,6 @@ import { useEffect } from 'react'
import Notification from '@/shared/components/notification' import Notification from '@/shared/components/notification'
import PaywallUpgradeButton from '@/shared/components/paywall-upgrade-button' import PaywallUpgradeButton from '@/shared/components/paywall-upgrade-button'
import { useEditorContext } from '@/shared/context/editor-context' import { useEditorContext } from '@/shared/context/editor-context'
import { useUserFeaturesContext } from '@/shared/context/user-features-context'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time' import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time'
@@ -10,8 +9,6 @@ import getMeta from '@/utils/meta'
import { AiFeatureLocations } from './types/ai' import { AiFeatureLocations } from './types/ai'
import { paywallTypeByLocation } from '../utils/ai-features' import { paywallTypeByLocation } from '../utils/ai-features'
const hasUnlimitedAi = getMeta('ol-hasUnlimitedAi')
function AiPaywallNotification({ function AiPaywallNotification({
isActionBelowContent = false, isActionBelowContent = false,
featureLocation, featureLocation,
@@ -26,7 +23,6 @@ function AiPaywallNotification({
premiumSuggestionResetDate, premiumSuggestionResetDate,
} = useEditorContext() } = useEditorContext()
const features = useUserFeaturesContext()
const user = getMeta('ol-user') const user = getMeta('ol-user')
const isCommons = user.hasInstitutionLicence const isCommons = user.hasInstitutionLicence
@@ -36,8 +32,7 @@ function AiPaywallNotification({
return null return null
} }
// todo: quota clean-up: remove once we are transitioned off aiErrorAssistant naming and replace with just hasUnlimitedAi, also remove null FF check const hasAddOn = getMeta('ol-hasUnlimitedAi')
const hasAddOn = hasUnlimitedAi || Boolean(features?.aiErrorAssistant)
// error assist only needs usage quota // error assist only needs usage quota
const canUseErrorAssist = hasSuggestionsLeft const canUseErrorAssist = hasSuggestionsLeft
@@ -34,12 +34,7 @@ export const UserFeaturesProvider: FC<React.PropsWithChildren> = ({
useEffect(() => { useEffect(() => {
const listener = async ({ isPremium }: { isPremium: boolean }) => { const listener = async ({ isPremium }: { isPremium: boolean }) => {
// todo: quota clean-up: remove once we are transitioned off aiErrorAssistant naming if (hasUnlimitedAi === isPremium) {
const hasPremiumQuota = hasUnlimitedAi
const alreadyPremium =
features?.aiErrorAssistant === isPremium ||
hasPremiumQuota === isPremium
if (alreadyPremium) {
// the user is premium on writefull and has the AI assist, no need to refresh the features // the user is premium on writefull and has the AI assist, no need to refresh the features
return return
} }
@@ -55,7 +50,7 @@ export const UserFeaturesProvider: FC<React.PropsWithChildren> = ({
listener listener
) )
} }
}, [features?.aiErrorAssistant, writefullInstance]) }, [writefullInstance])
return ( return (
<UserFeaturesContext.Provider value={features}> <UserFeaturesContext.Provider value={features}>
@@ -116,7 +116,6 @@ module.exports = {
trackChanges: false, trackChanges: false,
symbolPalette: false, symbolPalette: false,
aiUsageQuota: 'basic', aiUsageQuota: 'basic',
aiErrorAssistant: false,
}, },
personal: { personal: {
collaborators: 1, collaborators: 1,
@@ -134,7 +133,6 @@ module.exports = {
trackChanges: false, trackChanges: false,
symbolPalette: false, symbolPalette: false,
aiUsageQuota: 'basic', aiUsageQuota: 'basic',
aiErrorAssistant: false,
}, },
collaborator: { collaborator: {
collaborators: 10, collaborators: 10,
@@ -152,7 +150,6 @@ module.exports = {
trackChanges: true, trackChanges: true,
symbolPalette: true, symbolPalette: true,
aiUsageQuota: 'basic', aiUsageQuota: 'basic',
aiErrorAssistant: false,
}, },
professional: { professional: {
collaborators: -1, collaborators: -1,
@@ -170,7 +167,6 @@ module.exports = {
trackChanges: true, trackChanges: true,
symbolPalette: true, symbolPalette: true,
aiUsageQuota: 'basic', aiUsageQuota: 'basic',
aiErrorAssistant: false,
}, },
}), }),
@@ -6,7 +6,8 @@ import {
makeEditorProvider, makeEditorProvider,
} from '../../helpers/editor-providers' } from '../../helpers/editor-providers'
const PAYWALL_TEXT = 'You’ve reached the fair usage limit on your plan' const PAYWALL_TEXT = 'You’ve hit your daily AI limit'
const FAIR_USAGE_TEXT = 'You’ve reached the fair usage limit on your plan'
const futureDate = () => new Date(Date.now() + 60 * 60 * 1000) const futureDate = () => new Date(Date.now() + 60 * 60 * 1000)
@@ -39,14 +40,17 @@ function makeShowLogsCompileProvider(initialShowLogs: boolean) {
return Provider return Provider
} }
function mountSuggestFixPaywall(initialShowLogs = true) { function mountSuggestFixPaywall(
initialShowLogs = true,
{ hasUnlimitedAi = false }: { hasUnlimitedAi?: boolean } = {}
) {
cy.window().then(win => { cy.window().then(win => {
win.metaAttributesCache.set('ol-showAiFeatures', true) win.metaAttributesCache.set('ol-showAiFeatures', true)
win.metaAttributesCache.set('ol-hasUnlimitedAi', hasUnlimitedAi)
}) })
cy.mount( cy.mount(
<EditorProviders <EditorProviders
features={{ aiErrorAssistant: true }}
providers={{ providers={{
EditorProvider: makeEditorProvider({ EditorProvider: makeEditorProvider({
hasSuggestionsLeft: false, hasSuggestionsLeft: false,
@@ -72,6 +76,13 @@ describe('<ErrorAssistantAiPaywallNotification />', function () {
cy.contains(PAYWALL_TEXT).should('be.visible') cy.contains(PAYWALL_TEXT).should('be.visible')
}) })
it('renders the fair usage limit paywall for users with unlimited AI', function () {
mountSuggestFixPaywall(true, { hasUnlimitedAi: true })
dispatchSuggestFixPaywall()
cy.contains(FAIR_USAGE_TEXT).should('be.visible')
cy.contains(PAYWALL_TEXT).should('not.exist')
})
it('ignores paywall events from other origins', function () { it('ignores paywall events from other origins', function () {
mountSuggestFixPaywall() mountSuggestFixPaywall()
cy.window().then(win => { cy.window().then(win => {
@@ -10,9 +10,6 @@ describe('Workbench', { scrollBehavior: false }, function () {
beforeEach(function () { beforeEach(function () {
cy.window().then(win => { cy.window().then(win => {
win.metaAttributesCache.set('ol-showAiFeatures', true) win.metaAttributesCache.set('ol-showAiFeatures', true)
win.metaAttributesCache.set('ol-splitTestVariants', {
'ai-workbench-release': 'enabled',
})
win.metaAttributesCache.set('ol-inactiveTutorials', [ win.metaAttributesCache.set('ol-inactiveTutorials', [
AI_CONSENT_TUTORIAL_KEY, AI_CONSENT_TUTORIAL_KEY,
]) ])
@@ -30,13 +27,13 @@ describe('Workbench', { scrollBehavior: false }, function () {
</EditorViewContext.Provider> </EditorViewContext.Provider>
) )
const Providers: FC<PropsWithChildren<{ aiAssistEnabled?: boolean }>> = ({ const Providers: FC<PropsWithChildren<{ aiUsageQuota?: string }>> = ({
children, children,
aiAssistEnabled = true, aiUsageQuota = 'unlimited',
}) => { }) => {
return ( return (
<EditorProviders <EditorProviders
features={{ aiErrorAssistant: aiAssistEnabled }} features={{ aiUsageQuota: aiUsageQuota }}
providers={{ EditorViewProvider, TutorialProvider }} providers={{ EditorViewProvider, TutorialProvider }}
> >
<div style={{ backgroundColor: '#1b222c' }}>{children}</div> <div style={{ backgroundColor: '#1b222c' }}>{children}</div>
@@ -47,7 +44,7 @@ describe('Workbench', { scrollBehavior: false }, function () {
describe('when AI assist is enabled and consent is given', function () { describe('when AI assist is enabled and consent is given', function () {
it('should show the chat interface', function () { it('should show the chat interface', function () {
cy.mount( cy.mount(
<Providers aiAssistEnabled> <Providers aiUsageQuota="unlimited">
<Workbench /> <Workbench />
</Providers> </Providers>
) )
@@ -70,7 +67,7 @@ describe('Workbench', { scrollBehavior: false }, function () {
}) })
cy.mount( cy.mount(
<Providers aiAssistEnabled> <Providers aiUsageQuota="unlimited">
<Workbench /> <Workbench />
</Providers> </Providers>
) )
@@ -111,7 +108,7 @@ describe('Workbench', { scrollBehavior: false }, function () {
win.metaAttributesCache.set('ol-showAiFeatures', false) win.metaAttributesCache.set('ol-showAiFeatures', false)
}) })
cy.mount( cy.mount(
<Providers aiAssistEnabled={false}> <Providers aiUsageQuota="free">
<Workbench /> <Workbench />
</Providers> </Providers>
) )
@@ -87,8 +87,8 @@ export type EditorProvidersProps = {
socket?: Socket socket?: Socket
isRestrictedTokenMember?: boolean isRestrictedTokenMember?: boolean
scope?: Record<string, any> scope?: Record<string, any>
features?: Record<string, boolean> features?: Record<string, boolean | string>
projectFeatures?: Record<string, boolean> projectFeatures?: Record<string, boolean | string>
permissionsLevel?: PermissionsLevel permissionsLevel?: PermissionsLevel
children?: React.ReactNode children?: React.ReactNode
rootFolder?: Folder[] rootFolder?: Folder[]
@@ -72,14 +72,12 @@ describe('InstitutionsFeatures', function () {
features: { features: {
institution: 'all', institution: 'all',
aiUsageQuota: 'unlimited', aiUsageQuota: 'unlimited',
aiErrorAssistant: true,
}, },
} }
ctx.testFeaturesWithNoAddon = { ctx.testFeaturesWithNoAddon = {
features: { features: {
institution: 'all', institution: 'all',
aiUsageQuota: 'basic', aiUsageQuota: 'basic',
aiErrorAssistant: false,
}, },
} }
}) })
@@ -388,7 +388,6 @@ describe('FeaturesUpdater', function () {
expect(features).to.deep.equal({ expect(features).to.deep.equal({
default: 'features', default: 'features',
individual: 'features', individual: 'features',
aiErrorAssistant: true,
aiUsageQuota: 'unlimited', aiUsageQuota: 'unlimited',
}) })
}) })
@@ -407,7 +406,6 @@ describe('FeaturesUpdater', function () {
) )
expect(features).to.deep.equal({ expect(features).to.deep.equal({
default: 'features', default: 'features',
aiErrorAssistant: true,
aiUsageQuota: 'unlimited', aiUsageQuota: 'unlimited',
}) })
}) })
@@ -65,9 +65,6 @@ describe('WorkbenchRateLimiter', function () {
featureFlagEnabledForMongoUser: sinon.stub().resolves(true), featureFlagEnabledForMongoUser: sinon.stub().resolves(true),
}, },
} }
ctx.SplitTestHandler.promises.getAssignmentForUser
.withArgs(ctx.alphaUserId, 'ai-workbench-release')
.resolves({ variant: 'enabled' })
vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
ObjectId, ObjectId,
-2
View File
@@ -2,8 +2,6 @@ import { StripeCurrencyCode } from './currency'
export type Features = { export type Features = {
aiUsageQuota: 'free' | 'basic' | 'standard' | 'unlimited' aiUsageQuota: 'free' | 'basic' | 'standard' | 'unlimited'
// todo: quota clean-up: remove aiErrorAssistant once migration finishes
aiErrorAssistant?: boolean
collaborators: number collaborators: number
compileGroup: string compileGroup: string
compileTimeout: number compileTimeout: number
-1
View File
@@ -10,7 +10,6 @@ export type RefProviders = {
export type UserId = Brand<string, 'UserId'> export type UserId = Brand<string, 'UserId'>
export type Features = { export type Features = {
aiErrorAssistant?: boolean
aiUsageQuota?: string aiUsageQuota?: string
collaborators?: number collaborators?: number
compileGroup?: 'standard' | 'priority' compileGroup?: 'standard' | 'priority'