Skip to content

Commit 836b87f

Browse files
authored
fix(knowledge): classify rejected BYOK embedding keys (#7226)
1 parent 59b3f37 commit 836b87f

7 files changed

Lines changed: 181 additions & 13 deletions

File tree

apps/sim/background/knowledge-processing.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
2626
processDocumentAsync: mockProcessDocumentAsync,
2727
}))
2828

29-
import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
29+
import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
3030
import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit'
3131
import {
3232
PermanentDocumentProcessingError,
@@ -331,6 +331,27 @@ describe('knowledge processing worker', () => {
331331
})
332332
})
333333

334+
it('returns an actionable outcome when customer-managed embedding credentials are rejected', async () => {
335+
mockProcessDocumentAsync.mockRejectedValue(
336+
new EmbeddingAPIError('Embedding API failed: 401', 401, true)
337+
)
338+
339+
await expect(runDocumentProcessing(WORKSPACE_PAYLOAD)).resolves.toMatchObject({
340+
success: false,
341+
outcome: 'customer_configuration',
342+
code: 'embedding_credentials_rejected',
343+
error:
344+
'The configured embedding API key was rejected. Update the key and retry this document.',
345+
})
346+
})
347+
348+
it('preserves task failure for rejected platform embedding credentials', async () => {
349+
const platformError = new EmbeddingAPIError('Embedding API failed: 401', 401)
350+
mockProcessDocumentAsync.mockRejectedValue(platformError)
351+
352+
await expect(runDocumentProcessing(WORKSPACE_PAYLOAD)).rejects.toBe(platformError)
353+
})
354+
334355
it('preserves normal retries for transient failures', async () => {
335356
const transientError = new Error('Database connection timed out')
336357
mockProcessDocumentAsync.mockRejectedValue(transientError)

apps/sim/background/knowledge-processing.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { createLogger } from '@sim/logger'
22
import { task } from '@trigger.dev/sdk'
33
import { env, envNumber } from '@/lib/core/config/env'
4-
import { EMBEDDING_QUOTA_EXHAUSTED_MESSAGE, isEmbeddingQuotaExhaustion } from '@/lib/embeddings'
4+
import {
5+
BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE,
6+
EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
7+
isBYOKEmbeddingCredentialRejection,
8+
isEmbeddingQuotaExhaustion,
9+
} from '@/lib/embeddings'
510
import {
611
isPermanentDocumentProcessingError,
712
isUsageLimitDocumentProcessingError,
@@ -112,6 +117,21 @@ export async function runDocumentProcessing(
112117
processingTime: Date.now() - startedAt,
113118
}
114119
}
120+
if (isBYOKEmbeddingCredentialRejection(error)) {
121+
logger.warn(`[${requestId}] Customer-managed embedding credentials were rejected`, {
122+
filename: docData.filename,
123+
status: error.status,
124+
})
125+
return {
126+
success: false,
127+
outcome: 'customer_configuration' as const,
128+
code: 'embedding_credentials_rejected' as const,
129+
documentId,
130+
filename: docData.filename,
131+
error: BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE,
132+
processingTime: Date.now() - startedAt,
133+
}
134+
}
115135
if (isPermanentDocumentProcessingError(error)) {
116136
logger.warn(`[${requestId}] Document cannot be processed without changing its content`, {
117137
code: error.code,

apps/sim/lib/embeddings/client.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
embed,
1313
embedKnowledgeForDeployment,
1414
embedOpenRouter,
15+
isBYOKEmbeddingCredentialRejection,
1516
isEmbeddingQuotaExhaustion,
1617
isTransientEmbeddingError,
1718
MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES,
@@ -341,6 +342,7 @@ describe('embed', () => {
341342
expect(error).toBeInstanceOf(Error)
342343
expect((error as Error).message).toMatch(/Embedding API failed: 401/)
343344
expect((error as Error).message).not.toContain(echoedSecret)
345+
expect(isBYOKEmbeddingCredentialRejection(error)).toBe(true)
344346
// 401 is not retryable, so exactly one attempt is made.
345347
expect(fetchMock).toHaveBeenCalledTimes(1)
346348
})
@@ -992,6 +994,24 @@ describe('knowledge embedding transport fallback', () => {
992994
expect(result.isBYOK).toBe(true)
993995
})
994996

997+
it('distinguishes workspace credential rejection from a platform credential failure', async () => {
998+
fetchMock.mockResolvedValue(jsonResponse({ error: 'invalid key' }, 401))
999+
1000+
setEnv({ OPENAI_API_KEY: 'platform-openai-test' })
1001+
const platformError = await embedKnowledgeForDeployment(['hello'], options, true).catch(
1002+
(error) => error
1003+
)
1004+
expect(isBYOKEmbeddingCredentialRejection(platformError)).toBe(false)
1005+
1006+
mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-openai-test', isBYOK: true })
1007+
const workspaceError = await embedKnowledgeForDeployment(
1008+
['hello'],
1009+
{ ...options, workspaceId: 'workspace-1' },
1010+
true
1011+
).catch((error) => error)
1012+
expect(isBYOKEmbeddingCredentialRejection(workspaceError)).toBe(true)
1013+
})
1014+
9951015
it('does not use OpenRouter for non-OpenAI knowledge models', async () => {
9961016
setEnv({ GEMINI_API_KEY: 'gemini-test', OPENROUTER_API_KEY: 'or-test' })
9971017
fetchMock.mockResolvedValue(
@@ -1349,4 +1369,11 @@ describe('knowledge embedding transport fallback', () => {
13491369
expect(isTransientEmbeddingError(new EmbeddingAPIError('invalid key', 401))).toBe(false)
13501370
expect(isTransientEmbeddingError(new DOMException('timed out', 'AbortError'))).toBe(true)
13511371
})
1372+
1373+
it('does not misclassify quota-related BYOK rejections as authentication failures', () => {
1374+
const error = new EmbeddingAPIError('Embedding API failed: 403', 403, true)
1375+
error.quotaExhausted = true
1376+
1377+
expect(isBYOKEmbeddingCredentialRejection(error)).toBe(false)
1378+
})
13521379
})

apps/sim/lib/embeddings/client.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_RETRY_DE
135135
export class EmbeddingAPIError extends Error {
136136
public status: number
137137

138+
/** True when the rejected request used a customer-managed credential. */
139+
public readonly isBYOK: boolean
140+
138141
/** Rejected for an exhausted balance rather than a recoverable rate limit. */
139142
public quotaExhausted?: boolean
140143

@@ -144,10 +147,11 @@ export class EmbeddingAPIError extends Error {
144147
*/
145148
public retryAfterMs?: number
146149

147-
constructor(message: string, status: number) {
150+
constructor(message: string, status: number, isBYOK = false) {
148151
super(message)
149152
this.name = 'EmbeddingAPIError'
150153
this.status = status
154+
this.isBYOK = isBYOK
151155
}
152156
}
153157

@@ -170,6 +174,9 @@ export class EmbeddingOutputLimitError extends Error {
170174
export const EMBEDDING_QUOTA_EXHAUSTED_MESSAGE =
171175
'The embedding provider has exhausted its available quota. Add credit or replace the credential before retrying.'
172176

177+
export const BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE =
178+
'The configured embedding API key was rejected. Update the key and retry this document.'
179+
173180
/**
174181
* A provider credential has no remaining credit. This remains transient across
175182
* providers so a configured fallback can run, but it is terminal for the
@@ -182,7 +189,8 @@ export class EmbeddingQuotaExhaustedError extends EmbeddingAPIError {
182189
const status = cause instanceof EmbeddingAPIError ? cause.status : 429
183190
super(
184191
`The ${providerId} embedding credential has exhausted its available quota. Add credit or replace the credential before retrying.`,
185-
status
192+
status,
193+
cause instanceof EmbeddingAPIError && cause.isBYOK
186194
)
187195
this.name = 'EmbeddingQuotaExhaustedError'
188196
this.providerId = providerId
@@ -204,6 +212,21 @@ export function isEmbeddingQuotaExhaustion(error: unknown): boolean {
204212
return false
205213
}
206214

215+
/**
216+
* True when a customer-managed embedding credential was rejected outright.
217+
* These failures require a key or permission change; retrying the same request
218+
* cannot recover. Quota failures are classified separately even when a provider
219+
* reports them with HTTP 403.
220+
*/
221+
export function isBYOKEmbeddingCredentialRejection(error: unknown): error is EmbeddingAPIError {
222+
return (
223+
error instanceof EmbeddingAPIError &&
224+
error.isBYOK &&
225+
!error.quotaExhausted &&
226+
(error.status === 401 || error.status === 403)
227+
)
228+
}
229+
207230
/**
208231
* True when a rejection body reports an exhausted balance rather than a rate
209232
* limit. OpenAI returns 429 for both, but only a rate limit reopens: a spent
@@ -435,6 +458,7 @@ async function callEmbeddingAPI(
435458
*/
436459
requestedDimensions: number | undefined,
437460
expectedDimensions: number | undefined,
461+
isBYOK: boolean,
438462
signal?: AbortSignal
439463
): Promise<{ embeddings: number[][]; totalTokens: number; dimensions: number }> {
440464
return retryWithExponentialBackoff(
@@ -470,7 +494,8 @@ async function callEmbeddingAPI(
470494
const classificationBody = await readEmbeddingErrorBody(response)
471495
const error = new EmbeddingAPIError(
472496
`Embedding API failed: ${response.status}`,
473-
response.status
497+
response.status,
498+
isBYOK
474499
)
475500
error.quotaExhausted =
476501
isQuotaExhaustionBody(classificationBody) ||
@@ -639,12 +664,19 @@ async function embedWithProvider(
639664
provider.quotaCircuitIdentity,
640665
requestedDimensions,
641666
provider.dimensions,
667+
provider.isBYOK,
642668
signal
643669
)
644670
} catch (error) {
645671
const message = `Failed to generate embeddings for batch ${i + 1}/${batches.length}:`
646672
if (isEmbeddingQuotaExhaustion(error)) {
647673
logger.warn(message, { providerId: provider.providerId, quotaExhausted: true })
674+
} else if (isBYOKEmbeddingCredentialRejection(error)) {
675+
logger.warn(message, {
676+
providerId: provider.providerId,
677+
outcome: 'customer_configuration',
678+
status: error.status,
679+
})
648680
} else {
649681
logger.error(message, error)
650682
}
@@ -818,6 +850,7 @@ export async function embedOpenRouter(
818850
quotaCircuitIdentity,
819851
options.dimensions,
820852
expectedDimensions,
853+
true,
821854
options.signal
822855
)
823856

@@ -1001,14 +1034,20 @@ export async function embedKnowledgeForDeployment(
10011034
provider.providerId,
10021035
provider.quotaCircuitIdentity,
10031036
options.dimensions,
1004-
provider.dimensions
1037+
provider.dimensions,
1038+
provider.isBYOK
10051039
)),
10061040
provider,
10071041
}))
10081042
} catch (error) {
10091043
const message = `Failed to generate embeddings for batch ${i + 1}/${batches.length}:`
10101044
if (isEmbeddingQuotaExhaustion(error)) {
10111045
logger.warn(message, { quotaExhausted: true })
1046+
} else if (isBYOKEmbeddingCredentialRejection(error)) {
1047+
logger.warn(message, {
1048+
outcome: 'customer_configuration',
1049+
status: error.status,
1050+
})
10121051
} else {
10131052
logger.error(message, error)
10141053
}

apps/sim/lib/embeddings/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ export {
1010
resolveDimensions,
1111
} from '@/lib/embeddings/catalog'
1212
export {
13+
BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE,
1314
EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
1415
EmbeddingOutputLimitError,
1516
embed,
1617
embedKnowledge,
1718
embedOpenRouter,
1819
getEmbeddingAggregateItemLimit,
20+
isBYOKEmbeddingCredentialRejection,
1921
isEmbeddingQuotaExhaustion,
2022
} from '@/lib/embeddings/client'
2123
export { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models'

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

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@ import {
7373
markInsideTriggerRun,
7474
resetInsideTriggerRunForTests,
7575
} from '@/lib/core/config/trigger-runtime'
76-
import { EMBEDDING_QUOTA_EXHAUSTED_MESSAGE } from '@/lib/embeddings'
77-
import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
76+
import {
77+
BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE,
78+
EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
79+
} from '@/lib/embeddings'
80+
import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
7881
import {
7982
PermanentDocumentProcessingError,
8083
UsageLimitDocumentProcessingError,
@@ -742,6 +745,41 @@ describe('processDocumentAsync write guards', () => {
742745
expect(failure![0]).not.toHaveProperty('processingAttempts')
743746
})
744747

748+
it('dead-letters rejected customer-managed embedding credentials until the user retries', async () => {
749+
dbChainMockFns.limit
750+
.mockResolvedValueOnce([PERSISTED_CONTEXT])
751+
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
752+
.mockResolvedValueOnce([{ id: 'document-1' }])
753+
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
754+
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
755+
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
756+
)
757+
mockProcessDocument.mockResolvedValue({
758+
chunks: [{ text: 'Index me', metadata: { startIndex: 0, endIndex: 8 } }],
759+
metadata: { chunkCount: 1, tokenCount: 2, characterCount: 8 },
760+
})
761+
mockGenerateEmbeddings.mockRejectedValue(
762+
new EmbeddingAPIError('Embedding API failed: 401', 401, true)
763+
)
764+
765+
await expect(
766+
processDocumentAsync('knowledge-base-1', 'document-1', {
767+
filename: 'report.docx',
768+
fileUrl: 'https://example.com/report.docx',
769+
fileSize: 1,
770+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
771+
})
772+
).rejects.toMatchObject({ status: 401, isBYOK: true })
773+
774+
const failure = dbChainMockFns.set.mock.calls.find(
775+
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'failed'
776+
)
777+
expect(failure?.[0]).toMatchObject({
778+
processingError: BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE,
779+
processingAttempts: MAX_PROCESSING_ATTEMPTS,
780+
})
781+
})
782+
745783
it.each([
746784
{ chargedAtDispatch: true, refundsAttempt: true },
747785
{ chargedAtDispatch: false, refundsAttempt: false },

0 commit comments

Comments
 (0)