diff --git a/README.md b/README.md index 262ceda..60d4b2a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,43 @@ and the tool write the same place). | `embeddingRequestDelay` | Milliseconds between embedding requests, for rate-limited providers. | | `timeoutMs` | Cooperative budget for a foreground run. | +### How to find `embeddingDimensions` + +It is the width of the vector the model returns, and slopo **freezes it into the +database on the first index** — get it wrong and every later run refuses with +`Configuration mismatch` until you delete the index and re-embed. So ask the +endpoint rather than guessing from a model name. + +**The tool will tell you.** `status` probes the endpoint whenever a model and a +base URL are set — even when dimensions is not — and reports the width it +observed: + +```jsonc +{ "action": "status" } +// → Missing: embedding_dimensions. +// The endpoint is reachable and returned 768 dimensions, so set +// embeddingDimensions to 768. +``` + +**Or ask it directly**, which is the same request: + +```sh +# The width of the vector that comes back. +curl -s http://127.0.0.1:11434/v1/embeddings \ + -H 'content-type: application/json' \ + -d '{"model":"unclemusclez/jina-embeddings-v2-base-code:latest","input":["probe"]}' | + python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"][0]["embedding"]))' + +# On Ollama, the model's own recorded width, with no inference at all. +curl -s http://127.0.0.1:11434/api/show \ + -d '{"model":"unclemusclez/jina-embeddings-v2-base-code:latest"}' | + python3 -c 'import json,sys; print(json.load(sys.stdin)["model_info"]["jina-bert-v2.embedding_length"])' +``` + +Both must agree. If they do not, the endpoint is truncating or padding — for +example a server honouring a `dimensions` parameter — and it is the **returned** +width that slopo needs, because that is what lands in the database. + ### Choose the provider prefix carefully The provider prefix is not cosmetic — it selects which LiteLLM validator runs. diff --git a/client/client.js b/client/client.js index 0e56b8b..860cdd3 100644 --- a/client/client.js +++ b/client/client.js @@ -299,6 +299,10 @@ window.__ModuleLoader__.load({ const disabled = !state.writable const children = [] for (const field of FIELDS) { + // The secret is drawn once, by hand, below. Walking it here as well gave + // it a second control — an ordinary text input, echoing the key in + // clear — sitting beside the write-only one. + if (field.kind === 'secret') continue const meta = describeField(t, field) const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta } children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared)) diff --git a/index.js b/index.js index d2adf34..26d985d 100644 --- a/index.js +++ b/index.js @@ -537,21 +537,43 @@ export function apply(ctx, config) { // A direct fetch rather than the shell seam: the probe is one small // HTTP request and does not belong behind a sandbox boundary that // exists to confine filesystem writes. + // + // It runs whenever the endpoint can be NAMED — a base URL and a model + // — not only once every required setting is present. Gating it on + // completeness hid it in the one state that needs it most: nobody can + // look up how wide an arbitrary embedding model is, and the probe is + // what reports the width it observed. + const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '' + const model = String(slopoConfig.embedding_model ?? '') const endpoint = - missing.length === 0 + apiBase.trim() !== '' && model.trim() !== '' ? await probeEmbeddingEndpoint({ - apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '', - model: String(slopoConfig.embedding_model ?? ''), + apiBase, + model, dimensions: Number(slopoConfig.embedding_dimensions ?? 0), apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '', }) - : { checked: false, skipped: 'nothing is configured yet' } + : { + checked: false, + skipped: 'set embeddingModel and embeddingApiBase to probe the endpoint and read its dimensions', + } let message if (missing.length > 0) { message = `Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` + 'to set embeddingModel and embeddingDimensions, then "prepare".' + // The one thing a new user cannot look up anywhere: how wide this + // model's vectors are. Say it plainly when that is what is missing. + if ( + endpoint.checked === true && + typeof endpoint.dimensions === 'number' && + missing.includes('embedding_dimensions') + ) { + message += + ` The endpoint is reachable and returned ${endpoint.dimensions} dimensions, so ` + + `set embeddingDimensions to ${endpoint.dimensions}.` + } } else if (endpoint.ok === false) { message = `Configured, but the embedding backend failed its check: ${endpoint.error}. ` + diff --git a/lib/probe.js b/lib/probe.js index 886a399..557d429 100644 --- a/lib/probe.js +++ b/lib/probe.js @@ -156,7 +156,12 @@ export async function probeEmbeddingEndpoint({ // is not lossless JSON and fails the tool's output validation before the // result is ever materialised. if (actual !== expected) { - result.error = `the model returns ${actual} dimensions but embeddingDimensions is ${expected}` + // "not set yet" and "set wrong" are different problems with different + // remedies, and `dimensions: actual` is the answer to the first one. + result.error = + expected > 0 + ? `the model returns ${actual} dimensions but embeddingDimensions is ${expected}` + : `the model returns ${actual} dimensions and embeddingDimensions is not set` } return result } catch (error) { diff --git a/test/client.test.js b/test/client.test.js index 6ac1f41..9202b15 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -632,6 +632,28 @@ test('the API key is a write-only password control that reports only whether one assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/) }) +test('the API key has exactly one control, and it is never a plain text field', () => { + // The regression: the render loop walked every entry in FIELDS, so the secret + // drew a second control — an ordinary text input, echoing the key in clear — + // beside the write-only password control. One key, two boxes, one of them + // visible. + const { render } = registered() + const tree = render() + const labelled = findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === 'Embedding API key') + assert.equal(labelled.length, 1, 'the key must have exactly one control') + assert.equal(labelled[0].props.type, 'password') +}) + +test('the card draws one control per schema field, and no more', () => { + // A count, not a list of names: the label assertions above pass just as + // happily with a duplicate row sitting beside the real one. + const { render } = registered() + const controls = findAll(render(), (node) => node.type === 'input') + assert.equal(controls.length, 15, 'the schema declares 15 fields, so each gets one control') + const labels = controls.map((node) => node.props['aria-label']) + assert.equal(new Set(labels).size, labels.length, `duplicate controls: ${labels.join(', ')}`) +}) + test('a deployment with no key says so', () => { const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] }) const tree = render() diff --git a/test/plugin.test.js b/test/plugin.test.js index 65ce0d4..deba8fc 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -641,8 +641,37 @@ test('status returns lossless JSON when the endpoint probe SUCCEEDS', async () = } }) -test('status returns lossless JSON when the endpoint probe FAILS', async () => { - const { state } = harness({ +test('status asks the endpoint how wide the model is when embeddingDimensions is unset', async () => { + // The question a new user cannot answer from anywhere else. The probe used to + // be gated on every required setting being present, which meant it was + // unavailable in exactly the state that needs it — so `status` said "Missing: + // embedding_dimensions" and left you to guess. + const { createServer } = await import('node:http') + const server = createServer((request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ data: [{ embedding: new Array(1024).fill(0.01) }] })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() + try { + const { state } = harness({ + embeddingModel: 'ollama/example:latest', + embeddingDimensions: 0, + embeddingApiBase: `http://127.0.0.1:${port}`, + }) + const value = await state.tools.get('slopo').execute({ action: 'status' }, execContext('/tmp')) + assertLossless('status (dimensions unset)', value) + assert.deepEqual(value.details.missingSettings, ['embedding_dimensions']) + assert.equal(value.details.embeddingEndpoint.checked, true, 'the probe must still run') + assert.equal(value.details.embeddingEndpoint.dimensions, 1024, 'the observed width is the answer') + assert.match(value.message, /returned 1024 dimensions/) + assert.match(value.message, /set embeddingDimensions to 1024/) + } finally { + await new Promise((resolve) => server.close(resolve)) + } +}) + +test('status returns lossless JSON when the endpoint probe FAILS', async () => { const { state } = harness({ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, // Nothing is listening on this port, so the fetch rejects. diff --git a/test/probe.test.js b/test/probe.test.js index 13a9577..3bf7e5c 100644 --- a/test/probe.test.js +++ b/test/probe.test.js @@ -73,6 +73,22 @@ test('probeEmbeddingEndpoint catches a dimension mismatch, which is what slopo f assert.match(result.error, /returns 768 dimensions but embeddingDimensions is 1024/) }) +test('probeEmbeddingEndpoint reports the width when dimensions is not set yet', async () => { + // This is the case a new user is in, and the reason the probe runs before + // every required setting exists: nobody can look up how wide an arbitrary + // model is, so `dimensions` in the result IS the answer. + const result = await probeEmbeddingEndpoint({ + apiBase: 'http://host:11434', + model: 'ollama/m:latest', + dimensions: 0, + fetchImpl: fakeFetch({ body: { data: [{ embedding: new Array(768).fill(0) }] } }), + }) + assert.equal(result.ok, false, 'unset is not a match') + assert.equal(result.dimensions, 768, 'the observed width is reported regardless') + assert.equal(result.expected, 0) + assert.match(result.error, /returns 768 dimensions and embeddingDimensions is not set/) +}) + test('probeEmbeddingEndpoint reports an HTTP error instead of throwing', async () => { const result = await probeEmbeddingEndpoint({ apiBase: 'http://host:11434',