Skip to content

Commit f7f678b

Browse files
fix(knowledge): increase hosted rerank capacity (#7912)
1 parent 32d98f4 commit f7f678b

5 files changed

Lines changed: 240 additions & 12 deletions

File tree

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,10 @@ export const env = createEnv({
471471
KB_CONFIG_MISTRAL_OCR_MAX_CONCURRENT: z.number().int().positive().max(64).optional().default(2),
472472
/** JSON map from API-key SHA-256 fingerprints to organization IDs; keys in one org share capacity. */
473473
MISTRAL_OCR_QUOTA_GROUPS: z.string().optional(),
474-
KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: z.number().positive().optional().default(60),
474+
/** Explicit override for all rerank credentials; otherwise defaults to 60, or 600 for hosted Cohere. */
475+
KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: z.number().positive().optional(),
476+
/** Overrides the shared rerank setting only for Sim-hosted Cohere credentials. */
477+
KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: z.number().positive().optional(),
475478
KB_CONFIG_DOCUMENT_CONCURRENCY: z.number().optional().default(4), // Concurrent documents in the in-process (non-Trigger) path
476479
KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch
477480
KB_CONFIG_DOCUMENT_BATCH_SIZE: z.number().optional().default(10), // Documents per batch in the in-process (non-Trigger) path

‎apps/sim/lib/core/rate-limiter/provider-admission.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
45
import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock'
56
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
67

@@ -18,6 +19,7 @@ vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({
1819
}))
1920

2021
import { waitForProviderAdmission } from '@/lib/core/rate-limiter/provider-admission'
22+
import { DbTokenBucket } from '@/lib/core/rate-limiter/storage/db-token-bucket'
2123
import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
2224

2325
const INPUT = {
@@ -32,6 +34,11 @@ describe('provider admission', () => {
3234
beforeEach(() => {
3335
vi.useFakeTimers()
3436
vi.clearAllMocks()
37+
resetDbChainMock()
38+
setEnv({
39+
KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: undefined,
40+
KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: undefined,
41+
})
3542
getCooldownUntil.mockResolvedValue(null)
3643
consumeTokens.mockResolvedValue({ allowed: true, tokensRemaining: 1, resetAt: new Date() })
3744
})
@@ -66,6 +73,131 @@ describe('provider admission', () => {
6673
expect(consumeTokens).toHaveBeenCalledTimes(2)
6774
})
6875

76+
it.each([
77+
{ isHostedCredential: true, maxTokens: 16, refillRate: 10 },
78+
{ isHostedCredential: false, maxTokens: 2, refillRate: 1 },
79+
{ isHostedCredential: undefined, maxTokens: 2, refillRate: 1 },
80+
])('selects the rerank budget for hosted=$isHostedCredential', async (fixture) => {
81+
await waitForProviderAdmission({
82+
...INPUT,
83+
operation: 'rerank',
84+
providerId: 'cohere',
85+
isHostedCredential: fixture.isHostedCredential,
86+
})
87+
expect(consumeTokens.mock.calls[0][0]).toEqual([
88+
{
89+
key: 'provider:rerank:cohere:hashed-credential:requests',
90+
cost: 1,
91+
config: {
92+
maxTokens: fixture.maxTokens,
93+
refillRate: fixture.refillRate,
94+
refillIntervalMs: 1000,
95+
},
96+
},
97+
])
98+
})
99+
100+
it('preserves the shared override unless a hosted-specific override is set', async () => {
101+
setEnv({ KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: '120' })
102+
const input = { ...INPUT, operation: 'rerank' as const, providerId: 'cohere' }
103+
await waitForProviderAdmission({ ...input, isHostedCredential: true })
104+
await waitForProviderAdmission(input)
105+
setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: '300' })
106+
await waitForProviderAdmission({ ...input, isHostedCredential: true })
107+
await waitForProviderAdmission(input)
108+
expect(consumeTokens.mock.calls.map(([reservations]) => reservations[0].config)).toEqual([
109+
{ maxTokens: 16, refillRate: 2, refillIntervalMs: 1000 },
110+
{ maxTokens: 2, refillRate: 2, refillIntervalMs: 1000 },
111+
{ maxTokens: 16, refillRate: 5, refillIntervalMs: 1000 },
112+
{ maxTokens: 2, refillRate: 2, refillIntervalMs: 1000 },
113+
])
114+
})
115+
116+
it('caps the hosted burst when the configured minute budget is smaller', async () => {
117+
setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: '1' })
118+
await waitForProviderAdmission({
119+
...INPUT,
120+
operation: 'rerank',
121+
providerId: 'cohere',
122+
isHostedCredential: true,
123+
})
124+
expect(consumeTokens.mock.calls[0][0][0].config).toMatchObject({
125+
maxTokens: 1,
126+
refillRate: 1 / 60,
127+
})
128+
})
129+
130+
it.each(['0', '-1', '', 'invalid', 'Infinity'])(
131+
'rejects an invalid hosted rerank override (%s) before spending capacity',
132+
async (value) => {
133+
setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: value })
134+
await expect(
135+
waitForProviderAdmission({
136+
...INPUT,
137+
operation: 'rerank',
138+
providerId: 'cohere',
139+
isHostedCredential: true,
140+
})
141+
).rejects.toThrow('Hosted rerank requests per minute must be finite and at least 1')
142+
expect(consumeTokens).not.toHaveBeenCalled()
143+
}
144+
)
145+
146+
it.each([
147+
{ operation: 'embedding', providerId: 'openai', maxTokens: 64, refillRate: 10 },
148+
{ operation: 'ocr', providerId: 'mistral', maxTokens: 2, refillRate: 1 },
149+
{ operation: 'rerank', providerId: 'another-provider', maxTokens: 2, refillRate: 1 },
150+
] as const)('preserves the $operation budget for $providerId', async (fixture) => {
151+
setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: '300' })
152+
await waitForProviderAdmission({
153+
...INPUT,
154+
operation: fixture.operation,
155+
providerId: fixture.providerId,
156+
isHostedCredential: true,
157+
})
158+
const reservations = consumeTokens.mock.calls[0][0]
159+
expect(reservations.at(-1).config).toMatchObject({
160+
maxTokens: fixture.maxTokens,
161+
refillRate: fixture.refillRate,
162+
})
163+
})
164+
165+
it('sustains 600 hosted reranks per minute through the real bucket refill calculation', async () => {
166+
const input = {
167+
...INPUT,
168+
operation: 'rerank' as const,
169+
providerId: 'cohere',
170+
isHostedCredential: true,
171+
maxWaitMs: 1,
172+
}
173+
let stored: { key: string; tokens: string; lastRefillAt: Date } | undefined
174+
dbChainMockFns.values.mockImplementation((rows) => {
175+
stored ??= rows.find((row: { key: string }) => row.key.endsWith(':requests'))
176+
return { onConflictDoNothing: vi.fn().mockResolvedValue(undefined) }
177+
})
178+
dbChainMockFns.limit.mockImplementation(async () => [stored])
179+
dbChainMockFns.set.mockImplementation((values) => {
180+
Object.assign(stored!, values)
181+
return { where: vi.fn().mockResolvedValue(undefined) }
182+
})
183+
const bucket = new DbTokenBucket()
184+
consumeTokens.mockImplementation((reservations, options) =>
185+
bucket.consumeTokensAtomically(reservations, options)
186+
)
187+
188+
for (let request = 0; request < 16; request++) await waitForProviderAdmission(input)
189+
await expect(waitForProviderAdmission(input)).rejects.toMatchObject({ retryAfterMs: 1000 })
190+
for (let second = 0; second < 60; second++) {
191+
await vi.advanceTimersByTimeAsync(1000)
192+
for (let request = 0; request < 10; request++) await waitForProviderAdmission(input)
193+
await expect(waitForProviderAdmission(input)).rejects.toMatchObject({ retryAfterMs: 1000 })
194+
}
195+
expect(stored?.tokens).toBe('0')
196+
expect(new Set(consumeTokens.mock.calls.map(([reservations]) => reservations[0].key))).toEqual(
197+
new Set(['provider:rerank:cohere:hashed-credential:requests'])
198+
)
199+
})
200+
69201
it('stops waiting immediately when the caller aborts', async () => {
70202
consumeTokens.mockResolvedValue({ allowed: false, retryAfterMs: 5000 })
71203
const controller = new AbortController()

‎apps/sim/lib/core/rate-limiter/provider-admission.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface ProviderIdentity {
2121
const BULK_LANE_SHARE = 0.9
2222

2323
interface ProviderAdmissionInput extends ProviderIdentity {
24+
/** True only for platform-owned credentials resolved on hosted Sim. Does not change bucket identity. */
25+
isHostedCredential?: boolean
2426
inputTokens?: number
2527
signal?: AbortSignal
2628
maxWaitMs: number
@@ -35,8 +37,20 @@ interface ProviderAdmissionInput extends ProviderIdentity {
3537
* race for a handful of slots while the token budget sits unused.
3638
*/
3739
const EMBEDDING_REQUEST_BURST = 64
40+
const HOSTED_RERANK_REQUEST_BURST = 16
3841
const DEFAULT_REQUEST_BURST = 2
3942

43+
function hostedRerankRequestsPerMinute(): number {
44+
const configured =
45+
env.KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE ?? env.KB_CONFIG_RERANK_REQUESTS_PER_MINUTE
46+
if (configured === undefined) return 600
47+
const requestsPerMinute = Number(configured)
48+
if (!Number.isFinite(requestsPerMinute) || requestsPerMinute < 1) {
49+
throw new Error('Hosted rerank requests per minute must be finite and at least 1')
50+
}
51+
return requestsPerMinute
52+
}
53+
4054
/** A local admission wait expired; the document scheduler may retry the work later. */
4155
export class ProviderAdmissionTimeoutError extends Error {
4256
readonly retryable = false
@@ -59,12 +73,16 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P
5973
input.signal?.throwIfAborted()
6074
const deadlineAt = Date.now() + input.maxWaitMs
6175
const key = providerKey(input)
76+
const isHostedRerank =
77+
input.operation === 'rerank' && input.providerId === 'cohere' && input.isHostedCredential
6278
const requestsPerMinute =
6379
input.operation === 'embedding'
6480
? envNumber(env.KB_CONFIG_EMBEDDING_REQUESTS_PER_MINUTE, 600, { min: 1 })
6581
: input.operation === 'ocr'
6682
? envNumber(env.KB_CONFIG_OCR_REQUESTS_PER_MINUTE, 60, { min: 1 })
67-
: envNumber(env.KB_CONFIG_RERANK_REQUESTS_PER_MINUTE, 60, { min: 1 })
83+
: isHostedRerank
84+
? hostedRerankRequestsPerMinute()
85+
: envNumber(env.KB_CONFIG_RERANK_REQUESTS_PER_MINUTE, 60, { min: 1 })
6886
const tokenBudget =
6987
input.operation === 'embedding' && input.inputTokens
7088
? {
@@ -77,7 +95,11 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P
7795
throw new Error('Embedding request exceeds the configured per-credential token budget')
7896
}
7997
const requestBurst = Math.min(
80-
input.operation === 'embedding' ? EMBEDDING_REQUEST_BURST : DEFAULT_REQUEST_BURST,
98+
input.operation === 'embedding'
99+
? EMBEDDING_REQUEST_BURST
100+
: isHostedRerank
101+
? HOSTED_RERANK_REQUEST_BURST
102+
: DEFAULT_REQUEST_BURST,
81103
requestsPerMinute
82104
)
83105
const reservations: TokenBucketReservation[] = []

‎apps/sim/lib/knowledge/reranker.test.ts‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* @vitest-environment node
33
*/
44
import { setupGlobalFetchMock } from '@sim/testing/mocks'
5+
import { setEnv } from '@sim/testing/mocks/env.mock'
6+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
57
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
68
import type {
79
AtomicAdmissionOptions,
@@ -13,6 +15,8 @@ const admission = vi.hoisted(() => ({
1315
setCooldown: vi.fn(),
1416
cooldowns: new Map<string, Date>(),
1517
}))
18+
const { getBYOKKey } = vi.hoisted(() => ({ getBYOKKey: vi.fn() }))
19+
vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey }))
1620
vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({
1721
createStorageAdapter: () => ({
1822
consumeTokensAtomically: admission.consume,
@@ -31,6 +35,15 @@ const envSnapshot = { ...env }
3135
describe('Knowledge reranker model boundary', () => {
3236
beforeEach(() => {
3337
vi.clearAllMocks()
38+
setEnvFlags({ isHosted: true })
39+
getBYOKKey.mockResolvedValue(null)
40+
setEnv({
41+
KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: undefined,
42+
KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: undefined,
43+
COHERE_API_KEY_1: undefined,
44+
COHERE_API_KEY_2: undefined,
45+
COHERE_API_KEY_3: undefined,
46+
})
3447
admission.cooldowns.clear()
3548
admission.consume.mockImplementation(
3649
async (_reservations: readonly TokenBucketReservation[], options: AtomicAdmissionOptions) => {
@@ -55,11 +68,59 @@ describe('Knowledge reranker model boundary', () => {
5568

5669
afterEach(() => {
5770
vi.useRealTimers()
71+
resetEnvFlagsMock()
5872
vi.unstubAllGlobals()
5973
for (const key of Object.keys(env)) delete (env as Record<string, unknown>)[key]
6074
Object.assign(env, envSnapshot)
6175
})
6276

77+
it.each([
78+
{ hosted: true, source: 'env', expectedKey: 'cohere-key', burst: 16, refill: 10 },
79+
{ hosted: true, source: 'rotation', expectedKey: 'rotating-key', burst: 16, refill: 10 },
80+
{ hosted: true, source: 'workspace', expectedKey: 'byok-key', burst: 2, refill: 1 },
81+
{ hosted: true, source: 'organization', expectedKey: 'byok-key', burst: 2, refill: 1 },
82+
{ hosted: false, source: 'user', expectedKey: 'user-key', burst: 2, refill: 1 },
83+
{ hosted: false, source: 'env', expectedKey: 'cohere-key', burst: 2, refill: 1 },
84+
{ hosted: false, source: 'rotation', expectedKey: 'rotating-key', burst: 2, refill: 1 },
85+
{ hosted: false, source: 'workspace', expectedKey: 'byok-key', burst: 2, refill: 1 },
86+
{ hosted: false, source: 'organization', expectedKey: 'byok-key', burst: 2, refill: 1 },
87+
])('uses the $source credential budget on hosted=$hosted', async (fixture) => {
88+
setEnvFlags({ isHosted: fixture.hosted })
89+
const isBYOK = fixture.source === 'workspace' || fixture.source === 'organization'
90+
if (isBYOK) {
91+
getBYOKKey.mockResolvedValue({ apiKey: 'byok-key', scope: fixture.source, isBYOK: true })
92+
}
93+
if (fixture.source === 'rotation') {
94+
setEnv({ COHERE_API_KEY: undefined, COHERE_API_KEY_1: 'rotating-key' })
95+
}
96+
const result = await rerank('query', [{ id: 'one', text: 'content' }], {
97+
model: 'rerank-v4.0-fast',
98+
workspaceId: 'fixture-workspace',
99+
apiKey: fixture.hosted || fixture.source === 'user' ? 'user-key' : undefined,
100+
})
101+
expect(result.isBYOK).toBe(isBYOK)
102+
expect(fetch).toHaveBeenCalledWith(
103+
'https://api.cohere.com/v2/rerank',
104+
expect.objectContaining({
105+
headers: expect.objectContaining({ Authorization: `Bearer ${fixture.expectedKey}` }),
106+
})
107+
)
108+
expect(admission.consume.mock.calls[0][0]).toMatchObject([
109+
{ config: { maxTokens: fixture.burst, refillRate: fixture.refill, refillIntervalMs: 1000 } },
110+
])
111+
if (fixture.source === 'user') expect(getBYOKKey).not.toHaveBeenCalled()
112+
else expect(getBYOKKey).toHaveBeenCalledWith('fixture-workspace', 'cohere')
113+
})
114+
115+
it('fails before admission when no credential is configured', async () => {
116+
setEnv({ COHERE_API_KEY: undefined })
117+
await expect(
118+
rerank('query', [{ id: 'one', text: 'content' }], { model: 'rerank-v4.0-fast' })
119+
).rejects.toThrow('No Cohere API key configured')
120+
expect(admission.consume).not.toHaveBeenCalled()
121+
expect(fetch).not.toHaveBeenCalled()
122+
})
123+
63124
it('projects query and documents at egress while returning the original item', async () => {
64125
const registry = new ResolvedSecretTraceRegistry([
65126
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' },
@@ -132,10 +193,16 @@ describe('Knowledge reranker model boundary', () => {
132193
vi.mocked(fetch).mockResolvedValueOnce(
133194
new Response('{}', { status: 429, headers: { 'Retry-After': '2' } })
134195
)
135-
const first = rerank('first', [{ id: 'one', text: 'content' }], { model: 'rerank-v4.0-fast' })
196+
const first = rerank('first', [{ id: 'one', text: 'content' }], {
197+
model: 'rerank-v4.0-fast',
198+
workspaceId: 'fixture-workspace-one',
199+
})
136200
await vi.advanceTimersByTimeAsync(0)
137201
expect(admission.setCooldown).toHaveBeenCalledOnce()
138-
const second = rerank('second', [{ id: 'two', text: 'content' }], { model: 'rerank-v4.0-fast' })
202+
const second = rerank('second', [{ id: 'two', text: 'content' }], {
203+
model: 'rerank-v4.0-fast',
204+
workspaceId: 'fixture-workspace-two',
205+
})
139206
await vi.advanceTimersByTimeAsync(1999)
140207
expect(fetch).toHaveBeenCalledTimes(1)
141208
await vi.advanceTimersByTimeAsync(1)
@@ -147,7 +214,7 @@ describe('Knowledge reranker model boundary', () => {
147214
expect(new Set(reservations.map((item) => item.key)).size).toBe(1)
148215
expect(reservations[0].key).toMatch(/^provider:rerank:cohere:[a-f0-9]{64}:requests$/)
149216
expect(reservations[0].key).not.toContain('cohere-key')
150-
expect(reservations[0].config.refillRate).toBe(1)
217+
expect(reservations[0].config).toMatchObject({ maxTokens: 16, refillRate: 10 })
151218
})
152219

153220
it('bounds repeated 429s to four attempts with no timer left behind', async () => {

0 commit comments

Comments
 (0)