WorkbenchRateLimiter.sequential.test.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import mongodb from 'mongodb-legacy'
  4. import {
  5. cleanupTestDatabase,
  6. db,
  7. waitForDb,
  8. } from '../../../../app/src/infrastructure/mongodb.mjs'
  9. import { UserFeatureUsage } from '../../../../app/src/models/UserFeatureUsage.mjs'
  10. const { ObjectId } = mongodb
  11. const MODULE_PATH =
  12. '../../../../app/src/infrastructure/rate-limiters/WorkbenchRateLimiter'
  13. describe('WorkbenchRateLimiter', function () {
  14. beforeAll(async function () {
  15. await waitForDb()
  16. })
  17. beforeAll(cleanupTestDatabase)
  18. beforeEach(async function (ctx) {
  19. ctx.alphaUserId = new ObjectId()
  20. ctx.alphaUser = {
  21. _id: ctx.alphaUserId,
  22. alphaProgram: true,
  23. features: {
  24. aiUsageQuota: 'unlimited',
  25. },
  26. }
  27. ctx.userWithoutAiAddOnId = new ObjectId()
  28. ctx.userWithAiAddOn = {
  29. _id: ctx.userWithoutAiAddOnId,
  30. features: {
  31. aiUsageQuota: 'unlimited',
  32. },
  33. alphaProgram: false,
  34. }
  35. ctx.otherUserId = new ObjectId()
  36. ctx.otherUser = {
  37. _id: ctx.otherUserId,
  38. features: {
  39. aiUsageQuota: 'basic',
  40. },
  41. alphaProgram: false,
  42. }
  43. ctx.UserGetter = {
  44. promises: {
  45. getUser: sinon.stub(),
  46. },
  47. }
  48. ctx.UserGetter.promises.getUser
  49. .withArgs(ctx.alphaUserId)
  50. .resolves(ctx.alphaUser)
  51. ctx.UserGetter.promises.getUser
  52. .withArgs(ctx.userWithoutAiAddOnId)
  53. .resolves(ctx.userWithAiAddOn)
  54. ctx.UserGetter.promises.getUser
  55. .withArgs(ctx.otherUserId)
  56. .resolves(ctx.otherUser)
  57. ctx.SplitTestHandler = {
  58. promises: {
  59. getAssignmentForUser: sinon.stub(),
  60. featureFlagEnabledForMongoUser: sinon.stub().resolves(true),
  61. },
  62. }
  63. ctx.SplitTestHandler.promises.getAssignmentForUser
  64. .withArgs(ctx.alphaUserId, 'ai-workbench-release')
  65. .resolves({ variant: 'enabled' })
  66. vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
  67. ObjectId,
  68. db,
  69. waitForDb,
  70. }))
  71. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  72. default: ctx.UserGetter,
  73. }))
  74. vi.doMock(
  75. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  76. () => ({
  77. default: ctx.SplitTestHandler,
  78. })
  79. )
  80. vi.doMock(
  81. '../../../../app/src/Features/Analytics/AnalyticsManager',
  82. () => ({
  83. default: {
  84. recordEventForSession: sinon.stub(),
  85. },
  86. })
  87. )
  88. ctx.WorkbenchRateLimiter = (await import(MODULE_PATH)).default
  89. })
  90. describe('calculateTokenUsage', function () {
  91. it('treats input tokens as 1', function (ctx) {
  92. expect(
  93. ctx.WorkbenchRateLimiter.calculateTokenUsage({
  94. inputTokenDetails: {
  95. noCacheTokens: 100,
  96. cacheReadTokens: 0,
  97. },
  98. outputTokens: 0,
  99. })
  100. ).to.equal(100)
  101. })
  102. it('treats output tokens as 10', function (ctx) {
  103. expect(
  104. ctx.WorkbenchRateLimiter.calculateTokenUsage({
  105. inputTokenDetails: {
  106. noCacheTokens: 0,
  107. cacheReadTokens: 0,
  108. },
  109. outputTokens: 100,
  110. })
  111. ).to.equal(1000)
  112. })
  113. it('treats output tokens correctly', function (ctx) {
  114. expect(
  115. ctx.WorkbenchRateLimiter.calculateTokenUsage({
  116. inputTokenDetails: {
  117. noCacheTokens: 0,
  118. cacheReadTokens: 0,
  119. },
  120. outputTokens: 100,
  121. })
  122. ).to.equal(1000)
  123. })
  124. it('rounds up to nearest integer', function (ctx) {
  125. expect(
  126. ctx.WorkbenchRateLimiter.calculateTokenUsage({
  127. inputTokenDetails: {
  128. noCacheTokens: 1,
  129. cacheReadTokens: 0,
  130. },
  131. outputTokens: 0,
  132. })
  133. ).to.equal(1)
  134. })
  135. it('sums mixed tokens', function (ctx) {
  136. expect(
  137. ctx.WorkbenchRateLimiter.calculateTokenUsage({
  138. inputTokenDetails: {
  139. noCacheTokens: 10,
  140. cacheReadTokens: 10,
  141. },
  142. outputTokens: 10,
  143. })
  144. ).to.equal(10 + 100 + 0 + 1)
  145. })
  146. })
  147. describe('checkUsage', function () {
  148. describe('with no data', function () {
  149. beforeEach(async function (ctx) {
  150. await UserFeatureUsage.deleteMany({}).exec()
  151. ctx.req = { session: {} }
  152. ctx.res = {
  153. set: sinon.stub(),
  154. headersSent: false,
  155. }
  156. })
  157. it('should not throw', async function (ctx) {
  158. await expect(
  159. ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
  160. ).to.eventually.be.fulfilled
  161. })
  162. it('sets rate limit headers', async function (ctx) {
  163. await ctx.WorkbenchRateLimiter.checkUsage(
  164. ctx.alphaUserId,
  165. ctx.req,
  166. ctx.res
  167. )
  168. expect(ctx.res.set).to.have.been.calledWith(
  169. 'Token-RateLimit-Limit',
  170. '8000000'
  171. )
  172. expect(ctx.res.set).to.have.been.calledWith(
  173. 'Token-RateLimit-Remaining',
  174. '8000000'
  175. )
  176. // We can't mock the mongo date, so just check that something was set
  177. expect(ctx.res.set).to.have.been.calledWith(
  178. 'Token-RateLimit-Reset',
  179. matchRateLimit(24 * 60 * 60)
  180. )
  181. })
  182. })
  183. describe('with existing usage', function () {
  184. beforeEach(async function (ctx) {
  185. await UserFeatureUsage.deleteMany({}).exec()
  186. ctx.req = { session: {} }
  187. ctx.res = {
  188. set: sinon.stub(),
  189. headersSent: false,
  190. }
  191. const usageRecord = new UserFeatureUsage({
  192. _id: ctx.alphaUserId,
  193. features: {
  194. aiWorkbench: {
  195. usage: 2000000,
  196. periodStart: new Date(new Date().getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
  197. },
  198. },
  199. })
  200. await usageRecord.save()
  201. })
  202. it('should not throw if under limit', async function (ctx) {
  203. await expect(
  204. ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
  205. ).to.eventually.be.fulfilled
  206. })
  207. it('sets rate limit headers', async function (ctx) {
  208. await ctx.WorkbenchRateLimiter.checkUsage(
  209. ctx.alphaUserId,
  210. ctx.req,
  211. ctx.res
  212. )
  213. expect(ctx.res.set).to.have.been.calledWith(
  214. 'Token-RateLimit-Limit',
  215. '8000000'
  216. )
  217. expect(ctx.res.set).to.have.been.calledWith(
  218. 'Token-RateLimit-Remaining',
  219. '6000000'
  220. )
  221. expect(ctx.res.set).to.have.been.calledWith(
  222. 'Token-RateLimit-Reset',
  223. matchRateLimit(23 * 60 * 60)
  224. )
  225. })
  226. it('throws if over limit', async function (ctx) {
  227. const usageRecord = await UserFeatureUsage.findById(
  228. ctx.alphaUserId
  229. ).exec()
  230. usageRecord.features.aiWorkbench.usage = 9000000
  231. await usageRecord.save()
  232. await expect(
  233. ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
  234. ).to.eventually.be.rejectedWith(/rate limit exceeded/i)
  235. })
  236. })
  237. describe('with an expired old usage period', function () {
  238. beforeEach(async function (ctx) {
  239. await UserFeatureUsage.deleteMany({}).exec()
  240. ctx.res = {
  241. set: sinon.stub(),
  242. headersSent: false,
  243. }
  244. const usageRecord = new UserFeatureUsage({
  245. _id: ctx.alphaUserId,
  246. features: {
  247. aiWorkbench: {
  248. usage: 2000000,
  249. periodStart: new Date(new Date().getTime() - 25 * 60 * 60 * 1000), // 25 hours ago
  250. },
  251. },
  252. })
  253. await usageRecord.save()
  254. })
  255. it('should not throw', async function (ctx) {
  256. await expect(
  257. ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
  258. ).to.eventually.be.fulfilled
  259. })
  260. it('sets rate limit headers', async function (ctx) {
  261. await ctx.WorkbenchRateLimiter.checkUsage(
  262. ctx.alphaUserId,
  263. ctx.req,
  264. ctx.res
  265. )
  266. expect(ctx.res.set).to.have.been.calledWith(
  267. 'Token-RateLimit-Limit',
  268. '8000000'
  269. )
  270. expect(ctx.res.set).to.have.been.calledWith(
  271. 'Token-RateLimit-Remaining',
  272. '8000000'
  273. )
  274. // A new period
  275. expect(ctx.res.set).to.have.been.calledWith(
  276. 'Token-RateLimit-Reset',
  277. matchRateLimit(24 * 60 * 60)
  278. )
  279. })
  280. })
  281. })
  282. describe('resetTokenUsage', function () {
  283. beforeEach(async function () {
  284. await UserFeatureUsage.deleteMany({}).exec()
  285. })
  286. it('resets usage to 0 and refreshes periodStart when existing usage is present', async function (ctx) {
  287. const usageRecord = new UserFeatureUsage({
  288. _id: ctx.alphaUserId,
  289. features: {
  290. aiWorkbench: {
  291. usage: 5000000,
  292. periodStart: new Date(new Date().getTime() - 1 * 60 * 60 * 1000),
  293. },
  294. },
  295. })
  296. await usageRecord.save()
  297. const before = Date.now()
  298. await ctx.WorkbenchRateLimiter.resetTokenUsage(ctx.alphaUserId)
  299. const updated = await UserFeatureUsage.findById(ctx.alphaUserId).exec()
  300. expect(updated.features.aiWorkbench.usage).to.equal(0)
  301. expect(updated.features.aiWorkbench.periodStart.getTime()).to.be.at.least(
  302. before
  303. )
  304. })
  305. it('upserts a fresh usage record with zero usage when none exists', async function (ctx) {
  306. await ctx.WorkbenchRateLimiter.resetTokenUsage(ctx.alphaUserId)
  307. const created = await UserFeatureUsage.findById(ctx.alphaUserId).exec()
  308. expect(created).to.exist
  309. expect(created.features.aiWorkbench.usage).to.equal(0)
  310. })
  311. })
  312. describe('recordUsage', function () {
  313. beforeEach(async function (ctx) {
  314. await UserFeatureUsage.deleteMany({}).exec()
  315. ctx.res = {
  316. set: sinon.stub(),
  317. headersSent: false,
  318. }
  319. })
  320. describe('without existing usage', function () {
  321. it('creates new usage record if none exists', async function (ctx) {
  322. await ctx.WorkbenchRateLimiter.recordUsage(
  323. ctx.alphaUserId,
  324. ctx.res,
  325. 1500000
  326. )
  327. const usageRecord = await UserFeatureUsage.findById(
  328. ctx.alphaUserId
  329. ).exec()
  330. expect(usageRecord).to.exist
  331. expect(usageRecord.features.aiWorkbench.usage).to.equal(1500000)
  332. expect(
  333. usageRecord.features.aiWorkbench.periodStart.getTime()
  334. ).to.approximately(new Date().getTime(), 60_000)
  335. })
  336. })
  337. describe('with existing usage', function () {
  338. beforeEach(async function (ctx) {
  339. await UserFeatureUsage.deleteMany({}).exec()
  340. const usageRecord = new UserFeatureUsage({
  341. _id: ctx.alphaUserId,
  342. features: {
  343. aiWorkbench: {
  344. usage: 2000000,
  345. periodStart: new Date(new Date().getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
  346. },
  347. },
  348. })
  349. await usageRecord.save()
  350. await ctx.WorkbenchRateLimiter.recordUsage(
  351. ctx.alphaUserId,
  352. ctx.res,
  353. 1000000
  354. )
  355. })
  356. it('updates existing usage record', async function (ctx) {
  357. const updatedRecord = await UserFeatureUsage.findById(
  358. ctx.alphaUserId
  359. ).exec()
  360. expect(updatedRecord.features.aiWorkbench.usage).to.equal(3000000)
  361. })
  362. it('sets rate limit headers', async function (ctx) {
  363. expect(ctx.res.set).to.have.been.calledWith(
  364. 'Token-RateLimit-Limit',
  365. '8000000'
  366. )
  367. expect(ctx.res.set).to.have.been.calledWith(
  368. 'Token-RateLimit-Remaining',
  369. '5000000'
  370. )
  371. // Keeps the original period start time
  372. expect(ctx.res.set).to.have.been.calledWith(
  373. 'Token-RateLimit-Reset',
  374. matchRateLimit(23 * 60 * 60)
  375. )
  376. })
  377. })
  378. describe('with an expired old usage period', function () {
  379. beforeEach(async function (ctx) {
  380. await UserFeatureUsage.deleteMany({}).exec()
  381. const usageRecord = new UserFeatureUsage({
  382. _id: ctx.alphaUserId,
  383. features: {
  384. aiWorkbench: {
  385. usage: 2000000,
  386. periodStart: new Date(new Date().getTime() - 25 * 60 * 60 * 1000), // 25 hours ago
  387. },
  388. },
  389. })
  390. await usageRecord.save()
  391. await ctx.WorkbenchRateLimiter.recordUsage(
  392. ctx.alphaUserId,
  393. ctx.res,
  394. 1000000
  395. )
  396. })
  397. it('resets usage and period start', async function (ctx) {
  398. const updatedRecord = await UserFeatureUsage.findById(
  399. ctx.alphaUserId
  400. ).exec()
  401. expect(updatedRecord.features.aiWorkbench.usage).to.equal(1000000)
  402. })
  403. it('sets rate limit headers', async function (ctx) {
  404. expect(ctx.res.set).to.have.been.calledWith(
  405. 'Token-RateLimit-Limit',
  406. '8000000'
  407. )
  408. expect(ctx.res.set).to.have.been.calledWith(
  409. 'Token-RateLimit-Remaining',
  410. '7000000'
  411. )
  412. // New period start time
  413. expect(ctx.res.set).to.have.been.calledWith(
  414. 'Token-RateLimit-Reset',
  415. matchRateLimit(24 * 60 * 60)
  416. )
  417. })
  418. })
  419. })
  420. })
  421. function matchRateLimit(expectedValue, delta = 60) {
  422. return sinon.match(function (value) {
  423. const number = parseInt(value, 10)
  424. return Math.abs(number - expectedValue) <= delta
  425. }, `${expectedValue} ± ${delta}`)
  426. }