From 9ebd484e60a8912c16e0218e343a3bba7e9df947 Mon Sep 17 00:00:00 2001 From: dsh-slopo agent Date: Sat, 26 Sep 2026 07:02:08 +0100 Subject: [PATCH] fix: draw the API key once, and let status answer the dimensions question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a user setting this up hit immediately. The card drew `embeddingApiKey` twice. The render loop walked every entry in FIELDS, and the secret — which is drawn by hand, as a write-only password control — was one of them, so a second control appeared beside it: an ordinary text input, echoing the key in clear as you typed. One key, two boxes, one of them visible. The loop skips secrets now, and the tests count controls rather than checking that each expected label is present, because a duplicate beside the real control passed the old assertions. `status` could not answer the one question a new user cannot look up: how wide is this model? The probe that reports it was gated on every required setting being present, so in exactly the state that needs the answer — dimensions unset — it reported nothing and said "Missing: embedding_dimensions". It now runs whenever the endpoint can be named (a model and a base URL), and the status message names the width it observed: Missing: embedding_dimensions. The endpoint is reachable and returned 768 dimensions, so set embeddingDimensions to 768. The probe also tells "not set yet" apart from "set wrong", because the two have different remedies. Also documents both ways to read the width by hand, since a wrong value is frozen into the index at first use and costs a full re-embed to correct. --- README.md | 37 +++++++++++++++++++++++++++++++++++++ client/client.js | 4 ++++ index.js | 30 ++++++++++++++++++++++++++---- lib/probe.js | 7 ++++++- test/client.test.js | 22 ++++++++++++++++++++++ test/plugin.test.js | 33 +++++++++++++++++++++++++++++++-- test/probe.test.js | 16 ++++++++++++++++ 7 files changed, 142 insertions(+), 7 deletions(-) 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',