Skip to content

Commit 6535bee

Browse files
feat(knowledge): add OpenRouter embedding fallback
1 parent 40c0a57 commit 6535bee

16 files changed

Lines changed: 678 additions & 31 deletions

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
9191
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
9292
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
9393
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
94+
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
9495
# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds
9596
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference
9697
# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments)

apps/sim/lib/core/config/env-capabilities.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
envField,
1010
inspectCapability,
1111
inspectOAuthClientCapability,
12+
KNOWLEDGE_EMBEDDINGS_CAPABILITY,
1213
LLM_KEY_POOLS,
1314
OCR_CAPABILITY,
1415
requireCapability,
@@ -111,6 +112,43 @@ describe('env capabilities', () => {
111112
})
112113

113114
describe('fallback capabilities', () => {
115+
it('resolves configured knowledge embedding transports in fallback order', () => {
116+
expect(
117+
inspectCapability(KNOWLEDGE_EMBEDDINGS_CAPABILITY, {
118+
OPENROUTER_API_KEY: 'openrouter-key',
119+
}).providerIds
120+
).toEqual(['openrouter'])
121+
122+
expect(
123+
inspectCapability(KNOWLEDGE_EMBEDDINGS_CAPABILITY, {
124+
AZURE_OPENAI_API_KEY: 'azure-key',
125+
AZURE_OPENAI_ENDPOINT: 'https://azure.example.com',
126+
AZURE_OPENAI_API_VERSION: '2024-10-21',
127+
OPENAI_API_KEY_1: 'openai-key',
128+
OPENROUTER_API_KEY: 'openrouter-key',
129+
}).providerIds
130+
).toEqual(['azure-openai', 'openai', 'openrouter'])
131+
})
132+
133+
it('reports partially configured Azure knowledge embeddings', () => {
134+
const inspection = inspectCapability(KNOWLEDGE_EMBEDDINGS_CAPABILITY, {
135+
AZURE_OPENAI_API_KEY: 'azure-key',
136+
})
137+
138+
expect(inspection).toMatchObject({
139+
configured: false,
140+
providerIds: [],
141+
error: expect.any(EnvCapabilityConfigurationError),
142+
})
143+
expect(inspection.providers[0]).toMatchObject({
144+
state: 'partial',
145+
missingFields: expect.arrayContaining([
146+
'AZURE_OPENAI_ENDPOINT',
147+
'AZURE_OPENAI_API_VERSION',
148+
]),
149+
})
150+
})
151+
114152
it('resolves every ready email provider subset in declaration order', () => {
115153
for (let mask = 0; mask < 1 << EMAIL_PROVIDER_ORDER.length; mask += 1) {
116154
const expected = EMAIL_PROVIDER_ORDER.filter((_, index) => (mask & (1 << index)) !== 0)
@@ -251,6 +289,27 @@ describe('env capabilities', () => {
251289
expect(onFailure.mock.calls.map(([providerId]) => providerId)).toEqual(['resend', 'ses'])
252290
})
253291

292+
it('stops fallback immediately when the error predicate rejects an error', async () => {
293+
const fatal = new Error('invalid credentials')
294+
const first = { send: vi.fn().mockRejectedValue(fatal) }
295+
const second = { send: vi.fn().mockResolvedValue('should not run') }
296+
const fallback = wireFallback({
297+
definition: EMAIL_CAPABILITY,
298+
values: { RESEND_API_KEY: 're_test', AWS_SES_REGION: 'us-east-1' },
299+
factories: {
300+
resend: () => first,
301+
ses: () => second,
302+
smtp: () => null,
303+
azure: () => null,
304+
gmail: () => null,
305+
},
306+
shouldFallback: () => false,
307+
})
308+
309+
await expect(fallback.execute((provider) => provider.send())).rejects.toBe(fatal)
310+
expect(second.send).not.toHaveBeenCalled()
311+
})
312+
254313
it('fails immediately when a ready provider has no runtime implementation', () => {
255314
expect(() =>
256315
wireFallback({

apps/sim/lib/core/config/env-capabilities.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,13 +845,15 @@ export interface WireFallbackOptions<TDefinition extends FallbackCapabilityDefin
845845
definition: TDefinition
846846
values: EnvCapabilityValues
847847
factories: FallbackFactories<TDefinition, TProvider>
848+
shouldFallback?: (error: unknown, providerId: DeclaredProviderId<TDefinition>) => boolean
848849
onFailure?: (providerId: DeclaredProviderId<TDefinition>, error: unknown) => void
849850
}
850851

851852
export function wireFallback<const TDefinition extends FallbackCapabilityDefinition, TProvider>({
852853
definition,
853854
values,
854855
factories,
856+
shouldFallback,
855857
onFailure,
856858
}: WireFallbackOptions<TDefinition, TProvider>) {
857859
const resolution = inspectCapability(definition, values)
@@ -889,6 +891,7 @@ export function wireFallback<const TDefinition extends FallbackCapabilityDefinit
889891
try {
890892
return await operation(provider, providerId)
891893
} catch (error) {
894+
if (shouldFallback && !shouldFallback(error, providerId)) throw error
892895
failures.push(error)
893896
onFailure?.(providerId, error)
894897
}
@@ -1207,6 +1210,54 @@ export const OCR_CAPABILITY = defineCapability({
12071210
],
12081211
} as const)
12091212

1213+
export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
1214+
strategy: 'fallback',
1215+
id: 'knowledge-embeddings',
1216+
label: 'Knowledge embeddings',
1217+
providers: [
1218+
{
1219+
id: 'azure-openai',
1220+
label: 'Azure OpenAI',
1221+
activation: {
1222+
mode: 'any-present',
1223+
keys: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_API_VERSION'],
1224+
},
1225+
requires: allOf(
1226+
envField('AZURE_OPENAI_API_KEY'),
1227+
envField('AZURE_OPENAI_ENDPOINT', {
1228+
validation: {
1229+
kind: 'url',
1230+
protocols: ['http:', 'https:'],
1231+
message: 'must be a valid HTTP(S) URL',
1232+
},
1233+
}),
1234+
envField('AZURE_OPENAI_API_VERSION')
1235+
),
1236+
optionalFields: [envField('KB_OPENAI_MODEL_NAME')],
1237+
},
1238+
{
1239+
id: 'openai',
1240+
label: 'OpenAI',
1241+
activation: {
1242+
mode: 'any-present',
1243+
keys: ['OPENAI_API_KEY', 'OPENAI_API_KEY_1', 'OPENAI_API_KEY_2', 'OPENAI_API_KEY_3'],
1244+
},
1245+
requires: anyOf(
1246+
envField('OPENAI_API_KEY'),
1247+
envField('OPENAI_API_KEY_1'),
1248+
envField('OPENAI_API_KEY_2'),
1249+
envField('OPENAI_API_KEY_3')
1250+
),
1251+
},
1252+
{
1253+
id: 'openrouter',
1254+
label: 'OpenRouter',
1255+
activation: { mode: 'any-present', keys: ['OPENROUTER_API_KEY'] },
1256+
requires: envField('OPENROUTER_API_KEY'),
1257+
},
1258+
],
1259+
} as const)
1260+
12101261
export const OAUTH_CLIENT_CAPABILITIES = {
12111262
google: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'],
12121263
x: ['X_CLIENT_ID', 'X_CLIENT_SECRET'],
@@ -1250,6 +1301,7 @@ export const ENV_CAPABILITIES = [
12501301
ASYNC_JOBS_CAPABILITY,
12511302
CACHE_CAPABILITY,
12521303
OCR_CAPABILITY,
1304+
KNOWLEDGE_EMBEDDINGS_CAPABILITY,
12531305
] as const
12541306

12551307
export const LLM_KEY_POOLS = {

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export const env = createEnv({
164164
OPENAI_API_KEY_1: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
165165
OPENAI_API_KEY_2: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
166166
OPENAI_API_KEY_3: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
167+
OPENROUTER_API_KEY: z.string().min(1).optional(), // OpenRouter API key; self-hosted fallback for OpenAI knowledge-base embeddings
167168
MISTRAL_API_KEY: z.string().min(1).optional(), // Mistral AI API key
168169
ANTHROPIC_API_KEY_1: z.string().min(1).optional(), // Primary Anthropic Claude API key
169170
ANTHROPIC_API_KEY_2: z.string().min(1).optional(), // Additional Anthropic API key for load balancing

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

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { resetEnvMock, setEnv } from '@sim/testing'
45
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { embed } from '@/lib/embeddings/client'
6+
import {
7+
EmbeddingAPIError,
8+
embed,
9+
embedKnowledgeForDeployment,
10+
isTransientEmbeddingError,
11+
} from '@/lib/embeddings/client'
12+
13+
const { mockGetBYOKKey } = vi.hoisted(() => ({
14+
mockGetBYOKKey: vi.fn(),
15+
}))
16+
17+
vi.mock('@/lib/api-key/byok', () => ({
18+
getBYOKKey: mockGetBYOKKey,
19+
}))
620

721
/**
822
* Exercises the orchestrator end-to-end against a mocked transport: batching,
@@ -35,11 +49,25 @@ let fetchMock: ReturnType<typeof vi.fn>
3549
beforeEach(() => {
3650
fetchMock = vi.fn()
3751
global.fetch = fetchMock as unknown as typeof fetch
52+
mockGetBYOKKey.mockResolvedValue(null)
53+
setEnv({
54+
AZURE_OPENAI_API_KEY: undefined,
55+
AZURE_OPENAI_ENDPOINT: undefined,
56+
AZURE_OPENAI_API_VERSION: undefined,
57+
GEMINI_API_KEY: undefined,
58+
OPENAI_API_KEY: undefined,
59+
OPENAI_API_KEY_1: undefined,
60+
OPENAI_API_KEY_2: undefined,
61+
OPENAI_API_KEY_3: undefined,
62+
OPENROUTER_API_KEY: undefined,
63+
})
3864
})
3965

4066
afterEach(() => {
4167
global.fetch = originalFetch
68+
vi.useRealTimers()
4269
vi.restoreAllMocks()
70+
resetEnvMock()
4371
})
4472

4573
describe('embed', () => {
@@ -384,3 +412,147 @@ describe('embed', () => {
384412
})
385413
})
386414
})
415+
416+
describe('knowledge embedding transport fallback', () => {
417+
const options = {
418+
model: 'text-embedding-3-small',
419+
taskType: 'document' as const,
420+
dimensions: 1536,
421+
projectInputs: null,
422+
}
423+
424+
it('uses OpenRouter when it is the only configured self-hosted transport', async () => {
425+
setEnv({ OPENROUTER_API_KEY: 'or-test' })
426+
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]], 3)))
427+
428+
const result = await embedKnowledgeForDeployment(['hello'], options, false)
429+
430+
expect(fetchMock).toHaveBeenCalledOnce()
431+
const [url, init] = fetchMock.mock.calls[0]
432+
expect(url).toBe('https://openrouter.ai/api/v1/embeddings')
433+
expect(JSON.parse((init as RequestInit).body as string)).toMatchObject({
434+
model: 'openai/text-embedding-3-small',
435+
dimensions: 1536,
436+
})
437+
expect(result).toMatchObject({
438+
embeddings: [[1, 2]],
439+
modelName: 'text-embedding-3-small',
440+
dimensions: 1536,
441+
})
442+
})
443+
444+
it('keeps the original OpenAI path when OpenRouter is not configured', async () => {
445+
setEnv({ OPENAI_API_KEY: 'openai-test' })
446+
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
447+
448+
await embedKnowledgeForDeployment(['hello'], options, false)
449+
450+
expect(fetchMock).toHaveBeenCalledOnce()
451+
expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/embeddings')
452+
})
453+
454+
it('uses Azure before OpenAI and OpenRouter when all are configured', async () => {
455+
setEnv({
456+
AZURE_OPENAI_API_KEY: 'azure-test',
457+
AZURE_OPENAI_ENDPOINT: 'https://example.openai.azure.com',
458+
AZURE_OPENAI_API_VERSION: '2024-10-21',
459+
KB_OPENAI_MODEL_NAME: 'kb-embedding-deployment',
460+
OPENAI_API_KEY: 'openai-test',
461+
OPENROUTER_API_KEY: 'or-test',
462+
})
463+
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
464+
465+
const result = await embedKnowledgeForDeployment(['hello'], options, false)
466+
467+
expect(fetchMock).toHaveBeenCalledOnce()
468+
expect(fetchMock.mock.calls[0][0]).toBe(
469+
'https://example.openai.azure.com/openai/deployments/kb-embedding-deployment/embeddings?api-version=2024-10-21'
470+
)
471+
expect(result.modelName).toBe('kb-embedding-deployment')
472+
})
473+
474+
it('uses a workspace OpenAI key before OpenRouter', async () => {
475+
setEnv({ OPENROUTER_API_KEY: 'or-test' })
476+
mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-openai-test', isBYOK: true })
477+
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
478+
479+
const result = await embedKnowledgeForDeployment(
480+
['hello'],
481+
{ ...options, workspaceId: 'workspace-1' },
482+
false
483+
)
484+
485+
expect(mockGetBYOKKey).toHaveBeenCalledWith('workspace-1', 'openai')
486+
expect(fetchMock).toHaveBeenCalledOnce()
487+
const [url, init] = fetchMock.mock.calls[0]
488+
expect(url).toBe('https://api.openai.com/v1/embeddings')
489+
expect((init as RequestInit).headers).toMatchObject({
490+
Authorization: 'Bearer workspace-openai-test',
491+
})
492+
expect(result.isBYOK).toBe(true)
493+
})
494+
495+
it('does not use OpenRouter for non-OpenAI knowledge models', async () => {
496+
setEnv({ GEMINI_API_KEY: 'gemini-test', OPENROUTER_API_KEY: 'or-test' })
497+
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1, 2] }] }))
498+
499+
await embedKnowledgeForDeployment(
500+
['hello'],
501+
{ ...options, model: 'gemini-embedding-001' },
502+
false
503+
)
504+
505+
expect(fetchMock).toHaveBeenCalledOnce()
506+
expect(fetchMock.mock.calls[0][0]).toContain('generativelanguage.googleapis.com')
507+
})
508+
509+
it('ignores OpenRouter on hosted deployments', async () => {
510+
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
511+
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
512+
513+
await embedKnowledgeForDeployment(['hello'], options, true)
514+
515+
expect(fetchMock).toHaveBeenCalledOnce()
516+
expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/embeddings')
517+
})
518+
519+
it('does not fall back after a fatal provider error', async () => {
520+
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
521+
fetchMock.mockResolvedValue(jsonResponse({ error: 'invalid key' }, 401))
522+
523+
await expect(embedKnowledgeForDeployment(['hello'], options, false)).rejects.toThrow(
524+
/Embedding API failed: 401/
525+
)
526+
expect(fetchMock).toHaveBeenCalledOnce()
527+
})
528+
529+
it('falls back after transient retries and projects inputs only once', async () => {
530+
vi.useFakeTimers()
531+
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
532+
const projectInputs = vi.fn(() => ['projected'])
533+
fetchMock.mockImplementation(async (url) =>
534+
url === 'https://api.openai.com/v1/embeddings'
535+
? jsonResponse({ error: 'unavailable' }, 503)
536+
: jsonResponse(openAIBody([[7, 8]], 2))
537+
)
538+
539+
const pending = embedKnowledgeForDeployment(['secret'], { ...options, projectInputs }, false)
540+
await vi.runAllTimersAsync()
541+
const result = await pending
542+
543+
expect(fetchMock).toHaveBeenCalledTimes(5)
544+
expect(fetchMock.mock.calls.slice(0, 4).every(([url]) => url.includes('api.openai.com'))).toBe(
545+
true
546+
)
547+
expect(fetchMock.mock.calls[4][0]).toBe('https://openrouter.ai/api/v1/embeddings')
548+
expect(projectInputs).toHaveBeenCalledOnce()
549+
expect(result.embeddings).toEqual([[7, 8]])
550+
})
551+
552+
it('classifies only transient embedding failures for failover', () => {
553+
expect(isTransientEmbeddingError(new EmbeddingAPIError('unavailable', 503))).toBe(true)
554+
expect(isTransientEmbeddingError(new EmbeddingAPIError('rate limited', 429))).toBe(true)
555+
expect(isTransientEmbeddingError(new EmbeddingAPIError('invalid key', 401))).toBe(false)
556+
expect(isTransientEmbeddingError(new DOMException('timed out', 'AbortError'))).toBe(true)
557+
})
558+
})

0 commit comments

Comments
 (0)