Skip to content

Commit b6d4647

Browse files
committed
refactor: move embedding interface to spec contracts, add settings tests
1 parent e0c593f commit b6d4647

5 files changed

Lines changed: 164 additions & 101 deletions

File tree

packages/plugins/knowledge-turso/src/embedding.ts

Lines changed: 30 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,47 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* Pluggable embedding provider for the Turso knowledge adapter.
4+
* Embedding helpers for the Turso knowledge adapter.
55
*
6-
* Adapters call `embed()` once with the full batch of chunk texts at
7-
* upsert time, and once per query at search time. Implementations are
8-
* responsible for batching / rate-limiting against their upstream API.
6+
* The interface itself now lives in `@objectstack/spec/contracts` as
7+
* `IEmbedder` — see that file for the protocol-level contract.
8+
*
9+
* This module ships ONLY the deterministic `HashEmbedder` used for
10+
* tests and offline dev. For real models, install a dedicated
11+
* embedder plugin:
12+
*
13+
* - `@objectstack/embedder-openai` (OpenAI / 阿里通义 / 智谱 /
14+
* 硅基流动 / 火山 Doubao /
15+
* MiniMax / Ollama / 任何
16+
* OpenAI-shape 兼容端点)
17+
*
18+
* Migrating from `@objectstack/knowledge-turso` ≤ 6.6:
19+
* - `EmbeddingProvider` → `IEmbedder` (`@objectstack/spec/contracts`)
20+
* - `OpenAIEmbeddingProvider` → `OpenAIEmbedder` (`@objectstack/embedder-openai`)
21+
* - `HashEmbeddingProvider` → `HashEmbedder` (this file, unchanged behaviour)
922
*/
10-
export interface EmbeddingProvider {
11-
/** Stable id (mostly for logs). */
12-
readonly id: string;
13-
/** Output vector dimensionality — used to size the `F32_BLOB(N)` column. */
14-
readonly dimensions: number;
15-
/** Embed a batch of strings. Output order matches input order. */
16-
embed(texts: string[]): Promise<number[][]>;
17-
}
1823

19-
export interface OpenAIEmbeddingOptions {
20-
apiKey: string;
21-
/** @default 'text-embedding-3-small' */
22-
model?: string;
23-
/** Override dimensions (only some models support this). */
24-
dimensions?: number;
25-
/** Override base URL (Azure / proxy / Ollama-compatible servers). */
26-
baseUrl?: string;
27-
/** Inject for tests. Defaults to global fetch. */
28-
fetch?: typeof fetch;
29-
}
24+
import type { IEmbedder } from '@objectstack/spec/contracts';
3025

3126
/**
32-
* Known dimensions for OpenAI's first-party embedding models. Used as
33-
* the default when the caller doesn't pass `dimensions` explicitly.
27+
* @deprecated Use `IEmbedder` from `@objectstack/spec/contracts`.
28+
* Re-exported here as an alias to ease migration; will be removed in
29+
* a future major.
3430
*/
35-
const OPENAI_DEFAULT_DIMS: Record<string, number> = {
36-
'text-embedding-3-small': 1536,
37-
'text-embedding-3-large': 3072,
38-
'text-embedding-ada-002': 1536,
39-
};
40-
41-
/**
42-
* OpenAI-compatible embedding provider. Works against the real OpenAI
43-
* API, Azure OpenAI deployments, and any drop-in compatible server
44-
* (LiteLLM, vLLM, Ollama with the openai shim).
45-
*/
46-
export class OpenAIEmbeddingProvider implements EmbeddingProvider {
47-
readonly id = 'openai';
48-
readonly dimensions: number;
49-
private readonly model: string;
50-
private readonly baseUrl: string;
51-
private readonly apiKey: string;
52-
private readonly fetchImpl: typeof fetch;
53-
private readonly requestedDims?: number;
54-
55-
constructor(opts: OpenAIEmbeddingOptions) {
56-
if (!opts.apiKey) throw new Error('OpenAIEmbeddingProvider: apiKey required');
57-
this.apiKey = opts.apiKey;
58-
this.model = opts.model ?? 'text-embedding-3-small';
59-
this.baseUrl = (opts.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
60-
this.fetchImpl = opts.fetch ?? (globalThis.fetch as typeof fetch);
61-
this.requestedDims = opts.dimensions;
62-
this.dimensions =
63-
opts.dimensions ?? OPENAI_DEFAULT_DIMS[this.model] ?? 1536;
64-
if (!this.fetchImpl) {
65-
throw new Error('OpenAIEmbeddingProvider: no fetch available; pass options.fetch');
66-
}
67-
}
68-
69-
async embed(texts: string[]): Promise<number[][]> {
70-
if (texts.length === 0) return [];
71-
const body: Record<string, unknown> = { model: this.model, input: texts };
72-
if (this.requestedDims) body.dimensions = this.requestedDims;
73-
const res = await this.fetchImpl(`${this.baseUrl}/embeddings`, {
74-
method: 'POST',
75-
headers: {
76-
'content-type': 'application/json',
77-
authorization: `Bearer ${this.apiKey}`,
78-
},
79-
body: JSON.stringify(body),
80-
});
81-
if (!res.ok) {
82-
const text = await res.text().catch(() => '');
83-
throw new Error(
84-
`OpenAI embeddings → ${res.status} ${res.statusText}${text ? `: ${text.slice(0, 200)}` : ''}`,
85-
);
86-
}
87-
const json = (await res.json()) as { data?: Array<{ embedding: number[] }> };
88-
const data = json.data ?? [];
89-
if (data.length !== texts.length) {
90-
throw new Error(
91-
`OpenAI embeddings: expected ${texts.length} vectors, got ${data.length}`,
92-
);
93-
}
94-
return data.map((d) => d.embedding);
95-
}
96-
}
31+
export type EmbeddingProvider = IEmbedder;
9732

9833
/**
99-
* Deterministic, dependency-free embedding provider for unit tests
100-
* and offline development. Hashes tokens into a fixed-width vector —
101-
* not semantically meaningful, but cosine-distance preserves token
34+
* Deterministic, dependency-free embedder for unit tests and offline
35+
* development. Hashes tokens into a fixed-width vector — not
36+
* semantically meaningful, but cosine-distance preserves token
10237
* overlap, which is enough to validate adapter plumbing.
10338
*/
104-
export class HashEmbeddingProvider implements EmbeddingProvider {
39+
export class HashEmbedder implements IEmbedder {
10540
readonly id = 'hash';
10641
readonly dimensions: number;
10742

10843
constructor(dimensions = 64) {
109-
if (dimensions < 4) throw new Error('HashEmbeddingProvider: dimensions must be >= 4');
44+
if (dimensions < 4) throw new Error('HashEmbedder: dimensions must be >= 4');
11045
this.dimensions = dimensions;
11146
}
11247

@@ -136,6 +71,9 @@ export class HashEmbeddingProvider implements EmbeddingProvider {
13671
}
13772
}
13873

74+
/** @deprecated Renamed to {@link HashEmbedder}. */
75+
export const HashEmbeddingProvider = HashEmbedder;
76+
13977
function fnv1a(s: string): number {
14078
let h = 0x811c9dc5;
14179
for (let i = 0; i < s.length; i++) {

packages/plugins/knowledge-turso/src/index.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,18 @@
1010
*/
1111

1212
import type { Plugin, PluginContext } from '@objectstack/core';
13-
import type { IKnowledgeService } from '@objectstack/spec/contracts';
13+
import type { IKnowledgeService, IEmbedder } from '@objectstack/spec/contracts';
1414
import { KNOWLEDGE_SERVICE } from '@objectstack/spec/contracts';
1515
import { createClient, type Client } from '@libsql/client';
1616

1717
import { TursoKnowledgeAdapter, type TursoAdapterOptions } from './turso-adapter';
18-
import type { EmbeddingProvider } from './embedding';
1918

2019
export { TursoKnowledgeAdapter } from './turso-adapter';
2120
export type { TursoAdapterOptions } from './turso-adapter';
2221
export {
22+
HashEmbedder,
2323
HashEmbeddingProvider,
24-
OpenAIEmbeddingProvider,
2524
type EmbeddingProvider,
26-
type OpenAIEmbeddingOptions,
2725
} from './embedding';
2826

2927
export interface KnowledgeTursoPluginOptions {
@@ -38,8 +36,16 @@ export interface KnowledgeTursoPluginOptions {
3836
url?: string;
3937
authToken?: string;
4038
client?: Client;
41-
/** Embedding provider — required. */
42-
embedding: EmbeddingProvider;
39+
/**
40+
* Embedder used for both upsert and search.
41+
*
42+
* For real models, install a dedicated plugin and pass its instance:
43+
* - `@objectstack/embedder-openai` (OpenAI / 阿里通义 / 智谱 /
44+
* 硅基流动 / 火山 Doubao / Ollama / 任何 OpenAI-shape 兼容端点)
45+
*
46+
* For tests / smoke runs, use the bundled `HashEmbedder`.
47+
*/
48+
embedding: IEmbedder;
4349
/** Forwarded to the adapter. */
4450
chunkTarget?: TursoAdapterOptions['chunkTarget'];
4551
/** Forwarded to the adapter. */

packages/plugins/knowledge-turso/src/turso-adapter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@ import type {
55
IKnowledgeAdapter,
66
AdapterContext,
77
AdapterSearchOptions,
8+
IEmbedder,
89
} from '@objectstack/spec/contracts';
910
import type {
1011
KnowledgeDocument,
1112
KnowledgeHit,
1213
KnowledgeSource,
1314
} from '@objectstack/spec/ai';
14-
import type { EmbeddingProvider } from './embedding';
1515

1616
export interface TursoAdapterOptions {
1717
/** Stable adapter id used in `KnowledgeSource.adapter`. @default 'turso' */
1818
id?: string;
1919
/** libsql client (Turso cloud, embedded replica, file, or `:memory:`). */
2020
client: Client;
2121
/** Embedding provider used for both upsert and search. */
22-
embedder: EmbeddingProvider;
22+
embedder: IEmbedder;
2323
/** Soft cap on chunk size, in characters. @default 800 */
2424
chunkTarget?: number;
2525
/**
@@ -67,7 +67,7 @@ const DEFAULT_OVER_FETCH = 4;
6767
export class TursoKnowledgeAdapter implements IKnowledgeAdapter {
6868
readonly id: string;
6969
private readonly client: Client;
70-
private readonly embedder: EmbeddingProvider;
70+
private readonly embedder: IEmbedder;
7171
private readonly chunkTarget: number;
7272
private readonly overFetch: number;
7373
private readonly ready = new Map<string, Promise<{ table: string; dimensions: number }>>();

packages/services/service-ai/src/__tests__/ai-service.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,4 +1093,107 @@ describe('AIServicePlugin', () => {
10931093
process.env = oldEnv;
10941094
}
10951095
});
1096+
1097+
// ── settings binding ─────────────────────────────────────────────
1098+
describe('settings binding', () => {
1099+
function createCtxWithSettings(settings: any) {
1100+
const ctx = createMockContext();
1101+
// expose settings via the same getService mock
1102+
(ctx as any).getServices().set('settings', settings);
1103+
return ctx;
1104+
}
1105+
1106+
it('AIService.setAdapter swaps the active adapter', () => {
1107+
const a: LLMAdapter = { name: 'a', chat: async () => ({ content: 'a' }), complete: async () => ({ content: '' }) };
1108+
const b: LLMAdapter = { name: 'b', chat: async () => ({ content: 'b' }), complete: async () => ({ content: '' }) };
1109+
const svc = new AIService({ adapter: a, logger: silentLogger });
1110+
expect(svc.adapterName).toBe('a');
1111+
svc.setAdapter(b);
1112+
expect(svc.adapterName).toBe('b');
1113+
});
1114+
1115+
it('does not bind to settings when bindToSettings=false', async () => {
1116+
const settings = {
1117+
getNamespace: vi.fn(),
1118+
subscribe: vi.fn(),
1119+
registerAction: vi.fn(),
1120+
};
1121+
const plugin = new AIServicePlugin({ bindToSettings: false });
1122+
const ctx = createCtxWithSettings(settings);
1123+
await plugin.init(ctx);
1124+
await plugin.start!(ctx);
1125+
// No kernel:ready hook registered for settings binding
1126+
const kernelReadyHooks = (ctx.hook as any).mock.calls.filter(([n]: any[]) => n === 'kernel:ready');
1127+
expect(kernelReadyHooks.length).toBe(0);
1128+
});
1129+
1130+
it('binds to settings, applies values, subscribes, and registers live test action', async () => {
1131+
const customAdapter: LLMAdapter = {
1132+
name: 'preset', chat: async () => ({ content: 'preset' }), complete: async () => ({ content: '' }),
1133+
};
1134+
const settings = {
1135+
getNamespace: vi.fn(async () => ({
1136+
manifest: { namespace: 'ai' },
1137+
values: { provider: { value: 'memory' } },
1138+
})),
1139+
subscribe: vi.fn(),
1140+
registerAction: vi.fn(),
1141+
};
1142+
const plugin = new AIServicePlugin({ adapter: customAdapter });
1143+
const ctx = createCtxWithSettings(settings);
1144+
await plugin.init(ctx);
1145+
await plugin.start!(ctx);
1146+
1147+
// Find and invoke the kernel:ready hook that does settings binding
1148+
const kernelReady = (ctx.hook as any).mock.calls.find(([n]: any[]) => n === 'kernel:ready');
1149+
expect(kernelReady).toBeDefined();
1150+
await kernelReady[1]();
1151+
1152+
expect(settings.getNamespace).toHaveBeenCalledWith('ai');
1153+
expect(settings.subscribe).toHaveBeenCalledWith('ai', expect.any(Function));
1154+
expect(settings.registerAction).toHaveBeenCalledWith('ai', 'test', expect.any(Function));
1155+
1156+
// memory provider is no-op overlay → original adapter retained
1157+
const svc = ctx.getService<AIService>('ai');
1158+
expect(svc.adapterName).toBe('preset');
1159+
});
1160+
1161+
it('live test action returns warning for memory provider', async () => {
1162+
const settings: any = {
1163+
getNamespace: vi.fn(async () => ({ manifest: { namespace: 'ai' }, values: {} })),
1164+
subscribe: vi.fn(),
1165+
registerAction: vi.fn(),
1166+
};
1167+
const plugin = new AIServicePlugin();
1168+
const ctx = createCtxWithSettings(settings);
1169+
await plugin.init(ctx);
1170+
await plugin.start!(ctx);
1171+
const kernelReady = (ctx.hook as any).mock.calls.find(([n]: any[]) => n === 'kernel:ready');
1172+
await kernelReady[1]();
1173+
1174+
const handler = settings.registerAction.mock.calls.find((c: any[]) => c[0] === 'ai' && c[1] === 'test')[2];
1175+
const result = await handler({ values: {}, payload: { values: { provider: 'memory' } } });
1176+
expect(result.ok).toBe(true);
1177+
expect(result.severity).toBe('warning');
1178+
expect(result.message).toContain('echo stub');
1179+
});
1180+
1181+
it('live test action reports missing api key for openai', async () => {
1182+
const settings: any = {
1183+
getNamespace: vi.fn(async () => ({ manifest: { namespace: 'ai' }, values: {} })),
1184+
subscribe: vi.fn(),
1185+
registerAction: vi.fn(),
1186+
};
1187+
const plugin = new AIServicePlugin();
1188+
const ctx = createCtxWithSettings(settings);
1189+
await plugin.init(ctx);
1190+
await plugin.start!(ctx);
1191+
const kernelReady = (ctx.hook as any).mock.calls.find(([n]: any[]) => n === 'kernel:ready');
1192+
await kernelReady[1]();
1193+
const handler = settings.registerAction.mock.calls.find((c: any[]) => c[0] === 'ai' && c[1] === 'test')[2];
1194+
const result = await handler({ values: {}, payload: { values: { provider: 'openai' } } });
1195+
expect(result.ok).toBe(false);
1196+
expect(result.severity).toBe('error');
1197+
});
1198+
});
10961199
});

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)