Skip to content

Commit 7c4e250

Browse files
committed
fix(knowledge): bill Gemini spend for image reads that fail the pass
1 parent 2b4fa54 commit 7c4e250

10 files changed

Lines changed: 262 additions & 139 deletions

‎apps/sim/lib/knowledge/__integration__/confluence-images.integration.ts‎

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* Real sync workers, PostgreSQL, file storage, indexing and reconciliation for Confluence image
33
* attachments. The source listing is a synthetic connector behind the real Confluence meta, and
4-
* the vision extraction and embeddings are synthetic, so no request leaves the process.
4+
* the vision extraction and embeddings are synthetic, so no request leaves the process. A
5+
* `cutOff` image runs the real extractor against a Gemini reply stopped at its token limit.
56
*/
67
import { mkdtempSync } from 'node:fs'
78
import { rm } from 'node:fs/promises'
@@ -13,10 +14,12 @@ import {
1314
embedding,
1415
knowledgeConnector,
1516
organization,
17+
usageLog,
1618
user,
1719
workspace,
1820
} from '@sim/db/schema'
1921
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
22+
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
2023
import { and, eq, inArray, isNull } from 'drizzle-orm'
2124
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2225

@@ -27,6 +30,7 @@ const fixture = vi.hoisted(() => {
2730
/** externalId to version; a version bump changes the image's bytes and hash. */
2831
images: new Map<string, number>(),
2932
decorative: new Set<string>(),
33+
cutOff: new Set<string>(),
3034
extractions: [] as string[],
3135
/** Each chunk gets its own one-hot vector, so the shared HNSW graph sees no duplicates. */
3236
nextVector: 0,
@@ -53,22 +57,49 @@ vi.mock('@/lib/embeddings', async () => ({
5357
pricingId: 'text-embedding-3-small',
5458
}),
5559
}))
56-
vi.mock('@/lib/knowledge/documents/image-extraction', async (importOriginal) => ({
57-
...(await importOriginal<typeof import('@/lib/knowledge/documents/image-extraction')>()),
58-
extractImageText: async ({ filename }: { filename: string }) => {
59-
fixture.extractions.push(filename)
60-
const externalId = filename.replace(/\.png$/, '')
61-
if (fixture.decorative.has(externalId)) return { decorative: true, renderedText: '' }
62-
return {
63-
decorative: false,
64-
renderedText: `${externalId} version ${fixture.images.get(externalId)}: Region EMEA launches in Q1.`,
65-
usage: { model: 'gemini-3.8-flash', cost: 0, inputTokens: 10, outputTokens: 10 },
66-
}
67-
},
60+
vi.mock('@/lib/knowledge/documents/image-extraction-gemini', async (importOriginal) => ({
61+
...(await importOriginal<typeof import('@/lib/knowledge/documents/image-extraction-gemini')>()),
62+
requestGeminiImageExtraction: async () => ({
63+
text: '{"kind": "table", "rendered_text": "Region: EMEA',
64+
finishReason: 'MAX_TOKENS',
65+
inputTokens: 1200,
66+
outputTokens: 32_768,
67+
}),
6868
}))
69+
vi.mock('@/lib/knowledge/documents/image-extraction', async (importOriginal) => {
70+
const actual = await importOriginal<typeof import('@/lib/knowledge/documents/image-extraction')>()
71+
return {
72+
...actual,
73+
extractImageText: async (input: Parameters<typeof actual.extractImageText>[0]) => {
74+
const externalId = input.filename.replace(/\.png$/, '')
75+
if (fixture.cutOff.has(externalId)) return actual.extractImageText(input)
76+
fixture.extractions.push(input.filename)
77+
if (fixture.decorative.has(externalId)) return { decorative: true, renderedText: '' }
78+
return {
79+
decorative: false,
80+
renderedText: `${externalId} version ${fixture.images.get(externalId)}: Region EMEA launches in Q1.`,
81+
}
82+
},
83+
}
84+
})
6985
vi.mock('@/connectors/registry.server', async () => {
7086
const { confluenceConnectorMeta } = await import('@/connectors/confluence/meta')
7187
const enabled = (config: Record<string, unknown>) => config.imageAttachments === 'semantic'
88+
/** A real, noisy PNG over the semantic byte floor, so the real extractor reads it. */
89+
const cutOffImage = async () => {
90+
const sharp = (await import('sharp')).default
91+
return sharp({
92+
create: {
93+
width: 256,
94+
height: 256,
95+
channels: 3,
96+
background: '#808080',
97+
noise: { type: 'gaussian', mean: 128, sigma: 60 },
98+
},
99+
})
100+
.png()
101+
.toBuffer()
102+
}
72103
const stub = (externalId: string) => {
73104
const version = fixture.images.get(externalId) ?? 1
74105
return {
@@ -95,7 +126,9 @@ vi.mock('@/connectors/registry.server', async () => {
95126
externalId: string
96127
) => {
97128
if (!fixture.images.has(externalId)) return null
98-
const bytes = Buffer.from(`fixture png ${externalId} v${fixture.images.get(externalId)}`)
129+
const bytes = fixture.cutOff.has(externalId)
130+
? await cutOffImage()
131+
: Buffer.from(`fixture png ${externalId} v${fixture.images.get(externalId)}`)
99132
return {
100133
...stub(externalId),
101134
contentDeferred: false,
@@ -121,9 +154,11 @@ import {
121154
import * as connectorTokens from '@/lib/knowledge/connectors/access-token'
122155
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
123156
import { IMAGES_DEFERRED_NOTICE } from '@/lib/knowledge/connectors/sync-limits'
157+
import { resolveImageExtractionModel } from '@/lib/knowledge/documents/image-extraction-gemini'
124158

125159
describe('Confluence image attachments through sync, indexing and reconciliation', () => {
126160
const ids = createKnowledgeAclFixtureIds()
161+
const principal = createSessionPrincipal({ userId: ids.aliceId, sessionId: 'confluence-images' })
127162
let billing: Awaited<ReturnType<typeof resolveBillingAttribution>>
128163
const priorGeminiKeys = {
129164
GEMINI_API_KEY: env.GEMINI_API_KEY,
@@ -170,7 +205,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
170205
/** Changes the setting the way a user does; the update dispatches the next sync, which runs in-process. */
171206
async function setImageAttachments(imageAttachments: 'semantic' | 'off') {
172207
await updateKnowledgeConnector.execute({
173-
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' },
208+
principal,
174209
input: {
175210
connectorId: ids.connectorId,
176211
updates: {
@@ -182,7 +217,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
182217
}
183218
function setExcluded(documentId: string, operation: 'exclude' | 'restore') {
184219
return updateKnowledgeConnectorDocuments.execute({
185-
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' },
220+
principal,
186221
input: {
187222
knowledgeBaseId: ids.knowledgeBaseId,
188223
connectorId: ids.connectorId,
@@ -244,6 +279,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
244279
Object.assign(env, { GEMINI_API_KEY: 'fixture-gemini-key' })
245280
fixture.extractions.length = 0
246281
fixture.decorative.clear()
282+
fixture.cutOff.clear()
247283
fixture.images.clear()
248284
await db.delete(document).where(eq(document.connectorId, ids.connectorId))
249285
await configure('semantic')
@@ -335,6 +371,32 @@ describe('Confluence image attachments through sync, indexing and reconciliation
335371
expect(await setExcluded(excluded.id, 'restore')).toMatchObject({ count: 1 })
336372
}, 120_000)
337373

374+
it('bills the Gemini call once when a reply cut off at its token limit fails the image', async () => {
375+
fixture.images.set('image-a', 1)
376+
fixture.cutOff.add('image-a')
377+
await executeSync(ids.connectorId, { fullSync: true, billingAttribution: billing })
378+
379+
await expect
380+
.poll(async () => (await images()).map((row) => row.processingStatus), { timeout: 45_000 })
381+
.toEqual(['failed'])
382+
const [failed] = await images()
383+
expect(failed.processingError).toContain('more text than one extraction can return')
384+
expect(await vectors([failed.id])).toEqual([])
385+
const charges = await db
386+
.select()
387+
.from(usageLog)
388+
.where(
389+
and(
390+
eq(usageLog.workspaceId, ids.workspaceId),
391+
eq(usageLog.source, 'knowledge-base'),
392+
eq(usageLog.description, resolveImageExtractionModel())
393+
)
394+
)
395+
expect(charges).toHaveLength(1)
396+
expect(Number(charges[0].cost)).toBeGreaterThan(0)
397+
expect(charges[0].metadata).toEqual({ inputTokens: 1200, outputTokens: 32_768 })
398+
}, 120_000)
399+
338400
it('keeps images off for a connector created before the setting existed', async () => {
339401
fixture.images.set('image-a', 1)
340402
await configure(undefined)
@@ -378,7 +440,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
378440
'saves a new connector with Image Attachments on unless the request chose otherwise (%o)',
379441
async (chosen, saved) => {
380442
const created = await createKnowledgeConnector.execute({
381-
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' },
443+
principal,
382444
input: {
383445
knowledgeBaseId: ids.knowledgeBaseId,
384446
connectorType: 'confluence',

‎apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts‎

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ describe('knowledge document indexing usage', () => {
367367

368368
describe('knowledge image extraction usage', () => {
369369
const VISION_USAGE = {
370+
chargeId: 'charge-1',
370371
model: 'gemini-3.8-flash',
371372
cost: 0.004,
372373
inputTokens: 1200,
@@ -411,12 +412,26 @@ describe('knowledge image extraction usage', () => {
411412
)
412413
}
413414

415+
/** Processing that reports a vision charge the way the extractor does, then settles. */
416+
function extractThen(
417+
usage: typeof VISION_USAGE,
418+
settle: () => Promise<{ chunks: unknown[]; metadata: Record<string, number> }>
419+
) {
420+
mockProcessDocument.mockImplementation(async (...args: unknown[]) => {
421+
const access = args[6] as { onVisionUsage: (reported: typeof VISION_USAGE) => void }
422+
access.onVisionUsage(usage)
423+
return settle()
424+
})
425+
}
426+
427+
const NOTHING_INDEXED = {
428+
chunks: [],
429+
metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0 },
430+
}
431+
414432
it('bills a decorative image that indexed nothing', async () => {
415433
armImageReads('confluence')
416-
mockProcessDocument.mockResolvedValue({
417-
chunks: [],
418-
metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0, visionUsage: VISION_USAGE },
419-
})
434+
extractThen(VISION_USAGE, async () => NOTHING_INDEXED)
420435
await process()
421436

422437
expect(mockGenerateEmbeddings).not.toHaveBeenCalled()
@@ -427,36 +442,43 @@ describe('knowledge image extraction usage', () => {
427442
source: 'knowledge-base',
428443
description: 'gemini-3.8-flash',
429444
cost: 0.004,
430-
sourceReference: `knowledge-document-vision:${DOCUMENT_ID}:pass-1`,
445+
sourceReference: `knowledge-document-vision:${DOCUMENT_ID}:charge-1`,
431446
metadata: { inputTokens: 1200, outputTokens: 300 },
432447
}),
433448
])
434449
})
435450

436451
it('bills the vision call beside the embedding charge without changing it', async () => {
437452
armImageReads('confluence')
438-
mockProcessDocument.mockResolvedValue({
453+
extractThen(VISION_USAGE, async () => ({
439454
chunks: [{ text: 'Region: EMEA', metadata: { startIndex: 0, endIndex: 12 } }],
440-
metadata: { chunkCount: 1, tokenCount: 4, characterCount: 12, visionUsage: VISION_USAGE },
441-
})
455+
metadata: { chunkCount: 1, tokenCount: 4, characterCount: 12 },
456+
}))
442457
await process()
443458

444459
expect(mockRecordUsage).toHaveBeenCalledTimes(2)
445-
expect(recordedSourceReference(0)).toBe(`knowledge-document:${DOCUMENT_ID}:pass-1`)
446-
expect(recordedSourceReference(1)).toBe(`knowledge-document-vision:${DOCUMENT_ID}:pass-1`)
460+
expect([recordedSourceReference(0), recordedSourceReference(1)].sort()).toEqual([
461+
`knowledge-document-vision:${DOCUMENT_ID}:charge-1`,
462+
`knowledge-document:${DOCUMENT_ID}:pass-1`,
463+
])
464+
})
465+
466+
it('bills the vision call when a later stage fails the pass', async () => {
467+
armImageReads('confluence')
468+
extractThen(VISION_USAGE, async () => ({
469+
chunks: [{ text: 'Region: EMEA', metadata: { startIndex: 0, endIndex: 12 } }],
470+
metadata: { chunkCount: 1, tokenCount: 4, characterCount: 12 },
471+
}))
472+
mockGenerateEmbeddings.mockRejectedValue(new Error('embedding provider down'))
473+
await expect(process()).rejects.toThrow()
474+
475+
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
476+
expect(recordedSourceReference(0)).toBe(`knowledge-document-vision:${DOCUMENT_ID}:charge-1`)
447477
})
448478

449479
it('records no vision charge when the call cost nothing, as with a workspace key', async () => {
450480
armImageReads('confluence')
451-
mockProcessDocument.mockResolvedValue({
452-
chunks: [],
453-
metadata: {
454-
chunkCount: 0,
455-
tokenCount: 0,
456-
characterCount: 0,
457-
visionUsage: { ...VISION_USAGE, cost: 0 },
458-
},
459-
})
481+
extractThen({ ...VISION_USAGE, cost: 0 }, async () => NOTHING_INDEXED)
460482
await process()
461483
expect(mockRecordUsage).not.toHaveBeenCalled()
462484
})

‎apps/sim/lib/knowledge/documents/document-processing-source.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ describe('knowledge document processing source', () => {
293293
signal: expect.any(AbortSignal),
294294
processingDeadlineAt: expect.any(Number),
295295
imageExtraction: 'ocr',
296+
onVisionUsage: expect.any(Function),
296297
},
297298
PERSISTED_CONTEXT.workspaceId,
298299
undefined,
@@ -336,6 +337,7 @@ describe('knowledge document processing source', () => {
336337
signal: expect.any(AbortSignal),
337338
processingDeadlineAt: expect.any(Number),
338339
imageExtraction: 'ocr',
340+
onVisionUsage: expect.any(Function),
339341
},
340342
PERSISTED_CONTEXT.workspaceId,
341343
undefined,

‎apps/sim/lib/knowledge/documents/document-processor.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ export type SourceFileAccess = Pick<
236236
processingDeadlineAt?: number
237237
/** The document's source policy for images; absent keeps the OCR path. */
238238
imageExtraction?: ImageExtractionPolicy
239+
/** Receives what the vision model billed, even when processing then fails. */
240+
onVisionUsage?: (usage: VisionExtractionUsage) => void
239241
}
240242

241243
type ProcessingMethod = 'file-parser' | 'mistral-ocr' | 'vision-extract'
@@ -262,7 +264,6 @@ export async function processDocument(
262264
characterCount: number
263265
processingMethod: ProcessingMethod
264266
cloudUrl?: string
265-
visionUsage?: VisionExtractionUsage
266267
}
267268
}> {
268269
logger.info('Processing document', { mimeType })
@@ -277,6 +278,7 @@ export async function processDocument(
277278
checkpoint: access.ocrCheckpoint,
278279
deadlineAt: access.processingDeadlineAt,
279280
signal: access.signal,
281+
onUsage: access.onVisionUsage,
280282
})
281283
: undefined
282284
if (image?.decorative) {
@@ -291,7 +293,6 @@ export async function processDocument(
291293
tokenCount: 0,
292294
characterCount: 0,
293295
processingMethod: 'vision-extract',
294-
visionUsage: image.usage,
295296
},
296297
}
297298
}
@@ -413,7 +414,6 @@ export async function processDocument(
413414
characterCount,
414415
processingMethod,
415416
cloudUrl,
416-
visionUsage: image?.usage,
417417
},
418418
}
419419
} catch (error) {

‎apps/sim/lib/knowledge/documents/image-document-routing.test.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-inpu
4343

4444
const IMAGE_URL = 'https://example.com/schedule.png'
4545
const IMAGE_BYTES = Buffer.from('png bytes')
46-
const USAGE = { model: 'gemini-3.8-flash', cost: 0.004, inputTokens: 1200, outputTokens: 300 }
46+
const onVisionUsage = vi.fn()
4747

4848
function process(policy: ImageExtractionPolicy | undefined, mimeType = 'image/png') {
4949
return runWithKnowledgeModelInputProvenance(
@@ -53,6 +53,7 @@ function process(policy: ImageExtractionPolicy | undefined, mimeType = 'image/pn
5353
userId: 'user-1',
5454
imageExtraction: policy,
5555
processingDeadlineAt: Date.now() + 600_000,
56+
onVisionUsage,
5657
ocrCheckpoint: { knowledgeBaseId: 'kb-1', documentId: 'doc-1', indexingPassId: 'pass-1' },
5758
}),
5859
{ opaqueInputSafe: true }
@@ -68,7 +69,6 @@ describe('image document routing', () => {
6869
mockExtractImageText.mockResolvedValue({
6970
decorative: false,
7071
renderedText: 'Region: EMEA. Quarter: Q1. Launch date: March 3.',
71-
usage: USAGE,
7272
})
7373
})
7474

@@ -80,25 +80,23 @@ describe('image document routing', () => {
8080
source: IMAGE_BYTES,
8181
filename: 'Schedule',
8282
checkpoint: { knowledgeBaseId: 'kb-1', documentId: 'doc-1', indexingPassId: 'pass-1' },
83+
onUsage: onVisionUsage,
8384
})
8485
)
8586
expect(mockExecuteMistralParse).not.toHaveBeenCalled()
8687
expect(processed.chunks.map((chunk) => chunk.text).join(' ')).toContain('Region: EMEA')
87-
expect(processed.metadata).toMatchObject({
88-
processingMethod: 'vision-extract',
89-
visionUsage: USAGE,
90-
})
88+
expect(processed.metadata).toMatchObject({ processingMethod: 'vision-extract' })
9189
})
9290

9391
it('completes a decorative image with no chunks instead of failing it as unreadable', async () => {
94-
mockExtractImageText.mockResolvedValue({ decorative: true, renderedText: '', usage: USAGE })
92+
mockExtractImageText.mockResolvedValue({ decorative: true, renderedText: '' })
9593
const processed = await process('semantic')
9694
expect(processed.chunks).toEqual([])
97-
expect(processed.metadata).toMatchObject({ chunkCount: 0, visionUsage: USAGE })
95+
expect(processed.metadata).toMatchObject({ chunkCount: 0 })
9896
})
9997

10098
it('fails an informative image whose extraction came back empty', async () => {
101-
mockExtractImageText.mockResolvedValue({ decorative: false, renderedText: '', usage: USAGE })
99+
mockExtractImageText.mockResolvedValue({ decorative: false, renderedText: '' })
102100
await expect(process('semantic')).rejects.toMatchObject({ code: 'no_extractable_text' })
103101
})
104102

0 commit comments

Comments
 (0)