Skip to content

Commit 58a9cb5

Browse files
feat(embeddings): load OpenRouter model catalog
1 parent 000fde9 commit 58a9cb5

22 files changed

Lines changed: 654 additions & 257 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Generate embeddings from text using OpenAI's embedding models
5757

5858
### OpenRouter Embeddings
5959

60-
Generate embeddings from OpenAI embedding models through OpenRouter
60+
Generate embeddings through OpenRouter
6161

6262
#### Input
6363

@@ -67,7 +67,7 @@ Generate embeddings from OpenAI embedding models through OpenRouter
6767
| `model` | string | No | Embedding model to use |
6868
| `taskType` | string | No | What the embedding is for, when the model supports task conditioning: document, query, similarity, classification, or clustering |
6969
| `dimensions` | number | No | Output dimensions, when the model supports truncation. Defaults to native. |
70-
| `apiKey` | string | No | OpenRouter API key; optional on self-hosted deployments with OPENROUTER_API_KEY configured |
70+
| `apiKey` | string | Yes | API key for the selected embedding provider |
7171

7272
#### Output
7373

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' },
42+
{ id: 'openai/text-embedding-3-small' },
43+
{ id: 'qwen/qwen3-embedding-8b' },
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import {
4+
openRouterEmbeddingModelsUpstreamResponseSchema,
5+
providerModelsResponseSchema,
6+
} from '@/lib/api/contracts/providers'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { toOpenRouterEmbeddingModelId } from '@/lib/embeddings/openrouter-models'
9+
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
10+
11+
const logger = createLogger('OpenRouterEmbeddingModelsAPI')
12+
13+
export const GET = withRouteHandler(async (_request: NextRequest) => {
14+
if (isProviderBlacklisted('openrouter')) {
15+
logger.info('OpenRouter provider is blacklisted, returning empty embedding models')
16+
return NextResponse.json({ models: [] })
17+
}
18+
19+
const response = await fetch('https://openrouter.ai/api/v1/embeddings/models', {
20+
headers: { 'Content-Type': 'application/json' },
21+
next: { revalidate: 300 },
22+
})
23+
if (!response.ok) {
24+
throw new Error(
25+
`Failed to fetch OpenRouter embedding models: ${response.status} ${response.statusText}`
26+
)
27+
}
28+
29+
const data = openRouterEmbeddingModelsUpstreamResponseSchema.parse(await response.json())
30+
const uniqueModels = Array.from(
31+
new Set(data.data.map((model) => toOpenRouterEmbeddingModelId(model.id)))
32+
)
33+
const models = filterBlacklistedModels(uniqueModels)
34+
35+
logger.info('Successfully fetched OpenRouter embedding models', {
36+
count: models.length,
37+
filtered: uniqueModels.length - models.length,
38+
})
39+
return NextResponse.json(providerModelsResponseSchema.parse({ models }))
40+
})

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

Lines changed: 29 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import {
5-
createMockRequest,
6-
hybridAuthMockFns,
7-
resetEnvFlagsMock,
8-
resetEnvMock,
9-
setEnv,
10-
setEnvFlags,
11-
} from '@sim/testing'
12-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13-
14-
const { mockEmbed } = vi.hoisted(() => ({
4+
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockEmbed, mockEmbedOpenRouter } = vi.hoisted(() => ({
158
mockEmbed: vi.fn(),
9+
mockEmbedOpenRouter: vi.fn(),
1610
}))
1711

1812
vi.mock('@/lib/embeddings', async () => {
1913
const catalog = await import('@/lib/embeddings/catalog')
2014
return {
2115
embed: mockEmbed,
16+
embedOpenRouter: mockEmbedOpenRouter,
17+
DEFAULT_OPENROUTER_EMBEDDING_MODEL: 'openrouter/openai/text-embedding-3-small',
2218
findEmbeddingModelInfo: catalog.findEmbeddingModelInfo,
2319
getModelsForProvider: catalog.getModelsForProvider,
2420
resolveDimensions: catalog.resolveDimensions,
@@ -41,8 +37,6 @@ function post(body: Record<string, unknown>) {
4137
describe('POST /api/tools/embeddings', () => {
4238
beforeEach(() => {
4339
vi.clearAllMocks()
44-
setEnvFlags({ isHosted: false })
45-
setEnv({ OPENROUTER_API_KEY: undefined })
4640
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
4741
success: true,
4842
userId: 'user-1',
@@ -56,11 +50,15 @@ describe('POST /api/tools/embeddings', () => {
5650
pricingId: 'text-embedding-3-small',
5751
dimensions: 1536,
5852
})
59-
})
60-
61-
afterEach(() => {
62-
resetEnvFlagsMock()
63-
resetEnvMock()
53+
mockEmbedOpenRouter.mockResolvedValue({
54+
embeddings: [[0.1, 0.2]],
55+
totalTokens: 3,
56+
billableTokens: 0,
57+
isBYOK: true,
58+
modelName: 'openrouter/qwen/qwen3-embedding-8b',
59+
pricingId: 'openrouter/qwen/qwen3-embedding-8b',
60+
dimensions: 2,
61+
})
6462
})
6563

6664
it('rejects an unauthenticated caller', async () => {
@@ -134,66 +132,47 @@ describe('POST /api/tools/embeddings', () => {
134132
it('routes OpenRouter through its transport with an explicit key', async () => {
135133
const response = await post({
136134
provider: 'openrouter',
137-
model: 'text-embedding-3-large',
135+
model: 'openrouter/qwen/qwen3-embedding-8b',
138136
input: 'hello world',
139137
apiKey: 'or-test',
140-
dimensions: 1024,
141138
})
142139

143140
expect(response.status).toBe(200)
144-
expect(mockEmbed).toHaveBeenCalledWith(
141+
expect(mockEmbedOpenRouter).toHaveBeenCalledWith(
145142
['hello world'],
146143
expect.objectContaining({
147144
apiKey: 'or-test',
148-
model: 'text-embedding-3-large',
149-
transport: 'openrouter',
150-
dimensions: 1024,
145+
model: 'openrouter/qwen/qwen3-embedding-8b',
151146
})
152147
)
148+
expect(mockEmbed).not.toHaveBeenCalled()
153149
expect((await response.json()).provider).toBe('openrouter')
154150
})
155151

156-
it('uses OPENROUTER_API_KEY on self-hosted deployments when the block key is omitted', async () => {
157-
setEnv({ OPENROUTER_API_KEY: 'or-environment' })
158-
159-
const response = await post({
160-
provider: 'openrouter',
161-
model: 'text-embedding-3-small',
162-
input: 'hello world',
163-
})
164-
165-
expect(response.status).toBe(200)
166-
expect(mockEmbed).toHaveBeenCalledWith(
167-
['hello world'],
168-
expect.objectContaining({ apiKey: 'or-environment', transport: 'openrouter' })
169-
)
170-
})
171-
172-
it('rejects OpenRouter without a block or self-hosted environment key', async () => {
152+
it('rejects OpenRouter without an explicit key', async () => {
173153
const response = await post({
174154
provider: 'openrouter',
175-
model: 'text-embedding-3-small',
155+
model: 'openrouter/openai/text-embedding-3-small',
176156
input: 'hello world',
177157
})
178158

179159
expect(response.status).toBe(400)
180-
expect((await response.json()).error).toContain('API key is required for openrouter')
160+
expect((await response.json()).error).toContain('apiKey')
181161
expect(mockEmbed).not.toHaveBeenCalled()
162+
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
182163
})
183164

184-
it('does not read OPENROUTER_API_KEY directly on hosted deployments', async () => {
185-
setEnvFlags({ isHosted: true })
186-
setEnv({ OPENROUTER_API_KEY: 'hosted-platform-key' })
187-
165+
it('rejects an invalid OpenRouter model id', async () => {
188166
const response = await post({
189167
provider: 'openrouter',
190-
model: 'text-embedding-3-small',
168+
model: 'openrouter/not-qualified',
191169
input: 'hello world',
170+
apiKey: 'or-test',
192171
})
193172

194173
expect(response.status).toBe(400)
195-
expect((await response.json()).error).toContain('API key is required for openrouter')
196-
expect(mockEmbed).not.toHaveBeenCalled()
174+
expect((await response.json()).error).toContain('Invalid OpenRouter embedding model')
175+
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
197176
})
198177

199178
it('keeps API keys required for non-OpenRouter providers', async () => {

0 commit comments

Comments
 (0)