Skip to content

Commit cbac4a4

Browse files
committed
fix(embeddings): validate width per model and stop guessing an Ollama target
Third round of review findings: - Require an explicit OLLAMA_URL before resolving a model's width, matching what the embedding client already demands, so a loopback server cannot produce a knowledge base every later call refuses to serve - Validate EMBEDDING_OUTPUT_DIMS against the selected model rather than its family, since the family pattern accepted widths the model silently overrides - Raise a validation error for a missing model or unreportable width, so the route renders 400 instead of a generic 500 - Stop the Embeddings block auto-persisting whichever model Ollama lists first - Do not offer back a configured value that the provider being set up rejects, so switching families cannot carry the previous family's model forward - Cancel the response body of a rejected Ollama probe - Make the drop-on-inspection-failure test actually exercise that path - Correct the setup provider list, the older-Ollama filtering caveat, and the Sim Cloud OLLAMA_URL claim in the docs
1 parent af9a438 commit cbac4a4

10 files changed

Lines changed: 174 additions & 20 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Models differ in what they are good at and what they cost. `text-embedding-3-sma
1919

2020
Two things worth knowing before you build on it. Vectors are only comparable when they come from the same model at the same size, so changing either means re-embedding everything you intend to compare. And input longer than the model's limit is shortened to fit rather than rejected, with a warning in the run, so chunk long documents yourself when the tail matters.
2121

22-
Ollama is the exception to most of the above. It runs on your own deployment, so it needs no API key and adds no provider charge — Sim's own per-run charge still applies — and the model list is whatever you have pulled onto that server rather than a catalog Sim maintains. The block reads it live, drops the models that report a non-embedding capability, and shows each one's vector width next to its name where Ollama reports one. A server too old to report either will list its chat models too and label none of them, so check the model you pick. The block offers no task-type or dimension control for Ollama: task conditioning has no equivalent there, and while recent Ollama builds do accept a dimension override for Matryoshka models, older ones silently ignore it, so Sim uses each model's own width rather than one that may or may not take effect. Self-hosted deployments configure the server with `OLLAMA_URL`; on Sim Cloud there is no Ollama to reach, so the list comes back empty.
22+
Ollama is the exception to most of the above. It runs on your own deployment, so it needs no API key and adds no provider charge — Sim's own per-run charge still applies — and the model list is whatever you have pulled onto that server rather than a catalog Sim maintains. The block reads it live, drops the models that report a non-embedding capability, and shows each one's vector width next to its name where Ollama reports one. A server too old to report either will list its chat models too and label none of them, so check the model you pick. The block offers no task-type or dimension control for Ollama: task conditioning has no equivalent there, and while recent Ollama builds do accept a dimension override for Matryoshka models, older ones silently ignore it, so Sim uses each model's own width rather than one that may or may not take effect. Point Sim at the server with `OLLAMA_URL`. Sim Cloud runs no Ollama of its own, so without that variable the list comes back empty rather than dialling a loopback address that cannot answer — set it to a reachable server and Cloud will use it like any other deployment.
2323

2424
Sim's knowledge bases embed separately: a base fixes one model and one vector width when it is created, from a smaller set of models. This block is for embedding text yourself inside a workflow.
2525
{/* MANUAL-CONTENT-END */}

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,18 @@ surfaces as a failed document instead. Common pairings:
214214
| `3072` | `text-embedding-3-large`, `gemini-embedding-001` |
215215

216216
On a Compose install or source checkout, `sim-setup add knowledge-embeddings` walks through all of
217-
this — pick OpenAI, Azure OpenAI, Gemini, or Ollama and it writes the variables that family needs.
217+
this — pick OpenAI, Azure OpenAI, OpenRouter, Gemini, or Ollama and it writes the variables that
218+
family needs. The three OpenAI-family transports share the same model and width prompts.
218219
`sim-setup config` then reports the one family your `KB_EMBEDDING_MODEL` actually selects, rather
219220
than every provider you happen to hold a key for. Helm releases set these values through your own
220221
chart values instead, and an Ollama server is yours to run either way — `sim-setup` configures Sim
221222
to reach one, never installs it.
222223

223224
The Embeddings block reads the same `OLLAMA_URL`. It lists the models on that server that report an
224225
embedding capability, with the width each one emits where Ollama reports it, so a workflow can embed
225-
locally without an API key.
226+
locally without an API key. Ollama only began reporting capabilities in 0.5 — against an older
227+
server nothing can be filtered, so the list includes chat models and labels none of them with a
228+
width. Check what you pick there, or upgrade Ollama.
226229

227230
## Chat & PII
228231

‎apps/sim/blocks/blocks/embeddings.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ MODEL_SUB_BLOCKS.push({
101101
selectorKey: 'providers.ollamaEmbeddingModels',
102102
placeholder: 'Select a model on your Ollama server',
103103
required: true,
104+
/**
105+
* Suppresses the combobox's auto-select-first-option behaviour. Without it,
106+
* opening the block persists whichever model the server happens to list
107+
* first — and since each one emits a different width, that is a silent wrong
108+
* answer rather than a harmless default. `required` still stands: the user
109+
* must choose, they just are not chosen for.
110+
*/
111+
emptyIsValid: true,
104112
condition: { field: 'provider', value: 'ollama' },
105113
dependsOn: ['provider'],
106114
})

‎apps/sim/lib/embeddings/knowledge-embedding-family.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { knowledgeEmbeddingFamily } from '@/lib/core/config/env-capabilities'
5+
import {
6+
inspectCapability,
7+
KNOWLEDGE_EMBEDDINGS_CAPABILITY,
8+
knowledgeEmbeddingFamily,
9+
} from '@/lib/core/config/env-capabilities'
610
import {
711
DEFAULT_EMBEDDING_MODEL,
812
getEmbeddingModelInfo,
913
getKbEligibleModels,
14+
getKbEmbeddingDimensions,
15+
KB_EMBEDDING_STORAGE_DIMENSIONS,
1016
} from '@/lib/embeddings/catalog'
1117
import { isKbEmbeddingModel } from '@/lib/knowledge/embedding-models'
1218

@@ -28,6 +34,29 @@ describe('knowledgeEmbeddingFamily', () => {
2834
}
2935
})
3036

37+
/**
38+
* The capability also duplicates each model's storable widths, to reject a
39+
* width the selected model cannot emit. A width the catalog allows but the
40+
* capability rejects blocks a working deployment; the reverse passes a
41+
* configuration the runtime silently overrides.
42+
*/
43+
it('accepts exactly the widths the catalog says each model can be indexed at', () => {
44+
for (const model of getKbEligibleModels()) {
45+
const widths = getKbEmbeddingDimensions(getEmbeddingModelInfo(model))
46+
for (const width of KB_EMBEDDING_STORAGE_DIMENSIONS) {
47+
const issues = inspectCapability(KNOWLEDGE_EMBEDDINGS_CAPABILITY, {
48+
KB_EMBEDDING_MODEL: model,
49+
EMBEDDING_OUTPUT_DIMS: String(width),
50+
OPENAI_API_KEY: 'k',
51+
GEMINI_API_KEY: 'k',
52+
}).providers.flatMap((provider) => provider.invalidFields)
53+
expect(issues.includes('EMBEDDING_OUTPUT_DIMS'), `${model} @ ${width}`).toBe(
54+
!widths.includes(width)
55+
)
56+
}
57+
}
58+
})
59+
3160
it('classifies any model on the deployment’s own Ollama by its routing prefix', () => {
3261
for (const model of ['ollama/nomic-embed-text', 'ollama/mxbai-embed-large:335m']) {
3362
expect(knowledgeEmbeddingFamily({ KB_EMBEDDING_MODEL: model }), model).toBe('ollama')

‎apps/sim/lib/embeddings/ollama-model-catalog.server.test.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,27 @@ describe('Ollama embedding model catalog', () => {
9292

9393
it('keeps the rest of the catalog when one model cannot be inspected', async () => {
9494
serve({
95-
broken: { capabilities: ['embedding'] },
9695
'all-minilm:latest': {
9796
capabilities: ['embedding'],
9897
modelInfo: { 'bert.embedding_length': 384 },
9998
},
10099
})
101-
fetchMock.mockImplementationOnce(() =>
102-
Promise.resolve(
103-
Response.json({ models: [{ name: 'broken' }, { name: 'all-minilm:latest' }] })
104-
)
105-
)
100+
const served = fetchMock.getMockImplementation()
101+
/** `broken` has to fail its own `/api/show`, or the skip path never runs. */
102+
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
103+
if (String(url).endsWith('/api/tags')) {
104+
return Promise.resolve(
105+
Response.json({ models: [{ name: 'broken' }, { name: 'all-minilm:latest' }] })
106+
)
107+
}
108+
const { model } = JSON.parse(String(init?.body)) as { model: string }
109+
if (model === 'broken') return Promise.reject(new Error('inspection failed'))
110+
return served?.(url, init)
111+
})
106112

107-
const catalog = await fetchOllamaEmbeddingModelCatalog()
108-
expect(catalog).toContainEqual({ id: 'all-minilm:latest', dimensions: 384 })
113+
await expect(fetchOllamaEmbeddingModelCatalog()).resolves.toEqual([
114+
{ id: 'all-minilm:latest', dimensions: 384 },
115+
])
109116
})
110117

111118
it('offers nothing rather than throwing when no Ollama answers', async () => {

‎apps/sim/lib/embeddings/ollama-model-catalog.server.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ async function fetchOllamaJson(path: string, init: RequestInit, signal?: AbortSi
6060
...init,
6161
})
6262
if (!response.ok) {
63+
/** Release the connection: nothing reads the body of a rejected probe. */
64+
await response.body?.cancel().catch(() => {})
6365
throw new Error(`Ollama ${path} failed: ${response.status} ${response.statusText}`)
6466
}
6567
return readResponseJsonWithLimit(response, {
@@ -175,6 +177,15 @@ export async function getOllamaEmbeddingModelMetadata(
175177
): Promise<Required<OllamaEmbeddingModel>> {
176178
const name = isOllamaEmbeddingModel(model) ? ollamaEmbeddingModelName(model) : model
177179
if (!name) throw new OllamaEmbeddingModelNotFoundError(model)
180+
/**
181+
* The same requirement `resolveProvider` enforces before embedding. Without it
182+
* a developer machine with an unset `OLLAMA_URL` and a local server answering
183+
* on the loopback default would resolve a width here and create a knowledge
184+
* base that every later embedding call refuses to serve.
185+
*/
186+
if (!isOllamaUrlConfigured()) {
187+
throw new OllamaUnreachableError('OLLAMA_URL is not configured')
188+
}
178189

179190
let detail: { capabilities?: string[]; model_info?: Record<string, unknown> }
180191
try {

‎apps/sim/lib/knowledge/embeddings-configuration.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* @vitest-environment node
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { OrchestrationError } from '@/lib/core/orchestration/types'
6+
import { OllamaEmbeddingModelNotFoundError } from '@/lib/embeddings/ollama-model-catalog.server'
57

68
const { mockEnv, mockOllamaMetadata } = vi.hoisted(() => ({
79
mockEnv: {} as { KB_EMBEDDING_MODEL?: string; EMBEDDING_OUTPUT_DIMS?: string },
@@ -18,7 +20,8 @@ vi.mock('@/lib/core/config/env', async (importOriginal) => ({
1820
env: mockEnv,
1921
}))
2022

21-
vi.mock('@/lib/embeddings/ollama-model-catalog.server', () => ({
23+
vi.mock('@/lib/embeddings/ollama-model-catalog.server', async (importOriginal) => ({
24+
...(await importOriginal<typeof import('@/lib/embeddings/ollama-model-catalog.server')>()),
2225
getOllamaEmbeddingModelMetadata: mockOllamaMetadata,
2326
}))
2427

@@ -98,6 +101,17 @@ describe('getConfiguredKbEmbedding', () => {
98101
await expect(getConfiguredKbEmbedding()).rejects.toThrow('Set EMBEDDING_OUTPUT_DIMS')
99102
})
100103

104+
/**
105+
* A model the server does not have is the operator's to fix, so it is a
106+
* validation failure the route renders as 400 rather than a generic 500.
107+
*/
108+
it('classifies a missing model as caller-fixable', async () => {
109+
mockEnv.KB_EMBEDDING_MODEL = 'ollama/not-pulled'
110+
mockOllamaMetadata.mockRejectedValue(new OllamaEmbeddingModelNotFoundError('not-pulled'))
111+
112+
await expect(getConfiguredKbEmbedding()).rejects.toBeInstanceOf(OrchestrationError)
113+
})
114+
101115
it('refuses to create a base when the model emits a width no column can store', async () => {
102116
mockEnv.KB_EMBEDDING_MODEL = 'ollama/odd-width'
103117
mockOllamaMetadata.mockResolvedValue({ id: 'odd-width:latest', dimensions: 1152 })

‎apps/sim/lib/knowledge/embeddings.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import {
77
import { recordUsage } from '@/lib/billing/core/usage-log'
88
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
99
import { env, envNumber } from '@/lib/core/config/env'
10+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1011
import { embedKnowledge } from '@/lib/embeddings'
1112
import { isOllamaEmbeddingModel } from '@/lib/embeddings/catalog'
12-
import { getOllamaEmbeddingModelMetadata } from '@/lib/embeddings/ollama-model-catalog.server'
13+
import {
14+
getOllamaEmbeddingModelMetadata,
15+
OllamaEmbeddingModelNotFoundError,
16+
OllamaEmbeddingWidthUnknownError,
17+
} from '@/lib/embeddings/ollama-model-catalog.server'
1318
import {
1419
assertKbEmbeddingModel,
1520
DEFAULT_EMBEDDING_MODEL,
@@ -122,12 +127,25 @@ export async function getConfiguredKbEmbedding(): Promise<KbEmbeddingTarget> {
122127
try {
123128
dimensions = (await getOllamaEmbeddingModelMetadata(model)).dimensions
124129
} catch (error) {
125-
throw new Error(
126-
`Could not read the vector width of ${model} from the configured Ollama server (${getErrorMessage(error, 'Unknown error')}). Set EMBEDDING_OUTPUT_DIMS to the width it emits.`
127-
)
130+
/**
131+
* A model the server does not have, or one whose width it will not report,
132+
* is the operator's to fix and is surfaced as such. An unreachable server
133+
* is a dependency failure and keeps its default classification — the
134+
* orchestration vocabulary has no upstream-failure code, and the message
135+
* carries the cause either way.
136+
*/
137+
const message = `Could not read the vector width of ${model} from the configured Ollama server (${getErrorMessage(error, 'Unknown error')}). Set EMBEDDING_OUTPUT_DIMS to the width it emits.`
138+
if (
139+
error instanceof OllamaEmbeddingModelNotFoundError ||
140+
error instanceof OllamaEmbeddingWidthUnknownError
141+
) {
142+
throw new OrchestrationError('validation', message)
143+
}
144+
throw new Error(message, { cause: error })
128145
}
129146
if (!isKbEmbeddingDimensions(dimensions)) {
130-
throw new Error(
147+
throw new OrchestrationError(
148+
'validation',
131149
`${model} emits ${dimensions}-dimensional vectors, which knowledge bases cannot store. Choose a model emitting one of ${KB_EMBEDDING_STORAGE_DIMENSIONS.join(', ')}.`
132150
)
133151
}

‎packages/deployment-config/src/env-capabilities.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1262,9 +1262,46 @@ export type KnowledgeEmbeddingFamily = 'openai' | 'gemini' | 'ollama'
12621262

12631263
const OLLAMA_EMBEDDING_MODEL_PREFIX = 'ollama/'
12641264

1265+
/**
1266+
* Widths each knowledge-base-eligible model can be indexed at, as the app
1267+
* catalog defines them. Duplicated here because packages cannot import the
1268+
* catalog; the parity test pins both halves, widths included.
1269+
*/
1270+
const KB_EMBEDDING_MODEL_WIDTHS: Readonly<Record<string, readonly number[]>> = {
1271+
'text-embedding-3-small': [1536, 1024, 768],
1272+
'text-embedding-3-large': [3072, 1536, 1024, 768],
1273+
'gemini-embedding-001': [3072, 1536, 768],
1274+
}
1275+
12651276
/** Knowledge-base-eligible Gemini model ids, as the app catalog defines them. */
12661277
const GEMINI_KB_EMBEDDING_MODELS: readonly string[] = ['gemini-embedding-001']
12671278

1279+
/**
1280+
* Rejects a width the *selected model* cannot emit, which the per-field pattern
1281+
* cannot: that pattern is the union across a family, so `text-embedding-3-small`
1282+
* at 3,072 satisfies it while the runtime falls back to 1,536 with a warning.
1283+
* A validation that passes for a configuration the runtime silently overrides is
1284+
* worse than none, so the two are checked together here.
1285+
*/
1286+
function validateEmbeddingModelWidth(
1287+
values: EnvCapabilityValues
1288+
): readonly EnvProviderValidationIssue[] {
1289+
const model = String(readValue(values, 'KB_EMBEDDING_MODEL') ?? '')
1290+
const rawWidth = readValue(values, 'EMBEDDING_OUTPUT_DIMS')
1291+
if (!hasValue(values, 'EMBEDDING_OUTPUT_DIMS')) return []
1292+
1293+
const widths = KB_EMBEDDING_MODEL_WIDTHS[model]
1294+
if (!widths) return []
1295+
if (widths.includes(Number(String(rawWidth).trim()))) return []
1296+
return [
1297+
{
1298+
kind: 'invalid',
1299+
fields: ['EMBEDDING_OUTPUT_DIMS'],
1300+
message: `EMBEDDING_OUTPUT_DIMS must be one of ${widths.join(', ')} for ${model}`,
1301+
},
1302+
]
1303+
}
1304+
12681305
export function knowledgeEmbeddingFamily(values: EnvCapabilityValues): KnowledgeEmbeddingFamily {
12691306
/**
12701307
* Read raw, not trimmed: the runtime looks the value up exactly as configured,
@@ -1346,6 +1383,7 @@ export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
13461383
envField('KB_EMBEDDING_MODEL'),
13471384
embeddingOutputDimsField(OPENAI_EMBEDDING_WIDTHS),
13481385
],
1386+
validate: validateEmbeddingModelWidth,
13491387
},
13501388
{
13511389
id: 'openai',
@@ -1365,6 +1403,7 @@ export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
13651403
envField('KB_EMBEDDING_MODEL'),
13661404
embeddingOutputDimsField(OPENAI_EMBEDDING_WIDTHS),
13671405
],
1406+
validate: validateEmbeddingModelWidth,
13681407
},
13691408
{
13701409
id: 'openrouter',
@@ -1381,6 +1420,7 @@ export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
13811420
envField('KB_EMBEDDING_MODEL'),
13821421
embeddingOutputDimsField(OPENAI_EMBEDDING_WIDTHS),
13831422
],
1423+
validate: validateEmbeddingModelWidth,
13841424
},
13851425
{
13861426
id: 'gemini',
@@ -1400,6 +1440,7 @@ export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
14001440
envField('KB_EMBEDDING_MODEL')
14011441
),
14021442
optionalFields: [embeddingOutputDimsField(GEMINI_EMBEDDING_WIDTHS)],
1443+
validate: validateEmbeddingModelWidth,
14031444
},
14041445
{
14051446
/**
@@ -1419,7 +1460,18 @@ export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
14191460
message: 'must be a valid HTTP(S) URL',
14201461
},
14211462
}),
1422-
envField('KB_EMBEDDING_MODEL')
1463+
/**
1464+
* Constrained to the routing prefix, which is what makes it an Ollama
1465+
* model at all. It also stops the setup flow carrying a model from
1466+
* another family forward when an existing install switches to Ollama.
1467+
*/
1468+
envField('KB_EMBEDDING_MODEL', {
1469+
validation: {
1470+
kind: 'pattern',
1471+
pattern: /^ollama\/.+/,
1472+
message: 'must name a model on your Ollama server, as ollama/<model>',
1473+
},
1474+
})
14231475
),
14241476
optionalFields: [embeddingOutputDimsField(OLLAMA_EMBEDDING_WIDTHS)],
14251477
},

‎packages/sim-setup/src/capability-setup.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,19 @@ async function promptField(
404404
const existing = state.currentValues.get(prompt.key)
405405
const current = hasEnvCapabilityValue(state.currentValues, prompt.key)
406406
const draft = getCapabilitySetupDraftValues(state.setup, state.optionId, state.values)
407-
const initialValue = existing ?? draft[prompt.key] ?? prompt.defaultValue
407+
/**
408+
* A configured value is only offered back when it is valid for the provider
409+
* being configured. Switching families otherwise pre-fills the previous
410+
* family's model, and accepting the displayed value silently keeps it — the
411+
* transition then rejects the provider the operator just chose.
412+
*/
413+
const reusableExisting =
414+
existing !== undefined &&
415+
validateCapabilityFieldInput(state.setup.definition, prompt.key, existing, state.optionId) ===
416+
undefined
417+
? existing
418+
: undefined
419+
const initialValue = reusableExisting ?? draft[prompt.key] ?? prompt.defaultValue
408420
const validate = (value: string): string | undefined => {
409421
if (!value) return prompt.required && !existing ? 'required' : undefined
410422
return prompt.validate

0 commit comments

Comments
 (0)