Skip to content

Commit 1050e8a

Browse files
committed
fix(embeddings): preserve safe provider failure diagnostics
1 parent bfaeaba commit 1050e8a

10 files changed

Lines changed: 331 additions & 30 deletions

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
3434
}))
3535

3636
import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error'
37-
import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
37+
import { EmbeddingAPIError } from '@/lib/embeddings/api-error'
38+
import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
3839
import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit'
3940
import {
4041
OcrRequestRejectedError,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export class EmbeddingAPIError extends Error {
2+
public status: number
3+
4+
/** True when the rejected request used a customer-managed credential. */
5+
public readonly isBYOK: boolean
6+
7+
/** Rejected for an exhausted balance rather than a recoverable rate limit. */
8+
public quotaExhausted?: boolean
9+
10+
/**
11+
* Wait the provider asked for, read from the rejected response. Consumed by
12+
* {@link retryWithExponentialBackoff}, which prefers it over its own backoff.
13+
*/
14+
public retryAfterMs?: number
15+
16+
constructor(message: string, status: number, isBYOK = false) {
17+
super(message)
18+
this.name = 'EmbeddingAPIError'
19+
this.status = status
20+
this.isBYOK = isBYOK
21+
}
22+
}

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

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
* @vitest-environment node
33
*/
44

5-
import { resetEnvMock, setEnv } from '@sim/testing'
5+
import { createMockLogger, resetEnvMock, setEnv } from '@sim/testing'
66
import { interruptibleSleep } from '@sim/utils/helpers'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88
import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error'
9+
import { EmbeddingAPIError } from '@/lib/embeddings/api-error'
910
import {
1011
assertKnowledgeEmbeddingCapacityForDeployment,
1112
clampEmbeddingConcurrency,
1213
EMBEDDING_MAX_RETRIES,
1314
EMBEDDING_RETRY_BUDGET_MS,
14-
EmbeddingAPIError,
1515
EmbeddingOutputLimitError,
1616
EmbeddingQuotaExhaustedError,
1717
embed,
@@ -28,6 +28,11 @@ const { mockGetBYOKKey } = vi.hoisted(() => ({
2828
mockGetBYOKKey: vi.fn(),
2929
}))
3030

31+
const { mockDiagnosticWarn } = vi.hoisted(() => ({ mockDiagnosticWarn: vi.fn() }))
32+
vi.mock('@sim/logger', () => ({
33+
createLogger: () => ({ ...createMockLogger(), warn: mockDiagnosticWarn }),
34+
}))
35+
3136
const { quotaGates, mockAdmit, mockCooldown, mockQuotaCheck } = vi.hoisted(() => ({
3237
quotaGates: new Set<string>(),
3338
mockAdmit: vi.fn(),
@@ -116,6 +121,7 @@ function oversizedChunkedSuccessResponse(): Response {
116121
let fetchMock: ReturnType<typeof vi.fn>
117122

118123
beforeEach(() => {
124+
mockDiagnosticWarn.mockClear()
119125
mockQuotaCheck
120126
.mockReset()
121127
.mockImplementation(async (identity: { credentialFingerprint: string }) =>
@@ -154,6 +160,105 @@ afterEach(() => {
154160
resetEnvMock()
155161
})
156162

163+
describe('embedding HTTP failure diagnostics', () => {
164+
const options = { model: 'text-embedding-3-small', projectInputs: null } as const
165+
166+
it('logs safe OpenAI context internally while preserving the public error and retry policy', async () => {
167+
fetchMock.mockResolvedValue(
168+
jsonResponse(
169+
{ error: { code: 'model_not_found', message: 'private document and private-key' } },
170+
404,
171+
{ 'x-request-id': 'req_test', authorization: 'Bearer private-key' }
172+
)
173+
)
174+
const error = await embed(['private document'], { ...options, apiKey: 'private-key' }).catch(
175+
(caught) => caught
176+
)
177+
expect(error).toBeInstanceOf(EmbeddingAPIError)
178+
expect(error.message).toBe('Embedding API failed: 404')
179+
expect(isTransientEmbeddingError(error)).toBe(false)
180+
expect(fetchMock).toHaveBeenCalledTimes(1)
181+
expect(mockDiagnosticWarn).toHaveBeenCalledWith('Embedding provider request failed', {
182+
providerId: 'openai',
183+
modelName: 'text-embedding-3-small',
184+
status: 404,
185+
providerRequestId: 'req_test',
186+
providerErrorCode: 'model_not_found',
187+
providerErrorType: null,
188+
bodyFormat: 'json',
189+
})
190+
expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('private')
191+
expect(JSON.stringify(error)).not.toContain('req_test')
192+
expect(JSON.stringify(error)).not.toContain('model_not_found')
193+
})
194+
195+
it('identifies the actual Azure transport and deployment selected for a catalog model', async () => {
196+
setEnv({
197+
AZURE_OPENAI_API_KEY: 'private-azure-key',
198+
AZURE_OPENAI_ENDPOINT: 'https://azure.example',
199+
AZURE_OPENAI_API_VERSION: '2024-02-01',
200+
KB_OPENAI_MODEL_NAME: 'test-embedding-deployment',
201+
})
202+
fetchMock.mockResolvedValue(
203+
jsonResponse({ error: { code: 'DeploymentNotFound' } }, 404, {
204+
'apim-request-id': 'azure-request-test',
205+
})
206+
)
207+
await expect(embed(['text'], options)).rejects.toThrow('Embedding API failed: 404')
208+
expect(mockDiagnosticWarn).toHaveBeenCalledWith(
209+
'Embedding provider request failed',
210+
expect.objectContaining({
211+
providerId: 'azure-openai',
212+
modelName: 'test-embedding-deployment',
213+
providerRequestId: 'azure-request-test',
214+
providerErrorCode: 'DeploymentNotFound',
215+
})
216+
)
217+
expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('private-azure-key')
218+
expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('https://azure.example')
219+
expect(fetchMock).toHaveBeenCalledTimes(1)
220+
})
221+
222+
it('keeps the HTTP failure diagnosable when its response body cannot be read', async () => {
223+
fetchMock.mockImplementation(
224+
async () =>
225+
new Response(
226+
new ReadableStream({
227+
start(controller) {
228+
controller.error(new Error('private body failure'))
229+
},
230+
}),
231+
{ status: 404, headers: { 'x-request-id': 'req_unreadable' } }
232+
)
233+
)
234+
await expect(embed(['text'], { ...options, apiKey: 'key' })).rejects.toThrow(
235+
'Embedding API failed: 404'
236+
)
237+
expect(mockDiagnosticWarn).toHaveBeenCalledWith(
238+
'Embedding provider request failed',
239+
expect.objectContaining({
240+
status: 404,
241+
providerRequestId: 'req_unreadable',
242+
bodyFormat: 'unavailable',
243+
})
244+
)
245+
expect(fetchMock).toHaveBeenCalledTimes(1)
246+
expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('private')
247+
})
248+
249+
it('logs quota rejection before the existing quota circuit wraps the HTTP error', async () => {
250+
fetchMock.mockResolvedValue(jsonResponse({ error: { code: 'insufficient_quota' } }, 429))
251+
await expect(embed(['text'], { ...options, apiKey: 'key' })).rejects.toBeInstanceOf(
252+
EmbeddingQuotaExhaustedError
253+
)
254+
expect(mockDiagnosticWarn).toHaveBeenCalledWith(
255+
'Embedding provider request failed',
256+
expect.objectContaining({ status: 429, providerErrorCode: 'insufficient_quota' })
257+
)
258+
expect(fetchMock).toHaveBeenCalledTimes(1)
259+
})
260+
})
261+
157262
describe('embedding cancellation', () => {
158263
it('cancels a stalled response body after headers arrive without retrying', async () => {
159264
vi.useFakeTimers()

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

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { sha256Hex } from '@sim/security/hash'
33
import { chunkArray } from '@sim/utils/helpers'
4+
import { truncate } from '@sim/utils/string'
45
import { getBYOKKey } from '@/lib/api-key/byok'
56
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
67
import { env, envNumber } from '@/lib/core/config/env'
@@ -23,6 +24,7 @@ import {
2324
readResponseTextWithLimit,
2425
} from '@/lib/core/utils/stream-limits'
2526
import { getOllamaUrl } from '@/lib/core/utils/urls'
27+
import { EmbeddingAPIError } from '@/lib/embeddings/api-error'
2628
import {
2729
DEFAULT_EMBEDDING_MODEL,
2830
type EmbeddingModelInfo,
@@ -31,6 +33,7 @@ import {
3133
ollamaEmbeddingModelName,
3234
resolveDimensions,
3335
} from '@/lib/embeddings/catalog'
36+
import { getEmbeddingResponseDiagnostic } from '@/lib/embeddings/error-diagnostics'
3437
import { resolveProviderKey } from '@/lib/embeddings/keys'
3538
import { isOllamaServerConfigured } from '@/lib/embeddings/ollama-model-catalog.server'
3639
import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models'
@@ -168,29 +171,6 @@ export const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_R
168171
*/
169172
export const KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS = 60_000
170173

171-
export class EmbeddingAPIError extends Error {
172-
public status: number
173-
174-
/** True when the rejected request used a customer-managed credential. */
175-
public readonly isBYOK: boolean
176-
177-
/** Rejected for an exhausted balance rather than a recoverable rate limit. */
178-
public quotaExhausted?: boolean
179-
180-
/**
181-
* Wait the provider asked for, read from the rejected response. Consumed by
182-
* {@link retryWithExponentialBackoff}, which prefers it over its own backoff.
183-
*/
184-
public retryAfterMs?: number
185-
186-
constructor(message: string, status: number, isBYOK = false) {
187-
super(message)
188-
this.name = 'EmbeddingAPIError'
189-
this.status = status
190-
this.isBYOK = isBYOK
191-
}
192-
}
193-
194174
class EmbeddingResponseValidationError extends EmbeddingAPIError {
195175
constructor(message: string) {
196176
super(`Embedding API returned an invalid success response: ${message}`, 502)
@@ -293,7 +273,7 @@ function isQuotaExhaustionBody(errorText: string): boolean {
293273
}
294274
}
295275

296-
/** Reads a bounded provider body only for internal quota classification. */
276+
/** Reads a bounded provider body for internal diagnostics and quota classification. */
297277
async function readEmbeddingErrorBody(response: Response, signal?: AbortSignal): Promise<string> {
298278
try {
299279
return await readResponseTextWithLimit(response, {
@@ -542,6 +522,7 @@ async function callEmbeddingAPI(
542522
tokenizerProvider: string,
543523
taskType: EmbeddingTaskType,
544524
providerId: EmbeddingProviderKind,
525+
modelName: string,
545526
quotaCircuitIdentity: EmbeddingQuotaCircuitIdentity,
546527
/**
547528
* The caller's explicit reduction, or undefined when none was requested. Kept
@@ -605,6 +586,12 @@ async function callEmbeddingAPI(
605586

606587
if (!response.ok) {
607588
const classificationBody = await readEmbeddingErrorBody(response, controller.signal)
589+
logger.warn('Embedding provider request failed', {
590+
providerId,
591+
modelName: truncate(modelName, 256),
592+
status: response.status,
593+
...getEmbeddingResponseDiagnostic(response.headers, classificationBody),
594+
})
608595
const error = new EmbeddingAPIError(
609596
`Embedding API failed: ${response.status}`,
610597
response.status,
@@ -856,6 +843,7 @@ async function callCheckpointedEmbeddingBatch(
856843
provider.info.tokenizerProvider,
857844
taskType,
858845
provider.providerId,
846+
provider.modelName,
859847
provider.quotaCircuitIdentity,
860848
requestedDimensions,
861849
provider.dimensions,
@@ -1081,6 +1069,7 @@ export async function embedOpenRouter(
10811069
limits.tokenizerProvider,
10821070
'document',
10831071
'openrouter',
1072+
model,
10841073
quotaCircuitIdentity,
10851074
options.dimensions,
10861075
expectedDimensions,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/** @vitest-environment node */
2+
import { describe, expect, it } from 'vitest'
3+
import { getEmbeddingResponseDiagnostic } from '@/lib/embeddings/error-diagnostics'
4+
5+
describe('embedding response diagnostics', () => {
6+
it.each([
7+
['model_not_found', 'invalid_request_error'],
8+
['DeploymentNotFound', undefined],
9+
[404, 'NOT_FOUND'],
10+
])('retains safe machine codes for %s without free-form provider text', (code, type) => {
11+
const diagnostic = getEmbeddingResponseDiagnostic(
12+
new Headers({ 'x-request-id': 'req_test', authorization: 'Bearer private-key' }),
13+
JSON.stringify({
14+
error: { code, type, message: 'private document', param: 'private input' },
15+
})
16+
)
17+
expect(diagnostic).toEqual({
18+
providerRequestId: 'req_test',
19+
bodyFormat: 'json',
20+
providerErrorCode: String(code),
21+
providerErrorType: type ?? null,
22+
})
23+
expect(JSON.stringify(diagnostic)).not.toContain('private')
24+
})
25+
26+
it('reads Gemini status independently of its numeric error code', () => {
27+
expect(
28+
getEmbeddingResponseDiagnostic(
29+
new Headers(),
30+
JSON.stringify({ error: { code: 404, status: 'NOT_FOUND' } })
31+
)
32+
).toMatchObject({ providerErrorCode: '404', providerErrorType: 'NOT_FOUND' })
33+
})
34+
35+
it.each(['apim-request-id', 'x-ms-request-id'])('reads the Azure %s header', (header) => {
36+
expect(
37+
getEmbeddingResponseDiagnostic(new Headers({ [header]: 'request-test' }), '')
38+
).toMatchObject({ providerRequestId: 'request-test', bodyFormat: 'unavailable' })
39+
})
40+
41+
it.each(['', '<html>private gateway error</html>', '{"error":', 'null', '[]', '"private"'])(
42+
'tolerates an unavailable, non-JSON, or unexpected body: %s',
43+
(body) => {
44+
const diagnostic = getEmbeddingResponseDiagnostic(new Headers(), body)
45+
expect(diagnostic.providerErrorCode).toBeNull()
46+
expect(diagnostic.providerErrorType).toBeNull()
47+
expect(JSON.stringify(diagnostic)).not.toContain('private')
48+
}
49+
)
50+
51+
it.each([
52+
'private-key',
53+
'private document text',
54+
{ private: true },
55+
['private'],
56+
'x'.repeat(1000),
57+
])('does not copy unknown error fields into logs: %j', (value) => {
58+
expect(
59+
getEmbeddingResponseDiagnostic(
60+
new Headers(),
61+
JSON.stringify({ error: { code: value, type: value } })
62+
)
63+
).toMatchObject({ providerErrorCode: 'unrecognized', providerErrorType: 'unrecognized' })
64+
})
65+
66+
it.each(['Bearer private-key', 'https://private.example', 'x'.repeat(129)])(
67+
'omits malformed or oversized request IDs: %s',
68+
(requestId) => {
69+
expect(
70+
getEmbeddingResponseDiagnostic(new Headers({ 'x-request-id': requestId }), '')
71+
.providerRequestId
72+
).toBeNull()
73+
}
74+
)
75+
})

0 commit comments

Comments
 (0)