From 35e3b41b1b69715bb8c9c0e7c1e3ff6c2a371b28 Mon Sep 17 00:00:00 2001 From: mike-inkeep Date: Fri, 24 Jul 2026 22:01:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(ok):=20custom=20endpoint=20embedding=20mod?= =?UTF-8?q?els=20=E2=80=94=20auto-detected=20dimensions,=20model=20field,?= =?UTF-8?q?=20connection=20test=20(#2883)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ok): auto-detect embedding dimensions + model field and connection test Semantic search assumed every model returned 1536-dim vectors, so any custom OpenAI-compatible endpoint whose model has a different native size failed every response and degraded silently to keyword search. - Unset `search.semantic.dimensions` now means "whatever the endpoint returns": the embedder pins the size from the first response, the vector cache persists it, and restart reuses it. Cache identity keeps an `'auto'` sentinel so the detected value never re-triggers its own invalidation. - A size change mid-life (model swapped behind an alias) discards + rebuilds the corpus instead of scoring two sizes together; bounded per process. Detected on both the embed and query paths, so an unchanged corpus still recovers. - New loopback route POST /api/local-op/embeddings/test runs one probe embed of a fixed string against the SAVED endpoint and returns the detected size or a classified reason. - Settings → Search: "Custom endpoint" disclosure with endpoint + free-text model + Test connection; auto-expands when either is overridden; changing either warns about the re-index and egress first. - Account key relabelled "Embeddings API key" (it is sent to the configured endpoint, not just OpenAI's). * docs(ok): document auto-detected dimensions + Test connection; tidy exports - Reference docs: `dimensions` now defaults to auto-detected, the setup steps name the Settings disclosure + Test connection, and the egress note says the key goes to whichever endpoint is configured. - Status panel points at Test connection instead of re-listing places to check. - Drop exports nothing consumes (knip); log the classified provider reason on a failed query embed now that it is available. * fix(ok): pluralize the re-index page count in the provider-change warning Verified live against a self-hosted-style endpoint: a one-page project read "The full text of all 1 pages". * fix(ok): address review — cut off cleanly when the drift budget is spent - Spending MAX_DIMS_DRIFT_RESETS now drops `capable`, so every later search short-circuits instead of re-reaching the provider and re-logging the same error line per query. `runEmbedPass` honours the same flag. - Reset the drift budget on a provider-fingerprint change. Deliberately in `applyConfig` and NOT in `resetWarm` (as suggested in review): drift recovery calls `resetWarm` itself, so refunding there would leave the bound inert. - Log when the query path declines recovery (explicit dimensions, or budget spent) — the embed path already did, so a query-first mismatch was the one route that degraded with no trail. - Test-connection failures render destructive, not the same amber as the transient "still saving" notice, and carry role="alert". - Cover the budget bound: a provider flapping sizes every request stops costing rebuilds, and a deliberate provider change starts fresh. Verified the test binds by mutating the constant. - `ok embeddings set-model` / `clear-model`, mirroring set-url/clear-url. There is no generic `ok config set`, so the headless persona this feature targets could set the endpoint but not the model. * fix(ok): match aria-live to role on the test-connection result; assert capable - `role="alert"` implies assertive, so the explicit `aria-live="polite"` contradicted it and screen readers split on which wins. - Assert `capable` is restored after the provider-change budget reset — the direct guard against the reset being moved into `resetWarm`. * fix(ok): treat a non-JSON 200 as a config error, not a retryable network fault A base URL that lands on a proxy's HTML page or a site root answers 200 with markup; `res.json()` throws, and the generic catch classified that as `network` — four retries, then "couldn't reach the endpoint" for a host that answered instantly. It is now `malformed_response`: fatal, no retries, and the UI copy tells the user to check the base URL. That is the likeliest misconfiguration for the persona this feature exists for. Also from review: - Free the resident vectors when the drift budget is spent; nothing reads them again this process and the disk store still survives for a re-hydrate. - `onTestConnection` gets a `finally`, so a transport that rejects (against its contract) can't leave the button stuck on "Testing". - Bracket MAX_DIMS_DRIFT_RESETS instead of just proving the budget is finite: the test now walks exactly that many size changes, asserts recovery each time, and asserts give-up on the next. Mutating the constant to 1 or 3 both fail it. * fix(ok): drop the try/finally React Compiler cannot lower The compiler rejects a `try` with a finalizer outright, so the previous commit built fine under tsc and the DOM tests but failed the real app build. Same guarantee via catch-and-continue. * wip(ok): project-level embeddings keys — storage + resolution + CLI (backend) Checkpoint: nested project->endpoint->key map in ~/.ok/secrets.yml (bytes stay out of the project tree), single resolveEmbeddingsCredential seam (project key -> env/flat default-host-only -> keyless loopback), advisory lock for cross-process writes, project-scoped CLI set/clear + list. Tests + app UI next. * feat(ok): move embeddings key to the project Search screen + keyless local UI half of project-level keys: - The API key now lives on Settings → This project → Search, always-visible next to its endpoint (not buried in the disclosure) — the Account key screen is removed. Write-only, per-endpoint, optional. Status shows "not required" for a localhost endpoint so keyless Ollama/LM Studio users aren't nagged. - Keyless loopback: a localhost endpoint embeds + tests with no Authorization header at all; non-loopback still needs a key. - semantic-status gains `keyNotRequired`; keySource gains `project`. - Tests rewritten for the project+endpoint model (secrets-store, embedder resolution, factory set/clear, SearchSection key field, test-route integration via the real set-key route). * docs(ok): document project-level embeddings keys + keyless local; spec D16 * fix(ok): trim unused re-exports from the CLI embeddings-key shim (knip) * fix(ok): apply a newly-set embeddings key without a restart + dev/prod parity QA surfaced two gaps: - The key is resolved at warm time and isn't part of the provider fingerprint, so setting/clearing it left the service warmed with the old credential ("added a key but search stays lexical"). set/clear-key now call a new `reloadCredential()` that re-warms on the next search — cheap (cache re-hydrates from disk, no re-embed). - The dev server (hocuspocus-plugin) never injected the embeddings key store, so dev only ever resolved the env key / keyless while production reads ~/.ok/secrets.yml. Inject makeLazyEmbeddingsKeyStore() so dev matches prod (also what let me verify the stored-key path end-to-end in the browser). Verified live: keyless loopback embeds with no Authorization header; after set-key the next embed sends Bearer ; clear reverts to keyless. * fix(ok): strip spec decision markers from source comments (md-audit) * fix(ok): review round 5 — key-read/write observability + Enter-to-save + test gaps - Log a warning when the key-store read fails instead of silently mapping to "no key" (a stored-but-unreadable key otherwise looks absent and the user re-runs set-key chasing a ghost). - CLI set-key/clear-key wrap the store mutation in try/catch with a clean error + exit 1 (the headless path pipes keys from a secrets manager and must report a disk-write failure, not dump a stack). - Log on the secrets-store best-effort unlink failure (a "cleared" key that stayed on disk) and on a non-EEXIST lock failure (serialization silently off). - Enter-to-save on the key field, matching the endpoint/model inputs. - Tests: no_key test-route case (no network call), a reloadCredential-visible assertion in the factory set-key test (guards the no-restart behavior), and key-field Enter-to-save + save-failure DOM cases. --------- GitOrigin-RevId: d9fce206c7919fb33c3b2a6b5545e4a937977c12 --- .../custom-endpoint-embedding-models.md | 13 + docs/content/reference/configuration.mdx | 18 +- .../EmbeddingsKeySection.dom.test.tsx | 184 ----- .../settings/EmbeddingsKeySection.tsx | 174 ---- .../settings/SearchSection.dom.test.tsx | 464 ++++++++++- .../src/components/settings/SearchSection.tsx | 768 ++++++++++++++++-- .../settings/SettingsDialogBody.tsx | 14 +- .../transports/embeddings-key-transport.ts | 37 +- packages/app/src/locales/en/messages.json | 102 ++- packages/app/src/locales/en/messages.po | 169 +++- packages/app/src/locales/pseudo/messages.json | 102 ++- packages/app/src/locales/pseudo/messages.po | 159 +++- packages/app/src/server/hocuspocus-plugin.ts | 5 + .../local-op-embeddings-test.test.ts | 223 +++++ .../attribution-sweep-coverage.test.ts | 4 + .../conflict-gate-coverage.test.ts | 4 +- packages/cli/src/auth/embeddings-key-store.ts | 4 +- packages/cli/src/cli.ts | 2 +- .../cli/src/commands/embeddings/index.test.ts | 32 +- packages/cli/src/commands/embeddings/index.ts | 213 ++++- packages/cli/src/commands/removal-plan.ts | 8 +- packages/core/src/config/schema.ts | 37 +- packages/core/src/index.ts | 5 + packages/core/src/schemas/api/local-op.ts | 55 ++ packages/core/src/schemas/api/tags-search.ts | 8 +- packages/server/src/api-extension.ts | 177 +++- .../src/api-search-semantic-factory.test.ts | 19 +- .../src/embeddings/auto-dimensions.test.ts | 278 +++++++ .../server/src/embeddings/embedder.test.ts | 179 +++- packages/server/src/embeddings/embedder.ts | 337 ++++++-- .../src/embeddings/eval/semantic-eval.ts | 1 + .../embeddings/fake-provider.test-helper.ts | 179 ++++ packages/server/src/embeddings/index.ts | 11 +- .../src/embeddings/secrets-store.test.ts | 269 +++--- .../server/src/embeddings/secrets-store.ts | 399 +++++++-- .../semantic-search-service.test.ts | 24 + .../src/embeddings/semantic-search-service.ts | 190 ++++- .../server/src/embeddings/vector-cache.ts | 100 ++- packages/server/src/index.ts | 10 +- packages/server/src/server-factory.ts | 9 +- 40 files changed, 4052 insertions(+), 934 deletions(-) create mode 100644 .changeset/custom-endpoint-embedding-models.md delete mode 100644 packages/app/src/components/settings/EmbeddingsKeySection.dom.test.tsx delete mode 100644 packages/app/src/components/settings/EmbeddingsKeySection.tsx create mode 100644 packages/app/tests/integration/api-error-envelope/local-op-embeddings-test.test.ts create mode 100644 packages/server/src/embeddings/auto-dimensions.test.ts create mode 100644 packages/server/src/embeddings/fake-provider.test-helper.ts diff --git a/.changeset/custom-endpoint-embedding-models.md b/.changeset/custom-endpoint-embedding-models.md new file mode 100644 index 000000000..bc69ce247 --- /dev/null +++ b/.changeset/custom-endpoint-embedding-models.md @@ -0,0 +1,13 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Make custom embeddings endpoints work from the desktop app: auto-detected vector size, a model field, and a Test connection button. + +Semantic search previously assumed every model returned 1536-dimension vectors. Pointing it at a self-hosted or non-OpenAI OpenAI-compatible endpoint whose model has a different native size made every response fail validation, and the failure was silent — search just quietly fell back to keyword matching. Leaving `search.semantic.dimensions` unset now means "use whatever the endpoint returns": the size is detected from the first response, pinned in the vector cache, and reused across restarts without re-embedding. Setting `dimensions` explicitly keeps the strict check, so a server that ignores the request parameter still fails loudly instead of silently. If a provider starts returning a different size (a model swapped behind an alias), the cached vectors are discarded and rebuilt at the new size rather than scoring two sizes against each other. + +`ok embeddings set-model ` / `clear-model` join `set-url` / `clear-url`, so a headless setup can name the model its endpoint serves without hand-editing `config.yml`. + +**Embeddings API keys are now per project.** The key moved from a single machine-global value (edited in Settings → Account) to a per-project, per-endpoint key edited on the same Settings → This project → Search screen as the endpoint it belongs to — no more hunting across two screens. Keys still live only in the 0600 `~/.ok/secrets.yml` (never in the project tree), now keyed by project + endpoint so a key can never travel to a host it wasn't set for. A **localhost** endpoint (Ollama / LM Studio) needs no key at all. Existing single-key OpenAI setups keep working untouched — the old flat key is read as a fallback for the default OpenAI endpoint. `ok embeddings set-key` / `clear-key` are now project-scoped (add `--cwd`), and `ok embeddings list` shows every stored key (redacted). + +Settings → Search gains a "Custom endpoint" section holding the endpoint URL, a free-text Model field, and a **Test connection** button that runs one throwaway embed and reports either the detected vector size or a specific reason (no key, unreachable, HTTP 401, wrong response shape, and so on). The section expands automatically when either value is already overridden, and changing either one now warns that it re-embeds and re-sends the whole corpus before committing. The Account key control is relabelled "Embeddings API key" to reflect that it is sent to whichever endpoint a project has configured, with a note that local servers needing no key accept any placeholder. diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx index 5828438d5..ee97c8a1a 100644 --- a/docs/content/reference/configuration.mdx +++ b/docs/content/reference/configuration.mdx @@ -50,9 +50,9 @@ A key set in a file more specific than its scope (a user-scope key in `.ok/confi | `telemetry.localSink.logs.maxBytes` | number | `26214400` (~25 MB) | project | Max size of the local diagnostic logs file before it rotates. | | `telemetry.localSink.attributeDenylist` | `string[]` | 8 credential keys | project | Attribute keys whose values are redacted (`[REDACTED]`) before any local span/log is written. Extends the built-in denylist (`authorization`, `password`, `cookie`, etc.). | | `search.semantic.enabled` | boolean | `false` | project-local | Add embeddings-based semantic ranking to the MCP `search` tool and a **By meaning** mode to the cmd-K omnibar. Default off. **When on and a key is set, the search query and matching page content are sent to the configured embeddings provider** (content egress). See [Semantic search](#semantic-search) below. | -| `search.semantic.baseUrl` | string | `"https://api.openai.com/v1"` | project-local | Base URL of the OpenAI-compatible embeddings API. Override for Azure / self-hosted / other providers. The API key is **not** stored here — set it with `ok embeddings set-key`. | +| `search.semantic.baseUrl` | string | `"https://api.openai.com/v1"` | project-local | Base URL of the OpenAI-compatible embeddings API. Override to point at a self-hosted server (Ollama / vLLM / LM Studio) or another provider. The API key is **not** stored here — set it with `ok embeddings set-key`; it is sent to whichever endpoint this names. | | `search.semantic.model` | string | `"text-embedding-3-small"` | project-local | Embeddings model id. Must be served by the provider at `baseUrl`. Changing it re-embeds the corpus. | -| `search.semantic.dimensions` | number | (native) | project-local | Optional output vector size. Omit for the model's native size (1536 for `text-embedding-3-small`). Set a smaller value to shrink the on-disk cache, trading a little quality. | +| `search.semantic.dimensions` | number | (auto) | project-local | Optional output vector size. Omit (recommended) and the size is detected from the endpoint's first response and reused across restarts — that is what lets a non-OpenAI model work without knowing its size up front. Set a smaller value to shrink the on-disk cache, trading a little quality; an endpoint that ignores the request parameter then fails loudly instead of silently. | | `search.semantic.similarityFloor` | number | (unset) | project-local | Optional hard cutoff (0–1): drops semantic matches whose cosine similarity is below it. Retrieval is rank-based, so most setups leave it unset; set it only for a provider/model whose cosine scale you know. | | `linkPreviews.enabled` | boolean | `true` | project-local | Show a rich preview card (site name, page title, description, favicon) when you hover an external link in the editor. Default on (set to `false` to opt out). **When on, hovering an external link sends that link's URL to the destination site** to fetch its preview metadata — outbound egress, one request per previewed link. Previews of links to other documents in the project are read from the local index with no network request and are always on. See [Link previews](#link-previews) below. | @@ -93,23 +93,23 @@ Most users never set these — the schema settings above cover the common cases. | `OTEL_SDK_DISABLED` | OpenTelemetry OTLP push gate. Inverted sense: set to `false` to **enable** push (it is off by default). | | `OK_BUG_REPORT_INTAKE_URL` | Base URL of the bug-report intake the desktop app's **Help → Report a bug…** upload posts to (e.g. `https://openknowledge.ai`). Must be `https:` — plain `http:` is accepted only for loopback hosts (local testing). Unset (the default), Send makes no network request — it opens a prefilled email draft to support@inkeep.com that you send yourself. | | `OK_FEEDBACK_INTAKE_ORIGIN` | Origin the optional [uninstall feedback](/docs/reference/what-open-knowledge-writes#what-leaves-your-machine) submission posts to (default `https://openknowledge.ai`). Must be `https:` — plain `http:` is accepted only for loopback hosts (local testing). An unusable value drops the submission rather than falling back to the default. | -| `OK_EMBEDDINGS_API_KEY` | Fallback embeddings provider API key for [semantic search](#semantic-search), used when none is stored on disk. Prefer `ok embeddings set-key` (writes `~/.ok/secrets.yml`) for normal use; the env var is convenient for CI / scripted runs. | +| `OK_EMBEDDINGS_API_KEY` | Fallback embeddings key for [semantic search](#semantic-search), used when no project key is stored **and only for the default OpenAI endpoint** (a machine-wide env key is never sent to a custom host). Prefer `ok embeddings set-key` for normal use; the env var is convenient for CI / scripted runs against OpenAI. | ## Semantic search The MCP `search` tool can fuse an **embeddings-based semantic signal** into its ranking, so an agent's query surfaces conceptually-related pages even when they share no keywords (a query about "auth retries" can surface a page titled "Session Token Refresh"). It is **off by default** and additive — with it off, search everywhere (the MCP tool and the cmd-K omnibar) is purely lexical and stays on-machine. With it on, the omnibar gains a **By meaning** mode alongside its default lexical mode: typing never embeds; pressing Enter fires one semantic search, sending the query to the embeddings provider. - **Content egress.** When semantic search is enabled **and** a key is set, the search query and the matching page content are sent to the configured embeddings provider (OpenAI by default). Only content that is already in your corpus is embedded — anything excluded by `.okignore` / `.gitignore` is never sent — and embedding is lazy: nothing leaves the machine until a semantic search actually runs. The key lives only in a 0600 `~/.ok/secrets.yml` file (never in `config.yml`, logs, or telemetry). + **Content egress.** When semantic search is enabled **and** a key is set (or the endpoint is a keyless local server), the search query and the matching page content are sent to whichever OpenAI-compatible endpoint the project has configured (OpenAI by default). Only content that is already in your corpus is embedded — anything excluded by `.okignore` / `.gitignore` is never sent — and embedding is lazy: nothing leaves the machine until a semantic search actually runs. Keys live only in a 0600 `~/.ok/secrets.yml` file, keyed by project + endpoint so a key never travels to a host it wasn't set for; never in `config.yml`, the project tree, logs, or telemetry. -To turn it on (per machine): +To turn it on (per project, per machine): -1. Store the provider key in `~/.ok/secrets.yml` (0600, this machine only): `ok embeddings set-key` (reads the key from a hidden prompt or stdin). Check it with `ok embeddings status`; remove it with `ok embeddings clear-key`. -2. Enable it for the project: `ok embeddings enable`, or the **Settings → This project → Search** toggle. Either sets `search.semantic.enabled: true` in project-local config (`/.ok/local/config.yml`), picked up live by a running server; `ok embeddings disable` turns it off. -3. (Optional) point at a non-OpenAI provider (Azure / self-hosted): `ok embeddings set-url ` — or set `search.semantic.baseUrl` in project-local config; `ok embeddings clear-url` resets to the default. Tune `model` / `dimensions` in config. +1. Enable it for the project: the **Settings → This project → Search** toggle, or `ok embeddings enable`. Either sets `search.semantic.enabled: true` in project-local config (`/.ok/local/config.yml`), picked up live by a running server; `ok embeddings disable` turns it off. +2. Set the API key right there on the same screen (or `ok embeddings set-key` in the project). Keys are **per project** — one project's key is never shared with another unless both point at the same endpoint. A **localhost** endpoint (Ollama / LM Studio) needs no key at all — the field shows "not required". `ok embeddings list` shows every stored key (redacted); `ok embeddings clear-key` removes this project's. +3. (Optional) point at your own OpenAI-compatible endpoint (a self-hosted Ollama / vLLM / LM Studio server, or another provider): open **Custom endpoint** in that same section, set the endpoint URL and the model id, and press **Test connection** — it runs one throwaway embed and reports either the detected vector size or the specific reason it failed. The same knobs are available from the CLI (`ok embeddings set-url ` / `clear-url`, `ok embeddings set-model ` / `clear-model`) and in project-local config (`search.semantic.baseUrl` / `model`). The vector size is detected automatically, so `dimensions` normally stays unset. Changing the endpoint or the model discards the cached vectors and re-embeds the corpus. -The first semantic search kicks off a background embed of the corpus (cents for a whole vault with `text-embedding-3-small`); vectors are cached incrementally under `.ok/local/` and only changed docs re-embed. If the key is missing, the provider errors, or you're offline, search silently degrades to lexical — it never blocks or errors. Each MCP `search` response reports embedding coverage so an agent knows when vectors are still filling in. +The first semantic search kicks off a background embed of the corpus (cents for a whole vault with `text-embedding-3-small`); vectors are cached incrementally under `.ok/local/` and only changed docs re-embed. If the key is missing, the provider errors, or you're offline, search degrades to lexical — it never blocks or errors. Because that degradation is quiet, **Test connection** is the way to tell a working custom endpoint apart from one that is failing. Each MCP `search` response reports embedding coverage so an agent knows when vectors are still filling in. ## Link previews diff --git a/packages/app/src/components/settings/EmbeddingsKeySection.dom.test.tsx b/packages/app/src/components/settings/EmbeddingsKeySection.dom.test.tsx deleted file mode 100644 index 5b818dacb..000000000 --- a/packages/app/src/components/settings/EmbeddingsKeySection.dom.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Tier-3 RTL mount tests for Settings → Account → Embeddings provider key. - * - * Status (key presence) is driven through a mocked `/api/semantic-status` fetch; - * the set/clear actions through an injected stub transport. Asserts the - * write-only contract (the key is never rendered), the three presence states - * (no-key / file-key / env), and that Save/Clear call the transport. - */ - -import type { SemanticIndexStatus } from '@inkeep/open-knowledge-core'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import type { EmbeddingsKeyTransport } from '@/lib/transports/embeddings-key-transport'; -import { EmbeddingsKeySection } from './EmbeddingsKeySection'; - -type WindowGlobals = { NodeFilter?: typeof NodeFilter }; -type GlobalWithDomShims = typeof globalThis & - WindowGlobals & { window?: WindowGlobals; ResizeObserver?: unknown }; -const g = globalThis as GlobalWithDomShims; -if (g.NodeFilter === undefined && g.window?.NodeFilter !== undefined) - g.NodeFilter = g.window.NodeFilter; -if (g.ResizeObserver === undefined) { - class NoopResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - } - g.ResizeObserver = NoopResizeObserver; -} - -function statusOf(over: Partial): SemanticIndexStatus { - return { - enabled: false, - keyPresent: false, - keySource: null, - keyHint: null, - ready: false, - capable: false, - embedded: 0, - total: 0, - ...over, - }; -} - -let mockStatus: SemanticIndexStatus; -const originalFetch = global.fetch; - -function makeTransport(parts?: Partial): { - transport: EmbeddingsKeyTransport; - setCalls: string[]; - clearCalls: number; -} { - const setCalls: string[] = []; - let clearCalls = 0; - const transport: EmbeddingsKeyTransport = { - setKey: async (key) => { - setCalls.push(key); - return parts?.setKey ? parts.setKey(key) : { ok: true }; - }, - clearKey: async () => { - clearCalls += 1; - return parts?.clearKey ? parts.clearKey() : { ok: true }; - }, - }; - // clearCalls is captured by closure; expose a getter object. - return { - transport, - setCalls, - get clearCalls() { - return clearCalls; - }, - } as { transport: EmbeddingsKeyTransport; setCalls: string[]; clearCalls: number }; -} - -beforeEach(() => { - mockStatus = statusOf({}); - global.fetch = (async () => ({ - ok: true, - json: async () => mockStatus, - })) as unknown as typeof fetch; -}); -afterEach(() => { - cleanup(); - global.fetch = originalFetch; -}); - -describe('EmbeddingsKeySection', () => { - test('no key: shows an input + Save (disabled until typed), never renders a key', async () => { - const { transport } = makeTransport(); - mockStatus = statusOf({ keyPresent: false }); - render(); - - const input = screen.getByTestId('settings-embeddings-key-input'); - expect(input.getAttribute('type')).toBe('password'); - expect( - screen.getByTestId('settings-embeddings-key-save').getAttribute('disabled'), - ).not.toBeNull(); - expect(screen.queryByTestId('settings-embeddings-key-set')).toBeNull(); - }); - - test('save sends the key to the transport and clears the input', async () => { - const user = userEvent.setup(); - const rec = makeTransport(); - mockStatus = statusOf({ keyPresent: false }); - render(); - - const input = screen.getByTestId('settings-embeddings-key-input'); - await user.type(input, 'sk-secret-123'); - await user.click(screen.getByTestId('settings-embeddings-key-save')); - - expect(rec.setCalls).toEqual(['sk-secret-123']); - // Secret not retained in component state after it lands. - await waitFor(() => expect((input as HTMLInputElement).value).toBe('')); - }); - - test('save failure surfaces the error and keeps the typed key', async () => { - const user = userEvent.setup(); - const rec = makeTransport({ setKey: async () => ({ ok: false, error: 'Loopback required.' }) }); - mockStatus = statusOf({ keyPresent: false }); - render(); - - const input = screen.getByTestId('settings-embeddings-key-input'); - await user.type(input, 'sk-bad'); - await user.click(screen.getByTestId('settings-embeddings-key-save')); - - expect(await screen.findByTestId('settings-embeddings-key-error')).toBeDefined(); - // The typed key is NOT cleared on failure, so the user can retry without retyping. - expect((input as HTMLInputElement).value).toBe('sk-bad'); - }); - - test('key present (file): shows "API key set" + Clear, which calls the transport', async () => { - const user = userEvent.setup(); - const rec = makeTransport(); - mockStatus = statusOf({ keyPresent: true, keySource: 'file' }); - render(); - - expect(await screen.findByTestId('settings-embeddings-key-set')).toBeDefined(); - await user.click(screen.getByTestId('settings-embeddings-key-clear')); - expect(rec.clearCalls).toBe(1); - }); - - test('key present: shows the redacted tail (keyHint), never the full key', async () => { - const { transport } = makeTransport(); - mockStatus = statusOf({ keyPresent: true, keySource: 'file', keyHint: 'a1b2' }); - render(); - - const hint = await screen.findByTestId('settings-embeddings-key-hint'); - expect(hint.textContent).toContain('a1b2'); - // Only the redacted tail is shown — the section never renders a full-length key. - expect(hint.textContent?.length).toBeLessThan(20); - }); - - test('key present without a hint: the set state still renders (no tail line)', async () => { - const { transport } = makeTransport(); - mockStatus = statusOf({ keyPresent: true, keySource: 'file', keyHint: null }); - render(); - - expect(await screen.findByTestId('settings-embeddings-key-set')).toBeDefined(); - expect(screen.queryByTestId('settings-embeddings-key-hint')).toBeNull(); - }); - - test('clear failure surfaces the error', async () => { - const user = userEvent.setup(); - const rec = makeTransport({ - clearKey: async () => ({ ok: false, error: 'Loopback required.' }), - }); - mockStatus = statusOf({ keyPresent: true, keySource: 'file' }); - render(); - - await user.click(await screen.findByTestId('settings-embeddings-key-clear')); - expect(await screen.findByTestId('settings-embeddings-key-error')).toBeDefined(); - }); - - test('env-sourced key: read-only note, no input or clear', async () => { - const { transport } = makeTransport(); - mockStatus = statusOf({ keyPresent: true, keySource: 'env' }); - render(); - - expect(await screen.findByTestId('settings-embeddings-key-env')).toBeDefined(); - expect(screen.queryByTestId('settings-embeddings-key-input')).toBeNull(); - expect(screen.queryByTestId('settings-embeddings-key-clear')).toBeNull(); - }); -}); diff --git a/packages/app/src/components/settings/EmbeddingsKeySection.tsx b/packages/app/src/components/settings/EmbeddingsKeySection.tsx deleted file mode 100644 index 5c382a019..000000000 --- a/packages/app/src/components/settings/EmbeddingsKeySection.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Settings → Account — the embeddings provider API key control. - * - * The key is machine-global (one for all projects, stored in `~/.ok/secrets.yml`), - * so it lives here next to the GitHub credential rather than in the per-project - * Search section. Write-only: the key is never displayed or returned — the UI - * shows only presence (`keyPresent` from `GET /api/semantic-status`) and lets the - * user set / replace / clear it through the loopback transport. - * - * HTTP-only (Settings renders only in the editor window, which has the API - * server) — the transport is caller-injected for tests, defaulting to HTTP. - */ -import { Trans, useLingui } from '@lingui/react/macro'; -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useSemanticSearchStatus } from '@/hooks/use-semantic-search-status'; -import { - type EmbeddingsKeyTransport, - httpEmbeddingsKeyTransport, -} from '@/lib/transports/embeddings-key-transport'; - -export function EmbeddingsKeySection({ transport }: { transport?: EmbeddingsKeyTransport }) { - const { t } = useLingui(); - const resolved = transport ?? httpEmbeddingsKeyTransport(); - const { status, refresh } = useSemanticSearchStatus(); - const [keyInput, setKeyInput] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const keyPresent = status?.keyPresent ?? false; - const keySource = status?.keySource ?? null; - const keyHint = status?.keyHint ?? null; - - async function onSave() { - const key = keyInput.trim(); - if (!key || busy) return; - setBusy(true); - setError(null); - const result = await resolved.setKey(key); - setBusy(false); - if (result.ok) { - setKeyInput(''); // Don't keep the secret in component state after it lands. - refresh(); - } else { - setError(result.error ?? t`Couldn't save the key — please try again.`); - } - } - - async function onClear() { - if (busy) return; - setBusy(true); - setError(null); - const result = await resolved.clearKey(); - setBusy(false); - if (result.ok) refresh(); - else setError(result.error ?? t`Couldn't clear the key — please try again.`); - } - - return ( -
-
-

- Embeddings provider key -

-

- - Your embeddings provider API key, used only to create embeddings of your content for - semantic search. Stored once for this machine in{' '} - - ~/.ok/secrets.yml - {' '} - (readable only by your user account) and shared across all projects. Turn the feature on - per project in This project → Search, where you can also override the endpoint. - -

-
- -
- {keySource === 'env' ? ( -

- - Using the{' '} - - OK_EMBEDDINGS_API_KEY - {' '} - environment variable (managed outside OpenKnowledge). - -

- ) : ( - <> - {keyPresent ? ( -
-
-
- API key set -
- {keyHint ? ( -

- - {keyHint} -

- ) : null} -

- Stored on this machine. -

-
- -
- ) : null} - -
- -
- setKeyInput(e.target.value)} - placeholder={t`Paste your API key`} - autoComplete="off" - spellCheck={false} - disabled={busy} - data-testid="settings-embeddings-key-input" - className="h-8 font-mono text-sm" - /> - -
-
- - )} - - {error ? ( -

- {error} -

- ) : null} -
-
- ); -} diff --git a/packages/app/src/components/settings/SearchSection.dom.test.tsx b/packages/app/src/components/settings/SearchSection.dom.test.tsx index 08838a611..ab8afe91c 100644 --- a/packages/app/src/components/settings/SearchSection.dom.test.tsx +++ b/packages/app/src/components/settings/SearchSection.dom.test.tsx @@ -61,9 +61,17 @@ vi.doMock('@/lib/config-provider', () => ({ const { SearchSection } = await import('./SearchSection'); -function configWithSemantic({ enabled, baseUrl }: { enabled: boolean; baseUrl?: string }): Config { +function configWithSemantic({ + enabled, + baseUrl, + model, +}: { + enabled: boolean; + baseUrl?: string; + model?: string; +}): Config { return { - search: { semantic: { enabled, ...(baseUrl ? { baseUrl } : {}) } }, + search: { semantic: { enabled, ...(baseUrl ? { baseUrl } : {}), ...(model ? { model } : {}) } }, } as unknown as Config; } @@ -158,6 +166,7 @@ describe('SearchSection', () => { mockStatus = { enabled: true, keyPresent: true, + keyNotRequired: false, keySource: 'file', ready: true, capable: true, @@ -180,6 +189,7 @@ describe('SearchSection', () => { mockStatus = { enabled: true, keyPresent: true, + keyNotRequired: false, keySource: 'file', ready: true, capable: true, @@ -200,6 +210,7 @@ describe('SearchSection', () => { mockStatus = { enabled: true, keyPresent: true, + keyNotRequired: false, keySource: 'file', ready: true, capable: true, @@ -213,7 +224,7 @@ describe('SearchSection', () => { expect(coverage.textContent).toContain('first time a search needs them'); }); - test('on + NO key: shows the needs-a-key hint pointing at Account (instant, no warm)', async () => { + test('on + NO key: shows the needs-a-key hint pointing at the on-screen field (instant, no warm)', async () => { const { binding } = makeBinding(); mockProjectLocalBinding = binding; mockProjectLocalConfig = configWithSemantic({ enabled: true }); @@ -222,6 +233,7 @@ describe('SearchSection', () => { mockStatus = { enabled: true, keyPresent: false, + keyNotRequired: false, keySource: null, ready: false, capable: false, @@ -233,7 +245,7 @@ describe('SearchSection', () => { const hint = await screen.findByTestId('settings-search-needs-key'); expect(hint.textContent).toContain('no API key is set'); - expect(hint.textContent).toContain('Account'); + expect(hint.textContent).toContain('below'); expect(screen.queryByTestId('settings-search-coverage')).toBeNull(); expect(screen.queryByTestId('settings-search-pending')).toBeNull(); }); @@ -245,6 +257,7 @@ describe('SearchSection', () => { mockStatus = { enabled: true, keyPresent: true, + keyNotRequired: false, keySource: 'file', ready: true, capable: false, @@ -266,6 +279,7 @@ describe('SearchSection', () => { mockStatus = { enabled: true, keyPresent: true, + keyNotRequired: false, keySource: 'file', ready: false, capable: false, @@ -289,6 +303,7 @@ describe('SearchSection', () => { mockStatus = { enabled: false, keyPresent: false, + keyNotRequired: false, keySource: null, ready: false, capable: false, @@ -342,19 +357,64 @@ describe('SearchSection', () => { expect(await screen.findByTestId('settings-search-confirm')).toBeDefined(); }); - test('shows the default endpoint when no custom provider is configured', () => { + // The endpoint + model now live behind a disclosure. Opening it is part of + // every provider test because a value the user can't reach isn't configurable. + async function openCustomEndpoint(user: ReturnType) { + await user.click(screen.getByTestId('settings-search-custom-endpoint-trigger')); + return screen.getByTestId('settings-search-base-url') as HTMLInputElement; + } + + async function confirmProviderChange(user: ReturnType) { + await user.click(await screen.findByTestId('settings-search-provider-confirm-apply')); + } + + const DEFAULT_MODEL = 'text-embedding-3-small'; + + test('shows the default endpoint and model when nothing is overridden', async () => { + const user = userEvent.setup(); const { binding } = makeBinding(); mockProjectLocalBinding = binding; mockProjectLocalConfig = configWithSemantic({ enabled: false }); render(); + const input = await openCustomEndpoint(user); + expect(input.value).toBe(DEFAULT_EMBEDDINGS_BASE_URL); + expect((screen.getByTestId('settings-search-model') as HTMLInputElement).value).toBe( + DEFAULT_MODEL, + ); + }); + + test('the disclosure starts open when a custom endpoint is already configured', () => { + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ + enabled: false, + baseUrl: 'https://my-vllm.internal/v1', + }); + + render(); + + // Auto-expanded: an override set from the CLI must not be hidden from the + // person who comes to Settings looking for it. expect((screen.getByTestId('settings-search-base-url') as HTMLInputElement).value).toBe( - DEFAULT_EMBEDDINGS_BASE_URL, + 'https://my-vllm.internal/v1', ); }); - test('blurring the endpoint field writes the trimmed custom base URL', async () => { + test('the disclosure starts open when only the model is overridden', () => { + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: false, model: 'nomic-embed-text' }); + + render(); + + expect((screen.getByTestId('settings-search-model') as HTMLInputElement).value).toBe( + 'nomic-embed-text', + ); + }); + + test('blurring the endpoint field writes the trimmed custom base URL after confirmation', async () => { const user = userEvent.setup(); const { binding, calls } = makeBinding(); mockProjectLocalBinding = binding; @@ -362,17 +422,25 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); await user.type(input, ' https://azure.example.com/openai/v1/ '); await user.tab(); + // Nothing is written until the re-index + egress warning is accepted. + expect(calls).toEqual([]); + await confirmProviderChange(user); + expect(calls).toEqual([ - { search: { semantic: { baseUrl: 'https://azure.example.com/openai/v1/' } } }, + { + search: { + semantic: { baseUrl: 'https://azure.example.com/openai/v1/', model: DEFAULT_MODEL }, + }, + }, ]); }); - test('pressing Enter in the endpoint field writes the trimmed custom base URL', async () => { + test('pressing Enter in the endpoint field commits the same way', async () => { const user = userEvent.setup(); const { binding, calls } = makeBinding(); mockProjectLocalBinding = binding; @@ -380,15 +448,80 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); await user.type(input, ' https://azure.example.com/openai/v1/ {Enter}'); + await confirmProviderChange(user); expect(calls).toEqual([ - { search: { semantic: { baseUrl: 'https://azure.example.com/openai/v1/' } } }, + { + search: { + semantic: { baseUrl: 'https://azure.example.com/openai/v1/', model: DEFAULT_MODEL }, + }, + }, ]); }); + test('a custom model is written as free text', async () => { + const user = userEvent.setup(); + const { binding, calls } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: false }); + + render(); + + await openCustomEndpoint(user); + const model = screen.getByTestId('settings-search-model'); + await user.clear(model); + await user.type(model, 'nomic-embed-text{Enter}'); + await confirmProviderChange(user); + + expect(calls).toEqual([ + { + search: { semantic: { baseUrl: DEFAULT_EMBEDDINGS_BASE_URL, model: 'nomic-embed-text' } }, + }, + ]); + }); + + test('clearing the model field resets it to the default', async () => { + const user = userEvent.setup(); + const { binding, calls } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: false, model: 'nomic-embed-text' }); + + render(); + + const model = screen.getByTestId('settings-search-model'); + await user.clear(model); + await user.tab(); + await confirmProviderChange(user); + + expect(calls).toEqual([ + { search: { semantic: { baseUrl: DEFAULT_EMBEDDINGS_BASE_URL, model: DEFAULT_MODEL } } }, + ]); + }); + + test('cancelling the warning restores the previous values and writes nothing', async () => { + const user = userEvent.setup(); + const { binding, calls } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: false }); + + render(); + + const input = await openCustomEndpoint(user); + await user.clear(input); + await user.type(input, 'https://my-vllm.internal/v1{Enter}'); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + + expect(calls).toEqual([]); + await waitFor(() => + expect((screen.getByTestId('settings-search-base-url') as HTMLInputElement).value).toBe( + DEFAULT_EMBEDDINGS_BASE_URL, + ), + ); + }); + test('clearing the endpoint field resets it to the default OpenAI endpoint', async () => { const user = userEvent.setup(); const { binding, calls } = makeBinding(); @@ -403,8 +536,11 @@ describe('SearchSection', () => { const input = screen.getByTestId('settings-search-base-url'); await user.clear(input); await user.tab(); + await confirmProviderChange(user); - expect(calls).toEqual([{ search: { semantic: { baseUrl: DEFAULT_EMBEDDINGS_BASE_URL } } }]); + expect(calls).toEqual([ + { search: { semantic: { baseUrl: DEFAULT_EMBEDDINGS_BASE_URL, model: DEFAULT_MODEL } } }, + ]); }); test('a malformed URL is not flagged mid-typing, but errors on commit and blocks the write', async () => { @@ -415,7 +551,7 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); await user.type(input, 'not-a-url'); @@ -426,6 +562,7 @@ describe('SearchSection', () => { expect(screen.getByTestId('settings-search-base-url-error')).toBeDefined(); expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(screen.queryByTestId('settings-search-provider-confirm')).toBeNull(); expect(calls).toEqual([]); // guaranteed-to-fail endpoint is not persisted }); @@ -437,9 +574,9 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); - // Enter is the distinct commit path (preventDefault + commitBaseUrlInput). + // Enter is the distinct commit path (preventDefault + commitProviderEdits). await user.type(input, 'not-a-url{Enter}'); expect(screen.getByTestId('settings-search-base-url-error')).toBeDefined(); @@ -455,7 +592,7 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); await user.type(input, 'http://azure.example.com/v1'); await user.tab(); @@ -472,13 +609,16 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); await user.type(input, 'http://localhost:11434/v1'); await user.tab(); + await confirmProviderChange(user); expect(screen.queryByTestId('settings-search-base-url-error')).toBeNull(); - expect(calls).toEqual([{ search: { semantic: { baseUrl: 'http://localhost:11434/v1' } } }]); + expect(calls).toEqual([ + { search: { semantic: { baseUrl: 'http://localhost:11434/v1', model: DEFAULT_MODEL } } }, + ]); }); test('after a failed commit, fixing the value clears the error live and writes on re-commit', async () => { @@ -489,7 +629,7 @@ describe('SearchSection', () => { render(); - const input = screen.getByTestId('settings-search-base-url'); + const input = await openCustomEndpoint(user); await user.clear(input); await user.type(input, 'nope'); await user.tab(); // commit → error shows, field is now "touched" @@ -502,8 +642,290 @@ describe('SearchSection', () => { expect(input.getAttribute('aria-invalid')).toBe('false'); await user.tab(); + await confirmProviderChange(user); expect(calls).toEqual([ - { search: { semantic: { baseUrl: 'https://azure.example.com/openai/v1' } } }, + { + search: { + semantic: { baseUrl: 'https://azure.example.com/openai/v1', model: DEFAULT_MODEL }, + }, + }, ]); }); + + test('a successful connection test reports the detected vector size', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ + enabled: true, + baseUrl: 'https://my-vllm.internal/v1', + model: 'nomic-embed-text', + }); + + render( + ({ ok: true }), + clearKey: async () => ({ ok: true }), + testConnection: async () => ({ + ok: true, + endpoint: 'https://my-vllm.internal/v1', + model: 'nomic-embed-text', + dimensions: 1024, + }), + }} + />, + ); + + await user.click(screen.getByTestId('settings-search-test-connection')); + const result = await screen.findByTestId('settings-search-test-ok'); + // The detected size is the readout that replaces a hand-entered field. + expect(result.textContent).toContain('1024'); + }); + + test('a failing connection test names the specific reason', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ + enabled: true, + baseUrl: 'https://my-vllm.internal/v1', + }); + + render( + ({ ok: true }), + clearKey: async () => ({ ok: true }), + testConnection: async () => ({ + ok: false, + endpoint: 'https://my-vllm.internal/v1', + model: 'text-embedding-3-small', + reason: 'http_error', + status: 401, + }), + }} + />, + ); + + await user.click(screen.getByTestId('settings-search-test-connection')); + const result = await screen.findByTestId('settings-search-test-error'); + expect(result.textContent).toContain('401'); + }); + + test('a verdict for a stale endpoint says so instead of reporting it', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ + enabled: true, + baseUrl: 'https://my-vllm.internal/v1', + }); + + render( + ({ ok: true }), + clearKey: async () => ({ ok: true }), + // The server still has the endpoint the user just replaced. + testConnection: async () => ({ + ok: true, + endpoint: DEFAULT_EMBEDDINGS_BASE_URL, + model: 'text-embedding-3-small', + dimensions: 1536, + }), + }} + />, + ); + + await user.click(screen.getByTestId('settings-search-test-connection')); + expect(await screen.findByTestId('settings-search-test-stale')).toBeDefined(); + expect(screen.queryByTestId('settings-search-test-ok')).toBeNull(); + }); + + test('the API key field is always visible (not buried in the disclosure)', () => { + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: true }); + mockStatus = { + enabled: true, + keyPresent: false, + keyNotRequired: false, + keySource: null, + keyHint: null, + ready: false, + capable: false, + embedded: 0, + total: 3, + } as unknown as SemanticIndexStatus; + + render(); + // Present without opening the "Custom endpoint" disclosure. + expect(screen.getByTestId('settings-search-key-input')).toBeDefined(); + }); + + test('saving a key calls the transport and refreshes', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: true }); + mockStatus = { + enabled: true, + keyPresent: false, + keyNotRequired: false, + keySource: null, + keyHint: null, + ready: false, + capable: false, + embedded: 0, + total: 3, + } as unknown as SemanticIndexStatus; + + const setKey = vi.fn(async () => ({ ok: true }) as const); + render( + ({ ok: true }), + testConnection: async () => null, + }} + />, + ); + + const input = await screen.findByTestId('settings-search-key-input'); + await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false)); + await user.type(input, 'sk-my-key'); + await user.click(screen.getByTestId('settings-search-key-save')); + expect(setKey).toHaveBeenCalledWith('sk-my-key'); + }); + + test('pressing Enter in the key field saves (parity with endpoint/model)', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: true }); + mockStatus = { + enabled: true, + keyPresent: false, + keyNotRequired: false, + keySource: null, + keyHint: null, + ready: false, + capable: false, + embedded: 0, + total: 3, + } as unknown as SemanticIndexStatus; + + const setKey = vi.fn(async () => ({ ok: true }) as const); + render( + ({ ok: true }), + testConnection: async () => null, + }} + />, + ); + + const input = await screen.findByTestId('settings-search-key-input'); + await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false)); + await user.type(input, 'sk-enter-key{Enter}'); + expect(setKey).toHaveBeenCalledWith('sk-enter-key'); + }); + + test('a failed key save surfaces the error, does not clear the input', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: true }); + mockStatus = { + enabled: true, + keyPresent: false, + keyNotRequired: false, + keySource: null, + keyHint: null, + ready: false, + capable: false, + embedded: 0, + total: 3, + } as unknown as SemanticIndexStatus; + + render( + ({ ok: false, error: 'disk full' }), + clearKey: async () => ({ ok: true }), + testConnection: async () => null, + }} + />, + ); + + const input = await screen.findByTestId('settings-search-key-input'); + await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false)); + await user.type(input, 'sk-doomed'); + await user.click(screen.getByTestId('settings-search-key-save')); + const err = await screen.findByTestId('settings-search-key-error'); + expect(err.textContent).toContain('disk full'); + expect((input as HTMLInputElement).value).toBe('sk-doomed'); // not wiped on failure + }); + + test('a present key shows a redacted hint + Clear, never the key', async () => { + const user = userEvent.setup(); + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ enabled: true }); + mockStatus = { + enabled: true, + keyPresent: true, + keyNotRequired: false, + keySource: 'project', + keyHint: '9xяz'.slice(-4), + ready: true, + capable: true, + embedded: 3, + total: 3, + } as unknown as SemanticIndexStatus; + + const clearKey = vi.fn(async () => ({ ok: true }) as const); + render( + ({ ok: true }), + clearKey, + testConnection: async () => null, + }} + />, + ); + + expect(await screen.findByTestId('settings-search-key-hint')).toBeDefined(); + await user.click(screen.getByTestId('settings-search-key-clear')); + expect(clearKey).toHaveBeenCalled(); + }); + + test('a localhost endpoint shows the key as not required, and no needs-key nag', async () => { + const { binding } = makeBinding(); + mockProjectLocalBinding = binding; + mockProjectLocalConfig = configWithSemantic({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + }); + mockStatus = { + enabled: true, + keyPresent: false, + keyNotRequired: true, + keySource: null, + keyHint: null, + ready: false, + capable: false, + embedded: 0, + total: 3, + } as unknown as SemanticIndexStatus; + + render(); + await waitFor(() => + expect(screen.getByTestId('settings-search-key').textContent).toContain('Not required'), + ); + // No amber "add a key" nag when the endpoint needs none. + expect(screen.queryByTestId('settings-search-needs-key')).toBeNull(); + }); }); diff --git a/packages/app/src/components/settings/SearchSection.tsx b/packages/app/src/components/settings/SearchSection.tsx index d57654164..b43067b1b 100644 --- a/packages/app/src/components/settings/SearchSection.tsx +++ b/packages/app/src/components/settings/SearchSection.tsx @@ -13,20 +13,32 @@ * (the safe direction). * * The status panel below the toggle derives from the server's - * `GET /api/semantic-status` probe (`keyPresent` / `ready` / `capable`). The key - * is never set here — it's a machine-global credential managed in Settings → - * Account (stored in `~/.ok/secrets.yml`); this section only points there when a - * key is missing. + * `GET /api/semantic-status` probe (`keyPresent` / `ready` / `capable`). + * + * The API key lives on THIS screen now (per-project, next to its endpoint — no + * more machine-global Account key): a write-only field that stores the key, + * bound to the project's configured endpoint, in the 0600 `~/.ok/secrets.yml` + * (never in the project tree). It's optional — a localhost endpoint needs none. + * + * The endpoint and model live behind a "Custom endpoint" disclosure: they are + * power-user knobs, but they auto-expand when either is already overridden so a + * value set from the CLI is never hidden from the person who set it. Both are + * destructive to change — the cached vectors are keyed by provider + model, so + * a change re-embeds and re-sends the whole corpus — hence the confirmation. */ import { checkEmbeddingsBaseUrl, DEFAULT_EMBEDDINGS_BASE_URL, + DEFAULT_EMBEDDINGS_MODEL, humanFormat, + type LocalOpEmbeddingsTestResponse, } from '@inkeep/open-knowledge-core'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { useEffect, useRef, useState } from 'react'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { ChevronRight } from 'lucide-react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DialogBody, DialogClose, @@ -41,6 +53,10 @@ import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { useSemanticSearchStatus } from '@/hooks/use-semantic-search-status'; import { useConfigContext } from '@/lib/config-provider'; +import { + type EmbeddingsKeyTransport, + httpEmbeddingsKeyTransport, +} from '@/lib/transports/embeddings-key-transport'; // Refetch `/api/semantic-status` at these delays (ms) after a toggle so the // coverage panel repaints once the persistence debounce + config file-watcher @@ -48,10 +64,11 @@ import { useConfigContext } from '@/lib/config-provider'; // CC1 channel doesn't fire for config-doc edits, so this is the catch-up path. const SETTLE_REFRESH_DELAYS_MS = [2500, 5000] as const; -export function SearchSection() { +export function SearchSection({ transport }: { transport?: EmbeddingsKeyTransport }) { const { t } = useLingui(); const { projectLocalConfig, projectLocalSynced, projectLocalBinding } = useConfigContext(); const { status, refresh } = useSemanticSearchStatus(); + const resolvedTransport = transport ?? httpEmbeddingsKeyTransport(); const [confirmOpen, setConfirmOpen] = useState(false); const [baseUrlError, setBaseUrlError] = useState(null); // Don't nag mid-typing: surface the endpoint error only after the field has @@ -68,20 +85,48 @@ export function SearchSection() { const configuredBaseUrl = projectLocalConfig?.search?.semantic?.baseUrl ?? DEFAULT_EMBEDDINGS_BASE_URL; + const configuredModel = projectLocalConfig?.search?.semantic?.model ?? DEFAULT_EMBEDDINGS_MODEL; - // When the persisted endpoint changes externally (e.g. `ok embeddings set-url` - // in a terminal while Settings is open), the `key`-based Input remounts with a - // fresh value — reset the touched gate + error so it starts clean too. This is - // the React "adjust state during render on a changed value" pattern (no effect). - const [prevConfiguredBaseUrl, setPrevConfiguredBaseUrl] = useState(configuredBaseUrl); - if (configuredBaseUrl !== prevConfiguredBaseUrl) { - setPrevConfiguredBaseUrl(configuredBaseUrl); + const [baseUrlDraft, setBaseUrlDraft] = useState(configuredBaseUrl); + const [modelDraft, setModelDraft] = useState(configuredModel); + // The committed-but-unconfirmed provider change awaiting the re-index warning. + const [pendingProvider, setPendingProvider] = useState<{ + baseUrl: string; + model: string; + } | null>(null); + // null = follow the auto-expand rule; a boolean = the user has decided. + const [disclosureOverride, setDisclosureOverride] = useState(null); + const [testing, setTesting] = useState(false); + // `null` inside the result means the request itself never got a verdict. + const [testResult, setTestResult] = useState<{ + response: LocalOpEmbeddingsTestResponse | null; + } | null>(null); + + // When the persisted values change externally (e.g. `ok embeddings set-url` in + // a terminal while Settings is open), re-seed the drafts and clear the + // validation gate + any stale test verdict. This is the React "adjust state + // during render on a changed value" pattern (no effect). + const [prevConfigured, setPrevConfigured] = useState({ + baseUrl: configuredBaseUrl, + model: configuredModel, + }); + if (configuredBaseUrl !== prevConfigured.baseUrl || configuredModel !== prevConfigured.model) { + setPrevConfigured({ baseUrl: configuredBaseUrl, model: configuredModel }); + setBaseUrlDraft(configuredBaseUrl); + setModelDraft(configuredModel); setBaseUrlTouched(false); setBaseUrlError(null); + setTestResult(null); } const enabled = projectLocalConfig?.search?.semantic?.enabled ?? false; const bindingReady = projectLocalSynced && projectLocalBinding !== null; + const hasProviderOverride = + configuredBaseUrl !== DEFAULT_EMBEDDINGS_BASE_URL || + configuredModel !== DEFAULT_EMBEDDINGS_MODEL; + // Auto-expand when either knob is already overridden: an endpoint set from the + // CLI must not be invisible to the person looking for it in Settings. + const disclosureOpen = disclosureOverride ?? hasProviderOverride; function scheduleSettleRefresh() { for (const timer of settleTimersRef.current) clearTimeout(timer); @@ -112,24 +157,69 @@ export function SearchSection() { return next.trim() || DEFAULT_EMBEDDINGS_BASE_URL; } - function writeBaseUrl(next: string): boolean { + function normalizeModel(next: string): string { + return next.trim() || DEFAULT_EMBEDDINGS_MODEL; + } + + function writeProvider(next: { baseUrl: string; model: string }): boolean { if (projectLocalBinding === null) { toast.error(t`Search settings not yet loaded — try again in a moment`); return false; } - const normalized = normalizeBaseUrl(next); - if (normalized === configuredBaseUrl) return true; - const result = projectLocalBinding.patch({ search: { semantic: { baseUrl: normalized } } }); + const result = projectLocalBinding.patch({ + search: { semantic: { baseUrl: next.baseUrl, model: next.model } }, + }); if (!result.ok) { const detail = humanFormat(result.error); - toast.error(t`Failed to update the embeddings endpoint — ${detail}`); + toast.error(t`Failed to update the embeddings provider — ${detail}`); return false; } + // The previous provider's verdict says nothing about the new one. + setTestResult(null); refresh(); scheduleSettleRefresh(); return true; } + /** + * Commit a field edit. A change to either knob invalidates every cached + * vector, so it goes through the re-index + egress warning first; a no-op + * edit (retyping the same value, or blurring an untouched field) doesn't. + */ + function requestProviderChange(next: { baseUrl: string; model: string }): void { + if (next.baseUrl === configuredBaseUrl && next.model === configuredModel) return; + setPendingProvider(next); + } + + function onConfirmProviderChange(): void { + if (pendingProvider && writeProvider(pendingProvider)) setPendingProvider(null); + } + + function onCancelProviderChange(): void { + setPendingProvider(null); + setBaseUrlDraft(configuredBaseUrl); + setModelDraft(configuredModel); + setBaseUrlError(null); + } + + async function onTestConnection(): Promise { + if (testing) return; + setTesting(true); + setTestResult(null); + // The transport contracts to resolve `null` rather than throw, but a + // rejection here would otherwise leave the button reading "Testing" + // forever with no way back. Catch-and-continue rather than `finally` — + // React Compiler cannot lower a `try` with a finalizer. + let response: LocalOpEmbeddingsTestResponse | null = null; + try { + response = await resolvedTransport.testConnection(); + } catch { + response = null; + } + setTesting(false); + setTestResult({ response }); + } + function onToggleRequest(next: boolean) { if (next) { // Off → on: gate behind the egress confirmation. On → off is the safe @@ -161,16 +251,22 @@ export function SearchSection() { // Live-validate only after the field has been committed once (touched), so the // error doesn't flash while the user is still typing a URL. function onBaseUrlChange(value: string): void { + setBaseUrlDraft(value); if (baseUrlTouched) setBaseUrlError(baseUrlProblemMessage(value)); } - function commitBaseUrlInput(input: HTMLInputElement): void { + // Both fields commit together: one confirmation covers the single re-index + // they'd otherwise trigger twice, and a bad URL blocks the model edit too + // rather than persisting half of the change. + function commitProviderEdits(): void { setBaseUrlTouched(true); - const message = baseUrlProblemMessage(input.value); + const message = baseUrlProblemMessage(baseUrlDraft); setBaseUrlError(message); - if (message !== null) return; // invalid — leave the error shown, don't persist - const normalized = normalizeBaseUrl(input.value); - if (writeBaseUrl(input.value)) input.value = normalized; + if (message !== null) return; + requestProviderChange({ + baseUrl: normalizeBaseUrl(baseUrlDraft), + model: normalizeModel(modelDraft), + }); } // Status derives from the server's resolved view, not the client preference: @@ -181,11 +277,16 @@ export function SearchSection() { // worked, so `keyPresent && ready && !capable` is "provider rejected the key". const serverEnabled = status?.enabled ?? false; const keyPresent = status?.keyPresent ?? false; + const keyNotRequired = status?.keyNotRequired ?? false; + const keyHint = status?.keyHint ?? null; + const keySource = status?.keySource ?? null; const ready = status?.ready ?? false; const capable = status?.capable ?? false; const embedded = status?.embedded ?? 0; const total = status?.total ?? 0; + const endpointHost = hostOf(configuredBaseUrl) ?? configuredBaseUrl; + return (
-
-
- + + + + + Custom endpoint + + +

- Use the default OpenAI endpoint or override it with an OpenAI-compatible base URL for - Azure or a self-hosted provider. + Point semantic search at any OpenAI-compatible embeddings endpoint — a self-hosted + server or another provider. The API key above is for whichever endpoint you set here.

-
- onBaseUrlChange(e.currentTarget.value)} - onBlur={(e) => commitBaseUrlInput(e.currentTarget)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - commitBaseUrlInput(e.currentTarget); - } - }} - placeholder={DEFAULT_EMBEDDINGS_BASE_URL} - disabled={!bindingReady} - spellCheck={false} - autoComplete="off" - aria-invalid={baseUrlError !== null} - aria-describedby="settings-search-base-url-message" - data-testid="settings-search-base-url" - className="h-8 font-mono text-sm" - /> - {/* One persistent live region (not a conditionally-mounted node) so a - screen reader reliably announces the error when it appears — swapping - in a fresh aria-live element WITH content is often missed. */} -

- {baseUrlError ?? ( - Clear the field to reset back to the default OpenAI endpoint. - )} -

-
+ +
+ + onBaseUrlChange(e.currentTarget.value)} + onBlur={commitProviderEdits} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitProviderEdits(); + } + }} + placeholder={DEFAULT_EMBEDDINGS_BASE_URL} + disabled={!bindingReady} + spellCheck={false} + autoComplete="off" + aria-invalid={baseUrlError !== null} + aria-describedby="settings-search-base-url-message" + data-testid="settings-search-base-url" + className="h-8 font-mono text-sm" + /> + {/* One persistent live region (not a conditionally-mounted node) so a + screen reader reliably announces the error when it appears — swapping + in a fresh aria-live element WITH content is often missed. */} +

+ {baseUrlError ?? ( + Clear the field to reset back to the default OpenAI endpoint. + )} +

+
+ +
+ + setModelDraft(e.currentTarget.value)} + onBlur={commitProviderEdits} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitProviderEdits(); + } + }} + placeholder={DEFAULT_EMBEDDINGS_MODEL} + disabled={!bindingReady} + spellCheck={false} + autoComplete="off" + aria-describedby="settings-search-model-help" + data-testid="settings-search-model" + className="h-8 font-mono text-sm" + /> +

+ + The model id your endpoint serves. Its vector size is detected automatically — clear + the field to go back to {DEFAULT_EMBEDDINGS_MODEL}. + +

+
+ +
+ + +
+ + + +
+ ); +} + +interface EmbeddingsKeyFieldProps { + transport: EmbeddingsKeyTransport; + refresh: () => void; + endpointHost: string; + keyPresent: boolean; + keyHint: string | null; + keySource: 'project' | 'file' | 'env' | null; + keyNotRequired: boolean; + loaded: boolean; +} + +/** + * The embeddings API key — now on THIS screen, next to the endpoint it belongs + * to (no more hunting in Account). Write-only: the key is never displayed or + * returned, only presence + a redacted hint; the input sets / replaces / clears + * it through the loopback transport, which binds it to the project's currently + * configured endpoint. Optional — a localhost endpoint needs none. + */ +function EmbeddingsKeyField({ + transport, + refresh, + endpointHost, + keyPresent, + keyHint, + keySource, + keyNotRequired, + loaded, +}: EmbeddingsKeyFieldProps) { + const { t } = useLingui(); + const [keyInput, setKeyInput] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function onSave() { + const key = keyInput.trim(); + if (!key || busy) return; + setBusy(true); + setError(null); + const result = await transport.setKey(key); + setBusy(false); + if (result.ok) { + setKeyInput(''); // Don't keep the secret in component state after it lands. + refresh(); + } else { + setError(result.error ?? t`Couldn't save the key — please try again.`); + } + } + + async function onClear() { + if (busy) return; + setBusy(true); + setError(null); + const result = await transport.clearKey(); + setBusy(false); + if (result.ok) refresh(); + else setError(result.error ?? t`Couldn't clear the key — please try again.`); + } + + return ( +
+
+

+ Embeddings API key +

+

+ {keyNotRequired ? ( + + Not required for a localhost endpoint like{' '} + {endpointHost} — most local servers ignore it. + Add one only if yours needs it. + + ) : ( + + Sent to {endpointHost} to embed your content. + Stored on this machine only (in{' '} + + ~/.ok/secrets.yml + + ), never in the project. + + )} +

+
+ + {keySource === 'env' ? ( +

+ + Using the{' '} + + OK_EMBEDDINGS_API_KEY + {' '} + environment variable (managed outside OpenKnowledge). + +

+ ) : ( + <> + {keyPresent ? ( +
+
+
+ API key set +
+ {keyHint ? ( +

+ + {keyHint} +

+ ) : null} +
+ +
+ ) : null} + +
+ +
+ setKeyInput(e.target.value)} + onKeyDown={(e) => { + // Enter-to-save, matching the endpoint/model inputs and the + // strong submit expectation on a password field. + if (e.key === 'Enter') { + e.preventDefault(); + void onSave(); + } + }} + placeholder={t`Paste your API key`} + autoComplete="off" + spellCheck={false} + disabled={busy || !loaded} + data-testid="settings-search-key-input" + className="h-8 font-mono text-sm" + /> + +
+
+ + )} + + {error ? ( +

+ {error} +

+ ) : null}
); } @@ -307,6 +674,7 @@ interface SemanticStatusPanelProps { loaded: boolean; serverEnabled: boolean; keyPresent: boolean; + keyNotRequired: boolean; ready: boolean; capable: boolean; embedded: number; @@ -324,6 +692,7 @@ function SemanticStatusPanel({ loaded, serverEnabled, keyPresent, + keyNotRequired, ready, capable, embedded, @@ -342,9 +711,9 @@ function SemanticStatusPanel({ ); } - if (!keyPresent) { - // No key resolvable — instant (a free file/env read, no warm needed). Point - // at the canonical home for the machine-global key: Settings → Account. + if (!keyPresent && !keyNotRequired) { + // No key resolvable — instant (a free file read, no warm needed). Point at + // the key field right below, on this same screen. return (
Semantic search is on, but no API key is set — search falls back to keyword matching. Add - one in Settings → Account (it's stored once for your - whole machine). + one below.
); @@ -371,9 +739,8 @@ function SemanticStatusPanel({ > A key is set, but the embeddings provider rejected it or was unreachable — search fell - back to keyword matching. Check the key in{' '} - Settings → Account and the embeddings endpoint - setting. + back to keyword matching. Use Test connection under + Custom endpoint to see what went wrong. ); @@ -407,6 +774,247 @@ function SemanticStatusPanel({ ); } +interface TestConnectionResultProps { + result: { response: LocalOpEmbeddingsTestResponse | null } | null; + testing: boolean; + configuredBaseUrl: string; + configuredModel: string; +} + +/** + * The verdict from one live probe. This is the whole answer to "is my endpoint + * actually working" — every embeddings failure otherwise degrades quietly to + * keyword search, so a wrong URL / key / model looks exactly like a working + * setup that hasn't indexed yet. + * + * The detected vector size doubles as the readout for a value the user can no + * longer enter by hand. + */ +function TestConnectionResult({ + result, + testing, + configuredBaseUrl, + configuredModel, +}: TestConnectionResultProps) { + if (testing || result === null) return null; + + const { response } = result; + if (response === null) { + return ( + + Couldn't run the test. Check that OpenKnowledge is still running. + + ); + } + + // The probe reads the SAVED config, which trails an edit by the time it takes + // the change to reach disk. Say so rather than report a verdict for the + // endpoint the user just replaced. + if (response.endpoint !== configuredBaseUrl || response.model !== configuredModel) { + return ( + + Your change is still being saved — run the test again in a moment. + + ); + } + + if (response.ok) { + return ( + + Connected — this endpoint returns {response.dimensions}-dimension vectors. + + ); + } + + return ( + + + + ); +} + +/** Reason-specific copy — each one names the next thing to try. */ +function TestConnectionFailure({ + response, +}: { + response: Extract; +}) { + switch (response.reason) { + case 'no_key': + return No API key is set for this endpoint. Add one above.; + case 'invalid_endpoint': + return ( + + That endpoint can't be used. Enter an https:// URL — http:// is only allowed for + localhost. + + ); + case 'rate_limit': + return The provider is rate-limiting this key. Try again in a moment.; + case 'timeout': + return The endpoint didn't respond in time.; + case 'network': + return Couldn't reach the endpoint. Check the URL and that the server is up.; + case 'dims_mismatch': + return ( + + This endpoint ignored the vector size you configured. Remove{' '} + + search.semantic.dimensions + {' '} + to use the model's own size. + + ); + case 'malformed_response': + return ( + + The endpoint answered, but not with an OpenAI-compatible embeddings response. Check the + base URL. + + ); + default: + // http_error — the status is the actionable part (401 key, 404 model). + return response.status !== undefined ? ( + + The provider rejected the request (HTTP {response.status}). Check the API key and the + model id. + + ) : ( + The provider rejected the request. Check the API key and the model id. + ); + } +} + +function TestConnectionMessage({ + tone, + testId, + children, +}: { + tone: 'ok' | 'warn' | 'error'; + testId: string; + children: ReactNode; +}) { + // A failed probe must not look like the transient "still saving" notice — + // colour is the first thing read here, so a real failure gets the + // destructive palette rather than sharing amber with a warning. + const toneClass = { + ok: 'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-200', + warn: 'border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200', + error: 'border-destructive/50 bg-destructive/10 text-destructive', + }[tone]; + return ( +

+ {children} +

+ ); +} + +interface ProviderChangeConfirmDialogProps { + pending: { baseUrl: string; model: string } | null; + pageCount: number; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * Guards a change to the endpoint or model. Both are part of the vector cache's + * identity, so changing either throws away every cached vector and re-sends the + * full text of the corpus to the new destination — the same egress the enable + * toggle warns about, aimed somewhere new. + */ +function ProviderChangeConfirmDialog({ + pending, + pageCount, + onCancel, + onConfirm, +}: ProviderChangeConfirmDialogProps) { + const host = pending ? (hostOf(pending.baseUrl) ?? pending.baseUrl) : ''; + return ( + { + if (!open) onCancel(); + }} + > + + + + Change the embeddings provider? + + + + Cached embeddings belong to the endpoint and model that produced them, so this + rebuilds the index from scratch. + + + + +
+
    +
  • + + The full text of your 1 page will be sent to{' '} + {host} as it is re-embedded. + + } + other={ + + The full text of all {pageCount} pages will be sent to{' '} + {host} as they are re-embedded. + + } + /> +
  • +
  • + + Model: {pending?.model} + +
  • +
  • + + Re-embedding happens as searches run, so coverage restarts at zero and refills. + +
  • +
+
+
+ + + + + + +
+
+ ); +} + +/** Host of a base URL, or null when it isn't parseable (shown verbatim then). */ +function hostOf(baseUrl: string): string | null { + try { + return new URL(baseUrl).host; + } catch { + return null; + } +} + interface EnableSemanticSearchConfirmDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -465,11 +1073,7 @@ function EnableSemanticSearchConfirmDialog({
  • This setting is per-machine and isn't shared with collaborators. It needs an API - key set with{' '} - - ok embeddings set-key - - . + key (set below) unless your endpoint is a local server that doesn't require one.
  • diff --git a/packages/app/src/components/settings/SettingsDialogBody.tsx b/packages/app/src/components/settings/SettingsDialogBody.tsx index 332232af2..97ac4765a 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.tsx @@ -28,7 +28,6 @@ import { AiToolsSection } from './AiToolsSection'; import { AttachmentsSection } from './AttachmentsSection'; import { ConfigureAgentsSection } from './ConfigureAgentsSection'; import { ContentRulesSection } from './ContentRulesSection'; -import { EmbeddingsKeySection } from './EmbeddingsKeySection'; import { SectionSkeleton } from './field-controls'; import { HotkeysSection } from './HotkeysSection'; import { IntegrationsSection } from './IntegrationsSection'; @@ -98,15 +97,10 @@ export function SettingsDialogBody({ return ; } if (activeId === 'account') { - // Two machine-global credentials live here: the GitHub account and the - // embeddings provider key (the latter shared across all projects; semantic - // search is enabled per-project in This project → Search). - return ( -
    - - -
    - ); + // The GitHub account credential. The embeddings API key moved to This + // project → Search, next to the endpoint it belongs to (keys are per-project + // now, not machine-global). + return ; } if (activeId === 'sync') { // When there's no git remote, SyncSection renders a setup CTA (the diff --git a/packages/app/src/lib/transports/embeddings-key-transport.ts b/packages/app/src/lib/transports/embeddings-key-transport.ts index 9063ea664..de9273bef 100644 --- a/packages/app/src/lib/transports/embeddings-key-transport.ts +++ b/packages/app/src/lib/transports/embeddings-key-transport.ts @@ -1,24 +1,35 @@ /** - * Transport for setting / clearing the machine-global embeddings API key from - * the Settings → Account UI. + * Transport for the embeddings local-op routes the Settings UI drives: setting + * / clearing the API key for the project's configured endpoint, and probing it. * * HTTP-only: Settings renders only in the editor window (which has the loopback * API server), so — unlike the GitHub-auth transport — there's no IPC variant. * The key travels renderer → loopback `POST /api/local-op/embeddings/set-key` - * body → the server's 0600 `~/.ok/secrets.yml`. The key is never returned; - * presence is read separately via `GET /api/semantic-status` (`keyPresent`). + * body → the server's 0600 `~/.ok/secrets.yml`. The server binds it to THIS + * project + its configured endpoint (derived server-side, never from the body). + * The key is never returned; presence is read via `GET /api/semantic-status`. * - * Caller-injected (defaults to the HTTP impl) so the section's DOM tests drive + * Caller-injected (defaults to the HTTP impl) so the sections' DOM tests drive * it with a stub — the same pattern as `AuthQueryTransport`. */ -import { ProblemDetailsSchema } from '@inkeep/open-knowledge-core'; +import { + type LocalOpEmbeddingsTestResponse, + LocalOpEmbeddingsTestResponseSchema, + ProblemDetailsSchema, +} from '@inkeep/open-knowledge-core'; export interface EmbeddingsKeyTransport { /** Store the key in the secrets file. */ setKey(key: string): Promise<{ ok: true } | { ok: false; error?: string }>; /** Remove the stored key. */ clearKey(): Promise<{ ok: true } | { ok: false; error?: string }>; + /** + * Probe the SAVED endpoint with one throwaway embed. Resolves `null` when the + * request itself couldn't be made — distinct from a probe that reached a + * verdict, which comes back as the route's discriminated result. + */ + testConnection(): Promise; } // Surfaces the typed RFC 9457 title (loopback-required, invalid-origin, etc.). @@ -53,5 +64,19 @@ export function httpEmbeddingsKeyTransport(): EmbeddingsKeyTransport { return { setKey: (key) => post('/api/local-op/embeddings/set-key', { key }), clearKey: () => post('/api/local-op/embeddings/clear-key', {}), + async testConnection() { + try { + const res = await fetch('/api/local-op/embeddings/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + if (!res.ok) return null; + const parsed = LocalOpEmbeddingsTestResponseSchema.safeParse(await res.json()); + return parsed.success ? parsed.data : null; + } catch { + return null; + } + }, }; } diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 01dd1cf60..a7a3f25b7 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -166,6 +166,7 @@ " results." ], "2ojpo0": ["Dock sessions on the right"], + "2pR7ad": ["Embeddings API key"], "2q_Q7x": ["Visibility"], "2rLM8r": ["Couldn't start the agent thread — please try again."], "2ryd91": ["Failed to rename property"], @@ -201,7 +202,6 @@ "<0>Mirror — pick a source. Set <1>src + <2>anchor via the property panel to point at a <3> elsewhere." ], "3SZrdj": ["Check for updates…"], - "3TCafD": ["Stored on this machine."], "3TSz9S": ["Minimize"], "3VbwPp": ["Open ", ["keyName"], " in browser"], "3VsNTI": ["Loading document"], @@ -305,6 +305,9 @@ "5PrrYw": ["Start ", ["agentName"]], "5QGTaA": ["Could not copy relative path"], "5SwVv1": ["Not installed"], + "5Szq6N": [ + "This endpoint ignored the vector size you configured. Remove <0>search.semantic.dimensions to use the model's own size." + ], "5Tk8Fb": ["Loading files"], "5Umg9i": ["Disable external link previews"], "5WDhAk": ["Link to a page or external URL."], @@ -428,6 +431,7 @@ ["sanitized"], " already has files here. Pick a different name." ], + "7cAW1G": ["No API key is set for this endpoint. Add one above."], "7d1a0d": ["Public"], "7hctf2": ["Describe the project you want to create"], "7jIIFX": ["You're all set up!"], @@ -511,6 +515,7 @@ ], "9Vh9Jf": ["Reopen this window to sign in with your browser"], "9XIEYq": ["Attach the file in an email to <0/>"], + "9aVlF2": ["Change provider"], "9bCiQ8": ["No pages are missing incoming graph links."], "9bG48P": ["Sending"], "9clcD5": ["Open documents tagged #", ["chip"]], @@ -587,6 +592,9 @@ "AqnqMJ": ["API key set"], "AsqRE_": ["Default location for new attachments"], "AtBdnD": ["Couldn't delete skill: ", ["error"]], + "AwLCZA": [ + "The endpoint answered, but not with an OpenAI-compatible embeddings response. Check the base URL." + ], "B11p-O": ["Edit markdown link"], "B2DrPb": ["Auto-save"], "B2Srgd": [["packName"], " skill installed."], @@ -630,6 +638,11 @@ "Bx4EXG": ["Actions for ", ["label"]], "By-7Pn": ["Toggle a bullet list."], "BymzO4": ["Document outline"], + "C-mmmK": [ + "The model id your endpoint serves. Its vector size is detected automatically — clear the field to go back to ", + ["DEFAULT_EMBEDDINGS_MODEL"], + "." + ], "C-xgad": ["or use a starter pack"], "C0hoX6": ["Always included"], "C52RBg": [ @@ -673,6 +686,7 @@ "CvsD6Y": ["via ", ["viaTags"]], "CwOXMT": ["Value for ", ["keyName"]], "D-Oh1s": ["How to set up ", ["0"], " (opens in browser)"], + "D-_B7l": ["Connected — this endpoint returns ", ["0"], "-dimension vectors."], "D12vtS": ["(detached)"], "D2CwBE": [ "Create a new OpenKnowledge project from the <0>", @@ -697,6 +711,9 @@ "DFfRbX": ["Sync status: ", ["syncStateLabel"], " — git identity unset"], "DId6MT": ["Project-level pages with no outgoing graph edges."], "DJd000": ["Activate the previous editor tab."], + "DKYW1G": [ + "This setting is per-machine and isn't shared with collaborators. It needs an API key (set below) unless your endpoint is a local server that doesn't require one." + ], "DMeaxi": ["Downloading ", ["0"], " ", ["1"], "…"], "DPfwMq": ["Done"], "DTI-fp": [ @@ -758,6 +775,9 @@ "EEYbdt": ["Publish"], "EHu0x2": ["Syncing"], "EJuO7B": ["Connect a GitHub account to browse and sync your repositories."], + "EKwNEe": [ + "Cached embeddings belong to the endpoint and model that produced them, so this rebuilds the index from scratch." + ], "ELYtNJ": [".ok folders"], "EL_YRd": ["Mirror of <0>api-spec"], "ENpRJV": ["External link previews"], @@ -825,6 +845,7 @@ "FCAcjR": [ "Run a real terminal docked inside OpenKnowledge, starting in this project's folder." ], + "FCKppt": ["Endpoint"], "FCxvG5": ["First item"], "FDBwmS": ["Open folder…"], "FEK4oC": [ @@ -952,6 +973,7 @@ ". Please commit your changes or enable auto-sync." ], "Hp1l6f": ["Current"], + "Hp4rRh": ["Custom endpoint"], "HpK_8d": ["Reload"], "HptUxX": ["Number"], "HqOPpj": [ @@ -1088,7 +1110,6 @@ "L-rMC9": ["Reset to default"], "L1BQTH": [["0"], " isn't installed yet — opening its download page."], "L1ZE3M": ["Document statistics"], - "L1rRuU": ["Embeddings API endpoint"], "L3CEC1": ["this item"], "L4Cdd9": ["or create a new file <0/>"], "L6Hhh6": ["(root)"], @@ -1305,6 +1326,9 @@ "OiHJdA": [ "Move through slash, wiki-link, tag, and path suggestions; accept or dismiss the active suggestion." ], + "Oj-JV8": [ + "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Use <0>Test connection under Custom endpoint to see what went wrong." + ], "Okd2Xv": ["Toggle underline formatting."], "OlAl5i": ["Expand all"], "Oo_WWc": ["No pages found"], @@ -1349,11 +1373,15 @@ "PdJZ5p": ["No skills yet."], "Pgu10b": ["Saved as <0>", ["slug"], ".md"], "PgzIpb": ["Move to the previous visual-editor find result."], + "PhLu69": [ + "That endpoint can't be used. Enter an https:// URL — http:// is only allowed for localhost." + ], "PiH3UR": ["Copied!"], "PijLsG": ["File name (created in .ok/schemas/)"], "PkeYXL": ["Expanded graph mode"], "Pkw7J9": ["This folder is empty."], "PlGC6r": ["the git exclude file isn't writable"], + "PpJy-V": ["The endpoint didn't respond in time."], "PqYWiC": [ "Couldn't restart the server automatically. Try running `ok stop all` in a terminal, then reopen this project — or restart your computer if it persists." ], @@ -1437,6 +1465,11 @@ "RfaEio": ["Toggle source comment"], "RfilGt": ["No rules match your filters."], "RgbKEe": ["You don't have access to this repository."], + "RhX_1U": [ + "Sent to <0>", + ["endpointHost"], + " to embed your content. Stored on this machine only (in <1>~/.ok/secrets.yml), never in the project." + ], "Rj01Fz": ["Links"], "RlCInP": ["Slash commands"], "RlLl3G": ["Actions for ", ["0"]], @@ -1843,6 +1876,7 @@ "." ], "_FLBbi": [["sizeText"], " · secrets redacted · crash dump not redacted"], + "_GRWOJ": ["Change the embeddings provider?"], "_IGfXZ": ["OpenKnowledge quit unexpectedly last time."], "_IX_7x": ["Other"], "_JOGC0": ["Send feedback…"], @@ -1901,6 +1935,7 @@ "aHPdYV": ["Go to line ", ["displayLine"]], "aM23Wr": ["page-name"], "aOp3hw": ["Couldn't move to Trash"], + "aQD_86": ["Failed to update the embeddings provider — ", ["detail"]], "aRI3Om": ["Close Window"], "aWlJai": ["Starting sign-in flow"], "a_u4ck": ["Loading tags"], @@ -1967,6 +2002,7 @@ "c8iK2Y": ["No templates found."], "c8jBgX": ["Checking for updates on GitHub"], "cCd8Bs": ["Cut"], + "cD3aew": ["Re-embedding happens as searches run, so coverage restarts at zero and refills."], "cEoyWz": ["Add item to ", ["keyName"]], "cF9DyA": ["Reconnecting after server restart"], "cFCKYZ": ["Deny"], @@ -2042,9 +2078,6 @@ "dIYSqk": ["remote"], "dJw2_7": ["Saved with the project for your team."], "dMsx3p": ["Home page and navigation hub."], - "dN20LW": [ - "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one in <0>Settings → Account (it's stored once for your whole machine)." - ], "dNM58w": ["Claude Code (claude) isn't installed or on your PATH."], "dOHMA0": ["Failed to move"], "dOxPd4": ["Footnote"], @@ -2060,6 +2093,26 @@ "dXCi3J": ["Something broke"], "dXWMFn": ["Removes this skill from your editors."], "dYCvMf": ["Couldn't resume this conversation: ", ["0"]], + "darW93": [ + [ + "pageCount", + "plural", + { + "one": [ + "The full text of your 1 page will be sent to <0>", + ["host"], + " as it is re-embedded." + ], + "other": [ + "The full text of all ", + ["pageCount"], + " pages will be sent to <1>", + ["host"], + " as they are re-embedded." + ] + } + ] + ], "dbapvC": ["Follow us on social media"], "dbhUX1": ["Hide this file"], "dbtjJh": ["(opens in new tab)"], @@ -2075,6 +2128,7 @@ "e0NrBM": ["Project"], "e1Rn_k": ["Open containing folder"], "e1hptq": ["Embed a video with native player controls."], + "e2zZ3E": ["Test connection"], "e4FxjG": ["See all ", ["0"], " starter packs"], "e4GHWP": ["Pull"], "e4PbgC": ["Duplicate the file-tree item copied from the Files sidebar."], @@ -2089,7 +2143,6 @@ "eGUbmF": ["Copy selected file or folder"], "eJitUk": ["Upload from computer"], "eKThTQ": ["Failed to reorder"], - "eLl1VX": ["Failed to update the embeddings endpoint — ", ["detail"]], "eNQGRb": ["Inline LaTeX math rendered with KaTeX."], "ePK91l": ["Edit"], "eQkgKV": ["Installed"], @@ -2360,6 +2413,11 @@ "Ready-made folders and templates to get you started quickly. Select a pack to preview what gets created, then add it to your project." ], "k7Hoig": ["No content changes at this version."], + "k8xtrB": [ + "Not required for a localhost endpoint like <0>", + ["endpointHost"], + " — most local servers ignore it. Add one only if yours needs it." + ], "kCNU4q": [ "Indexing your pages — ", ["semanticIndexedCount"], @@ -2378,6 +2436,9 @@ " will appear once the branch syncs." ], "kNqMMd": ["Folder"], + "kRTXGw": [ + "Point semantic search at any OpenAI-compatible embeddings endpoint — a self-hosted server or another provider. The API key above is for whichever endpoint you set here." + ], "kUn-CN": ["Sync off"], "kV2YZv": [ "Don't see <0>Skills? Enable <1>Settings <2/> Capabilities <3/> Code execution and file creation first." @@ -2577,7 +2638,6 @@ "Stored at <0>.ok/templates/ in this project. Available in every folder (folder-scoped templates can override by filename)." ], "ng2Ibo": ["Branch name"], - "nh3wwx": ["Embeddings provider key"], "njBeMm": ["Both"], "njU86j": ["Property \"", ["trimmed"], "\" already exists"], "nmwqWS": ["Not answered"], @@ -2604,9 +2664,6 @@ "o2yoFu": ["Reveal in Finder, No workspace"], "o49mDZ": ["Loading timeline history"], "o4dcAf": ["Reveal in File Explorer, ", ["hint"]], - "o5gfs1": [ - "This setting is per-machine and isn't shared with collaborators. It needs an API key set with <0>ok embeddings set-key." - ], "o9CrsQ": ["research-note"], "oAFVKX": ["Failed to update the project sync default — ", ["detail"]], "oAGN8p": [ @@ -2623,9 +2680,6 @@ "Toggle the bottom terminal panel. With text selected, stage it in the terminal's AI input instead." ], "oCZkaG": ["Copy path"], - "oEgEn_": [ - "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Check the key in <0>Settings → Account and the embeddings endpoint setting." - ], "oJl7eO": ["Unknown component: ", ["componentName"], " — source editable below"], "oKBnk5": ["More requests were blocked."], "oQA-cA": ["Full path"], @@ -2684,12 +2738,10 @@ "phM8Kh": ["Found the repo but could not open the project. Please try again."], "pha01Y": ["Project-level pages with no incoming graph edges."], "pkAft1": ["Search failed."], - "pmOGtg": [ - "Use the default OpenAI endpoint or override it with an OpenAI-compatible base URL for Azure or a self-hosted provider." - ], "pmUArF": ["Workspace"], "ppDgFi": ["Restart with this app's version"], "psfGPX": ["MCP connections"], + "psl2Sj": ["Your change is still being saved — run the test again in a moment."], "pvnfJD": ["Dark"], "pzutoc": ["Italic"], "q2kXMG": ["Inline Math"], @@ -2709,6 +2761,7 @@ "qEIFZu": ["Key ending in ", ["keyHint"]], "qEue6Q": [" or "], "qFWMl-": ["View as text"], + "qIJIrD": ["Testing"], "qNOgUu": ["Edits at the source land here — no copy-paste drift."], "qRZh7J": ["Let Tab leave the source editor instead of indenting."], "qSKu2v": ["Source navigation and selection"], @@ -2821,6 +2874,7 @@ ], "saIASg": ["Second item"], "saYiIO": ["Path cannot contain null bytes"], + "scu3wk": ["Model"], "seeS6l": ["Setup OpenKnowledge in this folder?"], "sgmRY8": ["Skipped downloading ", ["0"], "."], "shNd9t": ["Slash command menu"], @@ -2839,6 +2893,7 @@ "stlgky": [ "On — your search queries and the text of matching pages are sent to your embeddings provider (OpenAI by default) to compute embeddings." ], + "sweBEk": ["Model: <0>", ["0"], ""], "sxTdZI": ["Loading paths…"], "sxjyRq": ["Worktrees"], "sxkWRg": ["Advanced"], @@ -2856,6 +2911,7 @@ "t9Qn8y": ["No agents enabled"], "tAPZEx": ["Below"], "tHKQ6j": ["Renaming"], + "tN5WTS": ["The provider rejected the request. Check the API key and the model id."], "tP5DDk": ["Apply link"], "tUvUfp": ["Use a folder you already have."], "t_YqKh": ["Remove"], @@ -2901,6 +2957,7 @@ "uMHsvM": [ "A <0>Modified badge marks schemas your config maps — on or off. Reset removes the mapping from config.yml." ], + "uNGmQn": ["Couldn't run the test. Check that OpenKnowledge is still running."], "uO9WLl": ["Push"], "uQNW00": ["Detached HEAD — checkout a branch to resume"], "uQewpR": [ @@ -2959,6 +3016,11 @@ "vYPvPB": ["Switch project…"], "vZVeFG": ["Upload failed — please try again"], "v_2_3v": ["Deleted ", ["file"]], + "v_cQn_": [ + "The provider rejected the request (HTTP ", + ["0"], + "). Check the API key and the model id." + ], "vbK_ZG": ["Files sidebar"], "ve8vEM": ["Code expired — please try again"], "vfRlCI": ["Could not copy full path"], @@ -2997,9 +3059,6 @@ "w_Sphq": ["Attachments"], "waFx9W": ["Managed"], "wbv9xO": ["Create new project"], - "wccmX0": [ - "Your embeddings provider API key, used only to create embeddings of your content for semantic search. Stored once for this machine in <0>~/.ok/secrets.yml (readable only by your user account) and shared across all projects. Turn the feature on per project in This project → Search, where you can also override the endpoint." - ], "wdxz7K": ["Source"], "weP6me": ["Couldn't load docs — type @ again to retry"], "wlwXUk": [ @@ -3024,10 +3083,14 @@ ")" ], "x3kWeu": ["Problem indicators in the file explorer"], + "x4pGVR": [ + "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one below." + ], "x57cJf": ["Open link"], "xCJdfg": ["Clear"], "xCdTnb": ["Close new tab"], "xDBki3": ["Heading suggestions"], + "xDEcka": ["The provider is rate-limiting this key. Try again in a moment."], "xFQRu4": ["I already have it locally"], "xG4tb6": ["Lost connection to \"", ["docName"], "\"."], "xHGM4U": ["Hide HTML preview"], @@ -3071,6 +3134,7 @@ "yEJ-sl": [ "The preview blocks resources that don't meet its security rules (for example plain http:// URLs, or code that uses eval). The content above may render incompletely." ], + "yFr-5x": ["Couldn't reach the endpoint. Check the URL and that the server is up."], "yFyZMJ": ["No pages matched \"", ["semanticQueryText"], "\" by meaning."], "yH7T4q": [ [ diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 9a43f05e9..273602bea 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -520,6 +520,10 @@ msgstr "{packName} skill installed." msgid "{packName} was already set up. Nothing to do." msgstr "{packName} was already set up. Nothing to do." +#: src/components/settings/SearchSection.tsx +msgid "{pageCount, plural, one {The full text of your 1 page will be sent to <0>{host} as it is re-embedded.} other {The full text of all {pageCount} pages will be sent to <1>{host} as they are re-embedded.}}" +msgstr "{pageCount, plural, one {The full text of your 1 page will be sent to <0>{host} as it is re-embedded.} other {The full text of all {pageCount} pages will be sent to <1>{host} as they are re-embedded.}}" + #: src/components/acp/ThreadView.tsx msgid "{percent}% used ({left}% left)" msgstr "{percent}% used ({left}% left)" @@ -701,8 +705,8 @@ msgid "A folder named <0>{sanitized} already has files here. Pick a differen msgstr "A folder named <0>{sanitized} already has files here. Pick a different name." #: src/components/settings/SearchSection.tsx -msgid "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Check the key in <0>Settings → Account and the embeddings endpoint setting." -msgstr "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Check the key in <0>Settings → Account and the embeddings endpoint setting." +msgid "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Use <0>Test connection under Custom endpoint to see what went wrong." +msgstr "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Use <0>Test connection under Custom endpoint to see what went wrong." #: src/components/SeedDialog.tsx msgid "A knowledge base that follows Google's Open Knowledge Format, a shared way of organizing what you know. Pick this if you want your notes to work well with other tools that understand the format." @@ -871,7 +875,7 @@ msgstr "Actual Size" msgid "Add" msgstr "Add" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Add a key" msgstr "Add a key" @@ -1126,7 +1130,7 @@ msgstr "any doc named {0}" msgid "Anyone can see" msgstr "Anyone can see" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "API key set" msgstr "API key set" @@ -1521,6 +1525,10 @@ msgstr "By meaning" msgid "By tag" msgstr "By tag" +#: src/components/settings/SearchSection.tsx +msgid "Cached embeddings belong to the endpoint and model that produced them, so this rebuilds the index from scratch." +msgstr "Cached embeddings belong to the endpoint and model that produced them, so this rebuilds the index from scratch." + #: src/components/CreateProjectDialog.tsx msgid "Can't nest projects. An OpenKnowledge project already exists at <0>{rootPath}. Choose a location outside it, or open that project instead." msgstr "Can't nest projects. An OpenKnowledge project already exists at <0>{rootPath}. Choose a location outside it, or open that project instead." @@ -1574,6 +1582,14 @@ msgstr "Cancelled" msgid "Cannot upload: no document is open" msgstr "Cannot upload: no document is open" +#: src/components/settings/SearchSection.tsx +msgid "Change provider" +msgstr "Change provider" + +#: src/components/settings/SearchSection.tsx +msgid "Change the embeddings provider?" +msgstr "Change the embeddings provider?" + #: src/components/command-palette-commands.ts msgid "Check for updates" msgstr "Check for updates" @@ -1682,7 +1698,7 @@ msgstr "Claude Code is installed, but OpenKnowledge tools aren't connected to it msgid "Claude Desktop" msgstr "Claude Desktop" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Clear" msgstr "Clear" @@ -2068,6 +2084,11 @@ msgstr "Connect tools" msgid "Connect your AI tools to OpenKnowledge" msgstr "Connect your AI tools to OpenKnowledge" +#. placeholder {0}: response.dimensions +#: src/components/settings/SearchSection.tsx +msgid "Connected — this endpoint returns {0}-dimension vectors." +msgstr "Connected — this endpoint returns {0}-dimension vectors." + #: src/components/AuthModal.tsx #: src/components/settings/AccountSection.tsx msgid "Connected as @{login}" @@ -2403,7 +2424,7 @@ msgstr "Could not write project files. Try a different location." msgid "Couldn't apply the change: {0}" msgstr "Couldn't apply the change: {0}" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Couldn't clear the key — please try again." msgstr "Couldn't clear the key — please try again." @@ -2549,6 +2570,10 @@ msgstr "Couldn't reach the agent registry." msgid "Couldn't reach the embeddings provider — press ↵ to retry" msgstr "Couldn't reach the embeddings provider — press ↵ to retry" +#: src/components/settings/SearchSection.tsx +msgid "Couldn't reach the endpoint. Check the URL and that the server is up." +msgstr "Couldn't reach the endpoint. Check the URL and that the server is up." + #: src/components/EditorActivityPool.tsx msgid "Couldn't reconnect after server restart" msgstr "Couldn't reconnect after server restart" @@ -2598,11 +2623,15 @@ msgstr "Couldn't restart the server. Try `ok start` in this folder." msgid "Couldn't resume this conversation: {0}" msgstr "Couldn't resume this conversation: {0}" +#: src/components/settings/SearchSection.tsx +msgid "Couldn't run the test. Check that OpenKnowledge is still running." +msgstr "Couldn't run the test. Check that OpenKnowledge is still running." + #: src/components/TemplateForm.tsx msgid "Couldn't save template: {error}" msgstr "Couldn't save template: {error}" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Couldn't save the key — please try again." msgstr "Couldn't save the key — please try again." @@ -2832,6 +2861,10 @@ msgstr "Current commit" msgid "Custom display text" msgstr "Custom display text" +#: src/components/settings/SearchSection.tsx +msgid "Custom endpoint" +msgstr "Custom endpoint" + #: src/editor/slash-command/embed-starter-items.tsx msgid "Custom HTML with a live preview pane (sandboxed iframe)." msgstr "Custom HTML with a live preview pane (sandboxed iframe)." @@ -3452,17 +3485,13 @@ msgid "Embed an image with optional alt text." msgstr "Embed an image with optional alt text." #: src/components/settings/SearchSection.tsx -msgid "Embeddings API endpoint" -msgstr "Embeddings API endpoint" +msgid "Embeddings API key" +msgstr "Embeddings API key" #: src/components/settings/SearchSection.tsx msgid "Embeddings are computed only when a search runs and are cached locally under <0>.ok/local." msgstr "Embeddings are computed only when a search runs and are cached locally under <0>.ok/local." -#: src/components/settings/EmbeddingsKeySection.tsx -msgid "Embeddings provider key" -msgstr "Embeddings provider key" - #: src/components/ArrayOfObjectsWidget.tsx #: src/components/ObjectWidget.tsx msgid "empty" @@ -3545,6 +3574,10 @@ msgstr "Enable Themes" msgid "Ended" msgstr "Ended" +#: src/components/settings/SearchSection.tsx +msgid "Endpoint" +msgstr "Endpoint" + #: src/components/empty-state/use-create-suggestions.ts msgid "Eng specs" msgstr "Eng specs" @@ -3974,8 +4007,8 @@ msgid "Failed to update the auto-approve setting — {detail}" msgstr "Failed to update the auto-approve setting — {detail}" #: src/components/settings/SearchSection.tsx -msgid "Failed to update the embeddings endpoint — {detail}" -msgstr "Failed to update the embeddings endpoint — {detail}" +msgid "Failed to update the embeddings provider — {detail}" +msgstr "Failed to update the embeddings provider — {detail}" #: src/components/settings/SyncSection.tsx msgid "Failed to update the project sync default — {detail}" @@ -5004,7 +5037,7 @@ msgstr "Keep this project in sync with your git remote. Follow fetches updates w msgid "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about." msgstr "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about." -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Key ending in {keyHint}" msgstr "Key ending in {keyHint}" @@ -5433,6 +5466,15 @@ msgstr "Missing page: {linkTitle}. Click to create." msgid "Mode" msgstr "Mode" +#: src/components/settings/SearchSection.tsx +msgid "Model" +msgstr "Model" + +#. placeholder {0}: pending?.model +#: src/components/settings/SearchSection.tsx +msgid "Model: <0>{0}" +msgstr "Model: <0>{0}" + #: src/components/FolderOverview.tsx #: src/components/settings/LintingSection.tsx #: src/components/settings/markdownlint-rule-browser.tsx @@ -5791,6 +5833,10 @@ msgstr "No agents match your search." msgid "No AI agents installed" msgstr "No AI agents installed" +#: src/components/settings/SearchSection.tsx +msgid "No API key is set for this endpoint. Add one above." +msgstr "No API key is set for this endpoint. Add one above." + #: src/components/ActivityPanelDiffView.tsx msgid "No changes" msgstr "No changes" @@ -6105,6 +6151,10 @@ msgstr "Not installed" msgid "Not now" msgstr "Not now" +#: src/components/settings/SearchSection.tsx +msgid "Not required for a localhost endpoint like <0>{endpointHost} — most local servers ignore it. Add one only if yours needs it." +msgstr "Not required for a localhost endpoint like <0>{endpointHost} — most local servers ignore it. Add one only if yours needs it." + #. placeholder {0}: summary.pattern #: src/components/settings/LintingSection.tsx msgid "nothing ({0} cannot match a doc)" @@ -6733,7 +6783,7 @@ msgstr "Paste URL, owner/repo, or search your repos" msgid "Paste was incomplete — {chunksCompleted} of {totalChunks} chunks landed.{restoreSuffix}" msgstr "Paste was incomplete — {chunksCompleted} of {totalChunks} chunks landed.{restoreSuffix}" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Paste your API key" msgstr "Paste your API key" @@ -6863,6 +6913,10 @@ msgstr "Please enter a valid email." msgid "Plugins" msgstr "Plugins" +#: src/components/settings/SearchSection.tsx +msgid "Point semantic search at any OpenAI-compatible embeddings endpoint — a self-hosted server or another provider. The API key above is for whichever endpoint you set here." +msgstr "Point semantic search at any OpenAI-compatible embeddings endpoint — a self-hosted server or another provider. The API key above is for whichever endpoint you set here." + #: src/components/settings/SettingsDialogBody.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "Preferences" @@ -7099,6 +7153,10 @@ msgstr "Re-authenticate with GitHub" msgid "Re-authenticate with GitHub Enterprise Server" msgstr "Re-authenticate with GitHub Enterprise Server" +#: src/components/settings/SearchSection.tsx +msgid "Re-embedding happens as searches run, so coverage restarts at zero and refills." +msgstr "Re-embedding happens as searches run, so coverage restarts at zero and refills." + #: src/components/empty-state/use-create-suggestions.ts msgid "Read through this codebase and draft a technical spec for the most complex module: an overview, the architecture, key files, and open questions, all linked from a specs index page." msgstr "Read through this codebase and draft a technical spec for the most complex module: an overview, the architecture, key files, and open questions, all linked from a specs index page." @@ -7408,7 +7466,7 @@ msgstr "Replace current match" msgid "Replace current match from field" msgstr "Replace current match from field" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Replace key" msgstr "Replace key" @@ -7712,7 +7770,7 @@ msgid "Sandboxed HTML embed — write HTML, see the rendered preview live." msgstr "Sandboxed HTML embed — write HTML, see the rendered preview live." #: src/components/AuthModal.tsx -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx #: src/editor/components/CodePreviewEditModal.tsx #: src/editor/extensions/InternalLinkPropPanel.tsx #: src/editor/extensions/WikiLinkPropPanel.tsx @@ -7749,7 +7807,7 @@ msgstr "Saved with the project for your team." msgid "Saves the file to <0>~/Downloads/openknowledge.skill and opens the Claude Desktop App. Requires Node.js or Bun on your PATH." msgstr "Saves the file to <0>~/Downloads/openknowledge.skill and opens the Claude Desktop App. Requires Node.js or Bun on your PATH." -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Saving" msgstr "Saving" @@ -7980,8 +8038,8 @@ msgid "Semantic search adds meaning-based ranking to the search tool so conceptu msgstr "Semantic search adds meaning-based ranking to the search tool so conceptually-related pages surface even without shared keywords." #: src/components/settings/SearchSection.tsx -msgid "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one in <0>Settings → Account (it's stored once for your whole machine)." -msgstr "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one in <0>Settings → Account (it's stored once for your whole machine)." +msgid "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one below." +msgstr "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one below." #: src/components/acp/ThreadView.tsx #: src/components/FeedbackForm.tsx @@ -8017,6 +8075,10 @@ msgstr "Sending report" msgid "Sent privately to the OpenKnowledge team, along with your note and app version. Never posted publicly." msgstr "Sent privately to the OpenKnowledge team, along with your note and app version. Never posted publicly." +#: src/components/settings/SearchSection.tsx +msgid "Sent to <0>{endpointHost} to embed your content. Stored on this machine only (in <1>~/.ok/secrets.yml), never in the project." +msgstr "Sent to <0>{endpointHost} to embed your content. Stored on this machine only (in <1>~/.ok/secrets.yml), never in the project." + #: src/editor/slash-command/items.tsx msgid "Separator" msgstr "Separator" @@ -8622,10 +8684,6 @@ msgstr "Stopping" msgid "Stored at <0>.ok/templates/ in this project. Available in every folder (folder-scoped templates can override by filename)." msgstr "Stored at <0>.ok/templates/ in this project. Available in every folder (folder-scoped templates can override by filename)." -#: src/components/settings/EmbeddingsKeySection.tsx -msgid "Stored on this machine." -msgstr "Stored on this machine." - #: src/lib/keyboard-shortcuts.ts msgid "Strikethrough" msgstr "Strikethrough" @@ -8972,6 +9030,14 @@ msgstr "Terminal settings not loaded yet — try again in a moment." msgid "Terminal settings not yet loaded — try again in a moment" msgstr "Terminal settings not yet loaded — try again in a moment" +#: src/components/settings/SearchSection.tsx +msgid "Test connection" +msgstr "Test connection" + +#: src/components/settings/SearchSection.tsx +msgid "Testing" +msgstr "Testing" + #: src/components/PropertyWidgets.tsx #: src/components/settings/CustomThemeEditor.tsx msgid "Text" @@ -8994,6 +9060,10 @@ msgstr "Thanks for the report!" msgid "That branch is already open in another worktree." msgstr "That branch is already open in another worktree." +#: src/components/settings/SearchSection.tsx +msgid "That endpoint can't be used. Enter an https:// URL — http:// is only allowed for localhost." +msgstr "That endpoint can't be used. Enter an https:// URL — http:// is only allowed for localhost." + #: src/components/ShareReceiveDialog.tsx msgid "That folder isn't an OpenKnowledge project yet. Initialize it and open?" msgstr "That folder isn't an OpenKnowledge project yet. Initialize it and open?" @@ -9040,6 +9110,14 @@ msgstr "The desktop app isn't responding. Refresh and try again." msgid "the doc {0}" msgstr "the doc {0}" +#: src/components/settings/SearchSection.tsx +msgid "The endpoint answered, but not with an OpenAI-compatible embeddings response. Check the base URL." +msgstr "The endpoint answered, but not with an OpenAI-compatible embeddings response. Check the base URL." + +#: src/components/settings/SearchSection.tsx +msgid "The endpoint didn't respond in time." +msgstr "The endpoint didn't respond in time." + #: src/components/FileTree.tsx msgid "The file is in your Trash; the file-watcher will reconcile." msgstr "The file is in your Trash; the file-watcher will reconcile." @@ -9070,6 +9148,10 @@ msgstr "the git exclude file isn't writable" msgid "The menu label agents pick this template by (required)." msgstr "The menu label agents pick this template by (required)." +#: src/components/settings/SearchSection.tsx +msgid "The model id your endpoint serves. Its vector size is detected automatically — clear the field to go back to {DEFAULT_EMBEDDINGS_MODEL}." +msgstr "The model id your endpoint serves. Its vector size is detected automatically — clear the field to go back to {DEFAULT_EMBEDDINGS_MODEL}." + #: src/editor/components/PreviewBlockedNotice.tsx msgid "The preview blocks resources that don't meet its security rules (for example plain http:// URLs, or code that uses eval). The content above may render incompletely." msgstr "The preview blocks resources that don't meet its security rules (for example plain http:// URLs, or code that uses eval). The content above may render incompletely." @@ -9078,6 +9160,19 @@ msgstr "The preview blocks resources that don't meet its security rules (for exa msgid "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." msgstr "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." +#: src/components/settings/SearchSection.tsx +msgid "The provider is rate-limiting this key. Try again in a moment." +msgstr "The provider is rate-limiting this key. Try again in a moment." + +#. placeholder {0}: response.status +#: src/components/settings/SearchSection.tsx +msgid "The provider rejected the request (HTTP {0}). Check the API key and the model id." +msgstr "The provider rejected the request (HTTP {0}). Check the API key and the model id." + +#: src/components/settings/SearchSection.tsx +msgid "The provider rejected the request. Check the API key and the model id." +msgstr "The provider rejected the request. Check the API key and the model id." + #: src/components/ReportBugDialogBody.tsx msgid "The report service couldn't be reached." msgstr "The report service couldn't be reached." @@ -9226,6 +9321,10 @@ msgstr "This doc isn't on GitHub yet. The link won't work until it's pushed." msgid "This document is already active in the editor. Use Open to collapse the graph." msgstr "This document is already active in the editor. Use Open to collapse the graph." +#: src/components/settings/SearchSection.tsx +msgid "This endpoint ignored the vector size you configured. Remove <0>search.semantic.dimensions to use the model's own size." +msgstr "This endpoint ignored the vector size you configured. Remove <0>search.semantic.dimensions to use the model's own size." + #: src/components/settings/frontmatter-schema-field-editor.tsx msgid "This field carries additional schema keywords the editor does not show — they are preserved on save." msgstr "This field carries additional schema keywords the editor does not show — they are preserved on save." @@ -9374,8 +9473,8 @@ msgid "This setting is per-machine and isn't shared with collaborators." msgstr "This setting is per-machine and isn't shared with collaborators." #: src/components/settings/SearchSection.tsx -msgid "This setting is per-machine and isn't shared with collaborators. It needs an API key set with <0>ok embeddings set-key." -msgstr "This setting is per-machine and isn't shared with collaborators. It needs an API key set with <0>ok embeddings set-key." +msgid "This setting is per-machine and isn't shared with collaborators. It needs an API key (set below) unless your endpoint is a local server that doesn't require one." +msgstr "This setting is per-machine and isn't shared with collaborators. It needs an API key (set below) unless your endpoint is a local server that doesn't require one." #: src/components/TemplateDeleteDialog.tsx msgid "This template lives in a parent folder — deleting it affects every folder beneath it that doesn't define its own version." @@ -9819,10 +9918,6 @@ msgstr "Use lowercase letters, digits, and <0>- only." msgid "Use lowercase letters, digits, and hyphens only." msgstr "Use lowercase letters, digits, and hyphens only." -#: src/components/settings/SearchSection.tsx -msgid "Use the default OpenAI endpoint or override it with an OpenAI-compatible base URL for Azure or a self-hosted provider." -msgstr "Use the default OpenAI endpoint or override it with an OpenAI-compatible base URL for Azure or a self-hosted provider." - #: src/components/settings/ScopeBadge.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "User" @@ -9832,7 +9927,7 @@ msgstr "User" msgid "User plugins are personal to this device — turn them on or off here. The choice lives in your user config and is never committed to the project. Each enabled plugin gets its own page under Plugins in the settings sidebar." msgstr "User plugins are personal to this device — turn them on or off here. The choice lives in your user config and is never committed to the project. Each enabled plugin gets its own page under Plugins in the settings sidebar." -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Using the <0>OK_EMBEDDINGS_API_KEY environment variable (managed outside OpenKnowledge)." msgstr "Using the <0>OK_EMBEDDINGS_API_KEY environment variable (managed outside OpenKnowledge)." @@ -10208,6 +10303,10 @@ msgstr "You're subscribed!" msgid "you@company.com" msgstr "you@company.com" +#: src/components/settings/SearchSection.tsx +msgid "Your change is still being saved — run the test again in a moment." +msgstr "Your change is still being saved — run the test again in a moment." + #: src/components/ShareBranchSwitchDialog.tsx msgid "Your copy of branch <0>{shareBranch} has changes that aren't on GitHub. The {targetNoun} will appear once the branch syncs." msgstr "Your copy of branch <0>{shareBranch} has changes that aren't on GitHub. The {targetNoun} will appear once the branch syncs." @@ -10221,10 +10320,6 @@ msgstr "Your current version is saved first, so this is reversible." msgid "Your edits stay on this computer" msgstr "Your edits stay on this computer" -#: src/components/settings/EmbeddingsKeySection.tsx -msgid "Your embeddings provider API key, used only to create embeddings of your content for semantic search. Stored once for this machine in <0>~/.ok/secrets.yml (readable only by your user account) and shared across all projects. Turn the feature on per project in This project → Search, where you can also override the endpoint." -msgstr "Your embeddings provider API key, used only to create embeddings of your content for semantic search. Stored once for this machine in <0>~/.ok/secrets.yml (readable only by your user account) and shared across all projects. Turn the feature on per project in This project → Search, where you can also override the endpoint." - #: src/components/settings/SyncSection.tsx #: src/components/SyncStatusBadge.tsx msgid "Your GitHub session expired — sign in again to verify push access." diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 2240d0129..bf2557465 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -166,6 +166,7 @@ " ŕēśũĺţś." ], "2ojpo0": ["Ďōćķ śēśśĩōńś ōń ţĥē ŕĩĝĥţ"], + "2pR7ad": ["Ēḿƀēďďĩńĝś ÀƤĨ ķēŷ"], "2q_Q7x": ["Vĩśĩƀĩĺĩţŷ"], "2rLM8r": ["Ćōũĺďń'ţ śţàŕţ ţĥē àĝēńţ ţĥŕēàď — ƥĺēàśē ţŕŷ àĝàĩń."], "2ryd91": ["Ƒàĩĺēď ţō ŕēńàḿē ƥŕōƥēŕţŷ"], @@ -201,7 +202,6 @@ "<0>Ḿĩŕŕōŕ — ƥĩćķ à śōũŕćē. Śēţ <1>śŕć + <2>àńćĥōŕ vĩà ţĥē ƥŕōƥēŕţŷ ƥàńēĺ ţō ƥōĩńţ àţ à <3> ēĺśēŵĥēŕē." ], "3SZrdj": ["Ćĥēćķ ƒōŕ ũƥďàţēś…"], - "3TCafD": ["Śţōŕēď ōń ţĥĩś ḿàćĥĩńē."], "3TSz9S": ["Ḿĩńĩḿĩźē"], "3VbwPp": ["Ōƥēń ", ["keyName"], " ĩń ƀŕōŵśēŕ"], "3VsNTI": ["Ĺōàďĩńĝ ďōćũḿēńţ"], @@ -305,6 +305,9 @@ "5PrrYw": ["Śţàŕţ ", ["agentName"]], "5QGTaA": ["Ćōũĺď ńōţ ćōƥŷ ŕēĺàţĩvē ƥàţĥ"], "5SwVv1": ["Ńōţ ĩńśţàĺĺēď"], + "5Szq6N": [ + "Ţĥĩś ēńďƥōĩńţ ĩĝńōŕēď ţĥē vēćţōŕ śĩźē ŷōũ ćōńƒĩĝũŕēď. Ŕēḿōvē <0>śēàŕćĥ.śēḿàńţĩć.ďĩḿēńśĩōńś ţō ũśē ţĥē ḿōďēĺ'ś ōŵń śĩźē." + ], "5Tk8Fb": ["Ĺōàďĩńĝ ƒĩĺēś"], "5Umg9i": ["Ďĩśàƀĺē ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"], "5WDhAk": ["Ĺĩńķ ţō à ƥàĝē ōŕ ēxţēŕńàĺ ŨŔĹ."], @@ -428,6 +431,7 @@ ["sanitized"], " àĺŕēàďŷ ĥàś ƒĩĺēś ĥēŕē. Ƥĩćķ à ďĩƒƒēŕēńţ ńàḿē." ], + "7cAW1G": ["Ńō ÀƤĨ ķēŷ ĩś śēţ ƒōŕ ţĥĩś ēńďƥōĩńţ. Àďď ōńē àƀōvē."], "7d1a0d": ["Ƥũƀĺĩć"], "7hctf2": ["Ďēśćŕĩƀē ţĥē ƥŕōĴēćţ ŷōũ ŵàńţ ţō ćŕēàţē"], "7jIIFX": ["Ŷōũ'ŕē àĺĺ śēţ ũƥ!"], @@ -511,6 +515,7 @@ ], "9Vh9Jf": ["Ŕēōƥēń ţĥĩś ŵĩńďōŵ ţō śĩĝń ĩń ŵĩţĥ ŷōũŕ ƀŕōŵśēŕ"], "9XIEYq": ["Àţţàćĥ ţĥē ƒĩĺē ĩń àń ēḿàĩĺ ţō <0/>"], + "9aVlF2": ["Ćĥàńĝē ƥŕōvĩďēŕ"], "9bCiQ8": ["Ńō ƥàĝēś àŕē ḿĩśśĩńĝ ĩńćōḿĩńĝ ĝŕàƥĥ ĺĩńķś."], "9bG48P": ["Śēńďĩńĝ"], "9clcD5": ["Ōƥēń ďōćũḿēńţś ţàĝĝēď #", ["chip"]], @@ -587,6 +592,9 @@ "AqnqMJ": ["ÀƤĨ ķēŷ śēţ"], "AsqRE_": ["Ďēƒàũĺţ ĺōćàţĩōń ƒōŕ ńēŵ àţţàćĥḿēńţś"], "AtBdnD": ["Ćōũĺďń'ţ ďēĺēţē śķĩĺĺ: ", ["error"]], + "AwLCZA": [ + "Ţĥē ēńďƥōĩńţ àńśŵēŕēď, ƀũţ ńōţ ŵĩţĥ àń ŌƥēńÀĨ-ćōḿƥàţĩƀĺē ēḿƀēďďĩńĝś ŕēśƥōńśē. Ćĥēćķ ţĥē ƀàśē ŨŔĹ." + ], "B11p-O": ["Ēďĩţ ḿàŕķďōŵń ĺĩńķ"], "B2DrPb": ["Àũţō-śàvē"], "B2Srgd": [["packName"], " śķĩĺĺ ĩńśţàĺĺēď."], @@ -630,6 +638,11 @@ "Bx4EXG": ["Àćţĩōńś ƒōŕ ", ["label"]], "By-7Pn": ["Ţōĝĝĺē à ƀũĺĺēţ ĺĩśţ."], "BymzO4": ["Ďōćũḿēńţ ōũţĺĩńē"], + "C-mmmK": [ + "Ţĥē ḿōďēĺ ĩď ŷōũŕ ēńďƥōĩńţ śēŕvēś. Ĩţś vēćţōŕ śĩźē ĩś ďēţēćţēď àũţōḿàţĩćàĺĺŷ — ćĺēàŕ ţĥē ƒĩēĺď ţō ĝō ƀàćķ ţō ", + ["DEFAULT_EMBEDDINGS_MODEL"], + "." + ], "C-xgad": ["ōŕ ũśē à śţàŕţēŕ ƥàćķ"], "C0hoX6": ["Àĺŵàŷś ĩńćĺũďēď"], "C52RBg": [ @@ -673,6 +686,7 @@ "CvsD6Y": ["vĩà ", ["viaTags"]], "CwOXMT": ["Vàĺũē ƒōŕ ", ["keyName"]], "D-Oh1s": ["Ĥōŵ ţō śēţ ũƥ ", ["0"], " (ōƥēńś ĩń ƀŕōŵśēŕ)"], + "D-_B7l": ["Ćōńńēćţēď — ţĥĩś ēńďƥōĩńţ ŕēţũŕńś ", ["0"], "-ďĩḿēńśĩōń vēćţōŕś."], "D12vtS": ["(ďēţàćĥēď)"], "D2CwBE": [ "Ćŕēàţē à ńēŵ ŌƥēńĶńōŵĺēďĝē ƥŕōĴēćţ ƒŕōḿ ţĥē <0>", @@ -697,6 +711,9 @@ "DFfRbX": ["Śŷńć śţàţũś: ", ["syncStateLabel"], " — ĝĩţ ĩďēńţĩţŷ ũńśēţ"], "DId6MT": ["ƤŕōĴēćţ-ĺēvēĺ ƥàĝēś ŵĩţĥ ńō ōũţĝōĩńĝ ĝŕàƥĥ ēďĝēś."], "DJd000": ["Àćţĩvàţē ţĥē ƥŕēvĩōũś ēďĩţōŕ ţàƀ."], + "DKYW1G": [ + "Ţĥĩś śēţţĩńĝ ĩś ƥēŕ-ḿàćĥĩńē àńď ĩśń'ţ śĥàŕēď ŵĩţĥ ćōĺĺàƀōŕàţōŕś. Ĩţ ńēēďś àń ÀƤĨ ķēŷ (śēţ ƀēĺōŵ) ũńĺēśś ŷōũŕ ēńďƥōĩńţ ĩś à ĺōćàĺ śēŕvēŕ ţĥàţ ďōēśń'ţ ŕēǫũĩŕē ōńē." + ], "DMeaxi": ["Ďōŵńĺōàďĩńĝ ", ["0"], " ", ["1"], "…"], "DPfwMq": ["Ďōńē"], "DTI-fp": [ @@ -758,6 +775,9 @@ "EEYbdt": ["Ƥũƀĺĩśĥ"], "EHu0x2": ["Śŷńćĩńĝ"], "EJuO7B": ["Ćōńńēćţ à ĜĩţĤũƀ àććōũńţ ţō ƀŕōŵśē àńď śŷńć ŷōũŕ ŕēƥōśĩţōŕĩēś."], + "EKwNEe": [ + "Ćàćĥēď ēḿƀēďďĩńĝś ƀēĺōńĝ ţō ţĥē ēńďƥōĩńţ àńď ḿōďēĺ ţĥàţ ƥŕōďũćēď ţĥēḿ, śō ţĥĩś ŕēƀũĩĺďś ţĥē ĩńďēx ƒŕōḿ śćŕàţćĥ." + ], "ELYtNJ": [".ōķ ƒōĺďēŕś"], "EL_YRd": ["Ḿĩŕŕōŕ ōƒ <0>àƥĩ-śƥēć"], "ENpRJV": ["Ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"], @@ -825,6 +845,7 @@ "FCAcjR": [ "Ŕũń à ŕēàĺ ţēŕḿĩńàĺ ďōćķēď ĩńśĩďē ŌƥēńĶńōŵĺēďĝē, śţàŕţĩńĝ ĩń ţĥĩś ƥŕōĴēćţ'ś ƒōĺďēŕ." ], + "FCKppt": ["Ēńďƥōĩńţ"], "FCxvG5": ["Ƒĩŕśţ ĩţēḿ"], "FDBwmS": ["Ōƥēń ƒōĺďēŕ…"], "FEK4oC": [ @@ -952,6 +973,7 @@ ". Ƥĺēàśē ćōḿḿĩţ ŷōũŕ ćĥàńĝēś ōŕ ēńàƀĺē àũţō-śŷńć." ], "Hp1l6f": ["Ćũŕŕēńţ"], + "Hp4rRh": ["Ćũśţōḿ ēńďƥōĩńţ"], "HpK_8d": ["Ŕēĺōàď"], "HptUxX": ["Ńũḿƀēŕ"], "HqOPpj": [ @@ -1088,7 +1110,6 @@ "L-rMC9": ["Ŕēśēţ ţō ďēƒàũĺţ"], "L1BQTH": [["0"], " ĩśń'ţ ĩńśţàĺĺēď ŷēţ — ōƥēńĩńĝ ĩţś ďōŵńĺōàď ƥàĝē."], "L1ZE3M": ["Ďōćũḿēńţ śţàţĩśţĩćś"], - "L1rRuU": ["Ēḿƀēďďĩńĝś ÀƤĨ ēńďƥōĩńţ"], "L3CEC1": ["ţĥĩś ĩţēḿ"], "L4Cdd9": ["ōŕ ćŕēàţē à ńēŵ ƒĩĺē <0/>"], "L6Hhh6": ["(ŕōōţ)"], @@ -1305,6 +1326,9 @@ "OiHJdA": [ "Ḿōvē ţĥŕōũĝĥ śĺàśĥ, ŵĩķĩ-ĺĩńķ, ţàĝ, àńď ƥàţĥ śũĝĝēśţĩōńś; àććēƥţ ōŕ ďĩśḿĩśś ţĥē àćţĩvē śũĝĝēśţĩōń." ], + "Oj-JV8": [ + "À ķēŷ ĩś śēţ, ƀũţ ţĥē ēḿƀēďďĩńĝś ƥŕōvĩďēŕ ŕēĴēćţēď ĩţ ōŕ ŵàś ũńŕēàćĥàƀĺē — śēàŕćĥ ƒēĺĺ ƀàćķ ţō ķēŷŵōŕď ḿàţćĥĩńĝ. Ũśē <0>Ţēśţ ćōńńēćţĩōń ũńďēŕ Ćũśţōḿ ēńďƥōĩńţ ţō śēē ŵĥàţ ŵēńţ ŵŕōńĝ." + ], "Okd2Xv": ["Ţōĝĝĺē ũńďēŕĺĩńē ƒōŕḿàţţĩńĝ."], "OlAl5i": ["Ēxƥàńď àĺĺ"], "Oo_WWc": ["Ńō ƥàĝēś ƒōũńď"], @@ -1349,11 +1373,15 @@ "PdJZ5p": ["Ńō śķĩĺĺś ŷēţ."], "Pgu10b": ["Śàvēď àś <0>", ["slug"], ".ḿď"], "PgzIpb": ["Ḿōvē ţō ţĥē ƥŕēvĩōũś vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."], + "PhLu69": [ + "Ţĥàţ ēńďƥōĩńţ ćàń'ţ ƀē ũśēď. Ēńţēŕ àń ĥţţƥś:// ŨŔĹ — ĥţţƥ:// ĩś ōńĺŷ àĺĺōŵēď ƒōŕ ĺōćàĺĥōśţ." + ], "PiH3UR": ["Ćōƥĩēď!"], "PijLsG": ["Ƒĩĺē ńàḿē (ćŕēàţēď ĩń .ōķ/śćĥēḿàś/)"], "PkeYXL": ["Ēxƥàńďēď ĝŕàƥĥ ḿōďē"], "Pkw7J9": ["Ţĥĩś ƒōĺďēŕ ĩś ēḿƥţŷ."], "PlGC6r": ["ţĥē ĝĩţ ēxćĺũďē ƒĩĺē ĩśń'ţ ŵŕĩţàƀĺē"], + "PpJy-V": ["Ţĥē ēńďƥōĩńţ ďĩďń'ţ ŕēśƥōńď ĩń ţĩḿē."], "PqYWiC": [ "Ćōũĺďń'ţ ŕēśţàŕţ ţĥē śēŕvēŕ àũţōḿàţĩćàĺĺŷ. Ţŕŷ ŕũńńĩńĝ `ōķ śţōƥ àĺĺ` ĩń à ţēŕḿĩńàĺ, ţĥēń ŕēōƥēń ţĥĩś ƥŕōĴēćţ — ōŕ ŕēśţàŕţ ŷōũŕ ćōḿƥũţēŕ ĩƒ ĩţ ƥēŕśĩśţś." ], @@ -1437,6 +1465,11 @@ "RfaEio": ["Ţōĝĝĺē śōũŕćē ćōḿḿēńţ"], "RfilGt": ["Ńō ŕũĺēś ḿàţćĥ ŷōũŕ ƒĩĺţēŕś."], "RgbKEe": ["Ŷōũ ďōń'ţ ĥàvē àććēśś ţō ţĥĩś ŕēƥōśĩţōŕŷ."], + "RhX_1U": [ + "Śēńţ ţō <0>", + ["endpointHost"], + " ţō ēḿƀēď ŷōũŕ ćōńţēńţ. Śţōŕēď ōń ţĥĩś ḿàćĥĩńē ōńĺŷ (ĩń <1>~/.ōķ/śēćŕēţś.ŷḿĺ), ńēvēŕ ĩń ţĥē ƥŕōĴēćţ." + ], "Rj01Fz": ["Ĺĩńķś"], "RlCInP": ["Śĺàśĥ ćōḿḿàńďś"], "RlLl3G": ["Àćţĩōńś ƒōŕ ", ["0"]], @@ -1843,6 +1876,7 @@ "." ], "_FLBbi": [["sizeText"], " · śēćŕēţś ŕēďàćţēď · ćŕàśĥ ďũḿƥ ńōţ ŕēďàćţēď"], + "_GRWOJ": ["Ćĥàńĝē ţĥē ēḿƀēďďĩńĝś ƥŕōvĩďēŕ?"], "_IGfXZ": ["ŌƥēńĶńōŵĺēďĝē ǫũĩţ ũńēxƥēćţēďĺŷ ĺàśţ ţĩḿē."], "_IX_7x": ["Ōţĥēŕ"], "_JOGC0": ["Śēńď ƒēēďƀàćķ…"], @@ -1901,6 +1935,7 @@ "aHPdYV": ["Ĝō ţō ĺĩńē ", ["displayLine"]], "aM23Wr": ["ƥàĝē-ńàḿē"], "aOp3hw": ["Ćōũĺďń'ţ ḿōvē ţō Ţŕàśĥ"], + "aQD_86": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē ēḿƀēďďĩńĝś ƥŕōvĩďēŕ — ", ["detail"]], "aRI3Om": ["Ćĺōśē Ŵĩńďōŵ"], "aWlJai": ["Śţàŕţĩńĝ śĩĝń-ĩń ƒĺōŵ"], "a_u4ck": ["Ĺōàďĩńĝ ţàĝś"], @@ -1967,6 +2002,7 @@ "c8iK2Y": ["Ńō ţēḿƥĺàţēś ƒōũńď."], "c8jBgX": ["Ćĥēćķĩńĝ ƒōŕ ũƥďàţēś ōń ĜĩţĤũƀ"], "cCd8Bs": ["Ćũţ"], + "cD3aew": ["Ŕē-ēḿƀēďďĩńĝ ĥàƥƥēńś àś śēàŕćĥēś ŕũń, śō ćōvēŕàĝē ŕēśţàŕţś àţ źēŕō àńď ŕēƒĩĺĺś."], "cEoyWz": ["Àďď ĩţēḿ ţō ", ["keyName"]], "cF9DyA": ["Ŕēćōńńēćţĩńĝ àƒţēŕ śēŕvēŕ ŕēśţàŕţ"], "cFCKYZ": ["Ďēńŷ"], @@ -2042,9 +2078,6 @@ "dIYSqk": ["ŕēḿōţē"], "dJw2_7": ["Śàvēď ŵĩţĥ ţĥē ƥŕōĴēćţ ƒōŕ ŷōũŕ ţēàḿ."], "dMsx3p": ["Ĥōḿē ƥàĝē àńď ńàvĩĝàţĩōń ĥũƀ."], - "dN20LW": [ - "Śēḿàńţĩć śēàŕćĥ ĩś ōń, ƀũţ ńō ÀƤĨ ķēŷ ĩś śēţ — śēàŕćĥ ƒàĺĺś ƀàćķ ţō ķēŷŵōŕď ḿàţćĥĩńĝ. Àďď ōńē ĩń <0>Śēţţĩńĝś → Àććōũńţ (ĩţ'ś śţōŕēď ōńćē ƒōŕ ŷōũŕ ŵĥōĺē ḿàćĥĩńē)." - ], "dNM58w": ["Ćĺàũďē Ćōďē (ćĺàũďē) ĩśń'ţ ĩńśţàĺĺēď ōŕ ōń ŷōũŕ ƤÀŢĤ."], "dOHMA0": ["Ƒàĩĺēď ţō ḿōvē"], "dOxPd4": ["Ƒōōţńōţē"], @@ -2060,6 +2093,26 @@ "dXCi3J": ["Śōḿēţĥĩńĝ ƀŕōķē"], "dXWMFn": ["Ŕēḿōvēś ţĥĩś śķĩĺĺ ƒŕōḿ ŷōũŕ ēďĩţōŕś."], "dYCvMf": ["Ćōũĺďń'ţ ŕēśũḿē ţĥĩś ćōńvēŕśàţĩōń: ", ["0"]], + "darW93": [ + [ + "pageCount", + "plural", + { + "one": [ + "Ţĥē ƒũĺĺ ţēxţ ōƒ ŷōũŕ 1 ƥàĝē ŵĩĺĺ ƀē śēńţ ţō <0>", + ["host"], + " àś ĩţ ĩś ŕē-ēḿƀēďďēď." + ], + "other": [ + "Ţĥē ƒũĺĺ ţēxţ ōƒ àĺĺ ", + ["pageCount"], + " ƥàĝēś ŵĩĺĺ ƀē śēńţ ţō <1>", + ["host"], + " àś ţĥēŷ àŕē ŕē-ēḿƀēďďēď." + ] + } + ] + ], "dbapvC": ["Ƒōĺĺōŵ ũś ōń śōćĩàĺ ḿēďĩà"], "dbhUX1": ["Ĥĩďē ţĥĩś ƒĩĺē"], "dbtjJh": ["(ōƥēńś ĩń ńēŵ ţàƀ)"], @@ -2075,6 +2128,7 @@ "e0NrBM": ["ƤŕōĴēćţ"], "e1Rn_k": ["Ōƥēń ćōńţàĩńĩńĝ ƒōĺďēŕ"], "e1hptq": ["Ēḿƀēď à vĩďēō ŵĩţĥ ńàţĩvē ƥĺàŷēŕ ćōńţŕōĺś."], + "e2zZ3E": ["Ţēśţ ćōńńēćţĩōń"], "e4FxjG": ["Śēē àĺĺ ", ["0"], " śţàŕţēŕ ƥàćķś"], "e4GHWP": ["Ƥũĺĺ"], "e4PbgC": ["Ďũƥĺĩćàţē ţĥē ƒĩĺē-ţŕēē ĩţēḿ ćōƥĩēď ƒŕōḿ ţĥē Ƒĩĺēś śĩďēƀàŕ."], @@ -2089,7 +2143,6 @@ "eGUbmF": ["Ćōƥŷ śēĺēćţēď ƒĩĺē ōŕ ƒōĺďēŕ"], "eJitUk": ["Ũƥĺōàď ƒŕōḿ ćōḿƥũţēŕ"], "eKThTQ": ["Ƒàĩĺēď ţō ŕēōŕďēŕ"], - "eLl1VX": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē ēḿƀēďďĩńĝś ēńďƥōĩńţ — ", ["detail"]], "eNQGRb": ["Ĩńĺĩńē ĹàŢēX ḿàţĥ ŕēńďēŕēď ŵĩţĥ ĶàŢēX."], "ePK91l": ["Ēďĩţ"], "eQkgKV": ["Ĩńśţàĺĺēď"], @@ -2360,6 +2413,11 @@ "Ŕēàďŷ-ḿàďē ƒōĺďēŕś àńď ţēḿƥĺàţēś ţō ĝēţ ŷōũ śţàŕţēď ǫũĩćķĺŷ. Śēĺēćţ à ƥàćķ ţō ƥŕēvĩēŵ ŵĥàţ ĝēţś ćŕēàţēď, ţĥēń àďď ĩţ ţō ŷōũŕ ƥŕōĴēćţ." ], "k7Hoig": ["Ńō ćōńţēńţ ćĥàńĝēś àţ ţĥĩś vēŕśĩōń."], + "k8xtrB": [ + "Ńōţ ŕēǫũĩŕēď ƒōŕ à ĺōćàĺĥōśţ ēńďƥōĩńţ ĺĩķē <0>", + ["endpointHost"], + " — ḿōśţ ĺōćàĺ śēŕvēŕś ĩĝńōŕē ĩţ. Àďď ōńē ōńĺŷ ĩƒ ŷōũŕś ńēēďś ĩţ." + ], "kCNU4q": [ "Ĩńďēxĩńĝ ŷōũŕ ƥàĝēś — ", ["semanticIndexedCount"], @@ -2378,6 +2436,9 @@ " ŵĩĺĺ àƥƥēàŕ ōńćē ţĥē ƀŕàńćĥ śŷńćś." ], "kNqMMd": ["Ƒōĺďēŕ"], + "kRTXGw": [ + "Ƥōĩńţ śēḿàńţĩć śēàŕćĥ àţ àńŷ ŌƥēńÀĨ-ćōḿƥàţĩƀĺē ēḿƀēďďĩńĝś ēńďƥōĩńţ — à śēĺƒ-ĥōśţēď śēŕvēŕ ōŕ àńōţĥēŕ ƥŕōvĩďēŕ. Ţĥē ÀƤĨ ķēŷ àƀōvē ĩś ƒōŕ ŵĥĩćĥēvēŕ ēńďƥōĩńţ ŷōũ śēţ ĥēŕē." + ], "kUn-CN": ["Śŷńć ōƒƒ"], "kV2YZv": [ "Ďōń'ţ śēē <0>Śķĩĺĺś? Ēńàƀĺē <1>Śēţţĩńĝś <2/> Ćàƥàƀĩĺĩţĩēś <3/> Ćōďē ēxēćũţĩōń àńď ƒĩĺē ćŕēàţĩōń ƒĩŕśţ." @@ -2577,7 +2638,6 @@ "Śţōŕēď àţ <0>.ōķ/ţēḿƥĺàţēś/ ĩń ţĥĩś ƥŕōĴēćţ. Àvàĩĺàƀĺē ĩń ēvēŕŷ ƒōĺďēŕ (ƒōĺďēŕ-śćōƥēď ţēḿƥĺàţēś ćàń ōvēŕŕĩďē ƀŷ ƒĩĺēńàḿē)." ], "ng2Ibo": ["ßŕàńćĥ ńàḿē"], - "nh3wwx": ["Ēḿƀēďďĩńĝś ƥŕōvĩďēŕ ķēŷ"], "njBeMm": ["ßōţĥ"], "njU86j": ["Ƥŕōƥēŕţŷ \"", ["trimmed"], "\" àĺŕēàďŷ ēxĩśţś"], "nmwqWS": ["Ńōţ àńśŵēŕēď"], @@ -2604,9 +2664,6 @@ "o2yoFu": ["Ŕēvēàĺ ĩń Ƒĩńďēŕ, Ńō ŵōŕķśƥàćē"], "o49mDZ": ["Ĺōàďĩńĝ ţĩḿēĺĩńē ĥĩśţōŕŷ"], "o4dcAf": ["Ŕēvēàĺ ĩń Ƒĩĺē Ēxƥĺōŕēŕ, ", ["hint"]], - "o5gfs1": [ - "Ţĥĩś śēţţĩńĝ ĩś ƥēŕ-ḿàćĥĩńē àńď ĩśń'ţ śĥàŕēď ŵĩţĥ ćōĺĺàƀōŕàţōŕś. Ĩţ ńēēďś àń ÀƤĨ ķēŷ śēţ ŵĩţĥ <0>ōķ ēḿƀēďďĩńĝś śēţ-ķēŷ." - ], "o9CrsQ": ["ŕēśēàŕćĥ-ńōţē"], "oAFVKX": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē ƥŕōĴēćţ śŷńć ďēƒàũĺţ — ", ["detail"]], "oAGN8p": [ @@ -2623,9 +2680,6 @@ "Ţōĝĝĺē ţĥē ƀōţţōḿ ţēŕḿĩńàĺ ƥàńēĺ. Ŵĩţĥ ţēxţ śēĺēćţēď, śţàĝē ĩţ ĩń ţĥē ţēŕḿĩńàĺ'ś ÀĨ ĩńƥũţ ĩńśţēàď." ], "oCZkaG": ["Ćōƥŷ ƥàţĥ"], - "oEgEn_": [ - "À ķēŷ ĩś śēţ, ƀũţ ţĥē ēḿƀēďďĩńĝś ƥŕōvĩďēŕ ŕēĴēćţēď ĩţ ōŕ ŵàś ũńŕēàćĥàƀĺē — śēàŕćĥ ƒēĺĺ ƀàćķ ţō ķēŷŵōŕď ḿàţćĥĩńĝ. Ćĥēćķ ţĥē ķēŷ ĩń <0>Śēţţĩńĝś → Àććōũńţ àńď ţĥē ēḿƀēďďĩńĝś ēńďƥōĩńţ śēţţĩńĝ." - ], "oJl7eO": ["Ũńķńōŵń ćōḿƥōńēńţ: ", ["componentName"], " — śōũŕćē ēďĩţàƀĺē ƀēĺōŵ"], "oKBnk5": ["Ḿōŕē ŕēǫũēśţś ŵēŕē ƀĺōćķēď."], "oQA-cA": ["Ƒũĺĺ ƥàţĥ"], @@ -2684,12 +2738,10 @@ "phM8Kh": ["Ƒōũńď ţĥē ŕēƥō ƀũţ ćōũĺď ńōţ ōƥēń ţĥē ƥŕōĴēćţ. Ƥĺēàśē ţŕŷ àĝàĩń."], "pha01Y": ["ƤŕōĴēćţ-ĺēvēĺ ƥàĝēś ŵĩţĥ ńō ĩńćōḿĩńĝ ĝŕàƥĥ ēďĝēś."], "pkAft1": ["Śēàŕćĥ ƒàĩĺēď."], - "pmOGtg": [ - "Ũśē ţĥē ďēƒàũĺţ ŌƥēńÀĨ ēńďƥōĩńţ ōŕ ōvēŕŕĩďē ĩţ ŵĩţĥ àń ŌƥēńÀĨ-ćōḿƥàţĩƀĺē ƀàśē ŨŔĹ ƒōŕ Àźũŕē ōŕ à śēĺƒ-ĥōśţēď ƥŕōvĩďēŕ." - ], "pmUArF": ["Ŵōŕķśƥàćē"], "ppDgFi": ["Ŕēśţàŕţ ŵĩţĥ ţĥĩś àƥƥ'ś vēŕśĩōń"], "psfGPX": ["ḾĆƤ ćōńńēćţĩōńś"], + "psl2Sj": ["Ŷōũŕ ćĥàńĝē ĩś śţĩĺĺ ƀēĩńĝ śàvēď — ŕũń ţĥē ţēśţ àĝàĩń ĩń à ḿōḿēńţ."], "pvnfJD": ["Ďàŕķ"], "pzutoc": ["Ĩţàĺĩć"], "q2kXMG": ["Ĩńĺĩńē Ḿàţĥ"], @@ -2709,6 +2761,7 @@ "qEIFZu": ["Ķēŷ ēńďĩńĝ ĩń ", ["keyHint"]], "qEue6Q": [" ōŕ "], "qFWMl-": ["Vĩēŵ àś ţēxţ"], + "qIJIrD": ["Ţēśţĩńĝ"], "qNOgUu": ["Ēďĩţś àţ ţĥē śōũŕćē ĺàńď ĥēŕē — ńō ćōƥŷ-ƥàśţē ďŕĩƒţ."], "qRZh7J": ["Ĺēţ Ţàƀ ĺēàvē ţĥē śōũŕćē ēďĩţōŕ ĩńśţēàď ōƒ ĩńďēńţĩńĝ."], "qSKu2v": ["Śōũŕćē ńàvĩĝàţĩōń àńď śēĺēćţĩōń"], @@ -2821,6 +2874,7 @@ ], "saIASg": ["Śēćōńď ĩţēḿ"], "saYiIO": ["Ƥàţĥ ćàńńōţ ćōńţàĩń ńũĺĺ ƀŷţēś"], + "scu3wk": ["Ḿōďēĺ"], "seeS6l": ["Śēţũƥ ŌƥēńĶńōŵĺēďĝē ĩń ţĥĩś ƒōĺďēŕ?"], "sgmRY8": ["Śķĩƥƥēď ďōŵńĺōàďĩńĝ ", ["0"], "."], "shNd9t": ["Śĺàśĥ ćōḿḿàńď ḿēńũ"], @@ -2839,6 +2893,7 @@ "stlgky": [ "Ōń — ŷōũŕ śēàŕćĥ ǫũēŕĩēś àńď ţĥē ţēxţ ōƒ ḿàţćĥĩńĝ ƥàĝēś àŕē śēńţ ţō ŷōũŕ ēḿƀēďďĩńĝś ƥŕōvĩďēŕ (ŌƥēńÀĨ ƀŷ ďēƒàũĺţ) ţō ćōḿƥũţē ēḿƀēďďĩńĝś." ], + "sweBEk": ["Ḿōďēĺ: <0>", ["0"], ""], "sxTdZI": ["Ĺōàďĩńĝ ƥàţĥś…"], "sxjyRq": ["Ŵōŕķţŕēēś"], "sxkWRg": ["Àďvàńćēď"], @@ -2856,6 +2911,7 @@ "t9Qn8y": ["Ńō àĝēńţś ēńàƀĺēď"], "tAPZEx": ["ßēĺōŵ"], "tHKQ6j": ["Ŕēńàḿĩńĝ"], + "tN5WTS": ["Ţĥē ƥŕōvĩďēŕ ŕēĴēćţēď ţĥē ŕēǫũēśţ. Ćĥēćķ ţĥē ÀƤĨ ķēŷ àńď ţĥē ḿōďēĺ ĩď."], "tP5DDk": ["Àƥƥĺŷ ĺĩńķ"], "tUvUfp": ["Ũśē à ƒōĺďēŕ ŷōũ àĺŕēàďŷ ĥàvē."], "t_YqKh": ["Ŕēḿōvē"], @@ -2901,6 +2957,7 @@ "uMHsvM": [ "À <0>Ḿōďĩƒĩēď ƀàďĝē ḿàŕķś śćĥēḿàś ŷōũŕ ćōńƒĩĝ ḿàƥś — ōń ōŕ ōƒƒ. Ŕēśēţ ŕēḿōvēś ţĥē ḿàƥƥĩńĝ ƒŕōḿ ćōńƒĩĝ.ŷḿĺ." ], + "uNGmQn": ["Ćōũĺďń'ţ ŕũń ţĥē ţēśţ. Ćĥēćķ ţĥàţ ŌƥēńĶńōŵĺēďĝē ĩś śţĩĺĺ ŕũńńĩńĝ."], "uO9WLl": ["Ƥũśĥ"], "uQNW00": ["Ďēţàćĥēď ĤĒÀĎ — ćĥēćķōũţ à ƀŕàńćĥ ţō ŕēśũḿē"], "uQewpR": [ @@ -2959,6 +3016,11 @@ "vYPvPB": ["Śŵĩţćĥ ƥŕōĴēćţ…"], "vZVeFG": ["Ũƥĺōàď ƒàĩĺēď — ƥĺēàśē ţŕŷ àĝàĩń"], "v_2_3v": ["Ďēĺēţēď ", ["file"]], + "v_cQn_": [ + "Ţĥē ƥŕōvĩďēŕ ŕēĴēćţēď ţĥē ŕēǫũēśţ (ĤŢŢƤ ", + ["0"], + "). Ćĥēćķ ţĥē ÀƤĨ ķēŷ àńď ţĥē ḿōďēĺ ĩď." + ], "vbK_ZG": ["Ƒĩĺēś śĩďēƀàŕ"], "ve8vEM": ["Ćōďē ēxƥĩŕēď — ƥĺēàśē ţŕŷ àĝàĩń"], "vfRlCI": ["Ćōũĺď ńōţ ćōƥŷ ƒũĺĺ ƥàţĥ"], @@ -2997,9 +3059,6 @@ "w_Sphq": ["Àţţàćĥḿēńţś"], "waFx9W": ["Ḿàńàĝēď"], "wbv9xO": ["Ćŕēàţē ńēŵ ƥŕōĴēćţ"], - "wccmX0": [ - "Ŷōũŕ ēḿƀēďďĩńĝś ƥŕōvĩďēŕ ÀƤĨ ķēŷ, ũśēď ōńĺŷ ţō ćŕēàţē ēḿƀēďďĩńĝś ōƒ ŷōũŕ ćōńţēńţ ƒōŕ śēḿàńţĩć śēàŕćĥ. Śţōŕēď ōńćē ƒōŕ ţĥĩś ḿàćĥĩńē ĩń <0>~/.ōķ/śēćŕēţś.ŷḿĺ (ŕēàďàƀĺē ōńĺŷ ƀŷ ŷōũŕ ũśēŕ àććōũńţ) àńď śĥàŕēď àćŕōśś àĺĺ ƥŕōĴēćţś. Ţũŕń ţĥē ƒēàţũŕē ōń ƥēŕ ƥŕōĴēćţ ĩń Ţĥĩś ƥŕōĴēćţ → Śēàŕćĥ, ŵĥēŕē ŷōũ ćàń àĺśō ōvēŕŕĩďē ţĥē ēńďƥōĩńţ." - ], "wdxz7K": ["Śōũŕćē"], "weP6me": ["Ćōũĺďń'ţ ĺōàď ďōćś — ţŷƥē @ àĝàĩń ţō ŕēţŕŷ"], "wlwXUk": [ @@ -3024,10 +3083,14 @@ ")" ], "x3kWeu": ["Ƥŕōƀĺēḿ ĩńďĩćàţōŕś ĩń ţĥē ƒĩĺē ēxƥĺōŕēŕ"], + "x4pGVR": [ + "Śēḿàńţĩć śēàŕćĥ ĩś ōń, ƀũţ ńō ÀƤĨ ķēŷ ĩś śēţ — śēàŕćĥ ƒàĺĺś ƀàćķ ţō ķēŷŵōŕď ḿàţćĥĩńĝ. Àďď ōńē ƀēĺōŵ." + ], "x57cJf": ["Ōƥēń ĺĩńķ"], "xCJdfg": ["Ćĺēàŕ"], "xCdTnb": ["Ćĺōśē ńēŵ ţàƀ"], "xDBki3": ["Ĥēàďĩńĝ śũĝĝēśţĩōńś"], + "xDEcka": ["Ţĥē ƥŕōvĩďēŕ ĩś ŕàţē-ĺĩḿĩţĩńĝ ţĥĩś ķēŷ. Ţŕŷ àĝàĩń ĩń à ḿōḿēńţ."], "xFQRu4": ["Ĩ àĺŕēàďŷ ĥàvē ĩţ ĺōćàĺĺŷ"], "xG4tb6": ["Ĺōśţ ćōńńēćţĩōń ţō \"", ["docName"], "\"."], "xHGM4U": ["Ĥĩďē ĤŢḾĹ ƥŕēvĩēŵ"], @@ -3071,6 +3134,7 @@ "yEJ-sl": [ "Ţĥē ƥŕēvĩēŵ ƀĺōćķś ŕēśōũŕćēś ţĥàţ ďōń'ţ ḿēēţ ĩţś śēćũŕĩţŷ ŕũĺēś (ƒōŕ ēxàḿƥĺē ƥĺàĩń ĥţţƥ:// ŨŔĹś, ōŕ ćōďē ţĥàţ ũśēś ēvàĺ). Ţĥē ćōńţēńţ àƀōvē ḿàŷ ŕēńďēŕ ĩńćōḿƥĺēţēĺŷ." ], + "yFr-5x": ["Ćōũĺďń'ţ ŕēàćĥ ţĥē ēńďƥōĩńţ. Ćĥēćķ ţĥē ŨŔĹ àńď ţĥàţ ţĥē śēŕvēŕ ĩś ũƥ."], "yFyZMJ": ["Ńō ƥàĝēś ḿàţćĥēď \"", ["semanticQueryText"], "\" ƀŷ ḿēàńĩńĝ."], "yH7T4q": [ [ diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index df409c81e..d7a1fe99a 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -520,6 +520,10 @@ msgstr "" msgid "{packName} was already set up. Nothing to do." msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "{pageCount, plural, one {The full text of your 1 page will be sent to <0>{host} as it is re-embedded.} other {The full text of all {pageCount} pages will be sent to <1>{host} as they are re-embedded.}}" +msgstr "" + #: src/components/acp/ThreadView.tsx msgid "{percent}% used ({left}% left)" msgstr "" @@ -697,7 +701,7 @@ msgid "A folder named <0>{sanitized} already has files here. Pick a differen msgstr "" #: src/components/settings/SearchSection.tsx -msgid "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Check the key in <0>Settings → Account and the embeddings endpoint setting." +msgid "A key is set, but the embeddings provider rejected it or was unreachable — search fell back to keyword matching. Use <0>Test connection under Custom endpoint to see what went wrong." msgstr "" #: src/components/SeedDialog.tsx @@ -867,7 +871,7 @@ msgstr "" msgid "Add" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Add a key" msgstr "" @@ -1122,7 +1126,7 @@ msgstr "" msgid "Anyone can see" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "API key set" msgstr "" @@ -1517,6 +1521,10 @@ msgstr "" msgid "By tag" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Cached embeddings belong to the endpoint and model that produced them, so this rebuilds the index from scratch." +msgstr "" + #: src/components/CreateProjectDialog.tsx msgid "Can't nest projects. An OpenKnowledge project already exists at <0>{rootPath}. Choose a location outside it, or open that project instead." msgstr "" @@ -1570,6 +1578,14 @@ msgstr "" msgid "Cannot upload: no document is open" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Change provider" +msgstr "" + +#: src/components/settings/SearchSection.tsx +msgid "Change the embeddings provider?" +msgstr "" + #: src/components/command-palette-commands.ts msgid "Check for updates" msgstr "" @@ -1678,7 +1694,7 @@ msgstr "" msgid "Claude Desktop" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Clear" msgstr "" @@ -2064,6 +2080,11 @@ msgstr "" msgid "Connect your AI tools to OpenKnowledge" msgstr "" +#. placeholder {0}: response.dimensions +#: src/components/settings/SearchSection.tsx +msgid "Connected — this endpoint returns {0}-dimension vectors." +msgstr "" + #: src/components/AuthModal.tsx #: src/components/settings/AccountSection.tsx msgid "Connected as @{login}" @@ -2399,7 +2420,7 @@ msgstr "" msgid "Couldn't apply the change: {0}" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Couldn't clear the key — please try again." msgstr "" @@ -2545,6 +2566,10 @@ msgstr "" msgid "Couldn't reach the embeddings provider — press ↵ to retry" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Couldn't reach the endpoint. Check the URL and that the server is up." +msgstr "" + #: src/components/EditorActivityPool.tsx msgid "Couldn't reconnect after server restart" msgstr "" @@ -2594,11 +2619,15 @@ msgstr "" msgid "Couldn't resume this conversation: {0}" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Couldn't run the test. Check that OpenKnowledge is still running." +msgstr "" + #: src/components/TemplateForm.tsx msgid "Couldn't save template: {error}" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Couldn't save the key — please try again." msgstr "" @@ -2828,6 +2857,10 @@ msgstr "" msgid "Custom display text" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Custom endpoint" +msgstr "" + #: src/editor/slash-command/embed-starter-items.tsx msgid "Custom HTML with a live preview pane (sandboxed iframe)." msgstr "" @@ -3448,17 +3481,13 @@ msgid "Embed an image with optional alt text." msgstr "" #: src/components/settings/SearchSection.tsx -msgid "Embeddings API endpoint" +msgid "Embeddings API key" msgstr "" #: src/components/settings/SearchSection.tsx msgid "Embeddings are computed only when a search runs and are cached locally under <0>.ok/local." msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx -msgid "Embeddings provider key" -msgstr "" - #: src/components/ArrayOfObjectsWidget.tsx #: src/components/ObjectWidget.tsx msgid "empty" @@ -3541,6 +3570,10 @@ msgstr "" msgid "Ended" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Endpoint" +msgstr "" + #: src/components/empty-state/use-create-suggestions.ts msgid "Eng specs" msgstr "" @@ -3970,7 +4003,7 @@ msgid "Failed to update the auto-approve setting — {detail}" msgstr "" #: src/components/settings/SearchSection.tsx -msgid "Failed to update the embeddings endpoint — {detail}" +msgid "Failed to update the embeddings provider — {detail}" msgstr "" #: src/components/settings/SyncSection.tsx @@ -5000,7 +5033,7 @@ msgstr "" msgid "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about." msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Key ending in {keyHint}" msgstr "" @@ -5429,6 +5462,15 @@ msgstr "" msgid "Mode" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Model" +msgstr "" + +#. placeholder {0}: pending?.model +#: src/components/settings/SearchSection.tsx +msgid "Model: <0>{0}" +msgstr "" + #: src/components/FolderOverview.tsx #: src/components/settings/LintingSection.tsx #: src/components/settings/markdownlint-rule-browser.tsx @@ -5787,6 +5829,10 @@ msgstr "" msgid "No AI agents installed" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "No API key is set for this endpoint. Add one above." +msgstr "" + #: src/components/ActivityPanelDiffView.tsx msgid "No changes" msgstr "" @@ -6101,6 +6147,10 @@ msgstr "" msgid "Not now" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Not required for a localhost endpoint like <0>{endpointHost} — most local servers ignore it. Add one only if yours needs it." +msgstr "" + #. placeholder {0}: summary.pattern #: src/components/settings/LintingSection.tsx msgid "nothing ({0} cannot match a doc)" @@ -6729,7 +6779,7 @@ msgstr "" msgid "Paste was incomplete — {chunksCompleted} of {totalChunks} chunks landed.{restoreSuffix}" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Paste your API key" msgstr "" @@ -6859,6 +6909,10 @@ msgstr "" msgid "Plugins" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Point semantic search at any OpenAI-compatible embeddings endpoint — a self-hosted server or another provider. The API key above is for whichever endpoint you set here." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "Preferences" @@ -7095,6 +7149,10 @@ msgstr "" msgid "Re-authenticate with GitHub Enterprise Server" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Re-embedding happens as searches run, so coverage restarts at zero and refills." +msgstr "" + #: src/components/empty-state/use-create-suggestions.ts msgid "Read through this codebase and draft a technical spec for the most complex module: an overview, the architecture, key files, and open questions, all linked from a specs index page." msgstr "" @@ -7404,7 +7462,7 @@ msgstr "" msgid "Replace current match from field" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Replace key" msgstr "" @@ -7708,7 +7766,7 @@ msgid "Sandboxed HTML embed — write HTML, see the rendered preview live." msgstr "" #: src/components/AuthModal.tsx -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx #: src/editor/components/CodePreviewEditModal.tsx #: src/editor/extensions/InternalLinkPropPanel.tsx #: src/editor/extensions/WikiLinkPropPanel.tsx @@ -7745,7 +7803,7 @@ msgstr "" msgid "Saves the file to <0>~/Downloads/openknowledge.skill and opens the Claude Desktop App. Requires Node.js or Bun on your PATH." msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Saving" msgstr "" @@ -7976,7 +8034,7 @@ msgid "Semantic search adds meaning-based ranking to the search tool so conceptu msgstr "" #: src/components/settings/SearchSection.tsx -msgid "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one in <0>Settings → Account (it's stored once for your whole machine)." +msgid "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one below." msgstr "" #: src/components/acp/ThreadView.tsx @@ -8013,6 +8071,10 @@ msgstr "" msgid "Sent privately to the OpenKnowledge team, along with your note and app version. Never posted publicly." msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Sent to <0>{endpointHost} to embed your content. Stored on this machine only (in <1>~/.ok/secrets.yml), never in the project." +msgstr "" + #: src/editor/slash-command/items.tsx msgid "Separator" msgstr "" @@ -8618,10 +8680,6 @@ msgstr "" msgid "Stored at <0>.ok/templates/ in this project. Available in every folder (folder-scoped templates can override by filename)." msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx -msgid "Stored on this machine." -msgstr "" - #: src/lib/keyboard-shortcuts.ts msgid "Strikethrough" msgstr "" @@ -8968,6 +9026,14 @@ msgstr "" msgid "Terminal settings not yet loaded — try again in a moment" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Test connection" +msgstr "" + +#: src/components/settings/SearchSection.tsx +msgid "Testing" +msgstr "" + #: src/components/PropertyWidgets.tsx #: src/components/settings/CustomThemeEditor.tsx msgid "Text" @@ -8990,6 +9056,10 @@ msgstr "" msgid "That branch is already open in another worktree." msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "That endpoint can't be used. Enter an https:// URL — http:// is only allowed for localhost." +msgstr "" + #: src/components/ShareReceiveDialog.tsx msgid "That folder isn't an OpenKnowledge project yet. Initialize it and open?" msgstr "" @@ -9036,6 +9106,14 @@ msgstr "" msgid "the doc {0}" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "The endpoint answered, but not with an OpenAI-compatible embeddings response. Check the base URL." +msgstr "" + +#: src/components/settings/SearchSection.tsx +msgid "The endpoint didn't respond in time." +msgstr "" + #: src/components/FileTree.tsx msgid "The file is in your Trash; the file-watcher will reconcile." msgstr "" @@ -9066,6 +9144,10 @@ msgstr "" msgid "The menu label agents pick this template by (required)." msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "The model id your endpoint serves. Its vector size is detected automatically — clear the field to go back to {DEFAULT_EMBEDDINGS_MODEL}." +msgstr "" + #: src/editor/components/PreviewBlockedNotice.tsx msgid "The preview blocks resources that don't meet its security rules (for example plain http:// URLs, or code that uses eval). The content above may render incompletely." msgstr "" @@ -9074,6 +9156,19 @@ msgstr "" msgid "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "The provider is rate-limiting this key. Try again in a moment." +msgstr "" + +#. placeholder {0}: response.status +#: src/components/settings/SearchSection.tsx +msgid "The provider rejected the request (HTTP {0}). Check the API key and the model id." +msgstr "" + +#: src/components/settings/SearchSection.tsx +msgid "The provider rejected the request. Check the API key and the model id." +msgstr "" + #: src/components/ReportBugDialogBody.tsx msgid "The report service couldn't be reached." msgstr "" @@ -9222,6 +9317,10 @@ msgstr "" msgid "This document is already active in the editor. Use Open to collapse the graph." msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "This endpoint ignored the vector size you configured. Remove <0>search.semantic.dimensions to use the model's own size." +msgstr "" + #: src/components/settings/frontmatter-schema-field-editor.tsx msgid "This field carries additional schema keywords the editor does not show — they are preserved on save." msgstr "" @@ -9370,7 +9469,7 @@ msgid "This setting is per-machine and isn't shared with collaborators." msgstr "" #: src/components/settings/SearchSection.tsx -msgid "This setting is per-machine and isn't shared with collaborators. It needs an API key set with <0>ok embeddings set-key." +msgid "This setting is per-machine and isn't shared with collaborators. It needs an API key (set below) unless your endpoint is a local server that doesn't require one." msgstr "" #: src/components/TemplateDeleteDialog.tsx @@ -9815,10 +9914,6 @@ msgstr "" msgid "Use lowercase letters, digits, and hyphens only." msgstr "" -#: src/components/settings/SearchSection.tsx -msgid "Use the default OpenAI endpoint or override it with an OpenAI-compatible base URL for Azure or a self-hosted provider." -msgstr "" - #: src/components/settings/ScopeBadge.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "User" @@ -9828,7 +9923,7 @@ msgstr "" msgid "User plugins are personal to this device — turn them on or off here. The choice lives in your user config and is never committed to the project. Each enabled plugin gets its own page under Plugins in the settings sidebar." msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx +#: src/components/settings/SearchSection.tsx msgid "Using the <0>OK_EMBEDDINGS_API_KEY environment variable (managed outside OpenKnowledge)." msgstr "" @@ -10204,6 +10299,10 @@ msgstr "" msgid "you@company.com" msgstr "" +#: src/components/settings/SearchSection.tsx +msgid "Your change is still being saved — run the test again in a moment." +msgstr "" + #: src/components/ShareBranchSwitchDialog.tsx msgid "Your copy of branch <0>{shareBranch} has changes that aren't on GitHub. The {targetNoun} will appear once the branch syncs." msgstr "" @@ -10217,10 +10316,6 @@ msgstr "" msgid "Your edits stay on this computer" msgstr "" -#: src/components/settings/EmbeddingsKeySection.tsx -msgid "Your embeddings provider API key, used only to create embeddings of your content for semantic search. Stored once for this machine in <0>~/.ok/secrets.yml (readable only by your user account) and shared across all projects. Turn the feature on per project in This project → Search, where you can also override the endpoint." -msgstr "" - #: src/components/settings/SyncSection.tsx #: src/components/SyncStatusBadge.tsx msgid "Your GitHub session expired — sign in again to verify push access." diff --git a/packages/app/src/server/hocuspocus-plugin.ts b/packages/app/src/server/hocuspocus-plugin.ts index 70001e1ea..f935d0530 100644 --- a/packages/app/src/server/hocuspocus-plugin.ts +++ b/packages/app/src/server/hocuspocus-plugin.ts @@ -27,6 +27,7 @@ import { createServer, getLogger, handleCollabSocketError, + makeLazyEmbeddingsKeyStore, parseKeepaliveConnectionId, releaseServerLock, toBroadcasterKey, @@ -143,6 +144,10 @@ export function hocuspocusPlugin(): Plugin { gitEnabled: isEphemeralTest ? false : !isTestIsolated || gitEnabledForTest, enableTestRoutes: true, quiet: true, + // Read the same 0600 ~/.ok/secrets.yml the production boot does, so the + // dev server resolves stored embeddings keys (dev/prod parity — without + // this, semantic search only ever resolves the env key / keyless). + embeddingsKeyStore: makeLazyEmbeddingsKeyStore(), ...(isEphemeralTest ? { ephemeral: true, singleDocRelPath: SINGLE_DOC_REL_PATH } : {}), }); diff --git a/packages/app/tests/integration/api-error-envelope/local-op-embeddings-test.test.ts b/packages/app/tests/integration/api-error-envelope/local-op-embeddings-test.test.ts new file mode 100644 index 000000000..c07b9bc5f --- /dev/null +++ b/packages/app/tests/integration/api-error-envelope/local-op-embeddings-test.test.ts @@ -0,0 +1,223 @@ +/** + * Per-handler narrow-integration test for `handleLocalOpEmbeddingsTest` + * (`POST /api/local-op/embeddings/test`). + * + * The probe is the only thing standing between "my custom endpoint works" and + * "semantic search silently fell back to keyword matching", so this drives it + * against a real loopback embeddings server rather than a stubbed fetch: the + * request really leaves the process, the response is really parsed, and the + * detected vector size is really read off the wire. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + LocalOpEmbeddingsTestResponseSchema, + ProblemDetailsSchema, +} from '@inkeep/open-knowledge-core'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { + type FakeEmbeddingsServer, + startFakeEmbeddingsServer, +} from '../../../../server/src/embeddings/fake-provider.test-helper.ts'; +import { HARNESS_BOOT_TIMEOUT_MS } from '../harness-boot-timeout'; +import { createTestServer, type TestServer } from '../test-harness'; + +let server: TestServer; +let provider: FakeEmbeddingsServer; +let contentDir: string; +let homeDir: string; + +const MODEL = 'nomic-embed-text'; +const PROVIDER_DIMS = 1024; + +beforeAll(async () => { + provider = await startFakeEmbeddingsServer({ dims: PROVIDER_DIMS }); + contentDir = mkdtempSync(join(tmpdir(), 'ok-emb-test-content-')); + homeDir = mkdtempSync(join(tmpdir(), 'ok-emb-test-home-')); + + mkdirSync(join(contentDir, '.ok', 'local'), { recursive: true }); + writeFileSync(join(contentDir, '.ok', 'config.yml'), '', 'utf-8'); + writeFileSync( + join(contentDir, '.ok', 'local', 'config.yml'), + `search:\n semantic:\n enabled: true\n baseUrl: ${provider.baseUrl}\n model: ${MODEL}\n`, + 'utf-8', + ); + mkdirSync(join(homeDir, '.ok'), { recursive: true }); + + server = await createTestServer({ contentDir, configHomedirOverride: homeDir }); + // Store the key through the real route so it binds to THIS project + endpoint + // (a flat OPENAI_API_KEY would not resolve for a custom endpoint by design). + await fetch(`http://127.0.0.1:${server.port}/api/local-op/embeddings/set-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'sk-test-probe-key' }), + }); +}, HARNESS_BOOT_TIMEOUT_MS); + +afterAll(async () => { + await server.cleanup(); + await provider.close(); + rmSync(contentDir, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); +}); + +async function postTest(body: unknown = {}): Promise { + return fetch(`http://127.0.0.1:${server.port}/api/local-op/embeddings/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +describe('local-op-embeddings-test', () => { + test('reports success plus the vector size the endpoint actually returned', async () => { + const res = await postTest(); + expect(res.status).toBe(200); + const parsed = LocalOpEmbeddingsTestResponseSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) throw new Error('expected a successful probe'); + expect(parsed.data.dimensions).toBe(PROVIDER_DIMS); + // Echoed so the UI can tell a verdict for the saved endpoint apart from one + // for an edit that hasn't reached the server yet. + expect(parsed.data.endpoint).toBe(provider.baseUrl); + expect(parsed.data.model).toBe(MODEL); + }); + + test('sends the configured model, a Bearer key, and only a fixed probe string', async () => { + const before = provider.requests.length; + await postTest(); + const request = provider.requests[before]; + expect(request.model).toBe(MODEL); + expect(request.authorization).toBe('Bearer sk-test-probe-key'); + expect(request.input).toHaveLength(1); + // No page text and no user query may ride along to an endpoint under test. + expect(request.input[0]).not.toContain('test-doc'); + }); + + test('does not read an endpoint from the request body', async () => { + // A body-supplied endpoint paired with the stored key would turn this route + // into a key-exfiltration oracle for anything that can reach loopback. + const before = provider.requests.length; + const res = await postTest({ baseUrl: 'https://attacker.example/v1' }); + const parsed = LocalOpEmbeddingsTestResponseSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect(parsed.data.endpoint).toBe(provider.baseUrl); + expect(provider.requests.length).toBe(before + 1); // the saved endpoint got it + }); + + test('malformed JSON body emits problem+json 400', async () => { + const res = await postTest('not-valid-json{'); + expect(res.status).toBe(400); + const parsed = ProblemDetailsSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.status).toBe(400); + }); + + test('GET is rejected', async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/api/local-op/embeddings/test`); + expect(res.status).toBe(405); + const parsed = ProblemDetailsSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.type).toBe('urn:ok:error:method-not-allowed'); + }); +}); + +describe('local-op-embeddings-test — provider failure', () => { + let failing: FakeEmbeddingsServer; + let failingServer: TestServer; + let failingContentDir: string; + let failingHomeDir: string; + + beforeAll(async () => { + failing = await startFakeEmbeddingsServer({ dims: PROVIDER_DIMS, failWithStatus: 401 }); + failingContentDir = mkdtempSync(join(tmpdir(), 'ok-emb-test-content-')); + failingHomeDir = mkdtempSync(join(tmpdir(), 'ok-emb-test-home-')); + mkdirSync(join(failingContentDir, '.ok', 'local'), { recursive: true }); + writeFileSync(join(failingContentDir, '.ok', 'config.yml'), '', 'utf-8'); + writeFileSync( + join(failingContentDir, '.ok', 'local', 'config.yml'), + `search:\n semantic:\n enabled: true\n baseUrl: ${failing.baseUrl}\n`, + 'utf-8', + ); + mkdirSync(join(failingHomeDir, '.ok'), { recursive: true }); + failingServer = await createTestServer({ + contentDir: failingContentDir, + configHomedirOverride: failingHomeDir, + }); + await fetch(`http://127.0.0.1:${failingServer.port}/api/local-op/embeddings/set-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'sk-bad' }), + }); + }, HARNESS_BOOT_TIMEOUT_MS); + + afterAll(async () => { + await failingServer.cleanup(); + await failing.close(); + rmSync(failingContentDir, { recursive: true, force: true }); + rmSync(failingHomeDir, { recursive: true, force: true }); + }); + + test('a rejected key comes back classified, with the status, at HTTP 200', async () => { + const res = await fetch(`http://127.0.0.1:${failingServer.port}/api/local-op/embeddings/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + // The probe reached a verdict — that is a successful test with a negative + // result, not a server error. + expect(res.status).toBe(200); + const parsed = LocalOpEmbeddingsTestResponseSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.ok) throw new Error('expected a failed probe'); + expect(parsed.data.reason).toBe('http_error'); + expect(parsed.data.status).toBe(401); + }); +}); + +describe('local-op-embeddings-test — no key configured', () => { + let noKeyServer: TestServer; + let noKeyContentDir: string; + let noKeyHomeDir: string; + + beforeAll(async () => { + noKeyContentDir = mkdtempSync(join(tmpdir(), 'ok-emb-test-content-')); + noKeyHomeDir = mkdtempSync(join(tmpdir(), 'ok-emb-test-home-')); + mkdirSync(join(noKeyContentDir, '.ok', 'local'), { recursive: true }); + writeFileSync(join(noKeyContentDir, '.ok', 'config.yml'), '', 'utf-8'); + // A NON-loopback endpoint with no key — keyless doesn't apply, so the route + // must report `no_key` WITHOUT making a network call. + writeFileSync( + join(noKeyContentDir, '.ok', 'local', 'config.yml'), + 'search:\n semantic:\n enabled: true\n baseUrl: https://api.openai.com/v1\n', + 'utf-8', + ); + mkdirSync(join(noKeyHomeDir, '.ok'), { recursive: true }); + noKeyServer = await createTestServer({ + contentDir: noKeyContentDir, + configHomedirOverride: noKeyHomeDir, + }); + }, HARNESS_BOOT_TIMEOUT_MS); + + afterAll(async () => { + await noKeyServer.cleanup(); + rmSync(noKeyContentDir, { recursive: true, force: true }); + rmSync(noKeyHomeDir, { recursive: true, force: true }); + }); + + test('reports no_key (no network call) when no key is stored for a remote endpoint', async () => { + const res = await fetch(`http://127.0.0.1:${noKeyServer.port}/api/local-op/embeddings/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + expect(res.status).toBe(200); + const parsed = LocalOpEmbeddingsTestResponseSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.ok) throw new Error('expected a failed probe'); + expect(parsed.data.reason).toBe('no_key'); + }); +}); diff --git a/packages/app/tests/integration/attribution-sweep-coverage.test.ts b/packages/app/tests/integration/attribution-sweep-coverage.test.ts index 22307fe8a..a53190f30 100644 --- a/packages/app/tests/integration/attribution-sweep-coverage.test.ts +++ b/packages/app/tests/integration/attribution-sweep-coverage.test.ts @@ -248,6 +248,10 @@ const EXEMPT_HANDLERS = new Set([ // sibling local-op auth handlers. No agent identity to thread. 'handleLocalOpEmbeddingsSetKey', 'handleLocalOpEmbeddingsClearKey', + // POST /api/local-op/embeddings/test — one probe embed of a fixed string + // against the configured endpoint. Reads config + the credential file and + // writes nothing; no document content, so no identity to thread. + 'handleLocalOpEmbeddingsTest', 'handleTestReset', // POST /api/test-flush-git — test-routes-only L2 git-flush drain; mutates // no document content (commits what persistence already wrote), so there diff --git a/packages/app/tests/integration/conflict-gate-coverage.test.ts b/packages/app/tests/integration/conflict-gate-coverage.test.ts index 52ee5921d..1b698c030 100644 --- a/packages/app/tests/integration/conflict-gate-coverage.test.ts +++ b/packages/app/tests/integration/conflict-gate-coverage.test.ts @@ -197,9 +197,11 @@ const EXEMPT_HANDLERS = new Set([ 'handleLocalOpAuthPat', 'handleLocalOpAuthGhLogin', // Loopback-gated writes of the machine-global embeddings key to the user's - // secrets file — no Y.Doc mutation, so the conflict-refusal gate doesn't apply. + // secrets file, plus the read-only endpoint probe — no Y.Doc mutation, so the + // conflict-refusal gate doesn't apply. 'handleLocalOpEmbeddingsSetKey', 'handleLocalOpEmbeddingsClearKey', + 'handleLocalOpEmbeddingsTest', 'handleSpawnCursorRoute', 'handleHandoffDispatchRoute', 'handleInstallSkill', diff --git a/packages/cli/src/auth/embeddings-key-store.ts b/packages/cli/src/auth/embeddings-key-store.ts index 67bc898bc..81c5b583b 100644 --- a/packages/cli/src/auth/embeddings-key-store.ts +++ b/packages/cli/src/auth/embeddings-key-store.ts @@ -8,8 +8,8 @@ */ export { - clearEmbeddingsKeyFromAllBackends, + clearAllEmbeddingsKeys, createEmbeddingsSecretStore, - describeStoredEmbeddingsKey, makeLazyEmbeddingsKeyStore, + resolveEmbeddingsCredential, } from '@inkeep/open-knowledge-server'; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4e8257772..7279e01cf 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -277,7 +277,7 @@ program.addCommand(authCommand(getCliLogger)); // embeddings command group — set-key / clear-key / status for semantic search. // A sibling of `auth` (which is GitHub-specific); manages the embeddings -// provider key in the OS keyring + the per-machine capability status. +// provider key in `~/.ok/secrets.yml` + the per-machine capability status. program.addCommand(embeddingsCommand()); // clone command — git clone + auto-start diff --git a/packages/cli/src/commands/embeddings/index.test.ts b/packages/cli/src/commands/embeddings/index.test.ts index 74b76ae7f..3edfdbb3e 100644 --- a/packages/cli/src/commands/embeddings/index.test.ts +++ b/packages/cli/src/commands/embeddings/index.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { DEFAULT_EMBEDDINGS_BASE_URL } from '@inkeep/open-knowledge-core'; +import { DEFAULT_EMBEDDINGS_BASE_URL, DEFAULT_EMBEDDINGS_MODEL } from '@inkeep/open-knowledge-core'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { embeddingsCommand } from './index.ts'; @@ -85,4 +85,34 @@ describe('ok embeddings set-url / clear-url', () => { expect(process.exitCode).toBe(0); expect(readLocalConfig(dir)).toContain(DEFAULT_EMBEDDINGS_BASE_URL); }); + + // The other half of pointing at your own server: a custom endpoint is only + // usable once you can name the model it serves, and this persona is often + // headless with no Settings UI to reach for. + test('set-model writes a free-text model id to project-local config', async () => { + await run('set-model', 'nomic-embed-text', '--cwd', dir); + expect(process.exitCode).toBe(0); + expect(readLocalConfig(dir)).toContain('nomic-embed-text'); + }); + + test('set-model trims surrounding whitespace before writing', async () => { + await run('set-model', ' nomic-embed-text ', '--cwd', dir); + expect(process.exitCode).toBe(0); + expect(readLocalConfig(dir)).toContain('nomic-embed-text'); + expect(readLocalConfig(dir)).not.toContain(' nomic-embed-text'); + }); + + test('set-model rejects an empty model id without writing (exit 1)', async () => { + await run('set-model', ' ', '--cwd', dir); + expect(process.exitCode).toBe(1); + expect(stderr).toContain('cannot be empty'); + expect(readLocalConfig(dir)).toBe(''); + }); + + test('clear-model resets the model to the default', async () => { + await run('set-model', 'nomic-embed-text', '--cwd', dir); + await run('clear-model', '--cwd', dir); + expect(process.exitCode).toBe(0); + expect(readLocalConfig(dir)).toContain(DEFAULT_EMBEDDINGS_MODEL); + }); }); diff --git a/packages/cli/src/commands/embeddings/index.ts b/packages/cli/src/commands/embeddings/index.ts index 5af183381..517a8bdad 100644 --- a/packages/cli/src/commands/embeddings/index.ts +++ b/packages/cli/src/commands/embeddings/index.ts @@ -2,28 +2,30 @@ * `ok embeddings` — manage the semantic-search embeddings provider key + status. * * Subcommands: - * - `set-key` store the provider API key in `~/.ok/secrets.yml` (0600). - * - `clear-key` remove the stored key. + * - `set-key` / `clear-key` store / remove the API key for THIS project's + * configured endpoint (keys are per-project + per-endpoint now). + * - `list` show every project + endpoint that has a stored key. * - `set-url` / `clear-url` set / reset `search.semantic.baseUrl` (project-local). + * - `set-model` / `clear-model` set / reset `search.semantic.model` (project-local). * - `enable` / `disable` flip `search.semantic.enabled` for the project (project-local). - * - `status` show key presence (machine-global) + enabled/capability/coverage (per-project). + * - `status` show key presence + enabled/capability/coverage (per-project). * * The key is read from stdin (piped) or a hidden prompt, stored only in the - * 0600 `~/.ok/secrets.yml` file (NOT the OS keychain — a keychain read prompts - * the user, unacceptable on the agent-triggered search path), and NEVER echoed, - * logged, or written to project config. A sibling of `ok auth` (GitHub-specific, - * which DOES use the keychain — it's more sensitive and not search-triggered). + * 0600 `~/.ok/secrets.yml` file keyed by (project, endpoint) — NOT the OS + * keychain (a keychain read prompts on the agent-triggered search path), and + * never in the project tree. NEVER echoed, logged, or written to project + * config. A sibling of `ok auth` (GitHub-specific, which DOES use the keychain). */ import { resolve } from 'node:path'; import { checkEmbeddingsBaseUrl, DEFAULT_EMBEDDINGS_BASE_URL, + DEFAULT_EMBEDDINGS_MODEL, humanFormat, } from '@inkeep/open-knowledge-core'; import { writeConfigPatch } from '@inkeep/open-knowledge-core/server'; import { - DEFAULT_EMBEDDINGS_DIMENSIONS, EMBEDDINGS_API_KEY_ENV, isProcessAlive, readProjectLocalSemanticConfig, @@ -33,9 +35,8 @@ import { import password from '@inquirer/password'; import { Command } from 'commander'; import { - clearEmbeddingsKeyFromAllBackends, createEmbeddingsSecretStore, - describeStoredEmbeddingsKey, + resolveEmbeddingsCredential, } from '../../auth/embeddings-key-store.ts'; /** Read the key from piped stdin, else a hidden interactive prompt. */ @@ -56,12 +57,24 @@ function readSemanticConfig(projectDir: string) { return readProjectLocalSemanticConfig(projectDir); } -/** Whether a key is resolvable: stored file OR env override. Never prompts. */ -async function resolveKeyPresence(): Promise<{ present: boolean; source: 'file' | 'env' | null }> { - const stored = await describeStoredEmbeddingsKey(); - if (stored.file) return { present: true, source: 'file' }; - if (process.env[EMBEDDINGS_API_KEY_ENV]) return { present: true, source: 'env' }; - return { present: false, source: null }; +/** + * Whether a key resolves for this project's configured endpoint, via the SAME + * resolver the server uses (project key → env on the default host → keyless + * loopback), so `status` can't disagree with what a search would do. + */ +async function resolveKeyPresence( + projectDir: string, + baseUrl: string, +): Promise<{ present: boolean; notRequired: boolean; source: 'project' | 'file' | 'env' | null }> { + const cred = await resolveEmbeddingsCredential( + createEmbeddingsSecretStore(), + projectDir, + baseUrl, + ); + if (cred.apiKey) { + return { present: true, notRequired: false, source: cred.source as 'project' | 'file' | 'env' }; + } + return { present: false, notRequired: cred.keyless, source: null }; } /** @@ -90,35 +103,82 @@ async function fetchLiveCoverage( function setKeyCommand(): Command { return new Command('set-key') - .description('Store your embeddings provider API key in ~/.ok/secrets.yml') - .action(async () => { + .description("Store the embeddings API key for THIS project's configured endpoint") + .option('--cwd ', 'Project directory (defaults to the current directory)') + .action(async (opts: { cwd?: string }) => { const key = await readKey(); if (!key) { process.stderr.write('No key provided.\n'); process.exitCode = 1; return; } - await createEmbeddingsSecretStore().set(key); + const projectDir = resolve(opts.cwd ?? process.cwd()); + const { baseUrl } = readSemanticConfig(projectDir); + try { + await createEmbeddingsSecretStore().setForProject(projectDir, baseUrl, key); + } catch (e) { + // The CLI is the headless path (keys piped from a secrets manager), so a + // disk-write failure must report clearly rather than dump a stack. + process.stderr.write( + `Failed to store the embeddings key: ${e instanceof Error ? e.message : String(e)}\n`, + ); + process.exitCode = 1; + return; + } // Never echo the key. process.stderr.write( - '✓ Embeddings provider API key stored in ~/.ok/secrets.yml (0600, this machine only).\n' + - 'Now enable it per project — the easiest path is OK Desktop → Settings → This\n' + - 'project → Search (toggle + endpoint settings), or run\n' + - '`ok embeddings enable` in the project folder.\n', + `✓ Embeddings API key stored for ${baseUrl}\n` + + ' (kept in ~/.ok/secrets.yml, 0600, this machine only — never in the project).\n' + + ' Enable semantic search with `ok embeddings enable`, or in\n' + + ' OK Desktop → Settings → This project → Search.\n', ); }); } function clearKeyCommand(): Command { return new Command('clear-key') - .description('Remove your stored embeddings provider API key') + .description("Remove the embeddings API key for THIS project's configured endpoint") + .option('--cwd ', 'Project directory (defaults to the current directory)') + .action(async (opts: { cwd?: string }) => { + const projectDir = resolve(opts.cwd ?? process.cwd()); + const { baseUrl } = readSemanticConfig(projectDir); + let removed: boolean; + try { + removed = await createEmbeddingsSecretStore().clearForProject(projectDir, baseUrl); + } catch (e) { + process.stderr.write( + `Failed to clear the embeddings key: ${e instanceof Error ? e.message : String(e)}\n`, + ); + process.exitCode = 1; + return; + } + if (!removed) { + process.stderr.write(`No stored embeddings key found for ${baseUrl}.\n`); + return; + } + process.stderr.write(`✓ Embeddings API key cleared for ${baseUrl}.\n`); + }); +} + +function listCommand(): Command { + return new Command('list') + .description('List every project + endpoint that has a stored embeddings key') .action(async () => { - const { touched } = await clearEmbeddingsKeyFromAllBackends(); - if (touched.length === 0) { - process.stderr.write('No stored embeddings provider key found.\n'); + const projects = await createEmbeddingsSecretStore().listProjects(); + if (projects.length === 0) { + process.stderr.write('No project embeddings keys stored.\n'); return; } - process.stderr.write(`✓ Embeddings provider API key cleared (${touched.join(', ')}).\n`); + // Read-only view; also the way to spot orphaned entries from projects that + // were moved or deleted (their path no longer exists on disk). + const lines: string[] = ['Stored embeddings keys (this machine):', '']; + for (const { projectKey, endpoints } of projects) { + lines.push(` ${projectKey}`); + for (const { endpoint, hint } of endpoints) { + lines.push(` ${endpoint} → ${hint ? `••••${hint}` : 'set'}`); + } + } + process.stdout.write(`${lines.join('\n')}\n`); }); } @@ -190,6 +250,71 @@ function clearUrlCommand(): Command { }); } +/** + * `set-model` / `clear-model` are the endpoint commands' other half: a custom + * endpoint is only usable once you can also name the model it serves. Without + * these the persona this whole feature targets — someone running their own + * server, often headless — can set the URL from the CLI but has to hand-edit + * `config.yml` for the model. + * + * Free-text by design (no preset list): whatever the endpoint serves is valid, + * and a curated catalog would rot. The vector size is detected from the + * provider, so there is no matching dimensions command. + */ +function setModelCommand(): Command { + return new Command('set-model') + .description('Set the embeddings model for this project (project-local)') + .argument('', 'Model id served by the configured endpoint, e.g. nomic-embed-text') + .option('--cwd ', 'Project directory (defaults to the current directory)') + .action(async (modelArg: string, opts: { cwd?: string }) => { + const model = modelArg.trim(); + if (!model) { + process.stderr.write('Model id cannot be empty. Use `clear-model` to reset the default.\n'); + process.exitCode = 1; + return; + } + const projectDir = resolve(opts.cwd ?? process.cwd()); + const result = await writeConfigPatch({ + cwd: projectDir, + scope: 'project-local', + patch: { search: { semantic: { model } } }, + }); + if (!result.ok) { + process.stderr.write(`Failed to set the embeddings model — ${humanFormat(result.error)}\n`); + process.exitCode = 1; + return; + } + process.stderr.write( + `✓ Embeddings model set to ${model} for ${projectDir}\n` + + ' Changing the model re-embeds the corpus on the next search.\n', + ); + }); +} + +function clearModelCommand(): Command { + return new Command('clear-model') + .description(`Reset the embeddings model to ${DEFAULT_EMBEDDINGS_MODEL} (project-local)`) + .option('--cwd ', 'Project directory (defaults to the current directory)') + .action(async (opts: { cwd?: string }) => { + const projectDir = resolve(opts.cwd ?? process.cwd()); + const result = await writeConfigPatch({ + cwd: projectDir, + scope: 'project-local', + patch: { search: { semantic: { model: DEFAULT_EMBEDDINGS_MODEL } } }, + }); + if (!result.ok) { + process.stderr.write( + `Failed to reset the embeddings model — ${humanFormat(result.error)}\n`, + ); + process.exitCode = 1; + return; + } + process.stderr.write( + `✓ Embeddings model reset to ${DEFAULT_EMBEDDINGS_MODEL} for ${projectDir}\n`, + ); + }); +} + /** * `enable` / `disable` flip `search.semantic.enabled` in the project-local config * (`/.ok/local/config.yml`) — the same field + scope the Settings toggle @@ -216,8 +341,9 @@ function toggleEnabledCommand(name: 'enable' | 'disable', value: boolean): Comma `✓ Semantic search ${value ? 'enabled' : 'disabled'} for ${projectDir}\n`, ); if (value) { - const { present } = await resolveKeyPresence(); - if (!present) { + const cfg = readSemanticConfig(projectDir); + const { present, notRequired } = await resolveKeyPresence(projectDir, cfg.baseUrl); + if (!present && !notRequired) { process.stderr.write( ' Note: no API key set yet — run `ok embeddings set-key`. Until then, search stays lexical.\n', ); @@ -234,8 +360,12 @@ function statusCommand(): Command { .action(async (opts: { cwd?: string; json?: boolean }) => { const projectDir = resolve(opts.cwd ?? process.cwd()); const cfg = readSemanticConfig(projectDir); - const { present: hasKey, source: keySource } = await resolveKeyPresence(); - const capable = cfg.enabled && hasKey; + const { + present: hasKey, + notRequired: keyNotRequired, + source: keySource, + } = await resolveKeyPresence(projectDir, cfg.baseUrl); + const capable = cfg.enabled && (hasKey || keyNotRequired); // Coverage is only meaningful once capable; skip the server probe otherwise. const coverage = capable ? await fetchLiveCoverage(projectDir) : null; @@ -243,7 +373,7 @@ function statusCommand(): Command { process.stdout.write( `${JSON.stringify({ project: projectDir, - machine: { keyPresent: hasKey, keySource }, + key: { present: hasKey, notRequired: keyNotRequired, source: keySource }, project_config: { enabled: cfg.enabled, capable, @@ -260,8 +390,10 @@ function statusCommand(): Command { } const keyLabel = hasKey - ? `set — ${keySource === 'env' ? `environment (${EMBEDDINGS_API_KEY_ENV})` : '~/.ok/secrets.yml'}` - : 'not set'; + ? `set for this endpoint — ${keySource === 'env' ? `environment (${EMBEDDINGS_API_KEY_ENV})` : '~/.ok/secrets.yml'}` + : keyNotRequired + ? 'not required (localhost endpoint)' + : 'not set'; const coverageLabel = !capable ? null : coverage @@ -272,20 +404,18 @@ function statusCommand(): Command { 'Semantic search', ` project: ${projectDir}`, '', - ' This machine (all projects):', - ` API key: ${keyLabel}`, - '', ' This project:', ` enabled: ${cfg.enabled ? 'yes' : 'no'}`, + ` API key: ${keyLabel}`, ` capability: ${capable ? 'AVAILABLE' : 'unavailable (search stays lexical)'}`, ...(coverageLabel ? [` coverage: ${coverageLabel}`] : []), ` provider: ${cfg.baseUrl}`, ` model: ${cfg.model}`, - ` dimensions: ${cfg.dimensions ?? `native (${DEFAULT_EMBEDDINGS_DIMENSIONS})`}`, + ` dimensions: ${cfg.dimensions ?? 'auto (detected from the endpoint)'}`, ]; const hints: string[] = []; - if (!hasKey) { + if (!hasKey && !keyNotRequired) { hints.push(`Set a key: ok embeddings set-key (or export ${EMBEDDINGS_API_KEY_ENV})`); } if (!cfg.enabled) { @@ -305,7 +435,10 @@ export function embeddingsCommand(): Command { .addCommand(clearKeyCommand()) .addCommand(setUrlCommand()) .addCommand(clearUrlCommand()) + .addCommand(setModelCommand()) + .addCommand(clearModelCommand()) .addCommand(toggleEnabledCommand('enable', true)) .addCommand(toggleEnabledCommand('disable', false)) + .addCommand(listCommand()) .addCommand(statusCommand()); } diff --git a/packages/cli/src/commands/removal-plan.ts b/packages/cli/src/commands/removal-plan.ts index abbcc5551..bd2019a89 100644 --- a/packages/cli/src/commands/removal-plan.ts +++ b/packages/cli/src/commands/removal-plan.ts @@ -27,7 +27,7 @@ import { atomicWriteFileSync } from '@inkeep/open-knowledge-core/server'; // existsSync gates the actual removal. import { resolveShadowDir } from '@inkeep/open-knowledge-core/shadow-repo-layout'; import { resolveLockDir } from '@inkeep/open-knowledge-server'; -import { clearEmbeddingsKeyFromAllBackends } from '../auth/embeddings-key-store.ts'; +import { clearAllEmbeddingsKeys } from '../auth/embeddings-key-store.ts'; import { clearTokenFromAllBackends } from '../auth/token-store.ts'; import { DESKTOP_LEGACY_PRODUCT_NAME, @@ -290,7 +290,7 @@ export function buildUninstallPlan(input: UninstallPlanInput): RemovalPlan { ops.push({ kind: 'embeddings-key', group: 'Credentials', - label: 'Remove the embeddings API key (secrets.yml)', + label: 'Remove all embeddings API keys (secrets.yml)', }); // 3. PATH shim revert — strip the managed block from each recorded rc file and @@ -456,7 +456,7 @@ export interface RunRemovalDeps { clearToken?: ( host: string, ) => Promise<{ touched: Array<'keychain' | 'file'>; keychainError?: string }>; - /** Clear the embeddings key. Injectable (touches ~/.ok/secrets.yml). */ + /** Clear a project's embeddings keys. Injectable (touches ~/.ok/secrets.yml). */ clearEmbeddingsKey?: () => Promise<{ touched: Array<'file'> }>; /** * Stop a server. Injectable (SIGTERMs real processes). Returns both the count @@ -479,7 +479,7 @@ export async function runRemoval( deps: RunRemovalDeps = {}, ): Promise { const clearToken = deps.clearToken ?? clearTokenFromAllBackends; - const clearEmbeddingsKey = deps.clearEmbeddingsKey ?? clearEmbeddingsKeyFromAllBackends; + const clearEmbeddingsKey = deps.clearEmbeddingsKey ?? clearAllEmbeddingsKeys; const stopServer = deps.stopServer ?? ((lockDir: string) => { diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index a68e8eec0..20851d630 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -27,7 +27,7 @@ export const DEFAULT_LOGS_MAX_BYTES = 26_214_400; // Non-secret embeddings-provider defaults. Shared with the server so the live // layered config read and the schema `.default()` below cannot drift. The API -// key is NEVER a config value — it lives only in the OS keyring. +// key is NEVER a config value — it lives only in `~/.ok/secrets.yml` (0600). export const DEFAULT_EMBEDDINGS_BASE_URL = 'https://api.openai.com/v1'; export const DEFAULT_EMBEDDINGS_MODEL = 'text-embedding-3-small'; @@ -44,6 +44,27 @@ export type EmbeddingsBaseUrlProblem = 'invalid-url' | 'insecure-scheme'; * endpoint at entry instead of letting it surface later as a provider-rejected * status. Whitespace is the caller's to trim. */ +/** + * True when the URL targets the local machine's loopback interface. Checked on + * the PARSED hostname (never a substring of the raw URL) so an attacker host + * like `http://localhost.evil.com` or `http://127.0.0.1.evil.com` can't pass — + * their `hostname` is the full foreign name, not `localhost`/`127.0.0.1`. + * The single source of truth for "may be keyless" and "http:// is permitted": + * a keyless or plaintext request is only ever allowed to a host that stays on + * this machine. Non-URLs are not loopback. + */ +export function isLoopbackEmbeddingsUrl(baseUrl: string): boolean { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return false; + } + // `URL.hostname` returns IPv6 hosts bracketed (`[::1]`), never bare `::1`. + const host = url.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; +} + export function checkEmbeddingsBaseUrl(baseUrl: string): EmbeddingsBaseUrlProblem | null { let url: URL; try { @@ -52,10 +73,7 @@ export function checkEmbeddingsBaseUrl(baseUrl: string): EmbeddingsBaseUrlProble return 'invalid-url'; } if (url.protocol === 'https:') return null; - // `URL.hostname` returns IPv6 hosts bracketed (`[::1]`), never bare `::1`. - const host = url.hostname.toLowerCase(); - const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; - if (url.protocol === 'http:' && isLoopback) return null; + if (url.protocol === 'http:' && isLoopbackEmbeddingsUrl(baseUrl)) return null; return 'insecure-scheme'; } @@ -589,13 +607,14 @@ export const ConfigSchema = z.looseObject({ // PROJECT-LOCAL scope: semantic search is an additive embeddings signal fused // into the MCP `search` tool's lexical ranking. It is per-machine, not // project-shared, because enabling it sends content to a third-party - // embeddings provider (egress) and needs an API key in the local OS keyring — + // embeddings provider (egress) and needs an API key in the local secrets file — // each teammate opts in deliberately for their own machine. Project scope // would force one teammate's egress choice across collaborators via git; user // scope would force it for every project. Default OFF — the feature ships dark. // // The non-secret provider knobs (baseUrl / model / dimensions) live here; the - // API key NEVER does — it lives only in the OS keyring (`ok embeddings set-key`). + // API key NEVER does — it lives only in the 0600 `~/.ok/secrets.yml` + // (`ok embeddings set-key`), out of the agent-readable project tree. search: z .looseObject({ semantic: z @@ -617,7 +636,7 @@ export const ConfigSchema = z.looseObject({ agentSettable: false, defaultScope: 'project-local', description: - 'Base URL of the OpenAI-compatible embeddings API (default https://api.openai.com/v1). Override for Azure / self-hosted / other providers. The API key is NOT stored here — set it with `ok embeddings set-key` (OS keyring).', + 'Base URL of the OpenAI-compatible embeddings API (default https://api.openai.com/v1). Override to point at a self-hosted server (Ollama / vLLM / LM Studio) or another provider. The API key is NOT stored here — set it with `ok embeddings set-key` (`~/.ok/secrets.yml`); it is sent to whichever endpoint this names.', }) .default(DEFAULT_EMBEDDINGS_BASE_URL), model: z @@ -639,7 +658,7 @@ export const ConfigSchema = z.looseObject({ agentSettable: false, defaultScope: 'project-local', description: - "Optional output vector dimensions. Omit to use the model's native size (1536 for text-embedding-3-small). Set a smaller value (text-embedding-3 supports e.g. 512 / 1024) to shrink the on-disk cache, trading a little retrieval quality. Changing it re-embeds the corpus.", + "Optional output vector dimensions. Omit (recommended) to detect the model's native size from its first response — that is what lets a non-OpenAI model work without knowing its size up front. Set a smaller value (text-embedding-3 supports e.g. 512 / 1024) to shrink the on-disk cache, trading a little retrieval quality; a server that ignores the request param then fails loudly instead of silently. Changing it re-embeds the corpus.", }) .optional(), similarityFloor: z diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f085ccc6f..b69099137 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -168,6 +168,7 @@ export { DEFAULT_LOGS_MAX_BYTES, DEFAULT_SPANS_MAX_BYTES, DEFAULT_TELEMETRY_ATTRIBUTE_DENYLIST, + isLoopbackEmbeddingsUrl, isValidAttachmentFolderPath, normalizeAttachmentFolderPath, } from './config/schema.ts'; @@ -734,6 +735,8 @@ export { EmbedDetectionSchema, type EmbedDetectSuccess, EmbedDetectSuccessSchema, + type EmbeddingsTestFailureReason, + EmbeddingsTestFailureReasonSchema, EmbedProbeEntrySchema, type EmbedProbeEntryWire, type EmptyRequest, @@ -818,6 +821,8 @@ export { LocalOpEmbeddingsMutationSuccessSchema, type LocalOpEmbeddingsSetKeyRequest, LocalOpEmbeddingsSetKeyRequestSchema, + type LocalOpEmbeddingsTestResponse, + LocalOpEmbeddingsTestResponseSchema, type LocalOpOkInitFailureReason, LocalOpOkInitFailureReasonSchema, type LocalOpOkInitRequest, diff --git a/packages/core/src/schemas/api/local-op.ts b/packages/core/src/schemas/api/local-op.ts index 020529a46..544f08ce7 100644 --- a/packages/core/src/schemas/api/local-op.ts +++ b/packages/core/src/schemas/api/local-op.ts @@ -140,6 +140,61 @@ export type LocalOpEmbeddingsMutationSuccess = z.infer< typeof LocalOpEmbeddingsMutationSuccessSchema >; +/** + * Why a live embeddings connection probe failed. + * + * The six provider-call outcomes mirror the server's `EmbeddingErrorReason` + * classification 1:1 (a compile-time assertion in `api-extension.ts` keeps the + * two in step), plus two states resolvable before any request leaves the + * machine: no key configured, and a base URL the plaintext-key guard refuses. + */ +export const EmbeddingsTestFailureReasonSchema = z.enum([ + 'no_key', + 'invalid_endpoint', + 'rate_limit', + 'timeout', + 'http_error', + 'network', + 'dims_mismatch', + 'malformed_response', +]) satisfies StandardSchemaV1; +export type EmbeddingsTestFailureReason = z.infer; + +/** + * Response body for `POST /api/local-op/embeddings/test` — one tiny probe embed + * of a fixed innocuous string (never user content) against the SAVED endpoint + + * the resolved key, discriminated on `ok`. Both branches return HTTP 200: a + * provider that rejects us is a successful test with a negative result, not a + * server error. + * + * `endpoint` / `model` echo what was actually probed, so the UI can tell the + * user their just-typed change hasn't reached the server yet instead of + * reporting a result for the previous endpoint. `dimensions` is the detected + * vector length — the readout that replaces a manual dimensions field. + * `status` carries the provider's HTTP status when there was one; no provider + * response body is ever echoed. + */ +export const LocalOpEmbeddingsTestResponseSchema = z.discriminatedUnion('ok', [ + z + .object({ + ok: z.literal(true), + endpoint: z.string().min(1), + model: z.string().min(1), + dimensions: z.number().int().positive(), + }) + .loose(), + z + .object({ + ok: z.literal(false), + endpoint: z.string().min(1), + model: z.string().min(1), + reason: EmbeddingsTestFailureReasonSchema, + status: z.number().int().optional(), + }) + .loose(), +]) satisfies StandardSchemaV1; +export type LocalOpEmbeddingsTestResponse = z.infer; + /** * Request body for `POST /api/local-op/auth/set-identity`. `name` and `email` * REQUIRED non-empty (after `.trim()` — empty-after-trim values fail schema diff --git a/packages/core/src/schemas/api/tags-search.ts b/packages/core/src/schemas/api/tags-search.ts index bf268c93a..55b6ee918 100644 --- a/packages/core/src/schemas/api/tags-search.ts +++ b/packages/core/src/schemas/api/tags-search.ts @@ -906,7 +906,13 @@ export const SemanticIndexStatusSchema = z .object({ enabled: z.boolean(), keyPresent: z.boolean(), - keySource: z.enum(['file', 'env']).nullable(), + /** + * True when the configured endpoint is loopback (localhost) and so needs no + * key — the UI shows "not required" instead of nagging for one. Only + * meaningful when `!keyPresent`. + */ + keyNotRequired: z.boolean(), + keySource: z.enum(['project', 'file', 'env']).nullable(), /** * A redacted tail (the last few characters) of the resolved key, so the UI * can show WHICH key is stored without the key ever being returned in full. diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index 47665a0b4..21c24c934 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -70,6 +70,8 @@ import { createWorkspaceSearchDocument, DEFAULT_ATTACHMENT_FOLDER_PATH, DEFAULT_DEDUP_MODE, + DEFAULT_EMBEDDINGS_BASE_URL, + DEFAULT_EMBEDDINGS_MODEL, DEFAULT_LINKS_VALIDATION, DEFAULT_LINTER_CONFIG, DeadLinksSuccessSchema, @@ -134,6 +136,8 @@ import { LocalOpCloneRequestSchema, LocalOpEmbeddingsMutationSuccessSchema, LocalOpEmbeddingsSetKeyRequestSchema, + type LocalOpEmbeddingsTestResponse, + LocalOpEmbeddingsTestResponseSchema, LocalOpOkInitRequestSchema, LocalOpOkInitResponseSchema, lintDocument, @@ -341,9 +345,10 @@ import { type SemanticQueryOutcome, } from './embeddings/embeddings-telemetry.ts'; import { - clearEmbeddingsKeyFromAllBackends, - EMBEDDINGS_API_KEY_ENV, FileEmbeddingsBackend, + probeEmbeddingEndpoint, + type ResolvedSemanticConfig, + resolveEmbeddingsCredential, SEMANTIC_MIN_QUERY_LENGTH, type SemanticSearchService, } from './embeddings/index.ts'; @@ -2919,6 +2924,14 @@ export interface ApiExtensionOptions { * the real path when omitted. */ embeddingsSecretsFile?: string; + /** + * Resolve the project-local `search.semantic.*` provider knobs, read FRESH so + * the Test-connection probe hits whatever is currently persisted — the same + * contract as the embedder loader, so a probe and a real embed can never + * disagree about which endpoint is configured. Omitted in tests that don't + * exercise the probe (the route then reports `no_key`-style unavailability). + */ + readSemanticProviderConfig?: () => ResolvedSemanticConfig; /** * Resolve the project's base `contentRules` config (project scope), read FRESH * per request so a config edit takes effect without a restart. The lint @@ -3048,6 +3061,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { semanticSearch, getSemanticSimilarityFloor, embeddingsSecretsFile, + readSemanticProviderConfig, getLinterBaseConfig, getLinksValidationSetting, ephemeral = false, @@ -18909,6 +18923,21 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { // click) avoids a lost update. Mirrors the other local-op handlers. const LOCAL_OP_EMBEDDINGS_GUARD = '/api/local-op/embeddings'; + // The (project, endpoint) a key op targets — derived ENTIRELY from the + // server's own identity + persisted config, NEVER a request body field. The + // route is loopback-gated but unauthenticated; letting the body name the + // project or endpoint would be a cross-project key-planting primitive. The + // body carries key bytes only. The project is this server's project; the + // endpoint is whatever the project currently has configured — so the key + // binds to exactly the endpoint the next embed will use. + function embeddingsKeyScope(): { projectDir: string; baseUrl: string } { + const cfg = readSemanticProviderConfig?.(); + return { + projectDir: projectDir ?? contentDir, + baseUrl: cfg?.baseUrl ?? DEFAULT_EMBEDDINGS_BASE_URL, + }; + } + const handleLocalOpEmbeddingsSetKey = withValidation( LocalOpEmbeddingsSetKeyRequestSchema, async (_req, res, body) => { @@ -18923,7 +18952,11 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { return; } try { - await new FileEmbeddingsBackend(embeddingsSecretsFile).set(body.key); + const { projectDir: pd, baseUrl } = embeddingsKeyScope(); + await new FileEmbeddingsBackend(embeddingsSecretsFile).setForProject(pd, baseUrl, body.key); + // Re-warm on the next search so the new key takes effect without a + // restart (the key isn't part of the provider fingerprint). + semanticSearch?.reloadCredential(); successResponse( res, 200, @@ -18965,7 +18998,9 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { return; } try { - await clearEmbeddingsKeyFromAllBackends(embeddingsSecretsFile); + const { projectDir: pd, baseUrl } = embeddingsKeyScope(); + await new FileEmbeddingsBackend(embeddingsSecretsFile).clearForProject(pd, baseUrl); + semanticSearch?.reloadCredential(); // re-warm so the cleared key takes effect now successResponse( res, 200, @@ -18993,6 +19028,98 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { }, ); + /** + * POST /api/local-op/embeddings/test — one live probe embed against the SAVED + * endpoint + the resolved key. + * + * The on-demand answer to "is my custom endpoint actually working". Every + * embeddings failure degrades quietly to keyword search, so without this a + * wrong URL / key / model looks exactly like a working setup that hasn't + * indexed yet. Sends one fixed, content-free probe string — never a page and + * never a query — and reports either the detected vector length or a + * classified reason. + * + * Deliberately takes NO endpoint from the request body: it probes what is + * persisted, so the route can never be pointed at an arbitrary host, and the + * echoed `endpoint`/`model` let the UI notice its own unsaved edit rather + * than misread a stale result. + */ + const HANDLE_LOCAL_OP_EMBEDDINGS_TEST = 'local-op-embeddings-test'; + // Its own guard slot: a probe waits on a remote provider, so sharing the + // set/clear mutex would let one slow test block the key controls. Serializing + // probes against each other still stops a double-click from double-calling. + const LOCAL_OP_EMBEDDINGS_TEST_GUARD = '/api/local-op/embeddings/test'; + + const handleLocalOpEmbeddingsTest = withValidation( + EmptyRequestSchema, + async (_req, res) => { + if (!localOpGuard.tryAcquire(LOCAL_OP_EMBEDDINGS_TEST_GUARD)) { + errorResponse( + res, + 429, + 'urn:ok:error:concurrent-operation', + 'A connection test is already in progress.', + { handler: HANDLE_LOCAL_OP_EMBEDDINGS_TEST, extraHeaders: { 'Retry-After': '5' } }, + ); + return; + } + try { + // Absent reader = no project-local layer plumbed, which resolves to the + // same defaults `readProjectLocalSemanticConfig` would return. + const config = readSemanticProviderConfig?.() ?? { + baseUrl: DEFAULT_EMBEDDINGS_BASE_URL, + model: DEFAULT_EMBEDDINGS_MODEL, + dimensions: undefined, + }; + // THE shared resolver, so a passing test guarantees the real embed path + // resolves the same credential to the same endpoint (project key → env + // on the default host → keyless loopback). + const cred = await resolveEmbeddingsCredential( + new FileEmbeddingsBackend(embeddingsSecretsFile), + projectDir ?? contentDir, + config.baseUrl, + ); + const echo = { endpoint: config.baseUrl, model: config.model }; + // Typed here rather than inline: `successResponse` takes `unknown`, so + // this annotation is what statically pins the embedder's classification + // to the wire enum — a new `EmbeddingErrorReason` fails to compile + // instead of failing schema validation at the wire boundary. + const probe = + cred.apiKey || cred.keyless + ? await probeEmbeddingEndpoint({ + baseUrl: config.baseUrl, + model: config.model, + dimensions: config.dimensions, + apiKey: cred.apiKey ?? undefined, + }) + : ({ ok: false, reason: 'no_key', status: undefined } as const); + const payload: LocalOpEmbeddingsTestResponse = probe.ok + ? { ok: true, ...echo, dimensions: probe.dimensions } + : { ok: false, ...echo, reason: probe.reason, status: probe.status }; + successResponse(res, 200, LocalOpEmbeddingsTestResponseSchema, payload, { + handler: HANDLE_LOCAL_OP_EMBEDDINGS_TEST, + extraHeaders: { 'Cache-Control': 'no-store' }, + }); + } catch (e) { + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'Failed to test the embeddings endpoint.', + { handler: HANDLE_LOCAL_OP_EMBEDDINGS_TEST, cause: e }, + ); + } finally { + localOpGuard.release(LOCAL_OP_EMBEDDINGS_TEST_GUARD); + } + }, + { + handler: HANDLE_LOCAL_OP_EMBEDDINGS_TEST, + method: 'POST', + preBodyGate: (req, res) => + checkLocalOpSecurity(req, res, { handler: HANDLE_LOCAL_OP_EMBEDDINGS_TEST }), + }, + ); + /** * GET /api/semantic-status — read-only setup/coverage probe for the Settings * UI. Reports the project-local `enabled` flag, `keyPresent` / `keySource` @@ -19021,18 +19148,27 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { capable = status.capable; embedded = status.embeddedCount; } - // Key presence is a free, prompt-free read of the 0600 secrets file (+ env - // override). The key itself is never returned — only `keyHint`, a redacted - // last-4 tail (never the full key), so the UI can show WHICH key is set. - // Lets the UI show "no key" the instant the toggle flips, without a warm. - const storedKey = await new FileEmbeddingsBackend(embeddingsSecretsFile).get(); - const envKey = process.env[EMBEDDINGS_API_KEY_ENV] ?? null; - const keySource: 'file' | 'env' | null = storedKey ? 'file' : envKey ? 'env' : null; - const keyPresent = keySource !== null; + // Resolve the SAME credential the embedder would, for this project + + // its configured endpoint, so status can't disagree with the real path. + // A free, prompt-free file/env read — no warm, no egress. The key itself + // is never returned; only `keyHint` (redacted last-4) so the UI can show + // WHICH key is set. `keyNotRequired` marks a loopback endpoint that needs + // no key at all, so the UI doesn't nag a keyless Ollama/LM Studio user. + const statusConfig = readSemanticProviderConfig?.(); + const statusBaseUrl = statusConfig?.baseUrl ?? DEFAULT_EMBEDDINGS_BASE_URL; + const cred = await resolveEmbeddingsCredential( + new FileEmbeddingsBackend(embeddingsSecretsFile), + projectDir ?? contentDir, + statusBaseUrl, + ); + const keyPresent = cred.apiKey !== null; + const keyNotRequired = !keyPresent && cred.keyless; + const keySource: 'project' | 'file' | 'env' | null = keyPresent + ? (cred.source as 'project' | 'file' | 'env') + : null; // Last 4 chars only, and only when the key is long enough that those 4 are // a negligible fraction (real provider keys are 40+ chars); never the key. - const resolvedKey = storedKey ?? envKey; - const keyHint = resolvedKey && resolvedKey.length >= 8 ? resolvedKey.slice(-4) : null; + const keyHint = cred.apiKey && cred.apiKey.length >= 8 ? cred.apiKey.slice(-4) : null; // Total embeddable pages = the same filtered set the search corpus uses. let total = 0; for (const [docName] of getFileIndex()) { @@ -19044,7 +19180,17 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { res, 200, SemanticIndexStatusSchema, - { enabled, keyPresent, keySource, keyHint, ready, capable, embedded, total }, + { + enabled, + keyPresent, + keyNotRequired, + keySource, + keyHint, + ready, + capable, + embedded, + total, + }, { handler: 'semantic-status', extraHeaders: { 'Cache-Control': 'no-store' } }, ); } catch (e) { @@ -20035,6 +20181,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { '/api/local-op/auth/set-identity': handleLocalOpAuthSetIdentity, '/api/local-op/embeddings/set-key': handleLocalOpEmbeddingsSetKey, '/api/local-op/embeddings/clear-key': handleLocalOpEmbeddingsClearKey, + '/api/local-op/embeddings/test': handleLocalOpEmbeddingsTest, '/api/installed-agents': handleInstalledAgentsRoute, '/api/spawn-cursor': handleSpawnCursorRoute, '/api/handoff': handleHandoffDispatchRoute, diff --git a/packages/server/src/api-search-semantic-factory.test.ts b/packages/server/src/api-search-semantic-factory.test.ts index 442e2d0bd..46ae12b77 100644 --- a/packages/server/src/api-search-semantic-factory.test.ts +++ b/packages/server/src/api-search-semantic-factory.test.ts @@ -472,12 +472,28 @@ describe('createServer boot — embeddings key set/clear handlers (Account contr expect(before.keyPresent).toBe(false); expect(existsSync(secretsPath)).toBe(false); + // Warm the service (the injected concept embedder is always capable) so + // we can observe that set-key re-warms it — the guard against a refactor + // silently dropping `reloadCredential()` (key set but search stays + // lexical until restart). + await searchViaServer(srv, { query: 'auth retries', semantic: true }); + const warmed = (await callViaServer(srv, 'GET', '/api/semantic-status')) as { + ready: boolean; + }; + expect(warmed.ready).toBe(true); + // Set the key via the loopback handler. const setRes = (await callViaServer(srv, 'POST', '/api/local-op/embeddings/set-key', { key: 'sk-account-ui-key', })) as { keyPresent: boolean }; expect(setRes.keyPresent).toBe(true); expect(readFileSync(secretsPath, 'utf-8')).toContain('sk-account-ui-key'); + // set-key reset the warm state, so the new credential is picked up on the + // next search rather than requiring a restart. + const afterSet = (await callViaServer(srv, 'GET', '/api/semantic-status')) as { + ready: boolean; + }; + expect(afterSet.ready).toBe(false); // Status reflects it (free file read). const after = (await callViaServer(srv, 'GET', '/api/semantic-status')) as { @@ -485,7 +501,8 @@ describe('createServer boot — embeddings key set/clear handlers (Account contr keySource: string | null; }; expect(after.keyPresent).toBe(true); - expect(after.keySource).toBe('file'); + // Written as a project+endpoint key now (not the legacy flat field). + expect(after.keySource).toBe('project'); // Clear it. const clearRes = (await callViaServer( diff --git a/packages/server/src/embeddings/auto-dimensions.test.ts b/packages/server/src/embeddings/auto-dimensions.test.ts new file mode 100644 index 000000000..a93676d3d --- /dev/null +++ b/packages/server/src/embeddings/auto-dimensions.test.ts @@ -0,0 +1,278 @@ +/** + * Auto-detected vector dimensions, end to end: the real HTTP embedder against + * the fake provider (`fake-provider.test-helper.ts`), driven by the real + * service and persisted through the real cache. + * + * What this is guarding: with `search.semantic.dimensions` unset, the embedder + * used to declare 1536 and reject every response from any model of another + * size, which surfaced to the user as "semantic search silently does nothing". + * Nothing below uses the concept embedder — it bypasses the HTTP client, so it + * cannot see this class of bug at all. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createWorkspaceSearchDocument, + type WorkspaceSearchDocument, +} from '@inkeep/open-knowledge-core'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { createOpenAiEmbedder } from './embedder.ts'; +import { + createFakeEmbeddingsFetch, + type FakeEmbeddingsProviderOptions, +} from './fake-provider.test-helper.ts'; +import { MAX_DIMS_DRIFT_RESETS, SemanticSearchService } from './semantic-search-service.ts'; + +const BASE_URL = 'https://fake-provider.test/v1'; + +function doc(path: string, content: string, modifiedTs = 1): WorkspaceSearchDocument { + return createWorkspaceSearchDocument({ kind: 'page', path, title: path, content, modifiedTs }); +} + +const corpus = [ + doc('session-tokens', 'The session token refresh flow re-issues credentials when they expire.'), + doc('sourdough', 'A recipe for sourdough bread with a long cold ferment.'), +]; + +let cacheDir: string; +beforeEach(() => { + cacheDir = mkdtempSync(join(tmpdir(), 'ok-autodims-')); +}); +afterEach(() => { + rmSync(cacheDir, { recursive: true, force: true }); +}); + +interface Rig { + service: SemanticSearchService; + requests: { input: string[]; dimensions?: number }[]; +} + +/** A service wired to the real embedder over a fake provider socket-alike. */ +function makeService( + provider: FakeEmbeddingsProviderOptions, + over: { dimensions?: number; model?: string } = {}, +): Rig { + const model = over.model ?? 'fake-model'; + const { fetchImpl, requests } = createFakeEmbeddingsFetch(provider); + const service = new SemanticSearchService({ + loadEmbedder: () => + Promise.resolve( + createOpenAiEmbedder( + { baseUrl: BASE_URL, model, dimensions: over.dimensions, apiKey: 'sk-fake' }, + { fetchImpl, sleep: () => Promise.resolve() }, + ), + ), + cacheDir, + enabled: true, + providerFingerprint: `${BASE_URL}|${model}|${over.dimensions ?? 'auto'}`, + }); + return { service, requests }; +} + +function readManifest(): { dims: number; identityDims: number | 'auto' } { + return JSON.parse(readFileSync(join(cacheDir, 'manifest.json'), 'utf-8')); +} + +describe('auto-detected embedding dimensions', () => { + test('a 1024-dim model with no configured size embeds and retrieves', async () => { + const { service } = makeService({ dims: 1024 }); + await service.embedCorpus(corpus); + + expect(service.getStatus().embeddedCount).toBe(corpus.length); + const scores = await service.queryScores('session credentials', corpus); + expect(scores?.size).toBe(corpus.length); + expect(readManifest().dims).toBe(1024); + }); + + test('the detected size survives a restart without re-embedding', async () => { + const first = makeService({ dims: 1024 }); + await first.service.embedCorpus(corpus); + const requestsAfterFirstRun = first.requests.length; + expect(requestsAfterFirstRun).toBeGreaterThan(0); + + // Same config, same cache dir — a fresh process. + const second = makeService({ dims: 1024 }); + await second.service.embedCorpus(corpus); + + expect(second.requests).toHaveLength(0); // nothing re-embedded + expect(second.service.getStatus().embeddedCount).toBe(corpus.length); + }); + + test('identity is the "auto" sentinel, never the detected value', async () => { + const { service } = makeService({ dims: 1024 }); + await service.embedCorpus(corpus); + const manifest = readManifest(); + // The two must differ in kind: folding the detected length into the + // identity is what would make every restart look like a config change. + expect(manifest.identityDims).toBe('auto'); + expect(manifest.dims).toBe(1024); + }); + + test('a legacy manifest (no identityDims) is adopted, not re-embedded', async () => { + const first = makeService({ dims: 1536 }); + await first.service.embedCorpus(corpus); + + // Rewrite the manifest in the pre-split shape an older build wrote. + const manifestPath = join(cacheDir, 'manifest.json'); + const legacy = JSON.parse(readFileSync(manifestPath, 'utf-8')); + delete legacy.identityDims; + rmSync(manifestPath); + const { writeFileSync } = await import('node:fs'); + writeFileSync(manifestPath, JSON.stringify(legacy)); + + const second = makeService({ dims: 1536 }); + await second.service.embedCorpus(corpus); + expect(second.requests).toHaveLength(0); + expect(second.service.getStatus().embeddedCount).toBe(corpus.length); + }); + + test('a provider that swaps model size mid-life rebuilds instead of dying', async () => { + // Serve the first pass at 1024, then answer everything at 1536. + const { service, requests } = makeService({ + dims: 1024, + driftDims: 1536, + driftAfterRequests: 1, + }); + await service.embedCorpus(corpus); + expect(service.getStatus().embeddedCount).toBeGreaterThan(0); + const afterFirst = requests.length; + + // The drift shows up on the next pass; the service discards and re-warms. + await service.embedCorpus(corpus.map((d) => doc(d.path, `${d.content} updated`, 2))); + expect(requests.length).toBeGreaterThan(afterFirst); + + // A further pass now succeeds at the new size rather than staying dead. + const updated = corpus.map((d) => doc(d.path, `${d.content} updated`, 2)); + await service.embedCorpus(updated); + expect(service.getStatus().embeddedCount).toBe(updated.length); + expect(readManifest().dims).toBe(1536); + }); + + test('a query-side size change recovers even when no document changed', async () => { + const { service } = makeService({ dims: 1024, driftDims: 1536, driftAfterRequests: 1 }); + await service.embedCorpus(corpus); + expect(service.getStatus().embeddedCount).toBe(corpus.length); + + // The corpus is unchanged, so every embed pass is a no-op reconcile: the + // query path is the only place the size change can be noticed. Without + // recovery here, semantic search stays silently dead until a doc changes. + expect(await service.queryScores('session credentials', corpus)).toBeNull(); + expect(service.getStatus().ready).toBe(false); // warm state dropped + + await service.embedCorpus(corpus); + const scores = await service.queryScores('session credentials', corpus); + expect(scores?.size).toBe(corpus.length); + expect(readManifest().dims).toBe(1536); + }); + + test('survives exactly MAX_DIMS_DRIFT_RESETS size changes, then gives up', async () => { + // Pinned deliberately: the budget is a spend ceiling AND the headroom a + // legitimate model update needs. A silent change to either side should + // require touching this line. + expect(MAX_DIMS_DRIFT_RESETS).toBe(2); + + // A provider whose size changes only when the test says so, so each drift + // event is one deliberate step rather than a side effect of call ordering. + let servedDims = 1024; + const requests: string[][] = []; + const fetchImpl = ((_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string) as { input: string[] }; + requests.push(body.input); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: body.input.map((_t, index) => ({ + index, + embedding: Array.from({ length: servedDims }, (_, i) => Math.sin(i + index)), + })), + }), + text: () => Promise.resolve(''), + } as Response); + }) as unknown as typeof fetch; + + const service = new SemanticSearchService({ + loadEmbedder: () => + Promise.resolve( + createOpenAiEmbedder( + { baseUrl: BASE_URL, model: 'aliased', apiKey: 'sk-fake' }, + { fetchImpl, sleep: () => Promise.resolve() }, + ), + ), + cacheDir, + enabled: true, + providerFingerprint: `${BASE_URL}|aliased|auto`, + }); + + let version = 0; + const nextCorpus = () => corpus.map((d) => doc(d.path, `${d.content} v${++version}`, version)); + + await service.embedCorpus(nextCorpus()); + expect(service.getStatus().capable).toBe(true); + + // Each iteration is one size change the service is expected to absorb. + for (let swap = 1; swap <= MAX_DIMS_DRIFT_RESETS; swap++) { + servedDims = servedDims === 1024 ? 1536 : 1024; + await service.embedCorpus(nextCorpus()); // drift → discard + re-warm + await service.embedCorpus(nextCorpus()); // rebuild at the new size + expect(service.getStatus().capable).toBe(true); + expect(service.getStatus().embeddedCount).toBe(corpus.length); + } + + // One more than the budget allows: give up rather than keep paying. + servedDims = servedDims === 1024 ? 1536 : 1024; + await service.embedCorpus(nextCorpus()); + expect(service.getStatus().capable).toBe(false); + + // And staying given-up costs nothing: no further provider calls, no scores. + const spent = requests.length; + await service.embedCorpus(nextCorpus()); + expect(await service.queryScores('session credentials', corpus)).toBeNull(); + expect(requests.length).toBe(spent); + + // A deliberate provider change is a fresh start, not a permanently spent + // budget. Asserting `capable` directly is what catches the budget reset + // being moved into `resetWarm`, which drift recovery calls itself. + service.applyConfig({ enabled: true, providerFingerprint: `${BASE_URL}|other|auto` }); + await service.embedCorpus(nextCorpus()); // drifts again — only recoverable on a refunded budget + await service.embedCorpus(nextCorpus()); // rebuilds at the current size + expect(requests.length).toBeGreaterThan(spent); + expect(service.getStatus().capable).toBe(true); + expect(service.getStatus().embeddedCount).toBe(corpus.length); + }); + + test('changing the model resets coverage to zero, then refills at the new size', async () => { + const first = makeService({ dims: 1536 }); + await first.service.embedCorpus(corpus); + expect(first.service.getStatus().embeddedCount).toBe(corpus.length); + + // What the Settings coverage panel reads after an endpoint/model change. + first.service.applyConfig({ + enabled: true, + providerFingerprint: `${BASE_URL}|another-model|auto`, + }); + expect(first.service.getStatus().embeddedCount).toBe(0); + + const second = makeService({ dims: 1024 }, { model: 'another-model' }); + await second.service.embedCorpus(corpus); + expect(second.service.getStatus().embeddedCount).toBe(corpus.length); + expect(readManifest().dims).toBe(1024); // the old 1536 vectors are gone + }); + + test('an explicitly configured size that the server ignores fails loudly, no rebuild', async () => { + const { service, requests } = makeService( + { dims: 1024, ignoreDimensionsParam: true }, + { dimensions: 1536 }, + ); + await service.embedCorpus(corpus); + + expect(requests[0]?.dimensions).toBe(1536); // we asked + expect(service.getStatus().embeddedCount).toBe(0); // it lied; nothing cached + // Re-embedding cannot fix a server that ignores the param, so the pass + // stops after one rejected batch instead of paying for the whole corpus. + expect(requests).toHaveLength(1); + }); +}); diff --git a/packages/server/src/embeddings/embedder.test.ts b/packages/server/src/embeddings/embedder.test.ts index 614adcae0..33731e381 100644 --- a/packages/server/src/embeddings/embedder.test.ts +++ b/packages/server/src/embeddings/embedder.test.ts @@ -4,6 +4,7 @@ import { createOpenAiEmbedder, EMBEDDINGS_API_KEY_ENV, EmbeddingDimsMismatchError, + type EmbeddingProviderError, loadOpenAiEmbedder, normalizeInPlace, normalizeProviderId, @@ -188,10 +189,10 @@ describe('createOpenAiEmbedder', () => { expect(caught).not.toBeNull(); }); - test('throws EmbeddingDimsMismatchError when the provider returns the wrong size', async () => { + test('throws EmbeddingDimsMismatchError when a CONFIGURED size is not honored', async () => { const { fetchImpl } = stubFetch([{ json: embeddingsResponse(1, 768) }]); const embedder = createOpenAiEmbedder( - { baseUrl: 'https://x/v1', model: 'm', apiKey: KEY }, // declares default 1536 + { baseUrl: 'https://x/v1', model: 'm', dimensions: 1536, apiKey: KEY }, { fetchImpl, sleep: noSleep }, ); let caught: unknown = null; @@ -199,6 +200,121 @@ describe('createOpenAiEmbedder', () => { caught = e; }); expect(caught).toBeInstanceOf(EmbeddingDimsMismatchError); + expect((caught as EmbeddingDimsMismatchError).reason).toBe('dims_mismatch'); + }); + + // The bug this whole feature exists for: a custom model whose native size + // isn't 1536 used to fail every single response and degrade to keyword-only. + test('with no configured size, adopts whatever length the provider returns', async () => { + const { fetchImpl } = stubFetch([{ json: embeddingsResponse(1, 1024) }]); + const embedder = createOpenAiEmbedder( + { baseUrl: 'https://x/v1', model: 'custom-1024', apiKey: KEY }, + { fetchImpl, sleep: noSleep }, + ); + expect(embedder.dims).toBeNull(); + const [vector] = await embedder.embed(['q'], { role: 'document' }); + expect(vector.length).toBe(1024); + expect(embedder.dims).toBe(1024); + }); + + test('once auto-detected, a later response of a different size is rejected', async () => { + const { fetchImpl } = stubFetch([ + { json: embeddingsResponse(1, 1024) }, + { json: embeddingsResponse(1, 1536) }, + ]); + const embedder = createOpenAiEmbedder( + { baseUrl: 'https://x/v1', model: 'aliased', apiKey: KEY }, + { fetchImpl, sleep: noSleep }, + ); + await embedder.embed(['first'], { role: 'document' }); + await expect(embedder.embed(['second'], { role: 'document' })).rejects.toThrow( + EmbeddingDimsMismatchError, + ); + }); + + // A gateway can fail over between two models mid-call; the caller would + // otherwise store a mix of lengths under one cache entry, and cosine + // similarity truncates to the shorter vector rather than erroring. + test('holds every batch of one call to the first batch’s size', async () => { + const { fetchImpl } = stubFetch([ + { json: embeddingsResponse(2, 1024) }, + { json: embeddingsResponse(1, 1536) }, + ]); + const embedder = createOpenAiEmbedder( + { baseUrl: 'https://x/v1', model: 'flapping', apiKey: KEY }, + { fetchImpl, sleep: noSleep, maxBatchSize: 2 }, + ); + await expect(embedder.embed(['a', 'b', 'c'], { role: 'document' })).rejects.toThrow( + EmbeddingDimsMismatchError, + ); + }); + + test('pinDims adopts a previous run’s size so the first response is checked', async () => { + const { fetchImpl } = stubFetch([{ json: embeddingsResponse(1, 1536) }]); + const embedder = createOpenAiEmbedder( + { baseUrl: 'https://x/v1', model: 'aliased', apiKey: KEY }, + { fetchImpl, sleep: noSleep }, + ); + embedder.pinDims?.(1024); + expect(embedder.dims).toBe(1024); + await expect(embedder.embed(['q'], { role: 'document' })).rejects.toThrow( + EmbeddingDimsMismatchError, + ); + }); + + test('a ragged response leaves no size pinned behind it', async () => { + const ragged = { + data: [ + { index: 0, embedding: [1, 0, 0] }, + { index: 1, embedding: [1, 0] }, + ], + }; + const { fetchImpl } = stubFetch([{ json: ragged }, { json: embeddingsResponse(1, 1024) }]); + const embedder = createOpenAiEmbedder( + { baseUrl: 'https://x/v1', model: 'm', apiKey: KEY }, + { fetchImpl, sleep: noSleep }, + ); + await expect(embedder.embed(['a', 'b'], { role: 'document' })).rejects.toThrow(); + expect(embedder.dims).toBeNull(); + await embedder.embed(['c'], { role: 'document' }); + expect(embedder.dims).toBe(1024); + }); + + // The likeliest custom-endpoint misconfiguration: a base URL that lands on a + // proxy's HTML error page or a site root and answers 200 with markup. + test('a 200 carrying non-JSON is a fatal config error, not a retryable network fault', async () => { + let calls = 0; + const fetchImpl = (() => { + calls += 1; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON at position 0')), + text: () => Promise.resolve('...'), + } as Response); + }) as unknown as typeof fetch; + const embedder = createOpenAiEmbedder( + { baseUrl: 'https://x/v1', model: 'm', apiKey: KEY }, + { fetchImpl, sleep: noSleep }, + ); + let caught: unknown = null; + await embedder.embed(['q'], { role: 'document' }).catch((e) => { + caught = e; + }); + expect((caught as EmbeddingProviderError).reason).toBe('malformed_response'); + expect(calls).toBe(1); // fatal — never retried + }); + + test('with no key, sends NO Authorization header (keyless loopback)', async () => { + const { fetchImpl, calls } = stubFetch([{ json: embeddingsResponse(1, 768) }]); + const embedder = createOpenAiEmbedder( + { baseUrl: 'http://localhost:11434/v1', model: 'nomic-embed-text' }, + { fetchImpl, sleep: noSleep }, + ); + await embedder.embed(['q'], { role: 'document' }); + expect(calls[0].authHeader).toBeUndefined(); + // Never the string "Bearer undefined". + expect(JSON.stringify(calls[0])).not.toContain('Bearer'); }); test('empty input does not hit the network', async () => { @@ -226,33 +342,66 @@ describe('normalizeProviderId', () => { describe('loadOpenAiEmbedder', () => { const config = { baseUrl: 'https://api.openai.com/v1', model: 'text-embedding-3-small' }; + const CUSTOM = 'https://my-vllm.internal/v1'; + const projectDir = '/tmp/proj'; + /** A store stub that returns `key` for any (project, endpoint) lookup. */ + const stubStore = (key: string | null) => ({ + resolveForProject: () => Promise.resolve({ key, source: key ? 'project' : null }), + }); test('returns null with no key store and no env var', async () => { - const embedder = await loadOpenAiEmbedder({ keyStore: null, config }); - expect(embedder).toBeNull(); + expect(await loadOpenAiEmbedder({ keyStore: null, projectDir, config })).toBeNull(); }); - test('uses the key store when present', async () => { - const keyStore = { get: () => Promise.resolve('sk-from-keyring') }; - const embedder = await loadOpenAiEmbedder({ keyStore, config }); + test("uses the store's resolved project key", async () => { + const embedder = await loadOpenAiEmbedder({ + keyStore: stubStore('sk-project-key'), + projectDir, + config, + }); expect(embedder).not.toBeNull(); expect(embedder?.modelId).toBe('text-embedding-3-small'); - expect(embedder?.dims).toBe(1536); + expect(embedder?.dims).toBeNull(); // no configured size — the provider decides expect(embedder?.providerId).toBe('https://api.openai.com/v1'); }); - test('falls back to the env var when the store has no key', async () => { + test('env var is used ONLY for the default OpenAI endpoint', async () => { process.env[EMBEDDINGS_API_KEY_ENV] = 'sk-from-env'; - const keyStore = { get: () => Promise.resolve(null) }; - const embedder = await loadOpenAiEmbedder({ keyStore, config }); - expect(embedder).not.toBeNull(); + // Default host + no stored key → env applies. + expect( + await loadOpenAiEmbedder({ keyStore: stubStore(null), projectDir, config }), + ).not.toBeNull(); + // A CUSTOM endpoint must NOT inherit the machine-wide env key. + expect( + await loadOpenAiEmbedder({ + keyStore: stubStore(null), + projectDir, + config: { ...config, baseUrl: CUSTOM }, + }), + ).toBeNull(); }); test('a throwing key store degrades to the env fallback (never throws)', async () => { - const keyStore = { get: () => Promise.reject(new Error('keyring exploded')) }; - expect(await loadOpenAiEmbedder({ keyStore, config })).toBeNull(); + const keyStore = { resolveForProject: () => Promise.reject(new Error('store exploded')) }; + expect(await loadOpenAiEmbedder({ keyStore, projectDir, config })).toBeNull(); process.env[EMBEDDINGS_API_KEY_ENV] = 'sk-env'; - expect(await loadOpenAiEmbedder({ keyStore, config })).not.toBeNull(); + expect(await loadOpenAiEmbedder({ keyStore, projectDir, config })).not.toBeNull(); + }); + + test('a keyless LOOPBACK endpoint loads without any key; a custom one does not', async () => { + const loopback = await loadOpenAiEmbedder({ + keyStore: stubStore(null), + projectDir, + config: { baseUrl: 'http://localhost:11434/v1', model: 'nomic-embed-text' }, + }); + expect(loopback).not.toBeNull(); // keyless is allowed for loopback + + const custom = await loadOpenAiEmbedder({ + keyStore: stubStore(null), + projectDir, + config: { baseUrl: CUSTOM, model: 'm' }, + }); + expect(custom).toBeNull(); // non-loopback + no key → unready }); }); diff --git a/packages/server/src/embeddings/embedder.ts b/packages/server/src/embeddings/embedder.ts index 9bdf9e224..639ee0d15 100644 --- a/packages/server/src/embeddings/embedder.ts +++ b/packages/server/src/embeddings/embedder.ts @@ -16,7 +16,13 @@ * zero egress. A null return means "no key → degrade to lexical search". */ -import { checkEmbeddingsBaseUrl, sleep as defaultSleep } from '@inkeep/open-knowledge-core'; +import { + checkEmbeddingsBaseUrl, + DEFAULT_EMBEDDINGS_BASE_URL, + sleep as defaultSleep, + isLoopbackEmbeddingsUrl, +} from '@inkeep/open-knowledge-core'; +import { getLogger } from '../logger.ts'; import { type EmbeddingErrorReason, recordEmbeddingProviderError, @@ -24,6 +30,8 @@ import { recordEmbeddingTokens, } from './embeddings-telemetry.ts'; +const log = getLogger('embeddings'); + /** * Default output dimensionality for `text-embedding-3-small`. Used when the * config leaves `dimensions` unset; also the concept embedder's default size. @@ -50,8 +58,20 @@ export interface Embedder { readonly providerId: string; /** Model identity — also part of the cache key (cross-model guard). */ readonly modelId: string; - /** Output dimensionality — also part of the cache key. */ - readonly dims: number; + /** + * Vector length: the configured size, the size detected from the first + * response, or `null` while auto-detecting and nothing has come back yet. + * Hardcoding a default here is what made every non-1536 model fail its first + * response, so an unset config means "whatever the provider returns". + */ + readonly dims: number | null; + /** + * Adopt a length pinned by a previous run (from the vector cache's manifest) + * so the first response of this run is checked against it rather than + * silently re-pinning over a corpus of the old size. No-op once a length is + * fixed. Absent on embedders whose size is inherent. + */ + pinDims?(dims: number): void; /** * Embed a batch of texts. `role` distinguishes query vs document spend for * telemetry (the provider treats both identically — `text-embedding-3` is @@ -62,40 +82,64 @@ export interface Embedder { } /** - * Read-only accessor for the embeddings API key. Implemented in the CLI / - * desktop wiring layer (reads the 0600 `~/.ok/secrets.yml` file) and injected - * into the server the same way as `ProbeTokenStore`, so the server stays - * agnostic to where the key is stored. + * Read-only accessor for the embeddings API key. Implemented by the 0600 + * `~/.ok/secrets.yml` backend and injected into the server the same way as + * `ProbeTokenStore`, so the server stays agnostic to where the key is stored. + * Resolution is project + endpoint scoped: the key is bound to the exact + * endpoint it was entered against, so it can never travel to a different host. */ export interface EmbeddingsKeyStore { - /** Resolve the stored key, or `null` when none is set. Never throws. */ - get(): Promise; + /** Key bound to (projectDir, baseUrl), or `{ key: null }`. Never throws. */ + resolveForProject( + projectDir: string, + baseUrl: string, + ): Promise<{ key: string | null; source?: string | null }>; } /** - * Thrown when the provider returns vectors of an unexpected length — almost - * always a misconfiguration (model whose native size ≠ the configured - * `dimensions`). Surfaced (not swallowed) so the service can log it once and - * degrade to lexical instead of corrupting the cache with ragged vectors. + * A provider call that failed, carrying the classification `attemptOnce` + * already computed. Without it the reason reaches the telemetry counter and + * then evaporates, leaving callers (the Test-connection probe) to re-derive it + * from a message string. Every error `embed()` throws is one of these. */ -export class EmbeddingDimsMismatchError extends Error { +export class EmbeddingProviderError extends Error { + constructor( + readonly reason: EmbeddingErrorReason, + message: string, + /** Provider HTTP status when the failure was a response, else undefined. */ + readonly status?: number, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = 'EmbeddingProviderError'; + } +} + +/** + * Thrown when the provider returns vectors of a length other than the one in + * force. Two distinct causes, and the caller must tell them apart: + * - `dimensions` is configured and the server ignores the request param + * (Ollama does) — a misconfiguration, re-embedding cannot fix it. + * - the size was auto-detected and has since changed (a model swapped behind + * an alias) — the cached corpus is stale and must be rebuilt. + * Surfaced either way, never swallowed: mixing lengths corrupts scoring + * silently, because cosine similarity truncates to the shorter vector. + */ +export class EmbeddingDimsMismatchError extends EmbeddingProviderError { readonly name = 'EmbeddingDimsMismatchError'; constructor( readonly expected: number, readonly got: number, ) { - super( - `embeddings provider returned ${got}-dim vectors, expected ${expected}. ` + - `Set search.semantic.dimensions to ${got} (or point at the right model).`, - ); + super('dims_mismatch', `embeddings provider returned ${got}-dim vectors, expected ${expected}`); } } /** A provider response whose shape/length doesn't match the request. */ -class MalformedEmbeddingResponseError extends Error { +class MalformedEmbeddingResponseError extends EmbeddingProviderError { readonly name = 'MalformedEmbeddingResponseError'; - constructor(expected: number, got: number) { - super(`embeddings response had ${got} vectors, expected ${expected}`); + constructor(message: string) { + super('malformed_response', message); } } @@ -129,13 +173,20 @@ export interface OpenAiEmbedderConfig { model: string; /** * Requested output dimensions. When set it is sent as the `dimensions` - * request param AND used as the cache dims; when omitted the provider's - * native size is used (defaulting the declared dims to - * {@link DEFAULT_EMBEDDINGS_DIMENSIONS}) and the param is not sent. + * request param AND enforced on every response (a provider that ignores the + * param fails loudly). When omitted the param is not sent and whatever length + * the provider returns is accepted — the only way a non-1536 model works + * without the user knowing its size up front. */ dimensions?: number; - /** The provider API key (Bearer). Never logged. */ - apiKey: string; + /** + * The provider API key (Bearer). Never logged. Optional: a loopback + * self-hosted server (Ollama / LM Studio) needs no key, and omitting it sends + * NO `Authorization` header at all rather than a bogus one. The caller decides + * whether keyless is allowed for the target host (`loadOpenAiEmbedder` only + * permits it for loopback); the client itself is agnostic. + */ + apiKey?: string; } export interface OpenAiEmbedderOptions { @@ -230,7 +281,11 @@ export function createOpenAiEmbedder( const queryTimeoutMs = options.queryTimeoutMs ?? DEFAULTS.queryTimeoutMs; assertSafeEmbeddingsBaseUrl(config.baseUrl); - const dims = config.dimensions ?? DEFAULT_EMBEDDINGS_DIMENSIONS; + // The length every response is held to. Starts `null` when `dimensions` is + // unset — the provider decides, and the first clean response pins it for the + // life of this embedder. Defaulting it to 1536 is what made every model that + // isn't OpenAI-sized fail on its very first response. + let pinnedDims = config.dimensions ?? null; const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/embeddings`; /** Split inputs into provider-request-sized batches (count + char budget). */ @@ -274,7 +329,8 @@ export function createOpenAiEmbedder( method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, + // No key → no header (keyless loopback), never `Bearer undefined`. + ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}), }, body, signal: controller.signal, @@ -286,15 +342,35 @@ export function createOpenAiEmbedder( // a provider error body can echo request fields. Keep only the status. await res.text().catch(() => ''); const reason: EmbeddingErrorReason = res.status === 429 ? 'rate_limit' : 'http_error'; - const error = new Error(`embeddings request failed: HTTP ${res.status}`); + const error = new EmbeddingProviderError( + reason, + `embeddings request failed: HTTP ${res.status}`, + res.status, + ); return RETRYABLE_STATUS.has(res.status) ? { kind: 'retry', reason, error } : { kind: 'fatal', reason, error }; } - const json = (await res.json()) as OpenAiEmbeddingResponse; - const vectors = parseEmbeddingResponse(json, expectedCount, dims); + // A 200 carrying something other than JSON is a configuration problem — + // a base URL that landed on a proxy's HTML page or a site root rather + // than an embeddings API. Left to the generic catch below it would read + // as a transient network fault: retried four times, then reported as + // "couldn't reach the endpoint" for a host that answered immediately. + let json: OpenAiEmbeddingResponse; + try { + json = (await res.json()) as OpenAiEmbeddingResponse; + } catch { + throw new MalformedEmbeddingResponseError( + 'embeddings endpoint returned a non-JSON body — check the base URL', + ); + } + const parsed = parseEmbeddingResponse(json, expectedCount); + // Commit the detection only once the WHOLE response parsed cleanly — a + // response that turned out ragged must not leave its first vector's + // length pinned behind it. + pinnedDims ??= parsed.dims; recordEmbeddingTokens(roleLabel, json.usage?.total_tokens ?? 0); - return { kind: 'ok', vectors }; + return { kind: 'ok', vectors: parsed.vectors }; } catch (err) { // Parse-time config errors are fatal (no point retrying); network / abort // (timeout) are retryable. @@ -328,7 +404,13 @@ export function createOpenAiEmbedder( if (result.kind === 'ok') return result.vectors; // Every non-ok attempt feeds the provider-error rate metric. recordEmbeddingProviderError(result.reason); - if (result.kind === 'fatal' || attempt >= maxRetries) throw result.error; + if (result.kind === 'fatal' || attempt >= maxRetries) { + throw result.error instanceof EmbeddingProviderError + ? result.error + : new EmbeddingProviderError(result.reason, result.error.message, undefined, { + cause: result.error, + }); + } attempt += 1; // Full-jitter exponential backoff. const ceiling = backoffBaseMs * 2 ** (attempt - 1); @@ -336,35 +418,57 @@ export function createOpenAiEmbedder( } } + /** Parse + validate one response, reporting the length it settled on. */ function parseEmbeddingResponse( json: OpenAiEmbeddingResponse, expectedCount: number, - expectedDims: number, - ): Float32Array[] { + ): { vectors: Float32Array[]; dims: number } { const data = json.data; if (!Array.isArray(data) || data.length !== expectedCount) { - throw new MalformedEmbeddingResponseError(expectedCount, data?.length ?? 0); + throw new MalformedEmbeddingResponseError( + `embeddings response had ${data?.length ?? 0} vectors, expected ${expectedCount}`, + ); } // Provider guarantees no order, but returns an `index` — sort defensively. const ordered = [...data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); const out: Float32Array[] = []; + // With nothing pinned yet the first vector sets the bar for the rest: a + // length that varies within one response is incoherent under any config. + let requiredDims = pinnedDims; for (const item of ordered) { const emb = item.embedding; - if (!Array.isArray(emb)) throw new MalformedEmbeddingResponseError(expectedCount, 0); - if (emb.length !== expectedDims) - throw new EmbeddingDimsMismatchError(expectedDims, emb.length); + if (!Array.isArray(emb)) { + throw new MalformedEmbeddingResponseError( + 'embeddings response contained a non-array embedding (expected float vectors)', + ); + } + requiredDims ??= emb.length; + if (emb.length !== requiredDims) { + throw new EmbeddingDimsMismatchError(requiredDims, emb.length); + } out.push(normalizeInPlace(Float32Array.from(emb))); } - return out; + if (requiredDims === null) { + throw new MalformedEmbeddingResponseError('embeddings response contained no vectors'); + } + return { vectors: out, dims: requiredDims }; } return { providerId: normalizeProviderId(config.baseUrl), modelId: config.model, - dims, + get dims() { + return pinnedDims; + }, + pinDims(next: number) { + pinnedDims ??= next; + }, async embed(texts, { role }) { if (texts.length === 0) return []; const out: Float32Array[] = []; + // Each batch is validated against the pin the previous one established, + // so a gateway that fails over mid-call can't hand back a mix of sizes + // that the caller would happily write into one cache entry. for (const batch of batchInputs(texts)) { out.push(...(await embedOneBatch(batch, role))); } @@ -373,9 +477,66 @@ export function createOpenAiEmbedder( }; } +/** True when `baseUrl` is the built-in OpenAI endpoint (identity comparison). */ +function isDefaultOpenAiEndpoint(baseUrl: string): boolean { + return normalizeProviderId(baseUrl) === normalizeProviderId(DEFAULT_EMBEDDINGS_BASE_URL); +} + +/** Where a resolved embeddings credential came from, or that none is needed/found. */ +export type EmbeddingsCredentialSource = 'project' | 'file' | 'env' | 'none'; + +export interface ResolvedEmbeddingsCredential { + /** The key to send, or `null`. */ + apiKey: string | null; + /** No key, but the endpoint is loopback so it may be probed/embedded keyless. */ + keyless: boolean; + source: EmbeddingsCredentialSource; +} + +/** + * THE single resolution seam — every path (warm-time embedder load, the Test + * button, status) resolves a credential here so they can never disagree about + * which key reaches which endpoint. Order: + * 1. the project's stored key for this exact endpoint (structurally bound). + * 2. `OK_EMBEDDINGS_API_KEY` — DEFAULT OpenAI endpoint ONLY. A machine-wide + * env key must never travel to a custom host; the store scopes the flat + * file key the same way. + * 3. no key + loopback endpoint → keyless (Ollama / LM Studio need no key). + * 4. otherwise no credential (→ semantic search stays unready). + * Makes no network call — resolution is "is there a key", not "does it work". + */ +export async function resolveEmbeddingsCredential( + keyStore: EmbeddingsKeyStore | null, + projectDir: string, + baseUrl: string, +): Promise { + const resolved = keyStore + ? await keyStore.resolveForProject(projectDir, baseUrl).catch((err) => { + // A read failure (unreadable/corrupt secrets file, EPERM) must not throw + // on the warm path, but silently mapping it to "no key" would report + // keyPresent:false for a key that IS stored — the user re-runs set-key + // chasing a ghost. Log it so the degradation leaves a trail. + log.warn({ err }, '[embeddings] failed to read the stored key — treating as unset'); + return null; + }) + : null; + if (resolved?.key) { + const source = resolved.source === 'file' ? 'file' : 'project'; + return { apiKey: resolved.key, keyless: false, source }; + } + if (isDefaultOpenAiEndpoint(baseUrl)) { + const env = process.env[EMBEDDINGS_API_KEY_ENV]; + if (env) return { apiKey: env, keyless: false, source: 'env' }; + } + if (isLoopbackEmbeddingsUrl(baseUrl)) return { apiKey: null, keyless: true, source: 'none' }; + return { apiKey: null, keyless: false, source: 'none' }; +} + export interface LoadOpenAiEmbedderInput { - /** Injected key store (the CLI's secrets file). `null` = no store; env fallback only. */ + /** Injected key store (the 0600 secrets file). `null` = no store; env/keyless only. */ keyStore: EmbeddingsKeyStore | null; + /** The running server's project directory — scopes key resolution. */ + projectDir: string; /** Non-secret provider config, read fresh so a config change re-warms cleanly. */ config: Pick; /** Embedder construction options (timeouts/batching/fetch — tests inject). */ @@ -383,14 +544,90 @@ export interface LoadOpenAiEmbedderInput { } /** - * Resolve the key (stored secrets file → `OK_EMBEDDINGS_API_KEY` env fallback) - * and build the embedder, or `null` when no key is available (→ lexical). Makes no - * network call — capability detection is "is there a key", not "does the API - * answer", so warming is free. + * Resolve the credential for this project + endpoint and build the embedder, or + * `null` when there's no key and the endpoint isn't loopback (→ lexical). Makes + * no network call, so warming is free. */ export async function loadOpenAiEmbedder(input: LoadOpenAiEmbedderInput): Promise { - const stored = input.keyStore ? await input.keyStore.get().catch(() => null) : null; - const apiKey = stored ?? process.env[EMBEDDINGS_API_KEY_ENV] ?? null; - if (!apiKey) return null; - return createOpenAiEmbedder({ ...input.config, apiKey }, input.options); + const cred = await resolveEmbeddingsCredential( + input.keyStore, + input.projectDir, + input.config.baseUrl, + ); + if (cred.apiKey) { + return createOpenAiEmbedder({ ...input.config, apiKey: cred.apiKey }, input.options); + } + // Keyless loopback: construct with no key → no Authorization header. + if (cred.keyless) return createOpenAiEmbedder({ ...input.config }, input.options); + return null; +} + +/** + * The one string the connection probe embeds. Fixed and content-free — a probe + * must never ship a page or a query to an endpoint the user is still testing. + */ +const EMBEDDINGS_PROBE_INPUT = 'OpenKnowledge embeddings connection test'; + +/** Probe budget. Short: this runs behind a button the user is waiting on. */ +const PROBE_TIMEOUT_MS = 10_000; + +export interface EmbeddingProbeInput { + baseUrl: string; + model: string; + /** Explicit dimensions, if configured — probing with it exercises the same + * strict length check the real embed path would apply. */ + dimensions?: number; + /** Omit for a keyless loopback endpoint — probes with no Authorization header. */ + apiKey?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +export type EmbeddingProbeResult = + | { ok: true; dimensions: number } + | { ok: false; reason: EmbeddingErrorReason | 'invalid_endpoint'; status?: number }; + +/** + * One live embed against the configured endpoint, classified. The on-demand + * answer to "is this thing actually working" — without it a wrong URL / key / + * model is indistinguishable from a working setup that simply hasn't indexed + * yet, because every failure degrades quietly to keyword search. + * + * No retries and a short timeout: a user waiting on a button wants the verdict, + * not four backoffs. The detected vector length on success doubles as the + * readout that replaces a hand-entered dimensions field. + */ +export async function probeEmbeddingEndpoint( + input: EmbeddingProbeInput, +): Promise { + let embedder: Embedder; + try { + embedder = createOpenAiEmbedder( + { + baseUrl: input.baseUrl, + model: input.model, + dimensions: input.dimensions, + apiKey: input.apiKey, + }, + { + fetchImpl: input.fetchImpl, + maxRetries: 0, + queryTimeoutMs: input.timeoutMs ?? PROBE_TIMEOUT_MS, + }, + ); + } catch { + // Construction only fails the base-URL guard (unparseable, or plaintext to + // a non-loopback host) — nothing left the machine. + return { ok: false, reason: 'invalid_endpoint' }; + } + try { + const [vector] = await embedder.embed([EMBEDDINGS_PROBE_INPUT], { role: 'query' }); + if (!vector || vector.length === 0) return { ok: false, reason: 'malformed_response' }; + return { ok: true, dimensions: vector.length }; + } catch (err) { + if (err instanceof EmbeddingProviderError) { + return { ok: false, reason: err.reason, status: err.status }; + } + return { ok: false, reason: 'network' }; + } } diff --git a/packages/server/src/embeddings/eval/semantic-eval.ts b/packages/server/src/embeddings/eval/semantic-eval.ts index b801a0ced..152980ec7 100644 --- a/packages/server/src/embeddings/eval/semantic-eval.ts +++ b/packages/server/src/embeddings/eval/semantic-eval.ts @@ -228,6 +228,7 @@ export function runHeldOutEval(prep: PreparedEval): HeldOutReport { export async function loadEvalEmbedder(): Promise { return loadOpenAiEmbedder({ keyStore: null, // env (OK_EMBEDDINGS_API_KEY) fallback only — gated runs set it + projectDir: process.cwd(), config: { baseUrl: process.env.OK_EMBEDDINGS_BASE_URL ?? 'https://api.openai.com/v1', model: process.env.OK_EMBEDDINGS_MODEL ?? 'text-embedding-3-small', diff --git a/packages/server/src/embeddings/fake-provider.test-helper.ts b/packages/server/src/embeddings/fake-provider.test-helper.ts new file mode 100644 index 000000000..472e4be81 --- /dev/null +++ b/packages/server/src/embeddings/fake-provider.test-helper.ts @@ -0,0 +1,179 @@ +/** + * A stand-in for a real OpenAI-compatible embeddings server, with the quirks + * that actually break us. + * + * The concept embedder is a fake `Embedder` — it bypasses the HTTP client + * entirely, so it can never exercise the response handling where custom + * endpoints go wrong. This fakes the wire instead: the production + * `createOpenAiEmbedder` runs unmodified against it, so batching, retries, + * parsing, and dimension detection are the real code paths. + * + * Modelled on observed behaviour of self-hosted servers: + * - a native vector size that is not 1536 (most non-OpenAI models), + * - accepting and then ignoring the `dimensions` request param (Ollama), + * - changing size partway through a run (a model swapped behind an alias). + */ + +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface FakeEmbeddingsProviderOptions { + /** Native vector length the model returns. */ + dims: number; + /** Length returned from `driftAfterRequests` onward — an alias swap. */ + driftDims?: number; + /** Successful requests served at `dims` before `driftDims` takes over. */ + driftAfterRequests?: number; + /** Accept the `dimensions` param and return the native size anyway. */ + ignoreDimensionsParam?: boolean; + /** Fail every request with this status instead of embedding. */ + failWithStatus?: number; + /** Reply with base64 strings instead of float arrays. */ + encodeAsBase64?: boolean; +} + +interface FakeEmbeddingsRequest { + model: string; + input: string[]; + dimensions?: number; + encodingFormat?: string; + authorization?: string; +} + +/** A deterministic unit-ish vector so callers can assert on real numbers. */ +function fakeVector(text: string, dims: number): number[] { + const out = new Array(dims); + let h = 2166136261 >>> 0; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + for (let i = 0; i < dims; i++) { + h = Math.imul(h ^ (h >>> 15), 2246822507) >>> 0; + out[i] = (h % 1000) / 1000 - 0.5; + } + return out; +} + +interface FakeResponse { + status: number; + body: unknown; +} + +/** The provider's decision for one request, shared by both drivers below. */ +function makeProvider(options: FakeEmbeddingsProviderOptions) { + const requests: FakeEmbeddingsRequest[] = []; + let served = 0; + + function respond(request: FakeEmbeddingsRequest): FakeResponse { + requests.push(request); + if (options.failWithStatus !== undefined) { + return { status: options.failWithStatus, body: { error: { message: 'nope' } } }; + } + const drifted = + options.driftDims !== undefined && served >= (options.driftAfterRequests ?? 1) + ? options.driftDims + : null; + served += 1; + // The `ignoreDimensionsParam` server takes the param and returns its native + // size regardless — the failure mode an explicit `dimensions` has to catch. + const dims = + drifted ?? + (options.ignoreDimensionsParam ? options.dims : (request.dimensions ?? options.dims)); + return { + status: 200, + body: { + data: request.input.map((text, index) => { + const vector = fakeVector(text, dims); + return { + index, + embedding: options.encodeAsBase64 + ? Buffer.from(new Float32Array(vector).buffer).toString('base64') + : vector, + }; + }), + usage: { total_tokens: request.input.length * 3 }, + }, + }; + } + + return { requests, respond }; +} + +function parseRequest(body: string, authorization: string | undefined): FakeEmbeddingsRequest { + const parsed = JSON.parse(body) as { + model: string; + input: string[]; + dimensions?: number; + encoding_format?: string; + }; + return { + model: parsed.model, + input: parsed.input, + dimensions: parsed.dimensions, + encodingFormat: parsed.encoding_format, + authorization, + }; +} + +export interface FakeEmbeddingsFetch { + fetchImpl: typeof fetch; + requests: FakeEmbeddingsRequest[]; +} + +/** In-process driver: pass `fetchImpl` straight to `createOpenAiEmbedder`. */ +export function createFakeEmbeddingsFetch( + options: FakeEmbeddingsProviderOptions, +): FakeEmbeddingsFetch { + const { requests, respond } = makeProvider(options); + const fetchImpl = ((_url: string, init: RequestInit) => { + const headers = init.headers as Record | undefined; + const reply = respond(parseRequest(init.body as string, headers?.Authorization)); + return Promise.resolve({ + ok: reply.status >= 200 && reply.status < 300, + status: reply.status, + json: () => Promise.resolve(reply.body), + text: () => Promise.resolve(JSON.stringify(reply.body)), + } as Response); + }) as unknown as typeof fetch; + return { fetchImpl, requests }; +} + +export interface FakeEmbeddingsServer { + /** Loopback base URL to configure as `search.semantic.baseUrl`. */ + baseUrl: string; + requests: FakeEmbeddingsRequest[]; + close(): Promise; +} + +/** + * Real-socket driver, for the paths that go through the HTTP API rather than an + * injected `fetchImpl` (the Test-connection route). Loopback, so the + * plaintext-key guard permits `http://`. + */ +export async function startFakeEmbeddingsServer( + options: FakeEmbeddingsProviderOptions, +): Promise { + const { requests, respond } = makeProvider(options); + const server: Server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const reply = respond( + parseRequest(Buffer.concat(chunks).toString('utf-8'), req.headers.authorization), + ); + res.writeHead(reply.status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(reply.body)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${port}/v1`, + requests, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} diff --git a/packages/server/src/embeddings/index.ts b/packages/server/src/embeddings/index.ts index 9f5b406db..fe32d94d6 100644 --- a/packages/server/src/embeddings/index.ts +++ b/packages/server/src/embeddings/index.ts @@ -14,18 +14,27 @@ export { DEFAULT_EMBEDDINGS_DIMENSIONS, EMBEDDINGS_API_KEY_ENV, type Embedder, + type EmbeddingsCredentialSource, type EmbeddingsKeyStore, loadOpenAiEmbedder, normalizeProviderId, + probeEmbeddingEndpoint, + type ResolvedEmbeddingsCredential, + resolveEmbeddingsCredential, } from './embedder.ts'; export { - clearEmbeddingsKeyFromAllBackends, + canonicalProjectKey, + clearAllEmbeddingsKeys, createEmbeddingsSecretStore, describeStoredEmbeddingsKey, + type EmbeddingsKeyPresence, type EmbeddingsKeyReader, + type EmbeddingsKeySource, + type EmbeddingsProjectListing, type EmbeddingsSecretStore, FileEmbeddingsBackend, makeLazyEmbeddingsKeyStore, + type ResolvedEmbeddingsKey, secretsFilePath, } from './secrets-store.ts'; export { diff --git a/packages/server/src/embeddings/secrets-store.test.ts b/packages/server/src/embeddings/secrets-store.test.ts index f3668b2ee..a0b74b7bd 100644 --- a/packages/server/src/embeddings/secrets-store.test.ts +++ b/packages/server/src/embeddings/secrets-store.test.ts @@ -4,8 +4,10 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -13,172 +15,217 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { parse } from 'yaml'; import { - clearEmbeddingsKeyFromAllBackends, + canonicalProjectKey, + clearAllEmbeddingsKeys, describeStoredEmbeddingsKey, FileEmbeddingsBackend, makeLazyEmbeddingsKeyStore, } from './secrets-store.ts'; const KEY = 'sk-secret-embeddings-key-1234567890'; +const KEY_2 = 'sk-second-embeddings-key-0987654321'; +const OPENAI = 'https://api.openai.com/v1'; +const CUSTOM = 'https://my-vllm.internal/v1'; +const _LOCAL = 'http://localhost:11434/v1'; let dir: string; let secretsFile: string; +let projectA: string; +let projectB: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'ok-embkey-')); + dir = realpathSync(mkdtempSync(join(tmpdir(), 'ok-embkey-'))); secretsFile = join(dir, '.ok', 'secrets.yml'); + projectA = join(dir, 'project-a'); + projectB = join(dir, 'project-b'); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); -describe('FileEmbeddingsBackend', () => { - test('set → get round-trips the key', async () => { - const store = new FileEmbeddingsBackend(secretsFile); - expect(await store.get()).toBeNull(); - await store.set(KEY); - expect(await store.get()).toBe(KEY); - }); - - test('writes the secrets file with 0600 permissions', async () => { - const store = new FileEmbeddingsBackend(secretsFile); - await store.set(KEY); - expect(existsSync(secretsFile)).toBe(true); - const mode = statSync(secretsFile).mode & 0o777; - expect(mode).toBe(0o600); - }); +function store(): FileEmbeddingsBackend { + return new FileEmbeddingsBackend(secretsFile); +} - test('re-asserts 0600 on a pre-existing, looser-permissioned secrets file', async () => { - // Simulate a secrets file created world-readable by an older build / external - // tool. `writeFileSync`'s mode only applies at creation, so the rewrite must - // chmod it back to 0600. - mkdirSync(join(dir, '.ok'), { recursive: true }); - writeFileSync(secretsFile, 'other: keep-me\n'); - chmodSync(secretsFile, 0o644); - await new FileEmbeddingsBackend(secretsFile).set(KEY); - expect(statSync(secretsFile).mode & 0o777).toBe(0o600); +describe('FileEmbeddingsBackend — project + endpoint scoped', () => { + test('set → resolve round-trips a project+endpoint key', async () => { + const s = store(); + expect((await s.resolveForProject(projectA, CUSTOM)).key).toBeNull(); + await s.setForProject(projectA, CUSTOM, KEY); + const resolved = await s.resolveForProject(projectA, CUSTOM); + expect(resolved.key).toBe(KEY); + expect(resolved.source).toBe('project'); }); - test('get() self-heals a world-readable file to 0600 on the read path (no write needed)', async () => { - // The key is read on every search but rewritten rarely, so a file left - // group/other-readable (older build / external tool / hand-edit) must be - // tightened on READ — otherwise it could stay world-readable indefinitely. - mkdirSync(join(dir, '.ok'), { recursive: true }); - writeFileSync(secretsFile, `OPENAI_API_KEY: ${KEY}\n`); - chmodSync(secretsFile, 0o644); - const store = new FileEmbeddingsBackend(secretsFile); - expect(await store.get()).toBe(KEY); // read still returns the key... - expect(statSync(secretsFile).mode & 0o777).toBe(0o600); // ...and tightened it + test('a key is bound to the endpoint it was set for — never travels to another', async () => { + const s = store(); + await s.setForProject(projectA, CUSTOM, KEY); + // Same project, DIFFERENT endpoint → no key (this is the structural exfil guard). + expect((await s.resolveForProject(projectA, 'https://other.host/v1')).key).toBeNull(); }); - test('get() leaves an already-0600 file untouched', async () => { - await new FileEmbeddingsBackend(secretsFile).set(KEY); // written 0600 - expect(await new FileEmbeddingsBackend(secretsFile).get()).toBe(KEY); - expect(statSync(secretsFile).mode & 0o777).toBe(0o600); + test('different projects hold different keys for the same endpoint', async () => { + const s = store(); + await s.setForProject(projectA, CUSTOM, KEY); + await s.setForProject(projectB, CUSTOM, KEY_2); + expect((await s.resolveForProject(projectA, CUSTOM)).key).toBe(KEY); + expect((await s.resolveForProject(projectB, CUSTOM)).key).toBe(KEY_2); }); - test('clear removes the key; get returns null again', async () => { - const store = new FileEmbeddingsBackend(secretsFile); - await store.set(KEY); - await store.clear(); - expect(await store.get()).toBeNull(); + test('replacing a key overwrites in place; other endpoints of the project survive', async () => { + const s = store(); + await s.setForProject(projectA, OPENAI, KEY); + await s.setForProject(projectA, CUSTOM, KEY_2); + await s.setForProject(projectA, OPENAI, 'sk-replaced'); + expect((await s.resolveForProject(projectA, OPENAI)).key).toBe('sk-replaced'); + expect((await s.resolveForProject(projectA, CUSTOM)).key).toBe(KEY_2); // untouched }); - test('clear unlinks the file when the key was the only secret', async () => { - const store = new FileEmbeddingsBackend(secretsFile); - await store.set(KEY); - expect(existsSync(secretsFile)).toBe(true); - await store.clear(); - // No stray empty file left behind (matches the method's stated intent). - expect(existsSync(secretsFile)).toBe(false); + test('clearForProject removes only that endpoint; returns whether one existed', async () => { + const s = store(); + await s.setForProject(projectA, OPENAI, KEY); + await s.setForProject(projectA, CUSTOM, KEY_2); + expect(await s.clearForProject(projectA, CUSTOM)).toBe(true); + expect((await s.resolveForProject(projectA, CUSTOM)).key).toBeNull(); + expect((await s.resolveForProject(projectA, OPENAI)).key).toBe(KEY); // sibling kept + expect(await s.clearForProject(projectA, CUSTOM)).toBe(false); // already gone }); - test('clear preserves other secrets in the file', async () => { - const store = new FileEmbeddingsBackend(secretsFile); - await store.set(KEY); - // Simulate a co-resident secret written by a future feature. - const raw = readFileSync(secretsFile, 'utf-8'); - writeFileSync(secretsFile, `${raw}other: keep-me\n`); - await store.clear(); - expect(await store.get()).toBeNull(); - expect(readFileSync(secretsFile, 'utf-8')).toContain('other: keep-me'); + test('endpoint identity is normalized — trailing slash / case do not create a miss', async () => { + const s = store(); + await s.setForProject(projectA, 'https://API.OpenAI.com/v1', KEY); + // Different spelling of the same endpoint resolves the same slot. + expect((await s.resolveForProject(projectA, 'https://api.openai.com/v1/')).key).toBe(KEY); }); +}); - test('empty / absent file reads as no key', async () => { - const store = new FileEmbeddingsBackend(secretsFile); - expect(await store.get()).toBeNull(); +describe('legacy flat key — default-OpenAI-host-only fallback', () => { + test('a flat OPENAI_API_KEY resolves for the DEFAULT endpoint (grandfathering)', async () => { + mkdirSync(join(dir, '.ok'), { recursive: true }); + writeFileSync(secretsFile, `OPENAI_API_KEY: ${KEY}\n`); + const resolved = await store().resolveForProject(projectA, OPENAI); + expect(resolved.key).toBe(KEY); + expect(resolved.source).toBe('file'); }); - test('get() falls back to a key stored under the legacy `embeddings` field', async () => { - // A key written by an earlier build (before the rename to OPENAI_API_KEY) - // must still resolve, not silently vanish. + test('the flat key does NOT leak to a custom endpoint', async () => { mkdirSync(join(dir, '.ok'), { recursive: true }); - writeFileSync(secretsFile, `embeddings: ${KEY}\n`); - expect(await new FileEmbeddingsBackend(secretsFile).get()).toBe(KEY); + writeFileSync(secretsFile, `OPENAI_API_KEY: ${KEY}\n`); + // The pre-per-project single key must never travel to a custom host. + expect((await store().resolveForProject(projectA, CUSTOM)).key).toBeNull(); }); - test('set() migrates the legacy field to OPENAI_API_KEY and drops it (self-clearing)', async () => { + test('a project key for the default endpoint wins over the flat key', async () => { mkdirSync(join(dir, '.ok'), { recursive: true }); - writeFileSync(secretsFile, 'embeddings: old-key\nother: keep-me\n'); - await new FileEmbeddingsBackend(secretsFile).set(KEY); - expect(await new FileEmbeddingsBackend(secretsFile).get()).toBe(KEY); - const data = parse(readFileSync(secretsFile, 'utf-8')) as Record; - expect(data.OPENAI_API_KEY).toBe(KEY); - expect(data.embeddings).toBeUndefined(); // legacy field dropped - expect(data.other).toBe('keep-me'); // co-resident secrets preserved + writeFileSync(secretsFile, `OPENAI_API_KEY: ${KEY}\n`); + const s = store(); + await s.setForProject(projectA, OPENAI, KEY_2); + expect((await s.resolveForProject(projectA, OPENAI)).key).toBe(KEY_2); }); - test('clear() removes the legacy field too — no resurrection via the fallback', async () => { + test('the legacy `embeddings` field is also read as the default-host fallback', async () => { mkdirSync(join(dir, '.ok'), { recursive: true }); writeFileSync(secretsFile, `embeddings: ${KEY}\n`); - const store = new FileEmbeddingsBackend(secretsFile); - await store.clear(); - expect(await store.get()).toBeNull(); + expect((await store().resolveForProject(projectA, OPENAI)).key).toBe(KEY); }); }); -describe('makeLazyEmbeddingsKeyStore', () => { - test('reads the key from the secrets file (file-only, no keychain)', async () => { - await new FileEmbeddingsBackend(secretsFile).set(KEY); - const reader = makeLazyEmbeddingsKeyStore(secretsFile); - expect(await reader.get()).toBe(KEY); +describe('file permissions + atomic writes', () => { + test('writes the secrets file 0600', async () => { + await store().setForProject(projectA, CUSTOM, KEY); + expect(statSync(secretsFile).mode & 0o777).toBe(0o600); }); - test('picks up a key written AFTER the reader was created (re-reads each get)', async () => { - const reader = makeLazyEmbeddingsKeyStore(secretsFile); - expect(await reader.get()).toBeNull(); - await new FileEmbeddingsBackend(secretsFile).set(KEY); - expect(await reader.get()).toBe(KEY); + test('re-asserts 0600 on a pre-existing looser file', async () => { + mkdirSync(join(dir, '.ok'), { recursive: true }); + writeFileSync(secretsFile, 'other: keep-me\n'); + chmodSync(secretsFile, 0o644); + await store().setForProject(projectA, CUSTOM, KEY); + expect(statSync(secretsFile).mode & 0o777).toBe(0o600); + // An unrelated top-level field is preserved through the read-modify-write. + expect(parse(readFileSync(secretsFile, 'utf-8')).other).toBe('keep-me'); }); - test('returns null (never throws) when nothing is stored', async () => { + test('resolve self-heals a world-readable file to 0600 on the read path', async () => { + mkdirSync(join(dir, '.ok'), { recursive: true }); + writeFileSync(secretsFile, `OPENAI_API_KEY: ${KEY}\n`); + chmodSync(secretsFile, 0o644); + await store().resolveForProject(projectA, OPENAI); + expect(statSync(secretsFile).mode & 0o777).toBe(0o600); + }); + + test('picks up a key written AFTER the reader was created (re-reads each call)', async () => { const reader = makeLazyEmbeddingsKeyStore(secretsFile); - expect(await reader.get()).toBeNull(); + expect((await reader.resolveForProject(projectA, CUSTOM)).key).toBeNull(); + await store().setForProject(projectA, CUSTOM, KEY); + expect((await reader.resolveForProject(projectA, CUSTOM)).key).toBe(KEY); }); }); -describe('describeStoredEmbeddingsKey', () => { - test('reports the file backend when the key lives there', async () => { - await new FileEmbeddingsBackend(secretsFile).set(KEY); - const desc = await describeStoredEmbeddingsKey(secretsFile); - expect(desc.file).toBe(true); +describe('clear semantics', () => { + test('clearForProject unlinks the file when it empties the whole store', async () => { + const s = store(); + await s.setForProject(projectA, CUSTOM, KEY); + await s.clearForProject(projectA, CUSTOM); + expect(existsSync(secretsFile)).toBe(false); }); - test('reports no file backend when nothing is stored', async () => { - const desc = await describeStoredEmbeddingsKey(secretsFile); - expect(desc.file).toBe(false); + test('clearForProject keeps unrelated top-level secrets', async () => { + mkdirSync(join(dir, '.ok'), { recursive: true }); + writeFileSync(secretsFile, 'other: keep-me\n'); + const s = store(); + await s.setForProject(projectA, CUSTOM, KEY); + await s.clearForProject(projectA, CUSTOM); + expect(parse(readFileSync(secretsFile, 'utf-8')).other).toBe('keep-me'); }); -}); -describe('clearEmbeddingsKeyFromAllBackends', () => { - test('reports the file backend when it held a key', async () => { - await new FileEmbeddingsBackend(secretsFile).set(KEY); - const { touched } = await clearEmbeddingsKeyFromAllBackends(secretsFile); - expect(touched).toContain('file'); - expect(await new FileEmbeddingsBackend(secretsFile).get()).toBeNull(); + test('clearAll wipes every project + the legacy flat key', async () => { + mkdirSync(join(dir, '.ok'), { recursive: true }); + writeFileSync(secretsFile, `OPENAI_API_KEY: ${KEY}\n`); + const s = store(); + await s.setForProject(projectA, CUSTOM, KEY); + await s.setForProject(projectB, OPENAI, KEY_2); + const { touched } = await clearAllEmbeddingsKeys(secretsFile); + expect(touched).toEqual(['file']); + expect(existsSync(secretsFile)).toBe(false); + expect(await clearAllEmbeddingsKeys(secretsFile)).toEqual({ touched: [] }); }); +}); - test('reports nothing when no key was stored', async () => { - const { touched } = await clearEmbeddingsKeyFromAllBackends(secretsFile); - expect(touched).toEqual([]); +describe('describe + list + canonicalProjectKey', () => { + test('describeStoredEmbeddingsKey reports presence + redacted hint, never the key', async () => { + await store().setForProject(projectA, CUSTOM, KEY); + const desc = await describeStoredEmbeddingsKey(projectA, CUSTOM, secretsFile); + expect(desc.present).toBe(true); + expect(desc.hint).toBe(KEY.slice(-4)); + expect(JSON.stringify(desc)).not.toContain(KEY); + }); + + test('listProjects enumerates every project + endpoint with a redacted hint', async () => { + const s = store(); + await s.setForProject(projectA, CUSTOM, KEY); + await s.setForProject(projectB, OPENAI, KEY_2); + const listed = await s.listProjects(); + expect(listed).toHaveLength(2); + const endpoints = listed.flatMap((p) => p.endpoints.map((e) => e.endpoint)).sort(); + expect(endpoints).toEqual([CUSTOM, OPENAI].sort()); + expect(JSON.stringify(listed)).not.toContain(KEY); + }); + + test('canonicalProjectKey resolves a symlinked dir to its real path', () => { + const link = join(dir, 'link-to-a'); + symlinkSync(projectA, link); + // A resolve-keyed store would file the CLI (link) and server (real) under + // different keys — realpath collapses them to one identity. + expect(canonicalProjectKey(link)).toBe(canonicalProjectKey(projectA)); + }); + + test('a symlinked project and its real path share one key slot', async () => { + const link = join(dir, 'link-to-a'); + symlinkSync(projectA, link); + const s = store(); + await s.setForProject(link, CUSTOM, KEY); + expect((await s.resolveForProject(projectA, CUSTOM)).key).toBe(KEY); }); }); diff --git a/packages/server/src/embeddings/secrets-store.ts b/packages/server/src/embeddings/secrets-store.ts index 207e5902b..6de2f5f1f 100644 --- a/packages/server/src/embeddings/secrets-store.ts +++ b/packages/server/src/embeddings/secrets-store.ts @@ -27,25 +27,85 @@ * code with it. */ -import { chmodSync, existsSync, readFileSync, statSync } from 'node:fs'; +import { + chmodSync, + closeSync, + existsSync, + openSync, + readFileSync, + realpathSync, + statSync, + unlinkSync, + writeSync, +} from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { DEFAULT_EMBEDDINGS_BASE_URL } from '@inkeep/open-knowledge-core'; import { parse as yamlParse, stringify as yamlStringify } from 'yaml'; -import { tracedMkdirSync, tracedUnlinkSync, tracedWriteFileSync } from '../fs-traced.ts'; +import { + tracedMkdirSync, + tracedRenameSync, + tracedUnlinkSync, + tracedWriteFileSync, +} from '../fs-traced.ts'; +import { normalizeProviderId } from './embedder.ts'; -/** `secrets.yml` field the embeddings API key is stored under — named - * `OPENAI_API_KEY` so it's self-evident to anyone who opens the file. */ +/** Identity of the built-in OpenAI endpoint — the only host the legacy flat key inherits to. */ +const DEFAULT_ENDPOINT_ID = normalizeProviderId(DEFAULT_EMBEDDINGS_BASE_URL); + +/** + * Machine-global legacy key field. Before per-project keys this was THE key — + * one value sent to whatever endpoint each project configured. Now retained as + * a host-agnostic read-only fallback (see {@link FileEmbeddingsBackend.resolveForProject} + * step 2) so an existing single-key setup keeps working untouched on upgrade; + * never written anymore. Named `OPENAI_API_KEY` so it's self-evident in the file. + */ const SECRETS_KEY_FIELD = 'OPENAI_API_KEY'; /** * Prior field name (used briefly before the rename to `OPENAI_API_KEY`). Read as * a fallback so a key written by an earlier build keeps resolving instead of - * silently vanishing, then dropped on the next `set()` — a one-shot, self- - * clearing migration with no separate command and no steady-state read cost. - * Harmless when no such key exists (most installs): the field is simply absent. + * silently vanishing. Harmless when no such key exists (most installs): the + * field is simply absent. */ const LEGACY_KEY_FIELD = 'embeddings'; +/** + * Per-project, per-endpoint key map: `projectKey → normalizedEndpoint → key`. + * The project dimension scopes keys to a project (different projects, different + * keys); the endpoint dimension is what makes the binding STRUCTURAL — a stored + * key has no path to a host it wasn't entered against, because resolution only + * ever reads the slot for the endpoint currently configured. Both a per-endpoint + * key store and a per-project override fall out of the one nested shape. + */ +const PROJECTS_FIELD = 'embeddings_projects'; +type ProjectsMap = Record>; + +/** Where a resolved key came from — surfaced (never the key) for status/UX. */ +export type EmbeddingsKeySource = 'project' | 'file' | 'env' | null; + +/** A resolved key plus its provenance. `key` null = nothing stored/inherited. */ +export interface ResolvedEmbeddingsKey { + key: string | null; + source: EmbeddingsKeySource; +} + +/** + * Canonical, comparable identity for a project directory. `realpath` — NOT bare + * `resolve` — because on macOS `/tmp/x` and `/private/tmp/x` are the same + * directory via a symlink, and a `resolve`-keyed store would file the CLI's + * `set-key` under one and the server's lookup under the other → a silent + * key-miss. Falls back to `resolve` when the dir doesn't exist yet (nothing to + * canonicalize against). `.native` normalizes drive-letter casing on Windows. + */ +export function canonicalProjectKey(projectDir: string): string { + try { + return realpathSync.native(projectDir); + } catch { + return resolve(projectDir); + } +} + /** * Absolute path of the secrets file (`/.ok/secrets.yml`). `homedirOverride` * redirects it for tests — the same seam the config layer uses @@ -55,16 +115,56 @@ export function secretsFilePath(homedirOverride?: string): string { return join(homedirOverride ?? homedir(), '.ok', 'secrets.yml'); } -/** Read-only accessor the server's embedder consumes (matches `EmbeddingsKeyStore`). */ +/** One project's key status for a specific endpoint (for status / list; never the key). */ +export interface EmbeddingsKeyPresence { + present: boolean; + /** Redacted last-4 tail when the resolved key is long enough, else null. */ + hint: string | null; + source: EmbeddingsKeySource; +} + +/** A project→endpoint→presence view for `ok embeddings list`. */ +export interface EmbeddingsProjectListing { + projectKey: string; + endpoints: Array<{ endpoint: string; hint: string | null }>; +} + +/** + * Read-only accessor the server's embedder consumes. Resolution is + * project + endpoint scoped: given the running server's projectDir and the + * endpoint it's configured for, return the key bound to exactly that slot (or a + * legacy fallback). `getLegacyKey` exposes the old flat field alone, for the + * env/legacy presence checks that don't have an endpoint in hand. + */ export interface EmbeddingsKeyReader { - get(): Promise; + resolveForProject(projectDir: string, baseUrl: string): Promise; + describeForProject(projectDir: string, baseUrl: string): Promise; + /** The machine-global legacy key alone (no project/endpoint), or null. */ + getLegacyKey(): Promise; } /** Full read/write store used by the CLI commands + the set/clear HTTP handlers. */ export interface EmbeddingsSecretStore extends EmbeddingsKeyReader { readonly backend: 'file'; - set(key: string): Promise; - clear(): Promise; + setForProject(projectDir: string, baseUrl: string, key: string): Promise; + clearForProject(projectDir: string, baseUrl: string): Promise; + clearAllForProject(projectDir: string): Promise; + listProjects(): Promise; +} + +/** Redacted last-4 tail, only when the key is long enough that 4 chars are negligible. */ +function keyHint(key: string | null): string | null { + return key && key.length >= 8 ? key.slice(-4) : null; +} + +/** Max wait for the cross-process secrets lock before proceeding unlocked. */ +const LOCK_TIMEOUT_MS = 2000; +/** A lock older than this is presumed abandoned (crashed writer) and stolen. */ +const LOCK_STALE_MS = 10_000; + +/** Block the thread ~`ms` without busy-spinning (sync path — no event loop). */ +function sleepSyncMs(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } export class FileEmbeddingsBackend implements EmbeddingsSecretStore { @@ -75,6 +175,68 @@ export class FileEmbeddingsBackend implements EmbeddingsSecretStore { this.secretsFile = secretsFile ?? secretsFilePath(); } + /** + * Serialize a read-modify-write against the shared secrets file across + * processes. Each project runs its own server, and every server writes the + * WHOLE map back, so without this two servers setting a key at the same + * instant would let the second clobber the first's project entry (a silent + * lost update — atomic rename prevents a corrupt file, not a lost write). + * + * An `O_EXCL` lockfile is the mutual exclusion; a stale lock (crashed writer) + * is stolen after {@link LOCK_STALE_MS}. Best-effort: if the lock can't be + * taken within {@link LOCK_TIMEOUT_MS} (or at all — read-only dir), proceed + * anyway rather than fail a credential write, degrading to the pre-existing + * last-writer-wins. `fn` re-reads inside the lock so it merges live state. + */ + private withLock(fn: () => T): T { + const lockPath = `${this.secretsFile}.lock`; + const dir = dirname(this.secretsFile); + if (!existsSync(dir)) tracedMkdirSync(dir, { recursive: true, mode: 0o700 }); + const start = Date.now(); + let held = false; + while (!held) { + try { + const fd = openSync(lockPath, 'wx', 0o600); + writeSync(fd, String(process.pid)); + closeSync(fd); + held = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + // Can't create the lock at all (e.g. EACCES on ~/.ok/) — proceed + // unlocked rather than fail a credential write, but say so: cross- + // process serialization is silently off until the perms are fixed. + const msg = err instanceof Error ? err.message : 'unknown error'; + process.stderr.write( + `[embeddings] could not acquire the secrets write-lock (${msg}); ` + + `proceeding without cross-process serialization.\n`, + ); + break; + } + try { + if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) { + unlinkSync(lockPath); + continue; // stole a stale lock — retry immediately + } + } catch { + continue; // lock vanished between open and stat — retry + } + if (Date.now() - start > LOCK_TIMEOUT_MS) break; // waited long enough — proceed unlocked + sleepSyncMs(15); + } + } + try { + return fn(); + } finally { + if (held) { + try { + unlinkSync(lockPath); + } catch { + // best-effort — a stale-break by another writer may have removed it + } + } + } + } + /** * Self-heal the secrets file's permissions when reading. `write()` sets 0600, * but a file from an older build (before chmod-on-write), an external tool, or @@ -129,58 +291,176 @@ export class FileEmbeddingsBackend implements EmbeddingsSecretStore { } } + /** + * Persist the whole map, atomically. Every mutation re-reads first (see the + * call sites) so an interleaving write from ANOTHER project's server is not + * clobbered wholesale; the residual is last-writer-wins if two servers set a + * key in the exact same instant — rare (distinct projects), and never + * corrupts the file because the swap is a rename, not an in-place rewrite. + */ private write(data: Record): void { const dir = dirname(this.secretsFile); // Traced writes: server-side disk I/O must emit an `fs.*` span (STOP rule; // @opentelemetry/instrumentation-fs doesn't work on Bun). 0600/0700 modes // pass straight through the wrappers. if (!existsSync(dir)) tracedMkdirSync(dir, { recursive: true, mode: 0o700 }); - tracedWriteFileSync(this.secretsFile, yamlStringify(data), { mode: 0o600 }); - // `mode` on writeFileSync only applies when the file is CREATED — re-assert - // 0600 every write so a pre-existing secrets file with looser permissions - // (older build, external tool) gets tightened. chmod is a metadata op, not a - // content write, so it's outside the fs-traced wrappers' scope. + // Write to a sibling temp file then rename over the target: a reader (a + // concurrent search resolving a key) never observes a half-written file. + const tmp = `${this.secretsFile}.tmp`; + tracedWriteFileSync(tmp, yamlStringify(data), { mode: 0o600 }); + chmodSync(tmp, 0o600); // `mode` only applies on create — re-assert before the swap + tracedRenameSync(tmp, this.secretsFile); chmodSync(this.secretsFile, 0o600); } - // Re-reads the file on every call so a key written by `ok embeddings set-key` - // (or the Account UI) after the server started is picked up by the next - // search's `get()`. - get(): Promise { - const data = this.read(); - // Fall back to the legacy field so a key from an earlier build still resolves. + /** Delete the file when the map is empty, else persist it. */ + private writeOrUnlink(data: Record): void { + if (Object.keys(data).length === 0) { + try { + tracedUnlinkSync(this.secretsFile); + } catch (err) { + // Already gone → fine. Otherwise the cleared key still sits on disk and + // the next read re-discovers it, so a "cleared" op silently didn't — + // say so rather than report success with the bytes still there. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + const msg = err instanceof Error ? err.message : 'unknown error'; + process.stderr.write( + `[embeddings] could not remove ${this.secretsFile} (${msg}); a cleared key may ` + + `remain on disk — delete the file manually if needed.\n`, + ); + } + } + return; + } + this.write(data); + } + + private readProjects(data: Record): ProjectsMap { + const raw = data[PROJECTS_FIELD]; + return raw && typeof raw === 'object' ? (raw as ProjectsMap) : {}; + } + + private legacyKey(data: Record): string | null { const value = data[SECRETS_KEY_FIELD] ?? data[LEGACY_KEY_FIELD]; - return Promise.resolve(typeof value === 'string' && value !== '' ? value : null); + return typeof value === 'string' && value !== '' ? value : null; } - set(key: string): Promise { + /** + * Resolve the key for a project + endpoint. Re-reads the file each call so a + * key set after the server started is picked up by the next search. + * 1. the project's slot for this exact endpoint identity — structurally bound. + * 2. else, ONLY for the default OpenAI endpoint, the legacy flat key — so a + * pre-existing single-key OpenAI setup keeps working untouched on upgrade. + * The fallback is deliberately NOT host-agnostic: a flat key must never travel + * to a custom endpoint it wasn't entered against (that would re-open the + * wrong-host exfil path the per-endpoint model closes). Custom endpoints were + * not reachable before this feature, so nothing that worked yesterday breaks. + * The env var is resolved one layer up (`loadOpenAiEmbedder`), under the same + * default-host scoping — the store doesn't read process state. + */ + resolveForProject(projectDir: string, baseUrl: string): Promise { const data = this.read(); - // Complete the one-shot migration: the key now lives under the current field. - delete data[LEGACY_KEY_FIELD]; - data[SECRETS_KEY_FIELD] = key; - this.write(data); + const endpoint = normalizeProviderId(baseUrl); + const slot = this.readProjects(data)[canonicalProjectKey(projectDir)]?.[endpoint]; + if (typeof slot === 'string' && slot !== '') { + return Promise.resolve({ key: slot, source: 'project' }); + } + if (endpoint === DEFAULT_ENDPOINT_ID) { + const legacy = this.legacyKey(data); + if (legacy) return Promise.resolve({ key: legacy, source: 'file' }); + } + return Promise.resolve({ key: null, source: null }); + } + + describeForProject(projectDir: string, baseUrl: string): Promise { + return this.resolveForProject(projectDir, baseUrl).then(({ key, source }) => ({ + present: key !== null, + hint: keyHint(key), + source, + })); + } + + getLegacyKey(): Promise { + return Promise.resolve(this.legacyKey(this.read())); + } + + setForProject(projectDir: string, baseUrl: string, key: string): Promise { + this.withLock(() => { + const data = this.read(); + const projects = this.readProjects(data); + const pkey = canonicalProjectKey(projectDir); + projects[pkey] = { ...projects[pkey], [normalizeProviderId(baseUrl)]: key }; + data[PROJECTS_FIELD] = projects; + this.write(data); + }); return Promise.resolve(); } - clear(): Promise { - const data = this.read(); - // Drop both fields so a cleared key can't resurrect via the legacy fallback. - if (SECRETS_KEY_FIELD in data || LEGACY_KEY_FIELD in data) { + /** Remove the project's key for this endpoint. Returns whether one existed. */ + clearForProject(projectDir: string, baseUrl: string): Promise { + const existed = this.withLock(() => { + const data = this.read(); + const projects = this.readProjects(data); + const pkey = canonicalProjectKey(projectDir); + const endpoint = normalizeProviderId(baseUrl); + if (projects[pkey]?.[endpoint] === undefined) return false; + delete projects[pkey][endpoint]; + if (Object.keys(projects[pkey]).length === 0) delete projects[pkey]; + if (Object.keys(projects).length === 0) delete data[PROJECTS_FIELD]; + else data[PROJECTS_FIELD] = projects; + this.writeOrUnlink(data); + return true; + }); + return Promise.resolve(existed); + } + + /** + * Remove ALL of a project's keys (every endpoint). For `ok deinit` / removing + * OK from a project — the whole project entry goes, not one endpoint's slot. + * Returns whether the project had any keys. + */ + clearAllForProject(projectDir: string): Promise { + const existed = this.withLock(() => { + const data = this.read(); + const projects = this.readProjects(data); + const pkey = canonicalProjectKey(projectDir); + if (projects[pkey] === undefined) return false; + delete projects[pkey]; + if (Object.keys(projects).length === 0) delete data[PROJECTS_FIELD]; + else data[PROJECTS_FIELD] = projects; + this.writeOrUnlink(data); + return true; + }); + return Promise.resolve(existed); + } + + /** Remove ALL embeddings state (every project + the legacy flat key). */ + clearAll(): Promise<{ touched: Array<'file'> }> { + const touched = this.withLock(() => { + const data = this.read(); + const had = + data[PROJECTS_FIELD] !== undefined || + data[SECRETS_KEY_FIELD] !== undefined || + data[LEGACY_KEY_FIELD] !== undefined; + delete data[PROJECTS_FIELD]; delete data[SECRETS_KEY_FIELD]; delete data[LEGACY_KEY_FIELD]; - // Don't leave a stray empty file behind if this was the only secret — - // unlink it so the next `get()` sees a genuinely absent store. - if (Object.keys(data).length === 0) { - try { - tracedUnlinkSync(this.secretsFile); - } catch { - // best-effort (already gone / unwritable) - } - } else { - this.write(data); - } - } - return Promise.resolve(); + if (had) this.writeOrUnlink(data); + return had; + }); + return Promise.resolve(touched ? { touched: ['file'] as Array<'file'> } : { touched: [] }); + } + + listProjects(): Promise { + const projects = this.readProjects(this.read()); + const listing = Object.entries(projects).map(([projectKey, endpoints]) => ({ + projectKey, + endpoints: Object.entries(endpoints).map(([endpoint, key]) => ({ + endpoint, + hint: keyHint(key), + })), + })); + return Promise.resolve(listing); } } @@ -201,25 +481,26 @@ export function makeLazyEmbeddingsKeyStore(secretsFile?: string): EmbeddingsKeyR return new FileEmbeddingsBackend(secretsFile); } -/** Report whether the secrets file holds a key. Used by `ok embeddings status`. */ +/** + * Presence + provenance of a project's key for a given endpoint. Used by + * `ok embeddings status` and the semantic-status HTTP read — never the key. + */ export async function describeStoredEmbeddingsKey( + projectDir: string, + baseUrl: string, secretsFile?: string, -): Promise<{ file: boolean }> { - return { file: (await new FileEmbeddingsBackend(secretsFile).get()) != null }; +): Promise { + return new FileEmbeddingsBackend(secretsFile).describeForProject(projectDir, baseUrl); } /** - * Clear the key from the secrets file. Returns which backends had an entry - * (always just `file` now) so callers can report honestly. + * Wipe ALL embeddings keys — every project's per-endpoint keys plus the legacy + * flat field. For the machine-wide `ok uninstall`. Returns `touched: ['file']` + * when something was removed, so callers can report honestly (shape kept for the + * removal-plan dep). */ -export async function clearEmbeddingsKeyFromAllBackends( +export async function clearAllEmbeddingsKeys( secretsFile?: string, ): Promise<{ touched: Array<'file'> }> { - const touched: Array<'file'> = []; - const file = new FileEmbeddingsBackend(secretsFile); - if ((await file.get()) != null) { - await file.clear(); - touched.push('file'); - } - return { touched }; + return new FileEmbeddingsBackend(secretsFile).clearAll(); } diff --git a/packages/server/src/embeddings/semantic-search-service.test.ts b/packages/server/src/embeddings/semantic-search-service.test.ts index c04bb028c..005abc3fe 100644 --- a/packages/server/src/embeddings/semantic-search-service.test.ts +++ b/packages/server/src/embeddings/semantic-search-service.test.ts @@ -186,6 +186,30 @@ describe('SemanticSearchService', () => { expect(svc.getStatus().embeddedCount).toBe(2); }); + test('reloadCredential re-warms so a key set after warming takes effect', async () => { + // Model the "key added mid-session" flow: warm returns a NEW embedder each + // call, and we assert the second warm ran (a fresh embedder was loaded). + let loads = 0; + const svc = new SemanticSearchService({ + loadEmbedder: () => { + loads += 1; + return Promise.resolve(createConceptEmbedder({ concepts })); + }, + cacheDir: null, + enabled: true, + }); + await svc.ensureWarm(); + expect(loads).toBe(1); + expect(svc.getStatus().capable).toBe(true); + + // A live warm won't re-read the key on its own; reloadCredential forces it. + svc.reloadCredential(); + expect(svc.getStatus().capable).toBe(false); // reset until the next search + await svc.ensureWarm(); + expect(loads).toBe(2); // the credential was re-resolved + expect(svc.getStatus().capable).toBe(true); + }); + test('disable racing an in-flight embed pass does NOT wipe the on-disk cache', async () => { const dir = mkdtempSync(join(tmpdir(), 'ok-vec-race-')); try { diff --git a/packages/server/src/embeddings/semantic-search-service.ts b/packages/server/src/embeddings/semantic-search-service.ts index 6ef3f3bb2..31fa8ef1d 100644 --- a/packages/server/src/embeddings/semantic-search-service.ts +++ b/packages/server/src/embeddings/semantic-search-service.ts @@ -28,7 +28,12 @@ import type { WorkspaceSearchDocument } from '@inkeep/open-knowledge-core'; import { getLogger } from '../logger.ts'; import { CHUNK_CONFIG_ID, chunkDocument } from './chunking.ts'; -import { cosineSimilarity, type Embedder } from './embedder.ts'; +import { + cosineSimilarity, + type Embedder, + EmbeddingDimsMismatchError, + EmbeddingProviderError, +} from './embedder.ts'; import { hashContent, VectorCache } from './vector-cache.ts'; const log = getLogger('embeddings'); @@ -57,6 +62,27 @@ const EMBED_BATCH_CHUNK_LIMIT = 96; */ const MAX_CONSECUTIVE_EMBED_FAILURES = 5; +/** + * How many times one process will throw away the cached corpus because the + * provider's vector length changed. Rebuilding is paid egress, so a gateway + * flapping between two models must not be able to bill for it in a loop; after + * the budget is spent the mismatch is logged loudly and search degrades to + * lexical until a restart. + */ +export const MAX_DIMS_DRIFT_RESETS = 2; + +/** + * Raised inside an embed pass when a provider call reported a vector length + * that doesn't match the one in force. Caught by the pass itself — never + * propagated to a caller. + */ +class DimsMismatchSignal extends Error { + readonly name = 'DimsMismatchSignal'; + constructor(readonly cause: EmbeddingDimsMismatchError) { + super(cause.message); + } +} + export interface SemanticSearchStatus { /** Config flag. */ enabled: boolean; @@ -98,6 +124,8 @@ export class SemanticSearchService { private warmPromise: Promise | null = null; private embedChain: Promise = Promise.resolve(); private queuedDocs: readonly WorkspaceSearchDocument[] | null = null; + /** Process-lifetime budget spent on {@link MAX_DIMS_DRIFT_RESETS}. */ + private dimsDriftResets = 0; constructor(options: SemanticSearchServiceOptions) { this.loadEmbedder = options.loadEmbedder; @@ -130,6 +158,11 @@ export class SemanticSearchService { applyConfig(input: { enabled: boolean; providerFingerprint: string }): void { if (input.providerFingerprint !== this.providerFingerprint) { this.providerFingerprint = input.providerFingerprint; + // A different provider deserves its own drift budget — the previous one + // having flapped says nothing about this one. Reset it HERE and not in + // `resetWarm`, which drift recovery itself calls: refunding the budget on + // every recovery would leave the bound doing nothing at all. + this.dimsDriftResets = 0; this.resetWarm(); } if (input.enabled === this.enabled) return; @@ -148,6 +181,62 @@ export class SemanticSearchService { this.cache = null; } + /** + * Re-resolve the embeddings credential on the next search. The key is read at + * warm time, NOT part of the provider fingerprint, so a live warm won't pick + * up a key that was set / cleared after it — leaving "I added a key but search + * stays lexical". The set/clear-key handlers call this so the change takes + * effect on the next search with no restart. Cheap: the cache re-hydrates from + * disk on re-warm (same fingerprint → no re-embed). + */ + reloadCredential(): void { + this.resetWarm(); + } + + /** + * Respond to the provider returning a different vector length than the cached + * corpus was built at — a model swapped behind an alias, or a size detected + * before a restart that no longer holds. + * + * Only meaningful when the size was auto-detected. With `dimensions` + * explicitly configured a mismatch means the server is ignoring the request + * param; rebuilding would burn the same egress and land in the same place, so + * that case stays a plain (surfaced) failure. + * + * Recovery is: throw the corpus away and drop the warm state. The next opt-in + * search re-warms against a cold cache, the embedder re-detects, and coverage + * refills — the same one-search-later recovery a run of embed failures gets. + * Returns whether it acted. + */ + private recoverFromDimsDrift(cache: VectorCache, err: EmbeddingDimsMismatchError): boolean { + if (cache.identityDims !== 'auto') return false; + if (this.cache !== cache) return false; // already replaced under us + if (this.dimsDriftResets >= MAX_DIMS_DRIFT_RESETS) { + // Give up for the rest of the process rather than pay for another + // rebuild. Dropping `capable` is what makes that stick: every later + // search short-circuits at the top of `queryScores`/`runEmbedPass` + // instead of re-reaching the provider and re-logging this same line. + log.error( + { expected: err.expected, got: err.got }, + '[embeddings] provider vector length keeps changing — disabling semantic search until restart', + ); + this.capable = false; + // Nothing will read these vectors again this process, and they can be + // a large resident allocation. Memory only — the disk store survives for + // a re-hydrate after a restart or a provider change. + cache.clearMemory(); + return false; + } + this.dimsDriftResets += 1; + log.warn( + { expected: err.expected, got: err.got }, + '[embeddings] provider vector length changed — discarding cached vectors and re-embedding', + ); + cache.discard(); + this.resetWarm(); + return true; + } + /** Lazy, single-flight key resolution + client construction + cache hydration. */ ensureWarm(): Promise { if (!this.enabled) return Promise.resolve(); @@ -169,14 +258,21 @@ export class SemanticSearchService { return; } this.embedder = embedder; - this.cache = new VectorCache({ + const cache = new VectorCache({ cacheDir: this.cacheDir, providerId: embedder.providerId, modelId: embedder.modelId, dims: embedder.dims, chunkConfigId: CHUNK_CONFIG_ID, }); - await this.cache.init(); + await cache.init(); + // Manifest-first: with `dimensions` unset, the length that matters is the + // one the stored vectors were written at. Handing it to the embedder now + // means the first response of this run is CHECKED against it — a provider + // that changed shape while we were down surfaces as a mismatch instead of + // quietly re-pinning and scoring two sizes against each other. + if (cache.dims !== null) embedder.pinDims?.(cache.dims); + this.cache = cache; this.capable = true; this.ready = true; } catch (err) { @@ -209,7 +305,10 @@ export class SemanticSearchService { private async runEmbedPass(documents: readonly WorkspaceSearchDocument[]): Promise { await this.ensureWarm(); - if (!this.enabled || !this.embedder || !this.cache) return; + // `capable` also carries the give-up state drift recovery sets once its + // budget is spent, so this stops the pass re-paying the provider for a + // mismatch already ruled unrecoverable. + if (!this.enabled || !this.capable || !this.embedder || !this.cache) return; const cache = this.cache; const embedder = this.embedder; const pageDocs = documents.filter((d) => d.kind === 'page'); @@ -239,6 +338,11 @@ export class SemanticSearchService { let consecutiveFailures = 0; const storeDoc = (p: Pending, vectors: Float32Array[]): void => { + // A cold auto cache learns its length here — the embedder has already + // held every vector in the call to one size, so the first is definitive. + // (A blank doc stores zero vectors and teaches us nothing.) + const observed = vectors[0]?.length; + if (observed !== undefined) cache.pinDims(observed); cache.store(p.doc.id, p.contentHash, p.doc.modifiedTs, vectors); }; @@ -256,6 +360,10 @@ export class SemanticSearchService { consecutiveFailures = 0; return true; } catch (batchErr) { + // A length mismatch isn't one bad document — it says everything cached + // is the wrong size. Let it out to the pass rather than spending the + // per-doc failure budget re-asking the provider the same question. + if (batchErr instanceof EmbeddingDimsMismatchError) throw new DimsMismatchSignal(batchErr); if (group.length === 1) { log.warn( { docId: group[0].doc.id, err: errMsg(batchErr) }, @@ -272,6 +380,7 @@ export class SemanticSearchService { storeDoc(p, v); consecutiveFailures = 0; } catch (docErr) { + if (docErr instanceof EmbeddingDimsMismatchError) throw new DimsMismatchSignal(docErr); log.warn( { docId: p.doc.id, err: errMsg(docErr) }, '[embeddings] failed to embed document', @@ -286,18 +395,33 @@ export class SemanticSearchService { let batch: Pending[] = []; let batchChunks = 0; - for (const p of pending) { - if (!this.enabled) break; - batch.push(p); - batchChunks += Math.max(1, p.chunks.length); - if (batchChunks >= EMBED_BATCH_CHUNK_LIMIT) { - const carryOn = await embedGroup(batch); - batch = []; - batchChunks = 0; - if (!carryOn) break; + try { + for (const p of pending) { + if (!this.enabled) break; + batch.push(p); + batchChunks += Math.max(1, p.chunks.length); + if (batchChunks >= EMBED_BATCH_CHUNK_LIMIT) { + const carryOn = await embedGroup(batch); + batch = []; + batchChunks = 0; + if (!carryOn) break; + } + } + if (batch.length > 0 && this.enabled) await embedGroup(batch); + } catch (err) { + if (!(err instanceof DimsMismatchSignal)) throw err; + // The mismatched vectors were never stored (the embedder threw before + // returning them), so what is cached is self-consistent — just the wrong + // size now. Recovery discards it; a configured-dimensions mismatch has + // nothing to recover and is only logged. + if (!this.recoverFromDimsDrift(cache, err.cause)) { + log.warn( + { err: err.cause }, + '[embeddings] provider returned an unexpected vector length — stopping this embed pass', + ); } + return; } - if (batch.length > 0 && this.enabled) await embedGroup(batch); // If the feature was disabled (or the provider fingerprint changed) while a // batch was in flight, `applyConfig`/`resetWarm` cleared + nulled `this.cache` @@ -322,27 +446,53 @@ export class SemanticSearchService { documents: readonly WorkspaceSearchDocument[], ): Promise | null> { if (!this.enabled || !this.capable || !this.ready) return null; - if (!this.embedder || !this.cache) return null; - if (this.cache.embeddedCount === 0) return null; + // Capture both before the await: a config change or a drift recovery can + // swap them mid-flight, and this path must never throw. + const cache = this.cache; + const embedder = this.embedder; + if (!embedder || !cache) return null; + if (cache.embeddedCount === 0) return null; const trimmed = query.trim(); if (!trimmed) return null; let queryVec: Float32Array | undefined; try { - [queryVec] = await this.embedder.embed([trimmed], { role: 'query' }); + [queryVec] = await embedder.embed([trimmed], { role: 'query' }); } catch (err) { - // Provider error / timeout on the query path is non-fatal: degrade to BM25. - log.warn({ err }, '[embeddings] query embed failed — degrading to lexical'); + // A length mismatch on the query path is how an alias swap surfaces when + // the corpus itself hasn't changed: every embed pass is a no-op + // reconcile, so nothing else would ever notice, and semantic search would + // stay silently dead. Recovery rebuilds; anything else just degrades. + if (err instanceof EmbeddingDimsMismatchError) { + // Recovery declines when the size was explicitly configured (nothing to + // rebuild) or the budget is spent. Log it either way — otherwise a + // query-first mismatch degrades to lexical leaving no trail at all, + // while the same mismatch on the embed path is logged. + if (!this.recoverFromDimsDrift(cache, err)) { + log.warn( + { err, expected: err.expected, got: err.got }, + '[embeddings] query vector length does not match the cached corpus — degrading to lexical', + ); + } + } else { + log.warn( + { err, reason: err instanceof EmbeddingProviderError ? err.reason : undefined }, + '[embeddings] query embed failed — degrading to lexical', + ); + } return null; } if (!queryVec) return null; const scores = new Map(); for (const doc of documents) { - const vectors = this.cache.getVectors(doc.id); + const vectors = cache.getVectors(doc.id); if (!vectors || vectors.length === 0) continue; let best = Number.NEGATIVE_INFINITY; for (const chunk of vectors) { + // `cosineSimilarity` truncates to the shorter vector, so a length + // mismatch would score as a plausible number rather than an error. + if (chunk.length !== queryVec.length) continue; const cos = cosineSimilarity(queryVec, chunk); if (cos > best) best = cos; } diff --git a/packages/server/src/embeddings/vector-cache.ts b/packages/server/src/embeddings/vector-cache.ts index 59150a611..5a09b3e37 100644 --- a/packages/server/src/embeddings/vector-cache.ts +++ b/packages/server/src/embeddings/vector-cache.ts @@ -10,7 +10,7 @@ * Layout (plain `fs`, NOT sqlite — must run under both Bun and Electron real-Node, * and `bun:sqlite` is Bun-only): * embeddings/ - * manifest.json { schemaVersion, modelId, dims, chunkConfigId, entries } + * manifest.json { schemaVersion, providerId, modelId, dims, identityDims, chunkConfigId, entries } * vec/.bin Float32 blob, length = chunkCount * dims * * Content-addressed: a blob is named by the SHA-256 of the document content, so @@ -25,6 +25,13 @@ * provider/model from silently scoring against another's query (a mismatch is a * silent retrieval failure, not a loud one). * + * Dims come in two flavours that must not be conflated. `identityDims` is what + * the user configured (a number, or `'auto'` when they left it to the + * provider), and it is the only one in the identity check. `dims` is the length + * actually observed — pinned from the manifest on restart or from the first + * embed, and compared for drift. Folding the observed length into the identity + * would make an auto cache invalidate itself on every restart. + * * Vectors are stored in platform-native Float32 byte order. The cache is * machine-local and never transported, so endianness is always self-consistent. */ @@ -58,17 +65,46 @@ interface ManifestFile { schemaVersion: number; providerId: string; modelId: string; + /** The concrete vector length these blobs were written at. */ dims: number; + /** + * The dims component of the cache IDENTITY: the configured size, or `'auto'` + * when the config leaves it to the provider. Absent in manifests written + * before the split (see `manifestIdentityDims`). + */ + identityDims?: number | 'auto'; chunkConfigId: string; entries: Record; } +/** What the identity-dims component of a cache fingerprint can be. */ +export type IdentityDims = number | 'auto'; + +/** + * Identity dims for a manifest, tolerating the pre-split shape. + * + * A legacy manifest carries only `dims`, which was simultaneously the identity + * and the value. Reading it back as `'auto'` when we are in auto mode is sound + * — every stored vector was length-checked against that exact `dims` at write + * time, so adopting them is the same guarantee a fresh detection would give — + * and it is what keeps the upgrade from charging every existing user a full + * re-embed. + */ +function manifestIdentityDims(manifest: ManifestFile, want: IdentityDims): IdentityDims { + if (manifest.identityDims !== undefined) return manifest.identityDims; + return want === 'auto' ? 'auto' : manifest.dims; +} + interface VectorCacheOptions { /** Cache home (`/.ok/local/embeddings`), or `null` for memory-only (tests). */ cacheDir: string | null; providerId: string; modelId: string; - dims: number; + /** + * Configured vector length, or `null` for auto — the length is then pinned + * from the manifest on restart, or from the first embed on a cold cache. + */ + dims: number | null; chunkConfigId: string; } @@ -114,7 +150,14 @@ export class VectorCache { private readonly manifestPath: string | null; readonly providerId: string; readonly modelId: string; - readonly dims: number; + /** + * The dims component of this cache's identity — a number when configured, + * `'auto'` when the provider decides. What gates the wipe. Deliberately NOT + * the detected length: an auto cache whose identity moved with the detected + * value would fail its own identity check on every restart and re-embed the + * whole corpus each boot. + */ + readonly identityDims: IdentityDims; readonly chunkConfigId: string; /** docId → manifest entry (contentHash + mtime stamp). */ @@ -125,6 +168,8 @@ export class VectorCache { private readonly persistedHashes = new Set(); /** Whether in-memory state has diverged from disk since the last persist. */ private dirty = false; + /** Concrete vector length; `null` until an auto cache pins one. */ + private pinnedDims: number | null; constructor(options: VectorCacheOptions) { this.cacheDir = options.cacheDir; @@ -132,10 +177,35 @@ export class VectorCache { this.manifestPath = options.cacheDir ? join(options.cacheDir, MANIFEST_NAME) : null; this.providerId = options.providerId; this.modelId = options.modelId; - this.dims = options.dims; + this.identityDims = options.dims ?? 'auto'; + this.pinnedDims = options.dims; this.chunkConfigId = options.chunkConfigId; } + /** Vector length in use, or `null` when auto and nothing is pinned yet. */ + get dims(): number | null { + return this.pinnedDims; + } + + /** + * Record the length the provider actually returned, so `persist` knows what + * these blobs are. Only ever fires on a cold auto cache: the embedder rejects + * any response of a different size, so a change arrives as an error rather + * than as a second pin. + */ + pinDims(dims: number): void { + this.pinnedDims ??= dims; + } + + /** Throw the store away, memory AND disk, e.g. its vectors are the wrong size now. */ + discard(): void { + this.entries.clear(); + this.vectorsByHash.clear(); + this.persistedHashes.clear(); + this.wipeDisk(); + this.dirty = false; + } + /** * Load the manifest + referenced blobs into memory. If the on-disk identity * (model/dims/chunk-config/schema) differs from this instance's, the store is @@ -159,7 +229,7 @@ export class VectorCache { manifest.schemaVersion === MANIFEST_SCHEMA_VERSION && manifest.providerId === this.providerId && manifest.modelId === this.modelId && - manifest.dims === this.dims && + manifestIdentityDims(manifest, this.identityDims) === this.identityDims && manifest.chunkConfigId === this.chunkConfigId; if (!identityMatches) { @@ -174,6 +244,15 @@ export class VectorCache { } if (!manifest) return; // unreachable once identity matched; narrows for TS + // Manifest-first: an auto cache takes its length from what was actually + // written, not from a re-detection. Without this the restart path has no + // length to deserialize with and would re-embed the whole corpus. + if (this.pinnedDims === null) { + if (!Number.isInteger(manifest.dims) || manifest.dims <= 0) return; + this.pinnedDims = manifest.dims; + } + const dims = this.pinnedDims; + for (const [docId, entry] of Object.entries(manifest.entries)) { if (!entry?.contentHash) continue; this.entries.set(docId, { contentHash: entry.contentHash, mtimeMs: entry.mtimeMs ?? 0 }); @@ -182,7 +261,7 @@ export class VectorCache { const blobPath = join(this.vecDir, `${entry.contentHash}.bin`); if (existsSync(blobPath)) { const bytes = await readFile(blobPath); - this.vectorsByHash.set(entry.contentHash, deserializeVectors(bytes, this.dims)); + this.vectorsByHash.set(entry.contentHash, deserializeVectors(bytes, dims)); this.persistedHashes.add(entry.contentHash); } } catch (err) { @@ -271,6 +350,12 @@ export class VectorCache { async persist(): Promise { if (!this.cacheDir || !this.manifestPath || !this.vecDir) return; if (!this.dirty) return; // nothing changed since last persist — skip the write + const dims = this.pinnedDims; + // Nothing has been embedded yet (or every doc in the corpus is blank, which + // stores zero vectors), so there is no length to write and any entries are + // empty ones that cost nothing to reconcile again next boot. Writing a null + // length would poison the next init's deserialize. + if (dims === null) return; try { await tracedMkdir(this.vecDir, { recursive: true }); const referenced = new Set(); @@ -288,7 +373,8 @@ export class VectorCache { schemaVersion: MANIFEST_SCHEMA_VERSION, providerId: this.providerId, modelId: this.modelId, - dims: this.dims, + dims, + identityDims: this.identityDims, chunkConfigId: this.chunkConfigId, entries: Object.fromEntries(this.entries), }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b9a2565d9..b46a5bf9e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -126,18 +126,26 @@ export { export { FILE_WATCHER_ORIGIN } from './disk-content-intake.ts'; export { DocumentDurabilityState, type StoreFailure } from './document-durability-state.ts'; export { - clearEmbeddingsKeyFromAllBackends, + canonicalProjectKey, + clearAllEmbeddingsKeys, createEmbeddingsSecretStore, DEFAULT_EMBEDDINGS_DIMENSIONS, describeStoredEmbeddingsKey, EMBEDDINGS_API_KEY_ENV, + type EmbeddingsCredentialSource, + type EmbeddingsKeyPresence, type EmbeddingsKeyReader, + type EmbeddingsKeySource, type EmbeddingsKeyStore, + type EmbeddingsProjectListing, type EmbeddingsSecretStore, FileEmbeddingsBackend, makeLazyEmbeddingsKeyStore, + type ResolvedEmbeddingsCredential, + type ResolvedEmbeddingsKey, type ResolvedSemanticConfig, readProjectLocalSemanticConfig, + resolveEmbeddingsCredential, } from './embeddings/index.ts'; export { applyExternalChange, diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index 53d1f5a15..ba585c1e8 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -91,7 +91,6 @@ import { docNameToRelativePath, getDocExtension, stripDocExtension } from './doc import { runDocLineageGuard } from './doc-lineage-guard.ts'; import { DocumentDurabilityState } from './document-durability-state.ts'; import { - DEFAULT_EMBEDDINGS_DIMENSIONS, type Embedder, type EmbeddingsKeyStore, loadOpenAiEmbedder, @@ -757,8 +756,12 @@ export function createServer(options: ServerOptions): ServerInstance { // Provider identity for the cache key + the service's re-warm trigger. A change // here (provider/model/dims) re-loads the embedder and invalidates the cache. + // Unset `dimensions` is its own stable identity, NOT the OpenAI default: the + // length is whatever the provider returns, and substituting a number here + // would disagree with the length the cache actually persisted — re-wiping and + // re-embedding the corpus on every restart. function semanticProviderFingerprint(cfg: ResolvedSemanticConfig): string { - return `${normalizeProviderId(cfg.baseUrl)}|${cfg.model}|${cfg.dimensions ?? DEFAULT_EMBEDDINGS_DIMENSIONS}`; + return `${normalizeProviderId(cfg.baseUrl)}|${cfg.model}|${cfg.dimensions ?? 'auto'}`; } // Re-apply a just-persisted config to the live in-process consumers by @@ -978,6 +981,7 @@ export function createServer(options: ServerOptions): ServerInstance { const cfg = readSemanticSearchConfig(); return loadOpenAiEmbedder({ keyStore: options.embeddingsKeyStore ?? null, + projectDir, config: { baseUrl: cfg.baseUrl, model: cfg.model, dimensions: cfg.dimensions }, }); }), @@ -1856,6 +1860,7 @@ export function createServer(options: ServerOptions): ServerInstance { getLinksValidationSetting: () => readLinksValidationSetting(), getLinkPreviewsEnabled: readLinkPreviewsEnabled, embeddingsSecretsFile: secretsFilePath(configHomedirOverride), + readSemanticProviderConfig: readSemanticSearchConfig, ephemeral, onReferencedAssetsCacheInvalidator: (invalidate) => { invalidateReferencedAssetsCache = invalidate;