Skip to content

Commit e0c593f

Browse files
committed
feat: add embedder plugin and live LLM adapter switching
1 parent 667bfc1 commit e0c593f

14 files changed

Lines changed: 1773 additions & 46 deletions

File tree

.changeset/turso-optional-peer.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Make `@objectstack/driver-turso` an **optional peer dependency** so default `npx @objectstack/cli start` no longer installs `@libsql/client` (~5MB + native binaries) nor `libsql` native modules.
7+
8+
Rationale: `objectstack start` defaults to `file:` URLs which route to `better-sqlite3` via `driver-sql` (10–15× faster than libsql for OLTP, see benchmarks). For RAG / vector workloads, `sqlite-vec` (~600KB) is the recommended local backend. Turso / libsql is only useful when the user explicitly opts in via `libsql://` / `https://` / `--database-driver turso`.
9+
10+
Changes:
11+
- `packages/cli/package.json`: moved `@objectstack/driver-turso` from `dependencies` to optional `peerDependencies` (`peerDependenciesMeta.optional = true`). npm 7+ does **not** auto-install optional peers; `optionalDependencies` would have still installed it.
12+
- `packages/runtime/package.json`: same.
13+
- All three dynamic-import sites for `driver-turso` (`runtime/src/standalone-stack.ts`, `runtime/src/cloud/artifact-environment-registry.ts`, `cli/src/commands/serve.ts`) now wrap the `import()` in try/catch with an actionable error message pointing users to `npm install @objectstack/driver-turso`.
14+
15+
Verified in `/tmp/os-sim`: fresh `npm install @objectstack/cli` no longer contains `node_modules/@libsql`, `node_modules/libsql`, or `node_modules/@objectstack/driver-turso`. `objectstack start` boots cleanly with better-sqlite3; `--database libsql://…` produces the friendly error.

packages/cli/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,14 @@
8686
"zod": "^4.4.3"
8787
},
8888
"peerDependencies": {
89-
"@objectstack/core": "workspace:^"
90-
},
91-
"optionalDependencies": {
89+
"@objectstack/core": "workspace:^",
9290
"@objectstack/driver-turso": "workspace:^"
9391
},
92+
"peerDependenciesMeta": {
93+
"@objectstack/driver-turso": {
94+
"optional": true
95+
}
96+
},
9497
"devDependencies": {
9598
"@oclif/plugin-help": "^6.2.49",
9699
"@oclif/plugin-plugins": "^5.4.69",
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# @objectstack/embedder-openai
2+
3+
OpenAI-compatible embedder for ObjectStack. Works against any endpoint that speaks the `POST /v1/embeddings` shape:
4+
5+
| Provider | `baseUrl` | Typical model |
6+
|---|---|---|
7+
| OpenAI | `https://api.openai.com/v1` | `text-embedding-3-small` |
8+
| Azure OpenAI | `https://{resource}.openai.azure.com/openai/deployments/{deployment}` | (deployment name) |
9+
| 阿里通义 DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `text-embedding-v3` |
10+
| 智谱 BigModel | `https://open.bigmodel.cn/api/paas/v4` | `embedding-3` |
11+
| 硅基流动 SiliconFlow | `https://api.siliconflow.cn/v1` | `BAAI/bge-m3` |
12+
| 火山引擎 Doubao | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-embedding-large-text-240915` |
13+
| MiniMax | `https://api.minimax.chat/v1` | `embo-01` |
14+
| Ollama (local) | `http://localhost:11434/v1` | `bge-m3`, `nomic-embed-text` |
15+
| LiteLLM / vLLM / 自建网关 | (your endpoint) | (your model) |
16+
17+
Implements `IEmbedder` from `@objectstack/spec/contracts` — drop directly into any knowledge adapter (e.g. `@objectstack/knowledge-turso`).
18+
19+
## Install
20+
21+
```bash
22+
pnpm add @objectstack/embedder-openai
23+
```
24+
25+
## Usage
26+
27+
### OpenAI
28+
29+
```ts
30+
import { OpenAIEmbedder } from '@objectstack/embedder-openai';
31+
32+
const embedder = new OpenAIEmbedder({
33+
apiKey: process.env.OPENAI_API_KEY!,
34+
model: 'text-embedding-3-small', // default
35+
});
36+
```
37+
38+
### 阿里通义 DashScope
39+
40+
```ts
41+
import { createOpenAIEmbedder } from '@objectstack/embedder-openai';
42+
43+
const embedder = createOpenAIEmbedder({
44+
preset: 'dashscope',
45+
apiKey: process.env.DASHSCOPE_API_KEY!,
46+
model: 'text-embedding-v3',
47+
});
48+
```
49+
50+
### 硅基流动 SiliconFlow(推荐桌面端)
51+
52+
```ts
53+
const embedder = createOpenAIEmbedder({
54+
preset: 'siliconflow',
55+
apiKey: process.env.SILICONFLOW_API_KEY!,
56+
model: 'BAAI/bge-m3',
57+
});
58+
```
59+
60+
### Ollama(完全离线)
61+
62+
```ts
63+
const embedder = createOpenAIEmbedder({
64+
preset: 'ollama',
65+
apiKey: 'ollama', // ignored by ollama but required by interface
66+
model: 'bge-m3',
67+
});
68+
```
69+
70+
### 智谱 BigModel
71+
72+
```ts
73+
const embedder = createOpenAIEmbedder({
74+
preset: 'zhipu',
75+
apiKey: process.env.ZHIPU_API_KEY!,
76+
model: 'embedding-3',
77+
});
78+
```
79+
80+
## Plug into a knowledge adapter
81+
82+
```ts
83+
import { OpenAIEmbedder } from '@objectstack/embedder-openai';
84+
import { KnowledgeTursoPlugin } from '@objectstack/knowledge-turso';
85+
86+
const embedder = new OpenAIEmbedder({ apiKey: process.env.OPENAI_API_KEY! });
87+
88+
kernel.use(new KnowledgeTursoPlugin({
89+
url: 'libsql://your-tenant.turso.io',
90+
authToken: env.TURSO_TOKEN,
91+
embedding: embedder,
92+
}));
93+
```
94+
95+
## Options
96+
97+
| Option | Default | Description |
98+
|---|---|---|
99+
| `apiKey` | — (required) | Bearer token. |
100+
| `model` | `'text-embedding-3-small'` | Upstream model id. |
101+
| `dimensions` | (model default) | Override output dim (Matryoshka models only). |
102+
| `baseUrl` | `'https://api.openai.com/v1'` | Endpoint root (no `/embeddings`). |
103+
| `id` | `'openai'` | Stable id surfaced as `IEmbedder.id`. |
104+
| `headers` | `{}` | Extra request headers. |
105+
| `fetch` | `globalThis.fetch` | Inject for tests / custom transport. |
106+
107+
## Contract
108+
109+
Implements [`IEmbedder`](../../spec/src/contracts/embedder.ts):
110+
111+
```ts
112+
interface IEmbedder {
113+
readonly id: string;
114+
readonly dimensions: number;
115+
embed(texts: string[]): Promise<number[][]>;
116+
}
117+
```
118+
119+
## License
120+
121+
Apache-2.0
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@objectstack/embedder-openai",
3+
"version": "6.6.0",
4+
"license": "Apache-2.0",
5+
"description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"dev": "tsc -w",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.9.1",
25+
"typescript": "^6.0.3",
26+
"vitest": "^4.1.7"
27+
},
28+
"keywords": [
29+
"objectstack",
30+
"embedder",
31+
"embedding",
32+
"openai",
33+
"rag",
34+
"vector",
35+
"dashscope",
36+
"zhipu",
37+
"siliconflow",
38+
"ollama"
39+
]
40+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import {
5+
OpenAIEmbedder,
6+
createOpenAIEmbedder,
7+
OPENAI_COMPATIBLE_PRESETS,
8+
} from '../index';
9+
10+
function mockFetch(body: unknown, status = 200): typeof fetch {
11+
return vi.fn(async () =>
12+
new Response(JSON.stringify(body), {
13+
status,
14+
headers: { 'content-type': 'application/json' },
15+
}),
16+
) as unknown as typeof fetch;
17+
}
18+
19+
describe('OpenAIEmbedder', () => {
20+
it('requires apiKey', () => {
21+
expect(() => new OpenAIEmbedder({ apiKey: '' })).toThrow(/apiKey required/);
22+
});
23+
24+
it('exposes id and known dimensions for default model', () => {
25+
const e = new OpenAIEmbedder({ apiKey: 'k', fetch: mockFetch({ data: [] }) });
26+
expect(e.id).toBe('openai');
27+
expect(e.dimensions).toBe(1536);
28+
});
29+
30+
it('looks up dimensions for known Chinese models', () => {
31+
const e = new OpenAIEmbedder({
32+
apiKey: 'k',
33+
model: 'text-embedding-v3',
34+
fetch: mockFetch({ data: [] }),
35+
});
36+
expect(e.dimensions).toBe(1024);
37+
});
38+
39+
it('honours explicit dimensions override', () => {
40+
const e = new OpenAIEmbedder({
41+
apiKey: 'k',
42+
dimensions: 256,
43+
fetch: mockFetch({ data: [] }),
44+
});
45+
expect(e.dimensions).toBe(256);
46+
});
47+
48+
it('returns [] for empty input without calling fetch', async () => {
49+
const fetchImpl = vi.fn() as unknown as typeof fetch;
50+
const e = new OpenAIEmbedder({ apiKey: 'k', fetch: fetchImpl });
51+
const out = await e.embed([]);
52+
expect(out).toEqual([]);
53+
expect(fetchImpl).not.toHaveBeenCalled();
54+
});
55+
56+
it('POSTs to the configured baseUrl with bearer auth', async () => {
57+
const fetchImpl = vi.fn(async () =>
58+
new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2] }] }), {
59+
status: 200,
60+
headers: { 'content-type': 'application/json' },
61+
}),
62+
) as unknown as typeof fetch;
63+
64+
const e = new OpenAIEmbedder({
65+
apiKey: 'sk-test',
66+
baseUrl: 'https://api.siliconflow.cn/v1',
67+
model: 'BAAI/bge-m3',
68+
fetch: fetchImpl,
69+
});
70+
const out = await e.embed(['hello']);
71+
72+
expect(out).toEqual([[0.1, 0.2]]);
73+
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
74+
expect(call[0]).toBe('https://api.siliconflow.cn/v1/embeddings');
75+
const init = call[1] as RequestInit;
76+
expect(init.method).toBe('POST');
77+
expect((init.headers as Record<string, string>).authorization).toBe('Bearer sk-test');
78+
expect(JSON.parse(init.body as string)).toEqual({ model: 'BAAI/bge-m3', input: ['hello'] });
79+
});
80+
81+
it('forwards dimensions in the request body when overridden', async () => {
82+
const fetchImpl = mockFetch({ data: [{ embedding: [1, 2] }] });
83+
const e = new OpenAIEmbedder({
84+
apiKey: 'k',
85+
dimensions: 512,
86+
fetch: fetchImpl,
87+
});
88+
await e.embed(['x']);
89+
const body = JSON.parse(
90+
(fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
91+
);
92+
expect(body.dimensions).toBe(512);
93+
});
94+
95+
it('throws a useful error on non-2xx', async () => {
96+
const fetchImpl = mockFetch({ error: 'bad key' }, 401);
97+
const e = new OpenAIEmbedder({ apiKey: 'k', fetch: fetchImpl });
98+
await expect(e.embed(['x'])).rejects.toThrow(/401/);
99+
});
100+
101+
it('throws when response vector count mismatches input', async () => {
102+
const fetchImpl = mockFetch({ data: [{ embedding: [1] }] });
103+
const e = new OpenAIEmbedder({ apiKey: 'k', fetch: fetchImpl });
104+
await expect(e.embed(['a', 'b'])).rejects.toThrow(/expected 2 vectors/);
105+
});
106+
107+
it('strips trailing slashes from baseUrl', async () => {
108+
const fetchImpl = mockFetch({ data: [{ embedding: [1] }] });
109+
const e = new OpenAIEmbedder({
110+
apiKey: 'k',
111+
baseUrl: 'https://x.example/v1///',
112+
fetch: fetchImpl,
113+
});
114+
await e.embed(['x']);
115+
expect(
116+
(fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0],
117+
).toBe('https://x.example/v1/embeddings');
118+
});
119+
120+
it('merges extra headers', async () => {
121+
const fetchImpl = mockFetch({ data: [{ embedding: [1] }] });
122+
const e = new OpenAIEmbedder({
123+
apiKey: 'k',
124+
fetch: fetchImpl,
125+
headers: { 'x-trace-id': 't1' },
126+
});
127+
await e.embed(['x']);
128+
const headers = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
129+
.calls[0][1].headers as Record<string, string>;
130+
expect(headers['x-trace-id']).toBe('t1');
131+
expect(headers.authorization).toBe('Bearer k');
132+
});
133+
});
134+
135+
describe('createOpenAIEmbedder presets', () => {
136+
it('maps preset names to baseUrl', () => {
137+
const e = createOpenAIEmbedder({
138+
preset: 'dashscope',
139+
apiKey: 'k',
140+
model: 'text-embedding-v3',
141+
fetch: mockFetch({ data: [] }),
142+
});
143+
expect(e.id).toBe('dashscope');
144+
expect(e.dimensions).toBe(1024);
145+
});
146+
147+
it('exposes well-known preset URLs', () => {
148+
expect(OPENAI_COMPATIBLE_PRESETS.siliconflow).toContain('siliconflow.cn');
149+
expect(OPENAI_COMPATIBLE_PRESETS.zhipu).toContain('bigmodel.cn');
150+
expect(OPENAI_COMPATIBLE_PRESETS.ollama).toContain('localhost:11434');
151+
});
152+
153+
it('explicit baseUrl wins over preset', async () => {
154+
const fetchImpl = mockFetch({ data: [{ embedding: [1] }] });
155+
const e = createOpenAIEmbedder({
156+
preset: 'openai',
157+
baseUrl: 'https://my-proxy.example/v1',
158+
apiKey: 'k',
159+
fetch: fetchImpl,
160+
});
161+
await e.embed(['x']);
162+
expect(
163+
(fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0],
164+
).toBe('https://my-proxy.example/v1/embeddings');
165+
});
166+
});

0 commit comments

Comments
 (0)