@crewhaus/embedder 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -11
- package/src/index.test.ts +354 -1
- package/src/index.ts +88 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/embedder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Embedding provider — openai/voyage/cohere/local/mock backends with prefix-grammar selector",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.3"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
2
|
import { EmbedderError, createEmbedder, parseEmbedderModel } from "./index";
|
|
3
3
|
|
|
4
4
|
describe("parseEmbedderModel", () => {
|
|
@@ -147,3 +147,356 @@ describe("Section 30 — embedder magnitude snapshots", () => {
|
|
|
147
147
|
expect(() => createEmbedder({ model: "cohere/embed-v3" })).toThrow();
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// createEmbedder — provider branch coverage (env + explicit key).
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** Save/restore a single env var around a callback. */
|
|
156
|
+
function withEnv(key: string, value: string | undefined, fn: () => void): void {
|
|
157
|
+
const original = process.env[key];
|
|
158
|
+
if (value === undefined) delete process.env[key];
|
|
159
|
+
else process.env[key] = value;
|
|
160
|
+
try {
|
|
161
|
+
fn();
|
|
162
|
+
} finally {
|
|
163
|
+
if (original === undefined) delete process.env[key];
|
|
164
|
+
else process.env[key] = original;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("createEmbedder — provider construction branches", () => {
|
|
169
|
+
test("openai reads OPENAI_API_KEY from env when no explicit key", () => {
|
|
170
|
+
withEnv("OPENAI_API_KEY", "sk-env", () => {
|
|
171
|
+
const e = createEmbedder({ model: "openai/text-embedding-3-small" });
|
|
172
|
+
expect(e.provider).toBe("openai");
|
|
173
|
+
expect(e.model).toBe("text-embedding-3-small");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("openai accepts an explicit apiKey override", () => {
|
|
178
|
+
withEnv("OPENAI_API_KEY", undefined, () => {
|
|
179
|
+
const e = createEmbedder({ model: "openai/text-embedding-3-small", apiKey: "sk-explicit" });
|
|
180
|
+
expect(e.provider).toBe("openai");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("voyage reads VOYAGE_API_KEY from env", () => {
|
|
185
|
+
withEnv("VOYAGE_API_KEY", "voyage-env", () => {
|
|
186
|
+
const e = createEmbedder({ model: "voyage/voyage-3" });
|
|
187
|
+
expect(e.provider).toBe("voyage");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("voyage throws fail-loud when VOYAGE_API_KEY is empty", () => {
|
|
192
|
+
withEnv("VOYAGE_API_KEY", "", () => {
|
|
193
|
+
expect(() => createEmbedder({ model: "voyage/voyage-3" })).toThrow(/VOYAGE_API_KEY/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("cohere reads COHERE_API_KEY from env", () => {
|
|
198
|
+
withEnv("COHERE_API_KEY", "cohere-env", () => {
|
|
199
|
+
const e = createEmbedder({ model: "cohere/embed-v3" });
|
|
200
|
+
expect(e.provider).toBe("cohere");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("cohere throws fail-loud when COHERE_API_KEY is empty", () => {
|
|
205
|
+
withEnv("COHERE_API_KEY", "", () => {
|
|
206
|
+
expect(() => createEmbedder({ model: "cohere/embed-v3" })).toThrow(/COHERE_API_KEY/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("local/ honors an explicit baseUrl override over the parsed one", () => {
|
|
211
|
+
const e = createEmbedder({
|
|
212
|
+
model: "local/x@http://parsed:1234",
|
|
213
|
+
baseUrl: "http://override:5678",
|
|
214
|
+
});
|
|
215
|
+
expect(e.provider).toBe("local");
|
|
216
|
+
expect(e.model).toBe("x");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// OpenAILikeEmbedder.embed — network path (fetch mocked, fully deterministic).
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
describe("OpenAILikeEmbedder.embed (mocked fetch)", () => {
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
// spyOn(globalThis, "fetch") registers with bun's mock registry; restore
|
|
227
|
+
// the real implementation after every test so nothing leaks across files.
|
|
228
|
+
mock.restore();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("empty input short-circuits without a network call", async () => {
|
|
232
|
+
const fetchSpy = spyOn(globalThis, "fetch");
|
|
233
|
+
const e = createEmbedder({ model: "openai/text-embedding-3-small", apiKey: "sk-x" });
|
|
234
|
+
expect(await e.embed([])).toEqual([]);
|
|
235
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("posts to /v1/embeddings with an Authorization header and parses data", async () => {
|
|
239
|
+
let capturedUrl = "";
|
|
240
|
+
let capturedInit: RequestInit | undefined;
|
|
241
|
+
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
242
|
+
capturedUrl = String(input);
|
|
243
|
+
capturedInit = init;
|
|
244
|
+
return new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), {
|
|
245
|
+
status: 200,
|
|
246
|
+
headers: { "content-type": "application/json" },
|
|
247
|
+
});
|
|
248
|
+
}) as unknown as typeof fetch;
|
|
249
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
250
|
+
|
|
251
|
+
const e = createEmbedder({ model: "openai/text-embedding-3-small", apiKey: "sk-secret" });
|
|
252
|
+
const out = await e.embed(["hello"]);
|
|
253
|
+
|
|
254
|
+
expect(out).toEqual([[0.1, 0.2, 0.3]]);
|
|
255
|
+
expect(capturedUrl).toBe("https://api.openai.com/v1/embeddings");
|
|
256
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
257
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
258
|
+
expect(headers["authorization"]).toBe("Bearer sk-secret");
|
|
259
|
+
expect(headers["content-type"]).toBe("application/json");
|
|
260
|
+
const body = JSON.parse(String(capturedInit?.body)) as { model: string; input: string[] };
|
|
261
|
+
expect(body.model).toBe("text-embedding-3-small");
|
|
262
|
+
expect(body.input).toEqual(["hello"]);
|
|
263
|
+
// No signal provided → fetch init must not carry one.
|
|
264
|
+
expect(capturedInit?.signal ?? null).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("local provider without an API key omits the Authorization header", async () => {
|
|
268
|
+
let capturedInit: RequestInit | undefined;
|
|
269
|
+
const fetchImpl = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
270
|
+
capturedInit = init;
|
|
271
|
+
return new Response(JSON.stringify({ data: [{ embedding: [1, 0] }] }), { status: 200 });
|
|
272
|
+
}) as unknown as typeof fetch;
|
|
273
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
274
|
+
|
|
275
|
+
const e = createEmbedder({ model: "local/nomic@http://localhost:11434" });
|
|
276
|
+
const out = await e.embed(["x"]);
|
|
277
|
+
|
|
278
|
+
expect(out).toEqual([[1, 0]]);
|
|
279
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
280
|
+
expect("authorization" in headers).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("respects a custom batchSize and concatenates batches in order", async () => {
|
|
284
|
+
const batches: string[][] = [];
|
|
285
|
+
const fetchImpl = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
286
|
+
const parsed = JSON.parse(String(init?.body)) as { input: string[] };
|
|
287
|
+
batches.push(parsed.input);
|
|
288
|
+
// Echo one embedding per input text so we can verify ordering.
|
|
289
|
+
const data = parsed.input.map((t) => ({ embedding: [t.length] }));
|
|
290
|
+
return new Response(JSON.stringify({ data }), { status: 200 });
|
|
291
|
+
}) as unknown as typeof fetch;
|
|
292
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
293
|
+
|
|
294
|
+
const e = createEmbedder({ model: "local/m@http://localhost:1234" });
|
|
295
|
+
const out = await e.embed(["a", "bb", "ccc", "dddd", "eeeee"], { batchSize: 2 });
|
|
296
|
+
|
|
297
|
+
// 5 texts / batchSize 2 → batches of [2, 2, 1].
|
|
298
|
+
expect(batches).toEqual([["a", "bb"], ["ccc", "dddd"], ["eeeee"]]);
|
|
299
|
+
expect(out).toEqual([[1], [2], [3], [4], [5]]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("forwards an AbortSignal to fetch", async () => {
|
|
303
|
+
let capturedSignal: AbortSignal | null | undefined;
|
|
304
|
+
const fetchImpl = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
305
|
+
capturedSignal = init?.signal;
|
|
306
|
+
return new Response(JSON.stringify({ data: [{ embedding: [0] }] }), { status: 200 });
|
|
307
|
+
}) as unknown as typeof fetch;
|
|
308
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
309
|
+
|
|
310
|
+
const controller = new AbortController();
|
|
311
|
+
const e = createEmbedder({ model: "local/m@http://localhost:1234" });
|
|
312
|
+
await e.embed(["x"], { signal: controller.signal });
|
|
313
|
+
expect(capturedSignal).toBe(controller.signal);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("throws EmbedderError with status + body on a non-ok response", async () => {
|
|
317
|
+
const fetchImpl = (async () =>
|
|
318
|
+
new Response("rate limited", {
|
|
319
|
+
status: 429,
|
|
320
|
+
statusText: "Too Many Requests",
|
|
321
|
+
})) as unknown as typeof fetch;
|
|
322
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
323
|
+
|
|
324
|
+
const e = createEmbedder({ model: "openai/text-embedding-3-small", apiKey: "sk-x" });
|
|
325
|
+
await expect(e.embed(["x"])).rejects.toThrow(EmbedderError);
|
|
326
|
+
await expect(e.embed(["x"])).rejects.toThrow(/429 rate limited/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("non-ok response with an unreadable body still throws (text() rejects)", async () => {
|
|
330
|
+
// A Response whose .text() rejects exercises the `.catch(() => "")` arm.
|
|
331
|
+
const brokenResponse = {
|
|
332
|
+
ok: false,
|
|
333
|
+
status: 500,
|
|
334
|
+
text: () => Promise.reject(new Error("stream broken")),
|
|
335
|
+
} as unknown as Response;
|
|
336
|
+
const fetchImpl = (async () => brokenResponse) as unknown as typeof fetch;
|
|
337
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
338
|
+
|
|
339
|
+
const e = createEmbedder({ model: "openai/text-embedding-3-small", apiKey: "sk-x" });
|
|
340
|
+
// Body coerces to empty string → message ends at the status, no trailing text.
|
|
341
|
+
await expect(e.embed(["x"])).rejects.toThrow(/500 $/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("a baseUrl already carrying /v1 is not doubled (URL-shape tolerance)", async () => {
|
|
345
|
+
let capturedUrl = "";
|
|
346
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
347
|
+
capturedUrl = String(input);
|
|
348
|
+
return new Response(JSON.stringify({ data: [{ embedding: [1] }] }), { status: 200 });
|
|
349
|
+
}) as unknown as typeof fetch;
|
|
350
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
351
|
+
|
|
352
|
+
const e = createEmbedder({ model: "local/nomic@http://localhost:11434/v1" });
|
|
353
|
+
await e.embed(["x"]);
|
|
354
|
+
expect(capturedUrl).toBe("http://localhost:11434/v1/embeddings");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("a baseUrl with a trailing slash + /v1/ also normalises", async () => {
|
|
358
|
+
let capturedUrl = "";
|
|
359
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
360
|
+
capturedUrl = String(input);
|
|
361
|
+
return new Response(JSON.stringify({ data: [{ embedding: [1] }] }), { status: 200 });
|
|
362
|
+
}) as unknown as typeof fetch;
|
|
363
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
364
|
+
|
|
365
|
+
const e = createEmbedder({ model: "local/nomic@http://localhost:11434/v1/" });
|
|
366
|
+
await e.embed(["x"]);
|
|
367
|
+
expect(capturedUrl).toBe("http://localhost:11434/v1/embeddings");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("cohere defaults to the /compatibility OpenAI-compat endpoint (bare host 404s)", async () => {
|
|
371
|
+
let capturedUrl = "";
|
|
372
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
373
|
+
capturedUrl = String(input);
|
|
374
|
+
return new Response(JSON.stringify({ data: [{ embedding: [0.5] }] }), { status: 200 });
|
|
375
|
+
}) as unknown as typeof fetch;
|
|
376
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
377
|
+
|
|
378
|
+
const e = createEmbedder({ model: "cohere/embed-v4.0", apiKey: "co-x" });
|
|
379
|
+
const out = await e.embed(["x"]);
|
|
380
|
+
expect(out).toEqual([[0.5]]);
|
|
381
|
+
expect(capturedUrl).toBe("https://api.cohere.ai/compatibility/v1/embeddings");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Gemini backend — REST embedContent (fetch mocked, fully deterministic).
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
describe("gemini embedder (mocked fetch)", () => {
|
|
390
|
+
afterEach(() => {
|
|
391
|
+
mock.restore();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("parses gemini/<model>", () => {
|
|
395
|
+
expect(parseEmbedderModel("gemini/gemini-embedding-001")).toEqual({
|
|
396
|
+
providerId: "gemini",
|
|
397
|
+
modelId: "gemini-embedding-001",
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("requires GEMINI_API_KEY or GOOGLE_API_KEY", () => {
|
|
402
|
+
const prevGemini = process.env["GEMINI_API_KEY"];
|
|
403
|
+
const prevGoogle = process.env["GOOGLE_API_KEY"];
|
|
404
|
+
process.env["GEMINI_API_KEY"] = "";
|
|
405
|
+
process.env["GOOGLE_API_KEY"] = "";
|
|
406
|
+
try {
|
|
407
|
+
expect(() => createEmbedder({ model: "gemini/gemini-embedding-001" })).toThrow(
|
|
408
|
+
/GEMINI_API_KEY/,
|
|
409
|
+
);
|
|
410
|
+
} finally {
|
|
411
|
+
process.env["GEMINI_API_KEY"] = prevGemini;
|
|
412
|
+
process.env["GOOGLE_API_KEY"] = prevGoogle;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("falls back to GOOGLE_API_KEY when GEMINI_API_KEY is unset", () => {
|
|
417
|
+
const prevGemini = process.env["GEMINI_API_KEY"];
|
|
418
|
+
const prevGoogle = process.env["GOOGLE_API_KEY"];
|
|
419
|
+
process.env["GEMINI_API_KEY"] = "";
|
|
420
|
+
process.env["GOOGLE_API_KEY"] = "g-key";
|
|
421
|
+
try {
|
|
422
|
+
const e = createEmbedder({ model: "gemini/gemini-embedding-001" });
|
|
423
|
+
expect(e.provider).toBe("gemini");
|
|
424
|
+
} finally {
|
|
425
|
+
process.env["GEMINI_API_KEY"] = prevGemini;
|
|
426
|
+
process.env["GOOGLE_API_KEY"] = prevGoogle;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("POSTs models/<model>:embedContent with key query param + {content:{parts:[{text}]}} body and reads embedding.values", async () => {
|
|
431
|
+
const calls: Array<{ url: string; body: unknown }> = [];
|
|
432
|
+
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
433
|
+
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) });
|
|
434
|
+
return new Response(JSON.stringify({ embedding: { values: [0.1, 0.2] } }), {
|
|
435
|
+
status: 200,
|
|
436
|
+
headers: { "content-type": "application/json" },
|
|
437
|
+
});
|
|
438
|
+
}) as unknown as typeof fetch;
|
|
439
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
440
|
+
|
|
441
|
+
const e = createEmbedder({ model: "gemini/gemini-embedding-001", apiKey: "g-secret" });
|
|
442
|
+
const out = await e.embed(["alpha", "beta"]);
|
|
443
|
+
|
|
444
|
+
// One request per text (the endpoint takes a single content body).
|
|
445
|
+
expect(calls.length).toBe(2);
|
|
446
|
+
expect(out).toEqual([
|
|
447
|
+
[0.1, 0.2],
|
|
448
|
+
[0.1, 0.2],
|
|
449
|
+
]);
|
|
450
|
+
expect(calls[0]?.url).toBe(
|
|
451
|
+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=g-secret",
|
|
452
|
+
);
|
|
453
|
+
expect(calls[0]?.body).toEqual({ content: { parts: [{ text: "alpha" }] } });
|
|
454
|
+
expect(calls[1]?.body).toEqual({ content: { parts: [{ text: "beta" }] } });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("empty input short-circuits without a network call", async () => {
|
|
458
|
+
const fetchSpy = spyOn(globalThis, "fetch");
|
|
459
|
+
const e = createEmbedder({ model: "gemini/gemini-embedding-001", apiKey: "g-x" });
|
|
460
|
+
expect(await e.embed([])).toEqual([]);
|
|
461
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("throws EmbedderError on a non-ok response", async () => {
|
|
465
|
+
const fetchImpl = (async () =>
|
|
466
|
+
new Response("permission denied", { status: 403 })) as unknown as typeof fetch;
|
|
467
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
468
|
+
|
|
469
|
+
const e = createEmbedder({ model: "gemini/gemini-embedding-001", apiKey: "g-x" });
|
|
470
|
+
await expect(e.embed(["x"])).rejects.toThrow(EmbedderError);
|
|
471
|
+
await expect(e.embed(["x"])).rejects.toThrow(/403 permission denied/);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("throws EmbedderError when the response carries no embedding.values", async () => {
|
|
475
|
+
const fetchImpl = (async () =>
|
|
476
|
+
new Response(JSON.stringify({ embedding: {} }), { status: 200 })) as unknown as typeof fetch;
|
|
477
|
+
spyOn(globalThis, "fetch").mockImplementation(fetchImpl);
|
|
478
|
+
|
|
479
|
+
const e = createEmbedder({ model: "gemini/gemini-embedding-001", apiKey: "g-x" });
|
|
480
|
+
await expect(e.embed(["x"])).rejects.toThrow(/embedding\.values/);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// hashedBow — zero-norm branch (no alphanumeric tokens → all-zero vector).
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
describe("mock embedder — zero vector edge case", () => {
|
|
489
|
+
test("punctuation/whitespace-only text yields an all-zero, non-normalised vector", async () => {
|
|
490
|
+
const e = createEmbedder({ model: "mock/det" });
|
|
491
|
+
const [vec] = await e.embed([" !!! ??? --- "]);
|
|
492
|
+
expect(vec?.length).toBe(256);
|
|
493
|
+
// norm === 0 → returns the raw zero vector unchanged (no divide-by-zero).
|
|
494
|
+
expect(vec?.every((x) => x === 0)).toBe(true);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("empty string also yields an all-zero vector", async () => {
|
|
498
|
+
const e = createEmbedder({ model: "mock/det" });
|
|
499
|
+
const [vec] = await e.embed([""]);
|
|
500
|
+
expect(vec?.every((x) => x === 0)).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* openai/text-embedding-3-small → OpenAI Embeddings API
|
|
7
7
|
* voyage/voyage-3 → Voyage AI
|
|
8
|
-
* cohere/embed-v3 → Cohere
|
|
8
|
+
* cohere/embed-v3 → Cohere (OpenAI-compat endpoint)
|
|
9
|
+
* gemini/gemini-embedding-001 → Google Generative Language REST API
|
|
9
10
|
* local/<model>@<base-url> → OpenAI-compatible local server
|
|
10
11
|
* mock/deterministic → in-process hashed BoW vectors (tests + smoke fallback)
|
|
11
12
|
*
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
import { CrewhausError } from "@crewhaus/errors";
|
|
25
26
|
|
|
26
|
-
export type EmbedderProviderId = "openai" | "voyage" | "cohere" | "local" | "mock";
|
|
27
|
+
export type EmbedderProviderId = "openai" | "voyage" | "cohere" | "gemini" | "local" | "mock";
|
|
27
28
|
|
|
28
29
|
export type ParsedEmbedderModel = {
|
|
29
30
|
readonly providerId: EmbedderProviderId;
|
|
@@ -44,6 +45,7 @@ export class EmbedderError extends CrewhausError {
|
|
|
44
45
|
* openai/text-embedding-3-small
|
|
45
46
|
* voyage/voyage-3
|
|
46
47
|
* cohere/embed-v3
|
|
48
|
+
* gemini/gemini-embedding-001
|
|
47
49
|
* local/<model>@<base-url>
|
|
48
50
|
* mock/<anything>
|
|
49
51
|
*/
|
|
@@ -60,6 +62,9 @@ export function parseEmbedderModel(spec: string): ParsedEmbedderModel {
|
|
|
60
62
|
if (spec.startsWith("cohere/")) {
|
|
61
63
|
return { providerId: "cohere", modelId: spec.slice("cohere/".length) };
|
|
62
64
|
}
|
|
65
|
+
if (spec.startsWith("gemini/")) {
|
|
66
|
+
return { providerId: "gemini", modelId: spec.slice("gemini/".length) };
|
|
67
|
+
}
|
|
63
68
|
if (spec.startsWith("local/")) {
|
|
64
69
|
const rest = spec.slice("local/".length);
|
|
65
70
|
const at = rest.lastIndexOf("@");
|
|
@@ -74,7 +79,7 @@ export function parseEmbedderModel(spec: string): ParsedEmbedderModel {
|
|
|
74
79
|
return { providerId: "mock", modelId: spec.slice("mock/".length) };
|
|
75
80
|
}
|
|
76
81
|
throw new EmbedderError(
|
|
77
|
-
`unknown embedder prefix in "${spec}" — expected openai/, voyage/, cohere/, local/<m>@<url>, or mock/`,
|
|
82
|
+
`unknown embedder prefix in "${spec}" — expected openai/, voyage/, cohere/, gemini/, local/<m>@<url>, or mock/`,
|
|
78
83
|
);
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -133,15 +138,30 @@ class MockEmbedder implements Embedder {
|
|
|
133
138
|
// servers like Ollama, vLLM, llama.cpp).
|
|
134
139
|
// ---------------------------------------------------------------------------
|
|
135
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Normalise a baseUrl before appending `/v1/embeddings`: strip a trailing
|
|
143
|
+
* slash AND a trailing `/v1` segment so both URL shapes users commonly
|
|
144
|
+
* supply work (`http://localhost:11434` and `http://localhost:11434/v1`
|
|
145
|
+
* both resolve to `.../v1/embeddings` — never `.../v1/v1/embeddings`).
|
|
146
|
+
*/
|
|
147
|
+
function normaliseEmbeddingsBaseUrl(baseUrl: string): string {
|
|
148
|
+
let out = baseUrl;
|
|
149
|
+
if (out.endsWith("/")) out = out.slice(0, -1);
|
|
150
|
+
if (out.endsWith("/v1")) out = out.slice(0, -"/v1".length);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
class OpenAILikeEmbedder implements Embedder {
|
|
137
155
|
readonly provider: EmbedderProviderId;
|
|
156
|
+
private readonly baseUrl: string;
|
|
138
157
|
constructor(
|
|
139
158
|
provider: EmbedderProviderId,
|
|
140
159
|
public readonly model: string,
|
|
141
|
-
|
|
160
|
+
baseUrl: string,
|
|
142
161
|
private readonly apiKey: string | undefined,
|
|
143
162
|
) {
|
|
144
163
|
this.provider = provider;
|
|
164
|
+
this.baseUrl = normaliseEmbeddingsBaseUrl(baseUrl);
|
|
145
165
|
}
|
|
146
166
|
async embed(texts: ReadonlyArray<string>, opts: EmbedOptions = {}): Promise<number[][]> {
|
|
147
167
|
if (texts.length === 0) return [];
|
|
@@ -172,6 +192,53 @@ class OpenAILikeEmbedder implements Embedder {
|
|
|
172
192
|
}
|
|
173
193
|
}
|
|
174
194
|
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Gemini — Google Generative Language REST API. Not OpenAI-shaped: one
|
|
197
|
+
// POST per text to `models/<model>:embedContent` with the API key as a
|
|
198
|
+
// query param, response carries `embedding.values`. The per-text loop is
|
|
199
|
+
// fine for the embedder's batch sizes (the API has no multi-text body on
|
|
200
|
+
// this endpoint shape).
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const GEMINI_EMBED_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
204
|
+
|
|
205
|
+
class GeminiEmbedder implements Embedder {
|
|
206
|
+
readonly provider: EmbedderProviderId = "gemini";
|
|
207
|
+
constructor(
|
|
208
|
+
public readonly model: string,
|
|
209
|
+
private readonly apiKey: string,
|
|
210
|
+
private readonly baseUrl: string = GEMINI_EMBED_BASE,
|
|
211
|
+
) {}
|
|
212
|
+
async embed(texts: ReadonlyArray<string>, opts: EmbedOptions = {}): Promise<number[][]> {
|
|
213
|
+
if (texts.length === 0) return [];
|
|
214
|
+
const out: number[][] = [];
|
|
215
|
+
for (const text of texts) {
|
|
216
|
+
const url = `${this.baseUrl}/models/${this.model}:embedContent?key=${encodeURIComponent(this.apiKey)}`;
|
|
217
|
+
const res = await fetch(url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "content-type": "application/json" },
|
|
220
|
+
body: JSON.stringify({ content: { parts: [{ text }] } }),
|
|
221
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
const body = await res.text().catch(() => "");
|
|
225
|
+
throw new EmbedderError(
|
|
226
|
+
`embedding request failed (gemini ${this.model}): ${res.status} ${body}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const json = (await res.json()) as { embedding?: { values?: number[] } };
|
|
230
|
+
const values = json.embedding?.values;
|
|
231
|
+
if (!Array.isArray(values)) {
|
|
232
|
+
throw new EmbedderError(
|
|
233
|
+
`gemini embedding response missing embedding.values (model ${this.model})`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
out.push(values);
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
175
242
|
// ---------------------------------------------------------------------------
|
|
176
243
|
// Factory.
|
|
177
244
|
// ---------------------------------------------------------------------------
|
|
@@ -218,13 +285,29 @@ export function createEmbedder(opts: CreateEmbedderOptions): Embedder {
|
|
|
218
285
|
if (apiKey === undefined || apiKey === "") {
|
|
219
286
|
throw new EmbedderError("cohere embedder requires COHERE_API_KEY");
|
|
220
287
|
}
|
|
288
|
+
// Cohere's OpenAI-compatible surface lives under /compatibility —
|
|
289
|
+
// the bare host has no /v1/embeddings path (it 404s).
|
|
221
290
|
return new OpenAILikeEmbedder(
|
|
222
291
|
"cohere",
|
|
223
292
|
parsed.modelId,
|
|
224
|
-
opts.baseUrl ?? "https://api.cohere.ai",
|
|
293
|
+
opts.baseUrl ?? "https://api.cohere.ai/compatibility",
|
|
225
294
|
apiKey,
|
|
226
295
|
);
|
|
227
296
|
}
|
|
297
|
+
case "gemini": {
|
|
298
|
+
// Empty-string env values count as unset so GOOGLE_API_KEY can take
|
|
299
|
+
// over when GEMINI_API_KEY="" (common in CI matrices).
|
|
300
|
+
const candidates = [
|
|
301
|
+
opts.apiKey,
|
|
302
|
+
process.env["GEMINI_API_KEY"],
|
|
303
|
+
process.env["GOOGLE_API_KEY"],
|
|
304
|
+
];
|
|
305
|
+
const apiKey = candidates.find((k) => k !== undefined && k !== "");
|
|
306
|
+
if (apiKey === undefined) {
|
|
307
|
+
throw new EmbedderError("gemini embedder requires GEMINI_API_KEY (or GOOGLE_API_KEY)");
|
|
308
|
+
}
|
|
309
|
+
return new GeminiEmbedder(parsed.modelId, apiKey, opts.baseUrl ?? GEMINI_EMBED_BASE);
|
|
310
|
+
}
|
|
228
311
|
case "local":
|
|
229
312
|
return new OpenAILikeEmbedder(
|
|
230
313
|
"local",
|