Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/docs/content/docs/en/integrations/embeddings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Sim's knowledge bases embed separately, at a fixed vector width and from a small

## Usage Instructions

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



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

### OpenRouter Embeddings

Generate embeddings through OpenRouter

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `input` | string | Yes | Text to embed, or an array of texts to embed in one call |
| `model` | string | No | Embedding model to use |
| `taskType` | string | No | What the embedding is for, when the model supports task conditioning: document, query, similarity, classification, or clustering |
| `dimensions` | number | No | Output dimensions, when the model supports truncation. Defaults to native. |
| `apiKey` | string | Yes | API key for the selected embedding provider |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `embeddings` | json | Generated embeddings |
| `model` | string | Model used |
| `provider` | string | Provider used |
| `dimensions` | number | Dimensionality of each vector |
| `usage` | json | Token usage |

### Gemini Embeddings

Generate embeddings from text using Google's Gemini embedding models
Expand Down
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference
# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockFilterBlacklistedModels: vi.fn(),
mockIsProviderBlacklisted: vi.fn(),
}))

vi.mock('@/providers/utils', () => ({
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))

import { GET } from '@/app/api/providers/openrouter/embeddings/models/route'

const request = () => createMockRequest('GET')

describe('GET /api/providers/openrouter/embeddings/models', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
mockIsProviderBlacklisted.mockReturnValue(false)
mockFilterBlacklistedModels.mockImplementation((models: string[]) => models)
})

afterAll(() => {
vi.unstubAllGlobals()
})

it('returns every unique embedding model with the OpenRouter prefix', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
data: [
{ id: 'qwen/qwen3-embedding-8b', context_length: 32768 },
{ id: 'openai/text-embedding-3-small', context_length: 8192 },
{ id: 'qwen/qwen3-embedding-8b', context_length: 32768 },
],
}),
})

const response = await GET(request(), undefined as never)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
models: ['openrouter/qwen/qwen3-embedding-8b', 'openrouter/openai/text-embedding-3-small'],
})
expect(mockFetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/embeddings/models',
expect.objectContaining({ next: { revalidate: 300 } })
)
})

it('does not fetch when OpenRouter is blacklisted', async () => {
mockIsProviderBlacklisted.mockReturnValue(true)

const response = await GET(request(), undefined as never)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ models: [] })
expect(mockFetch).not.toHaveBeenCalled()
})

it('fails fast when OpenRouter rejects the model-list request', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 503,
statusText: 'Service Unavailable',
})

const response = await GET(request(), undefined as never)

expect(response.status).toBe(500)
expect(mockFilterBlacklistedModels).not.toHaveBeenCalled()
})
})
24 changes: 24 additions & 0 deletions apps/sim/app/api/providers/openrouter/embeddings/models/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { providerModelsResponseSchema } from '@/lib/api/contracts/providers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server'
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'

const logger = createLogger('OpenRouterEmbeddingModelsAPI')

export const GET = withRouteHandler(async (_request: NextRequest) => {
if (isProviderBlacklisted('openrouter')) {
logger.info('OpenRouter provider is blacklisted, returning empty embedding models')
return NextResponse.json({ models: [] })
}

const uniqueModels = (await fetchOpenRouterEmbeddingModelCatalog()).map((model) => model.id)
const models = filterBlacklistedModels(uniqueModels)

logger.info('Successfully fetched OpenRouter embedding models', {
count: models.length,
filtered: uniqueModels.length - models.length,
})
return NextResponse.json(providerModelsResponseSchema.parse({ models }))
})
110 changes: 108 additions & 2 deletions apps/sim/app/api/tools/embeddings/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,37 @@
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockEmbed } = vi.hoisted(() => ({
mockEmbed: vi.fn(),
const { mockEmbed, mockEmbedOpenRouter, mockGetOpenRouterEmbeddingModelMetadata } = vi.hoisted(
() => ({
mockEmbed: vi.fn(),
mockEmbedOpenRouter: vi.fn(),
mockGetOpenRouterEmbeddingModelMetadata: vi.fn(),
})
)

vi.mock('@/lib/embeddings/openrouter-model-catalog.server', () => ({
getOpenRouterEmbeddingModelMetadata: mockGetOpenRouterEmbeddingModelMetadata,
OpenRouterEmbeddingModelNotFoundError: class OpenRouterEmbeddingModelNotFoundError extends Error {
constructor(model: string) {
super(`Unsupported OpenRouter embedding model: ${model}`)
this.name = 'OpenRouterEmbeddingModelNotFoundError'
}
},
}))

vi.mock('@/lib/embeddings', async () => {
const catalog = await import('@/lib/embeddings/catalog')
return {
embed: mockEmbed,
embedOpenRouter: mockEmbedOpenRouter,
DEFAULT_OPENROUTER_EMBEDDING_MODEL: 'openrouter/openai/text-embedding-3-small',
findEmbeddingModelInfo: catalog.findEmbeddingModelInfo,
getModelsForProvider: catalog.getModelsForProvider,
resolveDimensions: catalog.resolveDimensions,
}
})

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

const baseBody = {
Expand All @@ -34,6 +51,10 @@ function post(body: Record<string, unknown>) {
describe('POST /api/tools/embeddings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetOpenRouterEmbeddingModelMetadata.mockResolvedValue({
id: 'openrouter/qwen/qwen3-embedding-8b',
maxInputTokens: 32768,
})
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
Expand All @@ -47,6 +68,15 @@ describe('POST /api/tools/embeddings', () => {
pricingId: 'text-embedding-3-small',
dimensions: 1536,
})
mockEmbedOpenRouter.mockResolvedValue({
embeddings: [[0.1, 0.2]],
totalTokens: 3,
billableTokens: 0,
isBYOK: true,
modelName: 'openrouter/qwen/qwen3-embedding-8b',
pricingId: 'openrouter/qwen/qwen3-embedding-8b',
dimensions: 2,
})
})

it('rejects an unauthenticated caller', async () => {
Expand Down Expand Up @@ -117,6 +147,82 @@ describe('POST /api/tools/embeddings', () => {
)
})

it('routes OpenRouter through its transport with an explicit key', async () => {
const response = await post({
provider: 'openrouter',
model: 'openrouter/qwen/qwen3-embedding-8b',
input: 'hello world',
apiKey: 'or-test',
})

expect(response.status).toBe(200)
expect(mockEmbedOpenRouter).toHaveBeenCalledWith(
['hello world'],
expect.objectContaining({
apiKey: 'or-test',
maxInputTokens: 32768,
model: 'openrouter/qwen/qwen3-embedding-8b',
})
)
expect(mockEmbed).not.toHaveBeenCalled()
expect((await response.json()).provider).toBe('openrouter')
})

it('rejects OpenRouter without an explicit key', async () => {
const response = await post({
provider: 'openrouter',
model: 'openrouter/openai/text-embedding-3-small',
input: 'hello world',
})

expect(response.status).toBe(400)
expect((await response.json()).error).toContain('apiKey')
expect(mockEmbed).not.toHaveBeenCalled()
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
})

it('rejects an invalid OpenRouter model id', async () => {
const response = await post({
provider: 'openrouter',
model: 'openrouter/not-qualified',
input: 'hello world',
apiKey: 'or-test',
})

expect(response.status).toBe(400)
expect((await response.json()).error).toContain('Invalid OpenRouter embedding model')
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
})

it('rejects a qualified model that is absent from OpenRouter', async () => {
mockGetOpenRouterEmbeddingModelMetadata.mockRejectedValue(
new OpenRouterEmbeddingModelNotFoundError('openrouter/example/missing')
)

const response = await post({
provider: 'openrouter',
model: 'openrouter/example/missing',
input: 'hello world',
apiKey: 'or-test',
})

expect(response.status).toBe(400)
expect((await response.json()).error).toContain('Unsupported OpenRouter embedding model')
expect(mockEmbedOpenRouter).not.toHaveBeenCalled()
})

it('keeps API keys required for non-OpenRouter providers', async () => {
const response = await post({
provider: 'openai',
model: 'text-embedding-3-small',
input: 'hello world',
})

expect(response.status).toBe(400)
expect((await response.json()).error).toContain('apiKey')
expect(mockEmbed).not.toHaveBeenCalled()
})

it('surfaces a provider failure as 502', async () => {
mockEmbed.mockRejectedValue(new Error('Embedding API failed: 429 Too Many Requests'))
const response = await post(baseBody)
Expand Down
Loading
Loading