Skip to content
Merged
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
1 change: 1 addition & 0 deletions .agents/skills/design-taste-frontend/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: design-taste-frontend
source: https://github.com/leonxlnx/taste-skill — skills/taste-skill/SKILL.md
description: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.
---

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/emil-design-eng/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: emil-design-eng
source: https://github.com/emilkowalski/skill — skills/emil-design-eng/SKILL.md
description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
---

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/make-interfaces-feel-better/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: make-interfaces-feel-better
source: https://github.com/jakubkrehel/make-interfaces-feel-better — skills/make-interfaces-feel-better/SKILL.md
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, typography, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows.
---

Expand Down
21 changes: 20 additions & 1 deletion .github/actions/docker-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,28 @@ inputs:
runs:
using: composite
steps:
# One sticky disk per Dockerfile per platform. v1 keyed it on the repo name
# alone, so every image shared one disk: matrix jobs all clone the same
# parent snapshot and only the first to finish becomes the next parent, so
# the app image's `deps` layer was written and discarded every run (~300-465s
# rebuilt each time). Platform is in the key because amd64 and arm64 build
# the same Dockerfiles concurrently on main and share no layers. Ref is
# deliberately not: cross-ref reuse is the point, and an occasional overlap
# costs one rebuild.
- name: Resolve Docker layer cache key
id: cache-key
if: inputs.provider == '' || inputs.provider == 'blacksmith'
shell: bash
env:
FILE: ${{ inputs.file }}
PLATFORMS: ${{ inputs.platforms }}
run: echo "value=${GITHUB_REPOSITORY##*/}/${FILE#./}/${PLATFORMS//\//-}" >> "$GITHUB_OUTPUT"

- name: Set up Blacksmith builder
if: inputs.provider == '' || inputs.provider == 'blacksmith'
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
uses: useblacksmith/setup-docker-builder@a5256a73e30f09e37e3eceb8ca36043d17621d24 # v2
with:
cache-key: ${{ steps.cache-key.outputs.value }}

- name: Build and push (Blacksmith)
if: inputs.provider == '' || inputs.provider == 'blacksmith'
Expand Down
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,20 @@ jobs:
fail-fast: false
matrix:
include:
# Only the app image needs the paid 8-core/32 GB runner: next build
# exhausts the free 16 GB one (exit 137). The others build in <5 min.
# bs_runner mirrors that per-image sizing on Blacksmith — a single
# pinned tier put every image on 8 vCPU, where the non-app builds idle
# at 12-15% CPU and under 10% memory.
# Only the app image needs a large runner: next build exhausts the free
# 16 GB one (exit 137). The others build in <5 min and idle at 12-15%
# CPU on 8 vCPU, so they stay on the smaller tiers.
#
# 16 vCPU on Blacksmith because this build is the critical path to a
# deploy — nothing ships until the image is pushed — and its two
# dominant steps both scale with cores (`bun install` ~300-400s, `next
# build` ~260s). The same `next build` runs on 16 vCPU in the separate
# Build App verification job, which does not gate anything; this one
# was doing comparable work on half the cores.
- dockerfile: ./docker/app.Dockerfile
ecr_repo_secret: ECR_APP
gh_runner: linux-x64-8-core
bs_runner: blacksmith-8vcpu-ubuntu-2404
bs_runner: blacksmith-16vcpu-ubuntu-2404
- dockerfile: ./docker/db.Dockerfile
ecr_repo_secret: ECR_MIGRATIONS
gh_runner: ubuntu-latest
Expand Down Expand Up @@ -278,7 +283,7 @@ jobs:
ghcr_image: ghcr.io/simstudioai/simstudio
ecr_repo_secret: ECR_APP
gh_runner: linux-x64-8-core
bs_runner: blacksmith-8vcpu-ubuntu-2404
bs_runner: blacksmith-16vcpu-ubuntu-2404
- dockerfile: ./docker/db.Dockerfile
ghcr_image: ghcr.io/simstudioai/migrations
ecr_repo_secret: ECR_MIGRATIONS
Expand Down
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 @@ -92,6 +92,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