Skip to content

Commit e715c5b

Browse files
authored
fix(knowledge): name embedding quota, key, and deadline failures in knowledge search (#8405)
* fix(knowledge): name embedding quota, key, and deadline failures in knowledge search * fix(knowledge): keep keyless Ollama and mixed fallback quota attribution accurate * test(embeddings): prove the quota pause through returned errors, not mock call counts
1 parent 4e39944 commit e715c5b

6 files changed

Lines changed: 159 additions & 7 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,46 @@ describe('knowledge embedding transport fallback', () => {
606606
expect(isBYOKEmbeddingCredentialRejection(workspaceError)).toBe(true)
607607
})
608608

609+
it('attributes quota exhaustion to the workspace key, including while its pause is open', async () => {
610+
/**
611+
* Only a credential's first request is refused; the provider would answer every later
612+
* one. A second search can therefore fail only if the open pause refused it.
613+
*/
614+
const refusedCredentials = new Set<string>()
615+
fetchMock.mockImplementation(async (_url, init) => {
616+
const credential = String((init as RequestInit).headers?.Authorization)
617+
if (refusedCredentials.has(credential)) return jsonResponse(openAIBody([[1, 2]]))
618+
refusedCredentials.add(credential)
619+
return jsonResponse(
620+
{ error: { type: 'insufficient_quota', code: 'insufficient_quota' } },
621+
429
622+
)
623+
})
624+
const search = () =>
625+
embedKnowledgeForDeployment(
626+
['hello'],
627+
{ ...options, taskType: 'query' as const, workspaceId: 'workspace-1' },
628+
true
629+
).catch((error) => error)
630+
631+
mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-openai-test', isBYOK: true })
632+
const refused = await search()
633+
const paused = await search()
634+
expect(refused).toBeInstanceOf(EmbeddingQuotaExhaustedError)
635+
expect(paused).toBeInstanceOf(EmbeddingQuotaExhaustedError)
636+
expect(refused.isBYOK).toBe(true)
637+
expect(paused.isBYOK).toBe(true)
638+
639+
mockGetBYOKKey.mockResolvedValue(null)
640+
setEnv({ OPENAI_API_KEY: 'platform-openai-test' })
641+
const platformRefused = await search()
642+
const platformPaused = await search()
643+
expect(platformRefused).toBeInstanceOf(EmbeddingQuotaExhaustedError)
644+
expect(platformPaused).toBeInstanceOf(EmbeddingQuotaExhaustedError)
645+
expect(platformRefused.isBYOK).toBe(false)
646+
expect(platformPaused.isBYOK).toBe(false)
647+
})
648+
609649
it('ignores OpenRouter on hosted deployments', async () => {
610650
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
611651
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,22 @@ export const BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE =
212212
export class EmbeddingQuotaExhaustedError extends EmbeddingAPIError {
213213
public readonly providerId: EmbeddingProviderKind
214214

215-
constructor(providerId: EmbeddingProviderKind, cause?: unknown) {
215+
/**
216+
* `isBYOK` must be passed when there is no provider response to read it from — an
217+
* already-open quota pause or an admission refusal — so a workspace key's exhaustion
218+
* is never reported as the platform's. Ollama takes no credential: its provider-level
219+
* `isBYOK` only marks its tokens non-billable, so it never attributes to a customer key.
220+
*/
221+
constructor(
222+
providerId: EmbeddingProviderKind,
223+
cause?: unknown,
224+
isBYOK = cause instanceof EmbeddingAPIError && cause.isBYOK
225+
) {
216226
const status = cause instanceof EmbeddingAPIError ? cause.status : 429
217227
super(
218228
`The ${providerId} embedding credential has exhausted its available quota. Add credit or replace the credential before retrying.`,
219229
status,
220-
cause instanceof EmbeddingAPIError && cause.isBYOK
230+
isBYOK && providerId !== 'ollama'
221231
)
222232
this.name = 'EmbeddingQuotaExhaustedError'
223233
this.providerId = providerId
@@ -240,6 +250,18 @@ export function isEmbeddingQuotaExhaustion(error: unknown): boolean {
240250
return false
241251
}
242252

253+
/**
254+
* True when the operation failed on quota and a customer-managed credential is among
255+
* the exhausted ones: adding credit to that key is what lets it run again, even when a
256+
* platform fallback behind it is exhausted too.
257+
*/
258+
export function isBYOKEmbeddingQuotaExhaustion(error: unknown): boolean {
259+
if (error instanceof AggregateError) {
260+
return isEmbeddingQuotaExhaustion(error) && error.errors.some(isBYOKEmbeddingQuotaExhaustion)
261+
}
262+
return error instanceof EmbeddingAPIError && error.isBYOK && error.quotaExhausted === true
263+
}
264+
243265
/**
244266
* True when a customer-managed embedding credential was rejected outright.
245267
* These failures require a key or permission change; retrying the same request
@@ -524,7 +546,7 @@ async function callEmbeddingAPI(
524546
return retryWithExponentialBackoff(
525547
async (operationSignal, deadlineAt) => {
526548
if (await isEmbeddingQuotaCircuitOpen(admissionIdentity)) {
527-
throw new EmbeddingQuotaExhaustedError(providerId)
549+
throw new EmbeddingQuotaExhaustedError(providerId, undefined, isBYOK)
528550
}
529551

530552
try {
@@ -541,7 +563,7 @@ async function callEmbeddingAPI(
541563
})
542564
} catch (error) {
543565
if (error instanceof ProviderQuotaExhaustedError)
544-
throw new EmbeddingQuotaExhaustedError(providerId, error)
566+
throw new EmbeddingQuotaExhaustedError(providerId, error, isBYOK)
545567
throw error
546568
}
547569

@@ -1243,7 +1265,7 @@ export async function assertKnowledgeEmbeddingCapacityForDeployment(
12431265
const exhausted = await isEmbeddingQuotaCircuitOpen(embeddingAdmissionIdentity(provider))
12441266
options.signal?.throwIfAborted()
12451267
if (!exhausted) return
1246-
errors.push(new EmbeddingQuotaExhaustedError(provider.providerId))
1268+
errors.push(new EmbeddingQuotaExhaustedError(provider.providerId, undefined, provider.isBYOK))
12471269
}
12481270
if (errors.length === 1) throw errors[0]
12491271
throw new AggregateError(

‎apps/sim/lib/embeddings/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export {
2020
embedOpenRouter,
2121
getEmbeddingAggregateItemLimit,
2222
isBYOKEmbeddingCredentialRejection,
23+
isBYOKEmbeddingQuotaExhaustion,
2324
isEmbeddingQuotaExhaustion,
2425
} from '@/lib/embeddings/client'
2526
export { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models'

‎apps/sim/lib/internal/knowledge/execute-tool.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,9 @@ function projectError(
9393
): Response {
9494
signal?.throwIfAborted()
9595
const projected = policy.project(error)
96-
if (projected) return descriptorResponse(projected)
96+
if (projected && projected.status < 500) return descriptorResponse(projected)
9797
logger.error(`[${requestId}] Knowledge tool execution failed`, { error })
98+
if (projected) return descriptorResponse(projected)
9899
return descriptorResponse(
99100
policy.unhandled?.() ?? { status: 500, body: { error: 'Internal server error' } }
100101
)

‎apps/sim/lib/knowledge/api/route-policies.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,65 @@ import {
77
WorkspaceApiKeyScopeAuthorizationError,
88
} from '@/lib/core/application'
99
import { OrchestrationError } from '@/lib/core/orchestration/types'
10-
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
10+
import { EmbeddingAPIError } from '@/lib/embeddings/api-error'
11+
import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
12+
import {
13+
internalKnowledgeErrorPolicies,
14+
v2KnowledgeErrorPolicies,
15+
} from '@/lib/knowledge/api/route-policies'
16+
import { SearchDeadlineError } from '@/lib/knowledge/search/budget'
17+
18+
function quotaError(isBYOK: boolean, providerId: 'openai' | 'ollama' = 'openai') {
19+
return new EmbeddingQuotaExhaustedError(providerId, undefined, isBYOK)
20+
}
21+
22+
describe('internal knowledge search error policy', () => {
23+
it.each([
24+
['a workspace key out of quota', quotaError(true), 503, /this workspace's embedding API key/],
25+
['the platform key out of quota', quotaError(false), 503, /^Knowledge search is temporarily/],
26+
[
27+
'every fallback provider out of quota',
28+
new AggregateError([quotaError(false), quotaError(false)]),
29+
503,
30+
/^Knowledge search is temporarily/,
31+
],
32+
[
33+
'a workspace key out of quota ahead of an exhausted platform fallback',
34+
new AggregateError([quotaError(true), quotaError(false)]),
35+
503,
36+
/this workspace's embedding API key/,
37+
],
38+
[
39+
'a keyless Ollama server out of quota',
40+
quotaError(true, 'ollama'),
41+
503,
42+
/^Knowledge search is temporarily/,
43+
],
44+
[
45+
'a rejected workspace key',
46+
new EmbeddingAPIError('Embedding API failed: 401', 401, true),
47+
502,
48+
/was rejected/,
49+
],
50+
['the retrieval deadline', new SearchDeadlineError(), 504, /retrieval deadline/],
51+
])('names %s', (_case, error, status, message) => {
52+
const response = internalKnowledgeErrorPolicies.search.project(error)
53+
expect(response?.status).toBe(status)
54+
expect((response?.body as { error: string }).error).toMatch(message)
55+
})
56+
57+
it('leaves a platform key rejection and unknown failures to the generic server error', () => {
58+
const policy = internalKnowledgeErrorPolicies.search
59+
expect(
60+
policy.project(new EmbeddingAPIError('Embedding API failed: 401', 401, false))
61+
).toBeNull()
62+
expect(policy.project(new Error('connection reset'))).toBeNull()
63+
expect(policy.unhandled?.()).toMatchObject({
64+
status: 500,
65+
body: { error: 'Failed to perform vector search' },
66+
})
67+
})
68+
})
1169

1270
describe('v2 knowledge error policies', () => {
1371
it.each([

‎apps/sim/lib/knowledge/api/route-policies.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ import {
1111
} from '@/lib/api/server/routes'
1212
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1313
import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies'
14+
import {
15+
isBYOKEmbeddingCredentialRejection,
16+
isBYOKEmbeddingQuotaExhaustion,
17+
isEmbeddingQuotaExhaustion,
18+
} from '@/lib/embeddings'
1419
import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization'
1520
import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing'
1621
import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors'
1722
import { KnowledgeSearchProvenanceUnavailableError } from '@/lib/knowledge/application/search'
1823
import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions'
24+
import { SearchDeadlineError } from '@/lib/knowledge/search/budget'
1925
import { SearchIndexDormantError } from '@/lib/sim-search/indexed/gate'
2026
import { v2Error } from '@/app/api/v2/lib/response'
2127

@@ -40,6 +46,18 @@ const internalKnowledgeUploadErrorPolicy: InternalErrorPolicy = {
4046
internalErrorResponse(500, { error: 'Failed to process knowledge upload request' }),
4147
}
4248

49+
const BYOK_EMBEDDING_QUOTA_SEARCH_MESSAGE =
50+
"Knowledge search could not run: this workspace's embedding API key (Settings > Provider API keys) has no remaining quota. Add credit with the provider or replace the key."
51+
const BYOK_EMBEDDING_REJECTED_SEARCH_MESSAGE =
52+
"Knowledge search could not run: this workspace's embedding API key (Settings > Provider API keys) was rejected. Update the key and try again."
53+
const PLATFORM_EMBEDDING_QUOTA_SEARCH_MESSAGE =
54+
'Knowledge search is temporarily unavailable because the embedding provider has no remaining quota. Try again later.'
55+
56+
/**
57+
* Names the failures a caller can act on instead of collapsing them into the generic
58+
* vector-search `500`. Every status stays `5xx`, so retry and alerting behavior keyed
59+
* on server errors is unchanged; only the message and the specific code differ.
60+
*/
4361
const internalKnowledgeSearchErrorPolicy: InternalErrorPolicy = {
4462
project(error) {
4563
if (error instanceof KnowledgeUsageLimitExceededError) {
@@ -48,6 +66,18 @@ const internalKnowledgeSearchErrorPolicy: InternalErrorPolicy = {
4866
if (error instanceof KnowledgeSearchProvenanceUnavailableError) {
4967
return internalErrorResponse(422, { error: error.message })
5068
}
69+
if (isBYOKEmbeddingQuotaExhaustion(error)) {
70+
return internalErrorResponse(503, { error: BYOK_EMBEDDING_QUOTA_SEARCH_MESSAGE })
71+
}
72+
if (isEmbeddingQuotaExhaustion(error)) {
73+
return internalErrorResponse(503, { error: PLATFORM_EMBEDDING_QUOTA_SEARCH_MESSAGE })
74+
}
75+
if (isBYOKEmbeddingCredentialRejection(error)) {
76+
return internalErrorResponse(502, { error: BYOK_EMBEDDING_REJECTED_SEARCH_MESSAGE })
77+
}
78+
if (error instanceof SearchDeadlineError) {
79+
return internalErrorResponse(504, { error: error.message })
80+
}
5181
return internalOrchestrationErrorPolicy.project(error)
5282
},
5383
unhandled: () => internalErrorResponse(500, { error: 'Failed to perform vector search' }),

0 commit comments

Comments
 (0)