Skip to content

Commit 56910c0

Browse files
feat(embeddings): add OpenRouter support (#6396)
* feat(knowledge): add OpenRouter embedding fallback * fix(knowledge): preserve successful embedding batches * feat(embeddings): add OpenRouter provider * fix(knowledge): bill only platform embedding tokens * test(embeddings): include OpenRouter provider * feat(embeddings): load OpenRouter model catalog * fix(embeddings): preserve legacy provider default * fix(embeddings): batch OpenRouter requests * fix(embeddings): reset stale OpenRouter model
1 parent bc8826a commit 56910c0

42 files changed

Lines changed: 1995 additions & 243 deletions

Some content is hidden

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

apps/docs/content/docs/en/integrations/embeddings.mdx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Sim's knowledge bases embed separately, at a fixed vector width and from a small
2525

2626
## Usage Instructions
2727

28-
Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, Google Gemini, Cohere, and Mistral embedding models.
28+
Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, OpenRouter, Google Gemini, Cohere, and Mistral embedding models.
2929

3030

3131

@@ -55,6 +55,30 @@ Generate embeddings from text using OpenAI's embedding models
5555
| `dimensions` | number | Dimensionality of each vector |
5656
| `usage` | json | Token usage |
5757

58+
### OpenRouter Embeddings
59+
60+
Generate embeddings through OpenRouter
61+
62+
#### Input
63+
64+
| Parameter | Type | Required | Description |
65+
| --------- | ---- | -------- | ----------- |
66+
| `input` | string | Yes | Text to embed, or an array of texts to embed in one call |
67+
| `model` | string | No | Embedding model to use |
68+
| `taskType` | string | No | What the embedding is for, when the model supports task conditioning: document, query, similarity, classification, or clustering |
69+
| `dimensions` | number | No | Output dimensions, when the model supports truncation. Defaults to native. |
70+
| `apiKey` | string | Yes | API key for the selected embedding provider |
71+
72+
#### Output
73+
74+
| Parameter | Type | Description |
75+
| --------- | ---- | ----------- |
76+
| `embeddings` | json | Generated embeddings |
77+
| `model` | string | Model used |
78+
| `provider` | string | Provider used |
79+
| `dimensions` | number | Dimensionality of each vector |
80+
| `usage` | json | Token usage |
81+
5882
### Gemini Embeddings
5983

6084
Generate embeddings from text using Google's Gemini embedding models

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
9292
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
9393
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
9494
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
95+
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
9596
# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds
9697
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference
9798
# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi.hoisted(() => ({
8+
mockFetch: vi.fn(),
9+
mockFilterBlacklistedModels: vi.fn(),
10+
mockIsProviderBlacklisted: vi.fn(),
11+
}))
12+
13+
vi.mock('@/providers/utils', () => ({
14+
filterBlacklistedModels: mockFilterBlacklistedModels,
15+
isProviderBlacklisted: mockIsProviderBlacklisted,
16+
}))
17+
18+
import { GET } from '@/app/api/providers/openrouter/embeddings/models/route'
19+
20+
const request = () => createMockRequest('GET')
21+
22+
describe('GET /api/providers/openrouter/embeddings/models', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
vi.stubGlobal('fetch', mockFetch)
26+
mockIsProviderBlacklisted.mockReturnValue(false)
27+
mockFilterBlacklistedModels.mockImplementation((models: string[]) => models)
28+
})
29+
30+
afterAll(() => {
31+
vi.unstubAllGlobals()
32+
})
33+
34+
it('returns every unique embedding model with the OpenRouter prefix', async () => {
35+
mockFetch.mockResolvedValue({
36+
ok: true,
37+
status: 200,
38+
statusText: 'OK',
39+
json: async () => ({
40+
data: [
41+
{ id: 'qwen/qwen3-embedding-8b', context_length: 32768 },
42+
{ id: 'openai/text-embedding-3-small', context_length: 8192 },
43+
{ id: 'qwen/qwen3-embedding-8b', context_length: 32768 },
44+
],
45+
}),
46+
})
47+
48+
const response = await GET(request(), undefined as never)
49+
50+
expect(response.status).toBe(200)
51+
await expect(response.json()).resolves.toEqual({
52+
models: ['openrouter/qwen/qwen3-embedding-8b', 'openrouter/openai/text-embedding-3-small'],
53+
})
54+
expect(mockFetch).toHaveBeenCalledWith(
55+
'https://openrouter.ai/api/v1/embeddings/models',
56+
expect.objectContaining({ next: { revalidate: 300 } })
57+
)
58+
})
59+
60+
it('does not fetch when OpenRouter is blacklisted', async () => {
61+
mockIsProviderBlacklisted.mockReturnValue(true)
62+
63+
const response = await GET(request(), undefined as never)
64+
65+
expect(response.status).toBe(200)
66+
await expect(response.json()).resolves.toEqual({ models: [] })
67+
expect(mockFetch).not.toHaveBeenCalled()
68+
})
69+
70+
it('fails fast when OpenRouter rejects the model-list request', async () => {
71+
mockFetch.mockResolvedValue({
72+
ok: false,
73+
status: 503,
74+
statusText: 'Service Unavailable',
75+
})
76+
77+
const response = await GET(request(), undefined as never)
78+
79+
expect(response.status).toBe(500)
80+
expect(mockFilterBlacklistedModels).not.toHaveBeenCalled()
81+
})
82+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { providerModelsResponseSchema } from '@/lib/api/contracts/providers'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server'
6+
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
7+
8+
const logger = createLogger('OpenRouterEmbeddingModelsAPI')
9+
10+
export const GET = withRouteHandler(async (_request: NextRequest) => {
11+
if (isProviderBlacklisted('openrouter')) {
12+
logger.info('OpenRouter provider is blacklisted, returning empty embedding models')
13+
return NextResponse.json({ models: [] })
14+
}
15+
16+
const uniqueModels = (await fetchOpenRouterEmbeddingModelCatalog()).map((model) => model.id)
17+
const models = filterBlacklistedModels(uniqueModels)
18+
19+
logger.info('Successfully fetched OpenRouter embedding models', {
20+
count: models.length,
21+
filtered: uniqueModels.length - models.length,
22+
})
23+
return NextResponse.json(providerModelsResponseSchema.parse({ models }))
24+
})

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

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

7-
const { mockEmbed } = vi.hoisted(() => ({
8-
mockEmbed: vi.fn(),
7+
const { mockEmbed, mockEmbedOpenRouter, mockGetOpenRouterEmbeddingModelMetadata } = vi.hoisted(
8+
() => ({
9+
mockEmbed: vi.fn(),
10+
mockEmbedOpenRouter: vi.fn(),
11+
mockGetOpenRouterEmbeddingModelMetadata: vi.fn(),
12+
})
13+
)
14+
15+
vi.mock('@/lib/embeddings/openrouter-model-catalog.server', () => ({
16+
getOpenRouterEmbeddingModelMetadata: mockGetOpenRouterEmbeddingModelMetadata,
17+
OpenRouterEmbeddingModelNotFoundError: class OpenRouterEmbeddingModelNotFoundError extends Error {
18+
constructor(model: string) {
19+
super(`Unsupported OpenRouter embedding model: ${model}`)
20+
this.name = 'OpenRouterEmbeddingModelNotFoundError'
21+
}
22+
},
923
}))
1024

1125
vi.mock('@/lib/embeddings', async () => {
1226
const catalog = await import('@/lib/embeddings/catalog')
1327
return {
1428
embed: mockEmbed,
29+
embedOpenRouter: mockEmbedOpenRouter,
30+
DEFAULT_OPENROUTER_EMBEDDING_MODEL: 'openrouter/openai/text-embedding-3-small',
1531
findEmbeddingModelInfo: catalog.findEmbeddingModelInfo,
1632
getModelsForProvider: catalog.getModelsForProvider,
1733
resolveDimensions: catalog.resolveDimensions,
1834
}
1935
})
2036

37+
import { OpenRouterEmbeddingModelNotFoundError } from '@/lib/embeddings/openrouter-model-catalog.server'
2138
import { POST } from '@/app/api/tools/embeddings/route'
2239

2340
const baseBody = {
@@ -34,6 +51,10 @@ function post(body: Record<string, unknown>) {
3451
describe('POST /api/tools/embeddings', () => {
3552
beforeEach(() => {
3653
vi.clearAllMocks()
54+
mockGetOpenRouterEmbeddingModelMetadata.mockResolvedValue({
55+
id: 'openrouter/qwen/qwen3-embedding-8b',
56+
maxInputTokens: 32768,
57+
})
3758
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
3859
success: true,
3960
userId: 'user-1',
@@ -47,6 +68,15 @@ describe('POST /api/tools/embeddings', () => {
4768
pricingId: 'text-embedding-3-small',
4869
dimensions: 1536,
4970
})
71+
mockEmbedOpenRouter.mockResolvedValue({
72+
embeddings: [[0.1, 0.2]],
73+
totalTokens: 3,
74+
billableTokens: 0,
75+
isBYOK: true,
76+
modelName: 'openrouter/qwen/qwen3-embedding-8b',
77+
pricingId: 'openrouter/qwen/qwen3-embedding-8b',
78+
dimensions: 2,
79+
})
5080
})
5181

5282
it('rejects an unauthenticated caller', async () => {
@@ -117,6 +147,82 @@ describe('POST /api/tools/embeddings', () => {
117147
)
118148
})
119149

150+
it('routes OpenRouter through its transport with an explicit key', async () => {
151+
const response = await post({
152+
provider: 'openrouter',
153+
model: 'openrouter/qwen/qwen3-embedding-8b',
154+
input: 'hello world',
155+
apiKey: 'or-test',
156+
})
157+
158+
expect(response.status).toBe(200)
159+
expect(mockEmbedOpenRouter).toHaveBeenCalledWith(
160+
['hello world'],
161+
expect.objectContaining({
162+
apiKey: 'or-test',
163+
maxInputTokens: 32768,
164+
model: 'openrouter/qwen/qwen3-embedding-8b',
165+
})
166+
)
167+
expect(mockEmbed).not.toHaveBeenCalled()
168+
expect((await response.json()).provider).toBe('openrouter')
169+
})
170+
171+
it('rejects OpenRouter without an explicit key', async () => {
172+
const response = await post({
173+
provider: 'openrouter',
174+
model: 'openrouter/openai/text-embedding-3-small',
175+
input: 'hello world',
176+
})
177+
178+
expect(response.status).toBe(400)
179+
expect((await response.json()).error).toContain('apiKey')
180+
expect(mockEmbed).not.toHaveBeenCalled()
181+
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
182+
})
183+
184+
it('rejects an invalid OpenRouter model id', async () => {
185+
const response = await post({
186+
provider: 'openrouter',
187+
model: 'openrouter/not-qualified',
188+
input: 'hello world',
189+
apiKey: 'or-test',
190+
})
191+
192+
expect(response.status).toBe(400)
193+
expect((await response.json()).error).toContain('Invalid OpenRouter embedding model')
194+
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
195+
})
196+
197+
it('rejects a qualified model that is absent from OpenRouter', async () => {
198+
mockGetOpenRouterEmbeddingModelMetadata.mockRejectedValue(
199+
new OpenRouterEmbeddingModelNotFoundError('openrouter/example/missing')
200+
)
201+
202+
const response = await post({
203+
provider: 'openrouter',
204+
model: 'openrouter/example/missing',
205+
input: 'hello world',
206+
apiKey: 'or-test',
207+
})
208+
209+
expect(response.status).toBe(400)
210+
expect((await response.json()).error).toContain('Unsupported OpenRouter embedding model')
211+
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
212+
})
213+
214+
it('keeps API keys required for non-OpenRouter providers', async () => {
215+
const response = await post({
216+
provider: 'openai',
217+
model: 'text-embedding-3-small',
218+
input: 'hello world',
219+
})
220+
221+
expect(response.status).toBe(400)
222+
expect((await response.json()).error).toContain('apiKey')
223+
expect(mockEmbed).not.toHaveBeenCalled()
224+
})
225+
120226
it('surfaces a provider failure as 502', async () => {
121227
mockEmbed.mockRejectedValue(new Error('Embedding API failed: 429 Too Many Requests'))
122228
const response = await post(baseBody)

0 commit comments

Comments
 (0)