Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/custom-embeddings-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Configure a custom OpenAI-compatible embeddings endpoint from Settings → This project → Search instead of editing config files by hand. The semantic-search settings now expose the embeddings base URL directly, and the account copy plus `ok embeddings set-key` messaging now describe the key as belonging to the configured embeddings provider rather than only to OpenAI.
6 changes: 3 additions & 3 deletions packages/app/src/components/settings/EmbeddingsKeySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ export function EmbeddingsKeySection({ transport }: { transport?: EmbeddingsKeyT
</h3>
<p className="text-sm text-muted-foreground">
<Trans>
Your OpenAI API key, used only to create embeddings of your content for semantic search.
Stored once for this machine in{' '}
Your embeddings provider API key, used only to create embeddings of your content for
semantic search. Stored once for this machine in{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
~/.ok/secrets.yml
</code>{' '}
(readable only by your user account) and shared across all projects. Turn the feature on
per project in This project → Search.
per project in This project → Search, where you can also override the endpoint.
</Trans>
</p>
</div>
Expand Down
85 changes: 71 additions & 14 deletions packages/app/src/components/settings/SearchSection.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Config, ConfigBinding, SemanticIndexStatus } from '@inkeep/open-knowledge-core';
import {
type Config,
type ConfigBinding,
DEFAULT_EMBEDDINGS_BASE_URL,
type SemanticIndexStatus,
} from '@inkeep/open-knowledge-core';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Expand Down Expand Up @@ -44,8 +49,10 @@ mock.module('@/lib/config-provider', () => ({

const { SearchSection } = await import('./SearchSection');

function configWithSemanticEnabled(enabled: boolean): Config {
return { search: { semantic: { enabled } } } as unknown as Config;
function configWithSemantic({ enabled, baseUrl }: { enabled: boolean; baseUrl?: string }): Config {
return {
search: { semantic: { enabled, ...(baseUrl ? { baseUrl } : {}) } },
} as unknown as Config;
}

function makeBinding(): { binding: ConfigBinding; calls: unknown[] } {
Expand Down Expand Up @@ -87,7 +94,7 @@ describe('SearchSection', () => {
test('off: switch is unchecked, body says no content leaves, no coverage panel', () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(false);
mockProjectLocalConfig = configWithSemantic({ enabled: false });

render(<SearchSection />);

Expand Down Expand Up @@ -115,7 +122,7 @@ describe('SearchSection', () => {
const user = userEvent.setup();
const { binding, calls } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(false);
mockProjectLocalConfig = configWithSemantic({ enabled: false });

render(<SearchSection />);

Expand All @@ -133,7 +140,7 @@ describe('SearchSection', () => {
const user = userEvent.setup();
const { binding, calls } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: true,
keyPresent: true,
Expand All @@ -155,7 +162,7 @@ describe('SearchSection', () => {
test('on + keyed + warmed + capable: shows read-only coverage', async () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: true,
keyPresent: true,
Expand All @@ -175,7 +182,7 @@ describe('SearchSection', () => {
test('on + capable but nothing embedded yet: shows the lazy-warm hint', async () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: true,
keyPresent: true,
Expand All @@ -195,7 +202,7 @@ describe('SearchSection', () => {
test('on + NO key: shows the needs-a-key hint pointing at Account (instant, no warm)', async () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: true,
keyPresent: false,
Expand All @@ -218,7 +225,7 @@ describe('SearchSection', () => {
test('on + key present but provider rejected it: shows the provider-error hint', async () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: true,
keyPresent: true,
Expand All @@ -239,7 +246,7 @@ describe('SearchSection', () => {
test('on + keyed but not warmed: shows the pending state', async () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: true,
keyPresent: true,
Expand All @@ -261,7 +268,7 @@ describe('SearchSection', () => {
test('on but server not yet settled: shows the applying state', async () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(true);
mockProjectLocalConfig = configWithSemantic({ enabled: true });
mockStatus = {
enabled: false,
keyPresent: false,
Expand All @@ -285,7 +292,7 @@ describe('SearchSection', () => {
const user = userEvent.setup();
const { binding, calls } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemanticEnabled(false);
mockProjectLocalConfig = configWithSemantic({ enabled: false });

render(<SearchSection />);

Expand All @@ -305,7 +312,7 @@ describe('SearchSection', () => {
patch: () => ({ ok: false, error: { code: 'noop', message: 'fail' } }),
} as unknown as ConfigBinding;
mockProjectLocalBinding = failBinding;
mockProjectLocalConfig = configWithSemanticEnabled(false);
mockProjectLocalConfig = configWithSemantic({ enabled: false });

render(<SearchSection />);

Expand All @@ -314,4 +321,54 @@ describe('SearchSection', () => {

expect(await screen.findByTestId('settings-search-confirm')).toBeDefined();
});

test('shows the default endpoint when no custom provider is configured', () => {
const { binding } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemantic({ enabled: false });

render(<SearchSection />);

expect((screen.getByTestId('settings-search-base-url') as HTMLInputElement).value).toBe(
DEFAULT_EMBEDDINGS_BASE_URL,
);
});

test('blurring the endpoint field writes the trimmed custom base URL', async () => {
const user = userEvent.setup();
const { binding, calls } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemantic({ enabled: false });

render(<SearchSection />);

const input = screen.getByTestId('settings-search-base-url');
await user.clear(input);
await user.type(input, ' https://azure.example.com/openai/v1/ ');
await user.tab();

expect(calls).toContainEqual({
search: { semantic: { baseUrl: 'https://azure.example.com/openai/v1/' } },
});
});

test('clearing the endpoint field resets it to the default OpenAI endpoint', async () => {
const user = userEvent.setup();
const { binding, calls } = makeBinding();
mockProjectLocalBinding = binding;
mockProjectLocalConfig = configWithSemantic({
enabled: false,
baseUrl: 'https://azure.example.com/openai/v1',
});

render(<SearchSection />);

const input = screen.getByTestId('settings-search-base-url');
await user.clear(input);
await user.tab();

expect(calls).toContainEqual({
search: { semantic: { baseUrl: DEFAULT_EMBEDDINGS_BASE_URL } },
});
});
});
68 changes: 67 additions & 1 deletion packages/app/src/components/settings/SearchSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { humanFormat } from '@inkeep/open-knowledge-core';
import { DEFAULT_EMBEDDINGS_BASE_URL, humanFormat } from '@inkeep/open-knowledge-core';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
Expand All @@ -13,6 +13,7 @@ import {
Dialog as DialogRoot,
DialogTitle,
} from '@/components/ui/dialog';
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';
Expand All @@ -33,6 +34,9 @@ export function SearchSection() {
[],
);

const configuredBaseUrl =
projectLocalConfig?.search?.semantic?.baseUrl ?? DEFAULT_EMBEDDINGS_BASE_URL;

const enabled = projectLocalConfig?.search?.semantic?.enabled ?? false;
const bindingReady = projectLocalSynced && projectLocalBinding !== null;

Expand Down Expand Up @@ -61,6 +65,28 @@ export function SearchSection() {
return true;
}

function normalizeBaseUrl(next: string): string {
return next.trim() || DEFAULT_EMBEDDINGS_BASE_URL;
}

function writeBaseUrl(next: string): boolean {
Comment on lines +68 to +72
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 } } });
if (!result.ok) {
const detail = humanFormat(result.error);
toast.error(t`Failed to update the embeddings endpoint — ${detail}`);
return false;
}
refresh();
scheduleSettleRefresh();
return true;
}

function onToggleRequest(next: boolean) {
if (next) {
setConfirmOpen(true);
Expand All @@ -73,6 +99,11 @@ export function SearchSection() {
if (write(true)) setConfirmOpen(false);
}

function commitBaseUrlInput(input: HTMLInputElement): void {
const normalized = normalizeBaseUrl(input.value);
if (writeBaseUrl(input.value)) input.value = normalized;
}

const serverEnabled = status?.enabled ?? false;
const keyPresent = status?.keyPresent ?? false;
const ready = status?.ready ?? false;
Expand Down Expand Up @@ -138,6 +169,41 @@ export function SearchSection() {
) : null}
</div>

<div className="space-y-2 rounded-md border p-3">
<div className="space-y-1">
<label htmlFor="settings-search-base-url" className="text-sm font-medium">
<Trans>Embeddings API endpoint</Trans>
</label>
<p className="text-muted-foreground text-1sm">
<Trans>
Use the default OpenAI endpoint or override it with an OpenAI-compatible base URL for
Azure or a self-hosted provider.
</Trans>
</p>
</div>
<Input
key={configuredBaseUrl}
id="settings-search-base-url"
defaultValue={configuredBaseUrl}
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"
data-testid="settings-search-base-url"
className="h-8 font-mono text-sm"
/>
<p className="text-muted-foreground text-1sm" data-testid="settings-search-base-url-help">
<Trans>Clear the field to reset back to the default OpenAI endpoint.</Trans>
</p>
</div>

<EnableSemanticSearchConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
Expand Down
12 changes: 9 additions & 3 deletions packages/app/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -594,9 +594,6 @@
"FEr96N": ["Theme"],
"FNTx6u": ["Search \"", ["semanticSubmitQuery"], "\" by meaning"],
"FOGD7m": ["Create something great."],
"FOyCTr": [
"Your OpenAI API key, used only to create embeddings of your content for semantic search. Stored once for this machine in <0>~/.ok/secrets.yml</0> (readable only by your user account) and shared across all projects. Turn the feature on per project in This project → Search."
],
"FRYB1Q": ["/api/config returned a malformed body"],
"FRqZ9v": ["Couldn't update skill: ", ["0"]],
"FTUGgh": ["Enter a repository URL or owner/repo"],
Expand Down Expand Up @@ -770,6 +767,7 @@
"KujtjC": ["Find"],
"L-rMC9": ["Reset to default"],
"L1ZE3M": ["Document statistics"],
"L1rRuU": ["Embeddings API endpoint"],
"L3CEC1": ["this item"],
"L4Cdd9": ["or create a new file <0/>"],
"L6Hhh6": ["(root)"],
Expand Down Expand Up @@ -1403,6 +1401,7 @@
"eG8Jsg": ["Toggle source tab focus mode"],
"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"],
Expand Down Expand Up @@ -1618,6 +1617,7 @@
"l8G3S9": ["Auto-sync is off — you don't have permission to push to this repo"],
"lAiyU_": ["Only the current document uses <0>#", ["tag"], "</0>."],
"lGs_bQ": ["Page <0>*</0>"],
"lHCR4I": ["Clear the field to reset back to the default OpenAI endpoint."],
"lHwYMr": ["Server error: ", ["status"], " ", ["statusText"]],
"lI0Td2": ["Use letters, digits, <0>_</0> and <1>-</1> only."],
"lIn9L9": ["Disable sync"],
Expand Down Expand Up @@ -1785,6 +1785,9 @@
"pha01Y": ["Project-level pages with no incoming graph edges."],
"pkAft1": ["Search failed."],
"pmKdif": ["Failed: ", ["loadError"]],
"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"],
"pzutoc": ["Italic"],
Expand Down Expand Up @@ -2002,6 +2005,9 @@
"wXO4Tg": [["hrs"], "h ago"],
"wbv9xO": ["Create new project"],
"wbwHYD": ["Enable sync"],
"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</0> (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."
],
"weP6me": ["Couldn't load docs — type @ again to retry"],
"wlwXUk": [
"You picked ~/Downloads. Files there are usually transient — consider a stable folder instead."
Expand Down
Loading
Loading