Skip to content

Commit a9c9ed8

Browse files
authored
feat(knowledge): add Live sync option to KB connectors + fix embedding billing (#3916)
* feat(knowledge): add Live sync option to KB connector modal for Max/Enterprise users Adds a "Live" (every 5 min) sync frequency option gated to Max and Enterprise plan users. Includes client-side badge + disabled state, shared sync intervals constant, and server-side plan validation on both POST and PATCH connector routes. * fix(knowledge): record embedding usage cost for KB document processing Adds billing tracking to the KB embedding pipeline, which was previously generating OpenAI API calls with no cost recorded. Token counts are now captured from the actual API response and recorded via recordUsage after successful embedding insertion. BYOK workspaces are excluded from billing. Applies to all execution paths: direct, BullMQ, and Trigger.dev. * fix(knowledge): simplify embedding billing — use calculateCost, return modelName - Use calculateCost() from @/providers/utils instead of inline formula, consistent with how LLM billing works throughout the platform - Return modelName from GenerateEmbeddingsResult so billing uses the actual model (handles custom Azure deployments) instead of a hardcoded fallback string - Fix docs-chunker.ts empty-path fallback to satisfy full GenerateEmbeddingsResult type * fix(knowledge): remove dev bypass from hasLiveSyncAccess * chore(knowledge): rename sync-intervals to consts, fix stale TSDoc comment * improvement(knowledge): extract MaxBadge component, capture billing config once per document * fix(knowledge): add knowledge-base to usage_log_source enum, fix docs-chunker type * fix(knowledge): generate migration for knowledge-base usage_log_source enum value * fix(knowledge): add knowledge-base to usage_log_source enum via drizzle-kit * fix(knowledge): fix search embedding test mocks, parallelize billing lookups * fix(knowledge): warn when embedding model has no pricing entry * fix(knowledge): call checkAndBillOverageThreshold after embedding usage
1 parent b744cd2 commit a9c9ed8

File tree

18 files changed

+14873
-93
lines changed

18 files changed

+14873
-93
lines changed

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { z } from 'zod'
1313
import { decryptApiKey } from '@/lib/api-key/crypto'
1414
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
1515
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
16+
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
1617
import { generateRequestId } from '@/lib/core/utils/request'
1718
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
1819
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
@@ -116,6 +117,20 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
116117
)
117118
}
118119

120+
if (
121+
parsed.data.syncIntervalMinutes !== undefined &&
122+
parsed.data.syncIntervalMinutes > 0 &&
123+
parsed.data.syncIntervalMinutes < 60
124+
) {
125+
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
126+
if (!canUseLiveSync) {
127+
return NextResponse.json(
128+
{ error: 'Live sync requires a Max or Enterprise plan' },
129+
{ status: 403 }
130+
)
131+
}
132+
}
133+
119134
if (parsed.data.sourceConfig !== undefined) {
120135
const existingRows = await db
121136
.select()

apps/sim/app/api/knowledge/[id]/connectors/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import { encryptApiKey } from '@/lib/api-key/crypto'
88
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
99
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
1011
import { generateRequestId } from '@/lib/core/utils/request'
1112
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
1213
import { allocateTagSlots } from '@/lib/knowledge/constants'
@@ -97,6 +98,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
9798

9899
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
99100

101+
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
102+
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
103+
if (!canUseLiveSync) {
104+
return NextResponse.json(
105+
{ error: 'Live sync requires a Max or Enterprise plan' },
106+
{ status: 403 }
107+
)
108+
}
109+
}
110+
100111
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
101112
if (!connectorConfig) {
102113
return NextResponse.json(

apps/sim/app/api/knowledge/search/utils.test.ts

Lines changed: 43 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* @vitest-environment node
66
*/
77
import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
8+
import { mockNextFetchResponse } from '@sim/testing/mocks'
89
import { beforeEach, describe, expect, it, vi } from 'vitest'
910

1011
vi.mock('drizzle-orm')
@@ -14,16 +15,6 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
1415
retryWithExponentialBackoff: (fn: any) => fn(),
1516
}))
1617

17-
vi.stubGlobal(
18-
'fetch',
19-
vi.fn().mockResolvedValue({
20-
ok: true,
21-
json: async () => ({
22-
data: [{ embedding: [0.1, 0.2, 0.3] }],
23-
}),
24-
})
25-
)
26-
2718
vi.mock('@/lib/core/config/env', () => createEnvMock())
2819

2920
import {
@@ -178,17 +169,16 @@ describe('Knowledge Search Utils', () => {
178169
OPENAI_API_KEY: 'test-openai-key',
179170
})
180171

181-
const fetchSpy = vi.mocked(fetch)
182-
fetchSpy.mockResolvedValueOnce({
183-
ok: true,
184-
json: async () => ({
172+
mockNextFetchResponse({
173+
json: {
185174
data: [{ embedding: [0.1, 0.2, 0.3] }],
186-
}),
187-
} as any)
175+
usage: { prompt_tokens: 1, total_tokens: 1 },
176+
},
177+
})
188178

189179
const result = await generateSearchEmbedding('test query')
190180

191-
expect(fetchSpy).toHaveBeenCalledWith(
181+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
192182
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
193183
expect.objectContaining({
194184
headers: expect.objectContaining({
@@ -209,17 +199,16 @@ describe('Knowledge Search Utils', () => {
209199
OPENAI_API_KEY: 'test-openai-key',
210200
})
211201

212-
const fetchSpy = vi.mocked(fetch)
213-
fetchSpy.mockResolvedValueOnce({
214-
ok: true,
215-
json: async () => ({
202+
mockNextFetchResponse({
203+
json: {
216204
data: [{ embedding: [0.1, 0.2, 0.3] }],
217-
}),
218-
} as any)
205+
usage: { prompt_tokens: 1, total_tokens: 1 },
206+
},
207+
})
219208

220209
const result = await generateSearchEmbedding('test query')
221210

222-
expect(fetchSpy).toHaveBeenCalledWith(
211+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
223212
'https://api.openai.com/v1/embeddings',
224213
expect.objectContaining({
225214
headers: expect.objectContaining({
@@ -243,17 +232,16 @@ describe('Knowledge Search Utils', () => {
243232
OPENAI_API_KEY: 'test-openai-key',
244233
})
245234

246-
const fetchSpy = vi.mocked(fetch)
247-
fetchSpy.mockResolvedValueOnce({
248-
ok: true,
249-
json: async () => ({
235+
mockNextFetchResponse({
236+
json: {
250237
data: [{ embedding: [0.1, 0.2, 0.3] }],
251-
}),
252-
} as any)
238+
usage: { prompt_tokens: 1, total_tokens: 1 },
239+
},
240+
})
253241

254242
await generateSearchEmbedding('test query')
255243

256-
expect(fetchSpy).toHaveBeenCalledWith(
244+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
257245
expect.stringContaining('api-version='),
258246
expect.any(Object)
259247
)
@@ -273,17 +261,16 @@ describe('Knowledge Search Utils', () => {
273261
OPENAI_API_KEY: 'test-openai-key',
274262
})
275263

276-
const fetchSpy = vi.mocked(fetch)
277-
fetchSpy.mockResolvedValueOnce({
278-
ok: true,
279-
json: async () => ({
264+
mockNextFetchResponse({
265+
json: {
280266
data: [{ embedding: [0.1, 0.2, 0.3] }],
281-
}),
282-
} as any)
267+
usage: { prompt_tokens: 1, total_tokens: 1 },
268+
},
269+
})
283270

284271
await generateSearchEmbedding('test query', 'text-embedding-3-small')
285272

286-
expect(fetchSpy).toHaveBeenCalledWith(
273+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
287274
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
288275
expect.any(Object)
289276
)
@@ -311,13 +298,12 @@ describe('Knowledge Search Utils', () => {
311298
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
312299
})
313300

314-
const fetchSpy = vi.mocked(fetch)
315-
fetchSpy.mockResolvedValueOnce({
301+
mockNextFetchResponse({
316302
ok: false,
317303
status: 404,
318304
statusText: 'Not Found',
319-
text: async () => 'Deployment not found',
320-
} as any)
305+
text: 'Deployment not found',
306+
})
321307

322308
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
323309

@@ -332,13 +318,12 @@ describe('Knowledge Search Utils', () => {
332318
OPENAI_API_KEY: 'test-openai-key',
333319
})
334320

335-
const fetchSpy = vi.mocked(fetch)
336-
fetchSpy.mockResolvedValueOnce({
321+
mockNextFetchResponse({
337322
ok: false,
338323
status: 429,
339324
statusText: 'Too Many Requests',
340-
text: async () => 'Rate limit exceeded',
341-
} as any)
325+
text: 'Rate limit exceeded',
326+
})
342327

343328
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
344329

@@ -356,17 +341,16 @@ describe('Knowledge Search Utils', () => {
356341
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
357342
})
358343

359-
const fetchSpy = vi.mocked(fetch)
360-
fetchSpy.mockResolvedValueOnce({
361-
ok: true,
362-
json: async () => ({
344+
mockNextFetchResponse({
345+
json: {
363346
data: [{ embedding: [0.1, 0.2, 0.3] }],
364-
}),
365-
} as any)
347+
usage: { prompt_tokens: 1, total_tokens: 1 },
348+
},
349+
})
366350

367351
await generateSearchEmbedding('test query')
368352

369-
expect(fetchSpy).toHaveBeenCalledWith(
353+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
370354
expect.any(String),
371355
expect.objectContaining({
372356
body: JSON.stringify({
@@ -387,17 +371,16 @@ describe('Knowledge Search Utils', () => {
387371
OPENAI_API_KEY: 'test-openai-key',
388372
})
389373

390-
const fetchSpy = vi.mocked(fetch)
391-
fetchSpy.mockResolvedValueOnce({
392-
ok: true,
393-
json: async () => ({
374+
mockNextFetchResponse({
375+
json: {
394376
data: [{ embedding: [0.1, 0.2, 0.3] }],
395-
}),
396-
} as any)
377+
usage: { prompt_tokens: 1, total_tokens: 1 },
378+
},
379+
})
397380

398381
await generateSearchEmbedding('test query', 'text-embedding-3-small')
399382

400-
expect(fetchSpy).toHaveBeenCalledWith(
383+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
401384
expect.any(String),
402385
expect.objectContaining({
403386
body: JSON.stringify({

apps/sim/app/api/knowledge/utils.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ vi.stubGlobal(
7777
{ embedding: [0.1, 0.2], index: 0 },
7878
{ embedding: [0.3, 0.4], index: 1 },
7979
],
80+
usage: { prompt_tokens: 2, total_tokens: 2 },
8081
}),
8182
})
8283
)
@@ -294,7 +295,7 @@ describe('Knowledge Utils', () => {
294295
it.concurrent('should return same length as input', async () => {
295296
const result = await generateEmbeddings(['a', 'b'])
296297

297-
expect(result.length).toBe(2)
298+
expect(result.embeddings.length).toBe(2)
298299
})
299300

300301
it('should use Azure OpenAI when Azure config is provided', async () => {
@@ -313,6 +314,7 @@ describe('Knowledge Utils', () => {
313314
ok: true,
314315
json: async () => ({
315316
data: [{ embedding: [0.1, 0.2], index: 0 }],
317+
usage: { prompt_tokens: 1, total_tokens: 1 },
316318
}),
317319
} as any)
318320

@@ -342,6 +344,7 @@ describe('Knowledge Utils', () => {
342344
ok: true,
343345
json: async () => ({
344346
data: [{ embedding: [0.1, 0.2], index: 0 }],
347+
usage: { prompt_tokens: 1, total_tokens: 1 },
345348
}),
346349
} as any)
347350

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,23 @@ import {
1919
ModalHeader,
2020
Tooltip,
2121
} from '@/components/emcn'
22+
import { getSubscriptionAccessState } from '@/lib/billing/client'
2223
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
2324
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
2425
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
2526
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
27+
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
28+
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
29+
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
2630
import { getDependsOnFields } from '@/blocks/utils'
2731
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
2832
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
2933
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
3034
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
35+
import { useSubscriptionData } from '@/hooks/queries/subscription'
3136
import type { SelectorKey } from '@/hooks/selectors/types'
3237
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
3338

34-
const SYNC_INTERVALS = [
35-
{ label: 'Every hour', value: 60 },
36-
{ label: 'Every 6 hours', value: 360 },
37-
{ label: 'Daily', value: 1440 },
38-
{ label: 'Weekly', value: 10080 },
39-
{ label: 'Manual only', value: 0 },
40-
] as const
41-
4239
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
4340

4441
interface AddConnectorModalProps {
@@ -67,6 +64,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
6764
const { workspaceId } = useParams<{ workspaceId: string }>()
6865
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
6966

67+
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
68+
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
69+
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
70+
7071
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
7172
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
7273
const connectorProviderId = useMemo(
@@ -516,8 +517,13 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
516517
onValueChange={(val) => setSyncInterval(Number(val))}
517518
>
518519
{SYNC_INTERVALS.map((interval) => (
519-
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
520+
<ButtonGroupItem
521+
key={interval.value}
522+
value={String(interval.value)}
523+
disabled={interval.requiresMax && !hasMaxAccess}
524+
>
520525
{interval.label}
526+
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
521527
</ButtonGroupItem>
522528
))}
523529
</ButtonGroup>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const SYNC_INTERVALS = [
2+
{ label: 'Live', value: 5, requiresMax: true },
3+
{ label: 'Every hour', value: 60, requiresMax: false },
4+
{ label: 'Every 6 hours', value: 360, requiresMax: false },
5+
{ label: 'Daily', value: 1440, requiresMax: false },
6+
{ label: 'Weekly', value: 10080, requiresMax: false },
7+
{ label: 'Manual only', value: 0, requiresMax: false },
8+
] as const

0 commit comments

Comments
 (0)