Skip to content

Commit 05535d9

Browse files
fix(embeddings): batch OpenRouter requests
1 parent ca35ea0 commit 05535d9

10 files changed

Lines changed: 329 additions & 104 deletions

File tree

apps/sim/app/api/providers/openrouter/embeddings/models/route.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ describe('GET /api/providers/openrouter/embeddings/models', () => {
3838
statusText: 'OK',
3939
json: async () => ({
4040
data: [
41-
{ id: 'qwen/qwen3-embedding-8b' },
42-
{ id: 'openai/text-embedding-3-small' },
43-
{ id: 'qwen/qwen3-embedding-8b' },
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 },
4444
],
4545
}),
4646
})

apps/sim/app/api/providers/openrouter/embeddings/models/route.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3-
import {
4-
openRouterEmbeddingModelsUpstreamResponseSchema,
5-
providerModelsResponseSchema,
6-
} from '@/lib/api/contracts/providers'
3+
import { providerModelsResponseSchema } from '@/lib/api/contracts/providers'
74
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { toOpenRouterEmbeddingModelId } from '@/lib/embeddings/openrouter-models'
5+
import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server'
96
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
107

118
const logger = createLogger('OpenRouterEmbeddingModelsAPI')
@@ -16,20 +13,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
1613
return NextResponse.json({ models: [] })
1714
}
1815

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-
)
16+
const uniqueModels = (await fetchOpenRouterEmbeddingModelCatalog()).map((model) => model.id)
3317
const models = filterBlacklistedModels(uniqueModels)
3418

3519
logger.info('Successfully fetched OpenRouter embedding models', {

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

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

7-
const { mockEmbed, mockEmbedOpenRouter } = vi.hoisted(() => ({
8-
mockEmbed: vi.fn(),
9-
mockEmbedOpenRouter: 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+
},
1023
}))
1124

1225
vi.mock('@/lib/embeddings', async () => {
@@ -21,6 +34,7 @@ vi.mock('@/lib/embeddings', async () => {
2134
}
2235
})
2336

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

2640
const baseBody = {
@@ -37,6 +51,10 @@ function post(body: Record<string, unknown>) {
3751
describe('POST /api/tools/embeddings', () => {
3852
beforeEach(() => {
3953
vi.clearAllMocks()
54+
mockGetOpenRouterEmbeddingModelMetadata.mockResolvedValue({
55+
id: 'openrouter/qwen/qwen3-embedding-8b',
56+
maxInputTokens: 32768,
57+
})
4058
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
4159
success: true,
4260
userId: 'user-1',
@@ -142,6 +160,7 @@ describe('POST /api/tools/embeddings', () => {
142160
['hello world'],
143161
expect.objectContaining({
144162
apiKey: 'or-test',
163+
maxInputTokens: 32768,
145164
model: 'openrouter/qwen/qwen3-embedding-8b',
146165
})
147166
)
@@ -175,6 +194,23 @@ describe('POST /api/tools/embeddings', () => {
175194
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
176195
})
177196

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+
178214
it('keeps API keys required for non-OpenRouter providers', async () => {
179215
const response = await post({
180216
provider: 'openai',

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

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
findEmbeddingModelInfo,
1818
resolveDimensions,
1919
} from '@/lib/embeddings'
20+
import {
21+
getOpenRouterEmbeddingModelMetadata,
22+
type OpenRouterEmbeddingModelMetadata,
23+
OpenRouterEmbeddingModelNotFoundError,
24+
} from '@/lib/embeddings/openrouter-model-catalog.server'
2025
import { normalizeOpenRouterEmbeddingModelId } from '@/lib/embeddings/openrouter-models'
2126

2227
const logger = createLogger('EmbeddingsToolAPI')
@@ -112,6 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
112117
}
113118

114119
let resolvedModel: string
120+
let openRouterModelMetadata: OpenRouterEmbeddingModelMetadata | undefined
115121
if (provider === 'openrouter') {
116122
try {
117123
resolvedModel = normalizeOpenRouterEmbeddingModelId(
@@ -123,6 +129,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
123129
{ status: 400 }
124130
)
125131
}
132+
try {
133+
openRouterModelMetadata = await getOpenRouterEmbeddingModelMetadata(resolvedModel)
134+
} catch (error) {
135+
const modelError = error instanceof OpenRouterEmbeddingModelNotFoundError
136+
return NextResponse.json(
137+
{
138+
success: false,
139+
error: getErrorMessage(
140+
error,
141+
modelError
142+
? 'Unsupported OpenRouter embedding model'
143+
: 'Failed to load OpenRouter embedding model metadata'
144+
),
145+
},
146+
{ status: modelError ? 400 : 502 }
147+
)
148+
}
126149
} else {
127150
resolvedModel = model || DEFAULT_MODEL_BY_PROVIDER[provider]
128151
}
@@ -163,26 +186,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
163186
logger.info(`Embedding ${texts.length} input(s) with ${provider}/${resolvedModel}`)
164187

165188
try {
166-
const result =
167-
provider === 'openrouter'
168-
? await embedOpenRouter(texts, {
169-
model: resolvedModel,
170-
dimensions,
171-
apiKey,
172-
projectInputs: null,
173-
})
174-
: await embed(texts, {
175-
model: resolvedModel,
176-
taskType,
177-
dimensions,
178-
apiKey,
179-
/**
180-
* Callers reach this route through a tool whose `request.modelInput`
181-
* already projected `input` at the HTTP hop, so projecting again here
182-
* would run the substitution over already-projected content.
183-
*/
184-
projectInputs: null,
185-
})
189+
let result
190+
if (provider === 'openrouter') {
191+
if (!openRouterModelMetadata) {
192+
throw new Error('OpenRouter embedding model metadata was not resolved')
193+
}
194+
result = await embedOpenRouter(texts, {
195+
model: resolvedModel,
196+
dimensions,
197+
apiKey,
198+
maxInputTokens: openRouterModelMetadata.maxInputTokens,
199+
projectInputs: null,
200+
})
201+
} else {
202+
result = await embed(texts, {
203+
model: resolvedModel,
204+
taskType,
205+
dimensions,
206+
apiKey,
207+
/**
208+
* Callers reach this route through a tool whose `request.modelInput`
209+
* already projected `input` at the HTTP hop, so projecting again here
210+
* would run the substitution over already-projected content.
211+
*/
212+
projectInputs: null,
213+
})
214+
}
186215

187216
return NextResponse.json({
188217
success: true,

apps/sim/lib/api/contracts/providers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export const openRouterEmbeddingModelsUpstreamResponseSchema = z.object({
6464
z
6565
.object({
6666
id: z.string().min(1, 'OpenRouter embedding model id cannot be empty'),
67+
context_length: z
68+
.number()
69+
.int('OpenRouter embedding context length must be an integer')
70+
.positive('OpenRouter embedding context length must be positive'),
6771
})
6872
.passthrough()
6973
),

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ describe('embedOpenRouter', () => {
451451
const result = await embedOpenRouter(['alpha', 'beta'], {
452452
model: 'openrouter/qwen/qwen3-embedding-8b',
453453
apiKey: 'or-test',
454+
maxInputTokens: 32768,
454455
projectInputs: null,
455456
})
456457

@@ -480,6 +481,7 @@ describe('embedOpenRouter', () => {
480481
embedOpenRouter(['alpha', 'beta'], {
481482
model: 'openrouter/qwen/qwen3-embedding-8b',
482483
apiKey: 'or-test',
484+
maxInputTokens: 32768,
483485
projectInputs: null,
484486
})
485487
).rejects.toThrow('returned 1 embeddings for 2 inputs')
@@ -492,10 +494,58 @@ describe('embedOpenRouter', () => {
492494
embedOpenRouter(['alpha', 'beta'], {
493495
model: 'openrouter/qwen/qwen3-embedding-8b',
494496
apiKey: 'or-test',
497+
maxInputTokens: 32768,
495498
projectInputs: null,
496499
})
497500
).rejects.toThrow('inconsistent dimensions')
498501
})
502+
503+
it('truncates inputs to the selected model context length', async () => {
504+
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
505+
506+
await embedOpenRouter(['alpha beta gamma'], {
507+
model: 'openrouter/thenlper/gte-base',
508+
apiKey: 'or-test',
509+
maxInputTokens: 1,
510+
projectInputs: null,
511+
})
512+
513+
const [, init] = fetchMock.mock.calls[0]
514+
const body = JSON.parse((init as RequestInit).body as string)
515+
expect(body.input).toHaveLength(1)
516+
expect(body.input[0]).not.toBe('alpha beta gamma')
517+
})
518+
519+
it('splits dynamic models at the provider item limit and recombines in order', async () => {
520+
fetchMock.mockImplementation(async (_url, init) => {
521+
const body = JSON.parse((init as RequestInit).body as string)
522+
const inputs = body.input as string[]
523+
return jsonResponse(
524+
openAIBody(
525+
inputs.map((input) => [Number(input.slice(1))]),
526+
inputs.length
527+
)
528+
)
529+
})
530+
const inputs = Array.from({ length: 2049 }, (_, index) => `i${index}`)
531+
532+
const result = await embedOpenRouter(inputs, {
533+
model: 'openrouter/qwen/qwen3-embedding-8b',
534+
apiKey: 'or-test',
535+
maxInputTokens: 32768,
536+
projectInputs: null,
537+
})
538+
539+
expect(fetchMock).toHaveBeenCalledTimes(2)
540+
expect(
541+
fetchMock.mock.calls
542+
.map(([, init]) => JSON.parse((init as RequestInit).body as string).input.length)
543+
.sort((a, b) => a - b)
544+
).toEqual([1, 2048])
545+
expect(result.embeddings).toHaveLength(2049)
546+
expect(result.embeddings[0]).toEqual([0])
547+
expect(result.embeddings[2048]).toEqual([2048])
548+
})
499549
})
500550

501551
describe('knowledge embedding transport fallback', () => {

0 commit comments

Comments
 (0)