Skip to content

Commit ce2dff3

Browse files
v0.7.68: OpenRouter embeddings, Docker builds, prompt autosizing, idempotency
v0.7.68: OpenRouter embeddings, Docker builds, prompt autosizing, idempotency
2 parents ccdb03d + e0292fc commit ce2dff3

58 files changed

Lines changed: 2132 additions & 318 deletions

Some content is hidden

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

.agents/skills/design-taste-frontend/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
name: design-taste-frontend
3+
source: https://github.com/leonxlnx/taste-skill — skills/taste-skill/SKILL.md
34
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.
45
---
56

.agents/skills/emil-design-eng/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
name: emil-design-eng
3+
source: https://github.com/emilkowalski/skill — skills/emil-design-eng/SKILL.md
34
description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
45
---
56

.agents/skills/make-interfaces-feel-better/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
name: make-interfaces-feel-better
3+
source: https://github.com/jakubkrehel/make-interfaces-feel-better — skills/make-interfaces-feel-better/SKILL.md
34
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.
45
---
56

.github/actions/docker-build/action.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,28 @@ inputs:
2525
runs:
2626
using: composite
2727
steps:
28+
# One sticky disk per Dockerfile per platform. v1 keyed it on the repo name
29+
# alone, so every image shared one disk: matrix jobs all clone the same
30+
# parent snapshot and only the first to finish becomes the next parent, so
31+
# the app image's `deps` layer was written and discarded every run (~300-465s
32+
# rebuilt each time). Platform is in the key because amd64 and arm64 build
33+
# the same Dockerfiles concurrently on main and share no layers. Ref is
34+
# deliberately not: cross-ref reuse is the point, and an occasional overlap
35+
# costs one rebuild.
36+
- name: Resolve Docker layer cache key
37+
id: cache-key
38+
if: inputs.provider == '' || inputs.provider == 'blacksmith'
39+
shell: bash
40+
env:
41+
FILE: ${{ inputs.file }}
42+
PLATFORMS: ${{ inputs.platforms }}
43+
run: echo "value=${GITHUB_REPOSITORY##*/}/${FILE#./}/${PLATFORMS//\//-}" >> "$GITHUB_OUTPUT"
44+
2845
- name: Set up Blacksmith builder
2946
if: inputs.provider == '' || inputs.provider == 'blacksmith'
30-
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
47+
uses: useblacksmith/setup-docker-builder@a5256a73e30f09e37e3eceb8ca36043d17621d24 # v2
48+
with:
49+
cache-key: ${{ steps.cache-key.outputs.value }}
3150

3251
- name: Build and push (Blacksmith)
3352
if: inputs.provider == '' || inputs.provider == 'blacksmith'

.github/workflows/ci.yml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,20 @@ jobs:
155155
fail-fast: false
156156
matrix:
157157
include:
158-
# Only the app image needs the paid 8-core/32 GB runner: next build
159-
# exhausts the free 16 GB one (exit 137). The others build in <5 min.
160-
# bs_runner mirrors that per-image sizing on Blacksmith — a single
161-
# pinned tier put every image on 8 vCPU, where the non-app builds idle
162-
# at 12-15% CPU and under 10% memory.
158+
# Only the app image needs a large runner: next build exhausts the free
159+
# 16 GB one (exit 137). The others build in <5 min and idle at 12-15%
160+
# CPU on 8 vCPU, so they stay on the smaller tiers.
161+
#
162+
# 16 vCPU on Blacksmith because this build is the critical path to a
163+
# deploy — nothing ships until the image is pushed — and its two
164+
# dominant steps both scale with cores (`bun install` ~300-400s, `next
165+
# build` ~260s). The same `next build` runs on 16 vCPU in the separate
166+
# Build App verification job, which does not gate anything; this one
167+
# was doing comparable work on half the cores.
163168
- dockerfile: ./docker/app.Dockerfile
164169
ecr_repo_secret: ECR_APP
165170
gh_runner: linux-x64-8-core
166-
bs_runner: blacksmith-8vcpu-ubuntu-2404
171+
bs_runner: blacksmith-16vcpu-ubuntu-2404
167172
- dockerfile: ./docker/db.Dockerfile
168173
ecr_repo_secret: ECR_MIGRATIONS
169174
gh_runner: ubuntu-latest
@@ -278,7 +283,7 @@ jobs:
278283
ghcr_image: ghcr.io/simstudioai/simstudio
279284
ecr_repo_secret: ECR_APP
280285
gh_runner: linux-x64-8-core
281-
bs_runner: blacksmith-8vcpu-ubuntu-2404
286+
bs_runner: blacksmith-16vcpu-ubuntu-2404
282287
- dockerfile: ./docker/db.Dockerfile
283288
ghcr_image: ghcr.io/simstudioai/migrations
284289
ecr_repo_secret: ECR_MIGRATIONS

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)