Skip to content

Commit 0c38037

Browse files
authored
fix(knowledge): harden ingestion pipeline (#7077)
* fix(knowledge): harden ingestion pipeline * fix(knowledge): correct bounded ingestion edge cases * fix(knowledge): tighten redaction and chunk validation * fix(knowledge): close bounded ingestion gaps * fix(files): preserve parser complexity limits
1 parent b99411a commit 0c38037

133 files changed

Lines changed: 36907 additions & 2333 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-knowledge.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5278,6 +5278,13 @@
52785278
"maximum": 9007199254740991,
52795279
"description": "Documents unchanged."
52805280
},
5281+
"docsSkipped": {
5282+
"default": 0,
5283+
"description": "Documents intentionally skipped because they could not be indexed safely.",
5284+
"type": "integer",
5285+
"minimum": 0,
5286+
"maximum": 9007199254740991
5287+
},
52815288
"docsFailed": {
52825289
"type": "integer",
52835290
"minimum": 0,
@@ -5306,6 +5313,7 @@
53065313
"docsUpdated",
53075314
"docsDeleted",
53085315
"docsUnchanged",
5316+
"docsSkipped",
53095317
"docsFailed",
53105318
"errorMessage"
53115319
],

apps/sim/app/api/files/parse/route.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@sim/testing'
1717
import { NextRequest } from 'next/server'
1818
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
19+
import { FileParserError } from '@/lib/file-parsers/errors'
1920

2021
const {
2122
mockVerifyFileAccess,
@@ -310,6 +311,30 @@ describe('File Parse API Route', () => {
310311
expect(data.output.content).toBe('plain text content')
311312
})
312313

314+
it('should reject parser complexity limits instead of returning raw text', async () => {
315+
setupFileApiMocks({
316+
cloudEnabled: true,
317+
storageProvider: 's3',
318+
authenticated: true,
319+
})
320+
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('{"value":true}'))
321+
mockParseBuffer.mockRejectedValueOnce(
322+
new FileParserError('complexity_limit', 'JSON document exceeds the complexity limit')
323+
)
324+
325+
const req = createMockRequest('POST', {
326+
filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/data.json',
327+
})
328+
329+
const response = await POST(req)
330+
const data = await response.json()
331+
332+
expect(response.status).toBe(200)
333+
expect(data.success).toBe(false)
334+
expect(data.error).toContain('complexity limit')
335+
expect(data).not.toHaveProperty('output')
336+
})
337+
313338
it('should handle multiple files', async () => {
314339
setupFileApiMocks({
315340
cloudEnabled: false,

apps/sim/app/api/files/parse/route.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
1313
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
1414
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1515
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
16-
import { isHtmlComplexityError } from '@/lib/file-parsers/html-parser'
17-
import { isYamlComplexityError } from '@/lib/file-parsers/yaml-parser'
16+
import { isFileParserError } from '@/lib/file-parsers/errors'
1817
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
1918
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
2019
import {
@@ -1050,10 +1049,9 @@ async function handleGenericTextBuffer(
10501049
}
10511050
} catch (parserError) {
10521051
if (isPayloadSizeLimitError(parserError)) throw parserError
1053-
// Fail closed on a resource-exhaustion rejection instead of silently
1054-
// storing the crafted document as raw text.
1055-
if (isYamlComplexityError(parserError)) throw parserError
1056-
if (isHtmlComplexityError(parserError)) throw parserError
1052+
if (isFileParserError(parserError) && parserError.code === 'complexity_limit') {
1053+
throw parserError
1054+
}
10571055

10581056
logger.warn('Specialized parser failed, falling back to generic parsing:', parserError)
10591057
}

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

Lines changed: 23 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ function makeResult(id: string, distance = 0.1): SearchResult {
8989
}
9090
}
9191

92+
const TEST_EMBEDDING = [0.1, 0.2, 0.3, ...Array.from({ length: 1533 }, () => 0)]
93+
94+
function mockNextEmbeddingResponse(): void {
95+
vi.mocked(fetch).mockResolvedValueOnce(
96+
new Response(
97+
JSON.stringify({
98+
data: [{ embedding: TEST_EMBEDDING, index: 0 }],
99+
usage: { prompt_tokens: 1, total_tokens: 1 },
100+
}),
101+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
102+
)
103+
)
104+
}
105+
92106
describe('Knowledge Search Utils', () => {
93107
beforeEach(() => {
94108
vi.clearAllMocks()
@@ -553,12 +567,7 @@ describe('Knowledge Search Utils', () => {
553567
OPENAI_API_KEY: 'test-openai-key',
554568
})
555569

556-
mockNextFetchResponse({
557-
json: {
558-
data: [{ embedding: [0.1, 0.2, 0.3] }],
559-
usage: { prompt_tokens: 1, total_tokens: 1 },
560-
},
561-
})
570+
mockNextEmbeddingResponse()
562571

563572
const result = await generateSearchEmbedding('test query')
564573

@@ -570,7 +579,7 @@ describe('Knowledge Search Utils', () => {
570579
}),
571580
})
572581
)
573-
expect(result.embedding).toEqual([0.1, 0.2, 0.3])
582+
expect(result.embedding).toEqual(TEST_EMBEDDING)
574583

575584
// Clean up
576585
Object.keys(env).forEach((key) => delete (env as any)[key])
@@ -583,12 +592,7 @@ describe('Knowledge Search Utils', () => {
583592
OPENAI_API_KEY: 'test-openai-key',
584593
})
585594

586-
mockNextFetchResponse({
587-
json: {
588-
data: [{ embedding: [0.1, 0.2, 0.3] }],
589-
usage: { prompt_tokens: 1, total_tokens: 1 },
590-
},
591-
})
595+
mockNextEmbeddingResponse()
592596

593597
const result = await generateSearchEmbedding('test query')
594598

@@ -600,7 +604,7 @@ describe('Knowledge Search Utils', () => {
600604
}),
601605
})
602606
)
603-
expect(result.embedding).toEqual([0.1, 0.2, 0.3])
607+
expect(result.embedding).toEqual(TEST_EMBEDDING)
604608

605609
// Clean up
606610
Object.keys(env).forEach((key) => delete (env as any)[key])
@@ -616,12 +620,7 @@ describe('Knowledge Search Utils', () => {
616620
OPENAI_API_KEY: 'test-openai-key',
617621
})
618622

619-
mockNextFetchResponse({
620-
json: {
621-
data: [{ embedding: [0.1, 0.2, 0.3] }],
622-
usage: { prompt_tokens: 1, total_tokens: 1 },
623-
},
624-
})
623+
mockNextEmbeddingResponse()
625624

626625
await generateSearchEmbedding('test query')
627626

@@ -645,12 +644,7 @@ describe('Knowledge Search Utils', () => {
645644
OPENAI_API_KEY: 'test-openai-key',
646645
})
647646

648-
mockNextFetchResponse({
649-
json: {
650-
data: [{ embedding: [0.1, 0.2, 0.3] }],
651-
usage: { prompt_tokens: 1, total_tokens: 1 },
652-
},
653-
})
647+
mockNextEmbeddingResponse()
654648

655649
await generateSearchEmbedding('test query', 'text-embedding-3-small')
656650

@@ -733,12 +727,7 @@ describe('Knowledge Search Utils', () => {
733727
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
734728
})
735729

736-
mockNextFetchResponse({
737-
json: {
738-
data: [{ embedding: [0.1, 0.2, 0.3] }],
739-
usage: { prompt_tokens: 1, total_tokens: 1 },
740-
},
741-
})
730+
mockNextEmbeddingResponse()
742731

743732
await generateSearchEmbedding('test query')
744733

@@ -764,12 +753,7 @@ describe('Knowledge Search Utils', () => {
764753
OPENAI_API_KEY: 'test-openai-key',
765754
})
766755

767-
mockNextFetchResponse({
768-
json: {
769-
data: [{ embedding: [0.1, 0.2, 0.3] }],
770-
usage: { prompt_tokens: 1, total_tokens: 1 },
771-
},
772-
})
756+
mockNextEmbeddingResponse()
773757

774758
await generateSearchEmbedding('test query', 'text-embedding-3-small')
775759

@@ -792,12 +776,7 @@ describe('Knowledge Search Utils', () => {
792776
it('projects verified provenance only in the model-bound embedding payload', async () => {
793777
Object.keys(env).forEach((key) => delete (env as any)[key])
794778
Object.assign(env, { OPENAI_API_KEY: 'test-openai-key' })
795-
mockNextFetchResponse({
796-
json: {
797-
data: [{ embedding: [0.1, 0.2, 0.3] }],
798-
usage: { prompt_tokens: 1, total_tokens: 1 },
799-
},
800-
})
779+
mockNextEmbeddingResponse()
801780

802781
const registry = new ResolvedSecretTraceRegistry([
803782
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' },

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

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,24 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({
110110
}),
111111
}))
112112

113-
function createEmbeddingFetchMock() {
114-
return vi.fn().mockResolvedValue({
115-
ok: true,
116-
json: async () => ({
117-
data: [
118-
{ embedding: [0.1, 0.2], index: 0 },
119-
{ embedding: [0.3, 0.4], index: 1 },
120-
],
121-
usage: { prompt_tokens: 2, total_tokens: 2 },
113+
const TEST_EMBEDDING_DIMENSION = 1536
114+
115+
function createTestEmbedding(value: number): number[] {
116+
return Array.from({ length: TEST_EMBEDDING_DIMENSION }, () => value)
117+
}
118+
119+
function createEmbeddingResponse(values: number[]): Response {
120+
return new Response(
121+
JSON.stringify({
122+
data: values.map((value, index) => ({ embedding: createTestEmbedding(value), index })),
123+
usage: { prompt_tokens: values.length, total_tokens: values.length },
122124
}),
123-
})
125+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
126+
)
127+
}
128+
129+
function createEmbeddingFetchMock() {
130+
return vi.fn().mockResolvedValue(createEmbeddingResponse([0.1, 0.3]))
124131
}
125132

126133
vi.stubGlobal('fetch', createEmbeddingFetchMock())
@@ -166,6 +173,10 @@ describe('Knowledge Utils', () => {
166173
embeddingModel: 'text-embedding-3-small',
167174
billedAccountUserId: 'billing-user-1',
168175
uploadedBy: null,
176+
filename: 'file.txt',
177+
fileUrl: 'https://example.com/file.txt',
178+
fileSize: 10,
179+
mimeType: 'text/plain',
169180
},
170181
])
171182
/** Legacy untracked documents have exact-empty provenance. */
@@ -301,13 +312,7 @@ describe('Knowledge Utils', () => {
301312
})
302313

303314
const fetchSpy = vi.mocked(fetch)
304-
fetchSpy.mockResolvedValueOnce({
305-
ok: true,
306-
json: async () => ({
307-
data: [{ embedding: [0.1, 0.2], index: 0 }],
308-
usage: { prompt_tokens: 1, total_tokens: 1 },
309-
}),
310-
} as any)
315+
fetchSpy.mockResolvedValueOnce(createEmbeddingResponse([0.1]))
311316

312317
await generateEmbeddings(['test text'])
313318

@@ -331,13 +336,7 @@ describe('Knowledge Utils', () => {
331336
})
332337

333338
const fetchSpy = vi.mocked(fetch)
334-
fetchSpy.mockResolvedValueOnce({
335-
ok: true,
336-
json: async () => ({
337-
data: [{ embedding: [0.1, 0.2], index: 0 }],
338-
usage: { prompt_tokens: 1, total_tokens: 1 },
339-
}),
340-
} as any)
339+
fetchSpy.mockResolvedValueOnce(createEmbeddingResponse([0.1]))
341340

342341
await generateEmbeddings(['test text'])
343342

apps/sim/app/api/tools/embeddings/route.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,26 @@
44
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockEmbed, mockEmbedOpenRouter, mockGetOpenRouterEmbeddingModelMetadata } = vi.hoisted(
8-
() => ({
7+
const {
8+
MockEmbeddingOutputLimitError,
9+
mockEmbed,
10+
mockEmbedOpenRouter,
11+
mockGetOpenRouterEmbeddingModelMetadata,
12+
} = vi.hoisted(() => {
13+
class MockEmbeddingOutputLimitError extends Error {
14+
constructor(message: string) {
15+
super(message)
16+
this.name = 'EmbeddingOutputLimitError'
17+
}
18+
}
19+
20+
return {
21+
MockEmbeddingOutputLimitError,
922
mockEmbed: vi.fn(),
1023
mockEmbedOpenRouter: vi.fn(),
1124
mockGetOpenRouterEmbeddingModelMetadata: vi.fn(),
12-
})
13-
)
25+
}
26+
})
1427

1528
vi.mock('@/lib/embeddings/openrouter-model-catalog.server', () => ({
1629
getOpenRouterEmbeddingModelMetadata: mockGetOpenRouterEmbeddingModelMetadata,
@@ -27,6 +40,7 @@ vi.mock('@/lib/embeddings', async () => {
2740
return {
2841
embed: mockEmbed,
2942
embedOpenRouter: mockEmbedOpenRouter,
43+
EmbeddingOutputLimitError: MockEmbeddingOutputLimitError,
3044
DEFAULT_OPENROUTER_EMBEDDING_MODEL: 'openrouter/openai/text-embedding-3-small',
3145
findEmbeddingModelInfo: catalog.findEmbeddingModelInfo,
3246
getModelsForProvider: catalog.getModelsForProvider,
@@ -230,6 +244,15 @@ describe('POST /api/tools/embeddings', () => {
230244
expect((await response.json()).error).toContain('429')
231245
})
232246

247+
it('returns 413 when the requested embedding output exceeds the safe aggregate limit', async () => {
248+
mockEmbed.mockRejectedValue(
249+
new MockEmbeddingOutputLimitError('Embedding output exceeds the safe aggregate limit')
250+
)
251+
const response = await post(baseBody)
252+
expect(response.status).toBe(413)
253+
expect((await response.json()).error).toContain('safe aggregate limit')
254+
})
255+
233256
it('splits a JSON-array input into separate texts', async () => {
234257
await post({ ...baseBody, input: '["alpha","beta"]' })
235258
expect(mockEmbed).toHaveBeenCalledWith(['alpha', 'beta'], expect.anything())

apps/sim/app/api/tools/embeddings/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import {
1313
DEFAULT_MODEL_BY_PROVIDER,
1414
DEFAULT_OPENROUTER_EMBEDDING_MODEL,
15+
EmbeddingOutputLimitError,
1516
embed,
1617
embedOpenRouter,
1718
findEmbeddingModelInfo,
@@ -227,6 +228,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
227228
})
228229
} catch (error) {
229230
const message = getErrorMessage(error, 'Embedding generation failed')
231+
if (error instanceof EmbeddingOutputLimitError) {
232+
logger.warn('Embedding output exceeds safe limit', { error: message })
233+
return NextResponse.json({ success: false, error: message }, { status: 413 })
234+
}
230235
logger.error('Embedding generation failed', { error: message })
231236
return NextResponse.json({ success: false, error: message }, { status: 502 })
232237
}

0 commit comments

Comments
 (0)