@crewhaus/embedder 0.1.3 → 0.1.5

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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Catalog R12 `embedder` — provider-agnostic text embedding.
3
+ *
4
+ * Mirrors `model-router`'s prefix-grammar:
5
+ *
6
+ * openai/text-embedding-3-small → OpenAI Embeddings API
7
+ * voyage/voyage-3 → Voyage AI
8
+ * cohere/embed-v3 → Cohere (OpenAI-compat endpoint)
9
+ * gemini/gemini-embedding-001 → Google Generative Language REST API
10
+ * local/<model>@<base-url> → OpenAI-compatible local server
11
+ * mock/deterministic → in-process hashed BoW vectors (tests + smoke fallback)
12
+ *
13
+ * `embed(texts, opts?)` accepts up to 100 texts per call (provider rate
14
+ * limit) and returns `number[][]` of equal length. Empty input returns
15
+ * `[]` without making a network call.
16
+ *
17
+ * The mock backend produces a deterministic 256-dim vector based on a
18
+ * hashed bag-of-words representation, suitable for the Section 21 smoke
19
+ * test when `OPENAI_API_KEY` is unset.
20
+ *
21
+ * Layer R12. Pairs with `vector-store`, `tool-retrieve`, `pipeline-engine`.
22
+ */
23
+ import { CrewhausError } from "@crewhaus/errors";
24
+ export type EmbedderProviderId = "openai" | "voyage" | "cohere" | "gemini" | "local" | "mock";
25
+ export type ParsedEmbedderModel = {
26
+ readonly providerId: EmbedderProviderId;
27
+ readonly modelId: string;
28
+ readonly baseUrl?: string;
29
+ };
30
+ export declare class EmbedderError extends CrewhausError {
31
+ readonly name = "EmbedderError";
32
+ constructor(message: string, cause?: unknown);
33
+ }
34
+ /**
35
+ * Parse an embedder model string with the same prefix grammar
36
+ * `model-router` uses for chat models.
37
+ * openai/text-embedding-3-small
38
+ * voyage/voyage-3
39
+ * cohere/embed-v3
40
+ * gemini/gemini-embedding-001
41
+ * local/<model>@<base-url>
42
+ * mock/<anything>
43
+ */
44
+ export declare function parseEmbedderModel(spec: string): ParsedEmbedderModel;
45
+ export type EmbedOptions = {
46
+ readonly batchSize?: number;
47
+ readonly signal?: AbortSignal;
48
+ };
49
+ export interface Embedder {
50
+ readonly model: string;
51
+ readonly provider: EmbedderProviderId;
52
+ embed(texts: ReadonlyArray<string>, opts?: EmbedOptions): Promise<number[][]>;
53
+ }
54
+ export type CreateEmbedderOptions = {
55
+ readonly model: string;
56
+ /** Override the API key; defaults to provider-specific env vars. */
57
+ readonly apiKey?: string;
58
+ /** For "local/" prefix; otherwise ignored. */
59
+ readonly baseUrl?: string;
60
+ };
61
+ export declare function createEmbedder(opts: CreateEmbedderOptions): Embedder;
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Catalog R12 `embedder` — provider-agnostic text embedding.
3
+ *
4
+ * Mirrors `model-router`'s prefix-grammar:
5
+ *
6
+ * openai/text-embedding-3-small → OpenAI Embeddings API
7
+ * voyage/voyage-3 → Voyage AI
8
+ * cohere/embed-v3 → Cohere (OpenAI-compat endpoint)
9
+ * gemini/gemini-embedding-001 → Google Generative Language REST API
10
+ * local/<model>@<base-url> → OpenAI-compatible local server
11
+ * mock/deterministic → in-process hashed BoW vectors (tests + smoke fallback)
12
+ *
13
+ * `embed(texts, opts?)` accepts up to 100 texts per call (provider rate
14
+ * limit) and returns `number[][]` of equal length. Empty input returns
15
+ * `[]` without making a network call.
16
+ *
17
+ * The mock backend produces a deterministic 256-dim vector based on a
18
+ * hashed bag-of-words representation, suitable for the Section 21 smoke
19
+ * test when `OPENAI_API_KEY` is unset.
20
+ *
21
+ * Layer R12. Pairs with `vector-store`, `tool-retrieve`, `pipeline-engine`.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { CrewhausError } from "@crewhaus/errors";
25
+ export class EmbedderError extends CrewhausError {
26
+ name = "EmbedderError";
27
+ constructor(message, cause) {
28
+ super("config", message, cause);
29
+ }
30
+ }
31
+ /**
32
+ * Parse an embedder model string with the same prefix grammar
33
+ * `model-router` uses for chat models.
34
+ * openai/text-embedding-3-small
35
+ * voyage/voyage-3
36
+ * cohere/embed-v3
37
+ * gemini/gemini-embedding-001
38
+ * local/<model>@<base-url>
39
+ * mock/<anything>
40
+ */
41
+ export function parseEmbedderModel(spec) {
42
+ if (typeof spec !== "string" || spec.length === 0) {
43
+ throw new EmbedderError("embedder model must be a non-empty string");
44
+ }
45
+ if (spec.startsWith("openai/")) {
46
+ return { providerId: "openai", modelId: spec.slice("openai/".length) };
47
+ }
48
+ if (spec.startsWith("voyage/")) {
49
+ return { providerId: "voyage", modelId: spec.slice("voyage/".length) };
50
+ }
51
+ if (spec.startsWith("cohere/")) {
52
+ return { providerId: "cohere", modelId: spec.slice("cohere/".length) };
53
+ }
54
+ if (spec.startsWith("gemini/")) {
55
+ return { providerId: "gemini", modelId: spec.slice("gemini/".length) };
56
+ }
57
+ if (spec.startsWith("local/")) {
58
+ const rest = spec.slice("local/".length);
59
+ const at = rest.lastIndexOf("@");
60
+ if (at === -1) {
61
+ throw new EmbedderError(`local/ embedder must be local/<model>@<base-url> (got "${spec}")`);
62
+ }
63
+ const modelId = rest.slice(0, at);
64
+ const baseUrl = rest.slice(at + 1);
65
+ return { providerId: "local", modelId, baseUrl };
66
+ }
67
+ if (spec.startsWith("mock/")) {
68
+ return { providerId: "mock", modelId: spec.slice("mock/".length) };
69
+ }
70
+ throw new EmbedderError(`unknown embedder prefix in "${spec}" — expected openai/, voyage/, cohere/, gemini/, local/<m>@<url>, or mock/`);
71
+ }
72
+ const DEFAULT_BATCH = 100;
73
+ // ---------------------------------------------------------------------------
74
+ // Mock backend — deterministic hashed bag-of-words.
75
+ // ---------------------------------------------------------------------------
76
+ const MOCK_DIM = 256;
77
+ function hashedBow(text, dim) {
78
+ const out = new Array(dim).fill(0);
79
+ // Tokenise on word boundaries; lowercased; ASCII-only (good enough for hashed BoW).
80
+ const tokens = text
81
+ .toLowerCase()
82
+ .normalize("NFKD")
83
+ .replace(/[^a-z0-9]+/g, " ")
84
+ .split(" ")
85
+ .filter((t) => t.length > 0);
86
+ for (const t of tokens) {
87
+ const h = createHash("sha256").update(t).digest();
88
+ const slot = h.readUInt32BE(0) % dim;
89
+ const sign = (h.readUInt8(4) & 1) === 0 ? 1 : -1;
90
+ out[slot] = (out[slot] ?? 0) + sign;
91
+ }
92
+ // L2-normalise so cosine similarity ≈ dot product.
93
+ let norm = 0;
94
+ for (const v of out)
95
+ norm += v * v;
96
+ norm = Math.sqrt(norm);
97
+ if (norm === 0)
98
+ return out;
99
+ return out.map((v) => v / norm);
100
+ }
101
+ class MockEmbedder {
102
+ model;
103
+ provider = "mock";
104
+ constructor(model) {
105
+ this.model = model;
106
+ }
107
+ async embed(texts) {
108
+ return texts.map((t) => hashedBow(t, MOCK_DIM));
109
+ }
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // OpenAI / OpenAI-compatible (covers OpenAI cloud + local OpenAI-shaped
113
+ // servers like Ollama, vLLM, llama.cpp).
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * Normalise a baseUrl before appending `/v1/embeddings`: strip a trailing
117
+ * slash AND a trailing `/v1` segment so both URL shapes users commonly
118
+ * supply work (`http://localhost:11434` and `http://localhost:11434/v1`
119
+ * both resolve to `.../v1/embeddings` — never `.../v1/v1/embeddings`).
120
+ */
121
+ function normaliseEmbeddingsBaseUrl(baseUrl) {
122
+ let out = baseUrl;
123
+ if (out.endsWith("/"))
124
+ out = out.slice(0, -1);
125
+ if (out.endsWith("/v1"))
126
+ out = out.slice(0, -"/v1".length);
127
+ return out;
128
+ }
129
+ class OpenAILikeEmbedder {
130
+ model;
131
+ apiKey;
132
+ provider;
133
+ baseUrl;
134
+ constructor(provider, model, baseUrl, apiKey) {
135
+ this.model = model;
136
+ this.apiKey = apiKey;
137
+ this.provider = provider;
138
+ this.baseUrl = normaliseEmbeddingsBaseUrl(baseUrl);
139
+ }
140
+ async embed(texts, opts = {}) {
141
+ if (texts.length === 0)
142
+ return [];
143
+ const batchSize = opts.batchSize ?? DEFAULT_BATCH;
144
+ const out = [];
145
+ for (let i = 0; i < texts.length; i += batchSize) {
146
+ const batch = texts.slice(i, i + batchSize);
147
+ const headers = { "content-type": "application/json" };
148
+ if (this.apiKey !== undefined && this.apiKey.length > 0) {
149
+ headers["authorization"] = `Bearer ${this.apiKey}`;
150
+ }
151
+ const res = await fetch(`${this.baseUrl}/v1/embeddings`, {
152
+ method: "POST",
153
+ headers,
154
+ body: JSON.stringify({ model: this.model, input: batch }),
155
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
156
+ });
157
+ if (!res.ok) {
158
+ const text = await res.text().catch(() => "");
159
+ throw new EmbedderError(`embedding request failed (${this.provider} ${this.model}): ${res.status} ${text}`);
160
+ }
161
+ const json = (await res.json());
162
+ for (const row of json.data)
163
+ out.push(row.embedding);
164
+ }
165
+ return out;
166
+ }
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Gemini — Google Generative Language REST API. Not OpenAI-shaped: one
170
+ // POST per text to `models/<model>:embedContent` with the API key as a
171
+ // query param, response carries `embedding.values`. The per-text loop is
172
+ // fine for the embedder's batch sizes (the API has no multi-text body on
173
+ // this endpoint shape).
174
+ // ---------------------------------------------------------------------------
175
+ const GEMINI_EMBED_BASE = "https://generativelanguage.googleapis.com/v1beta";
176
+ class GeminiEmbedder {
177
+ model;
178
+ apiKey;
179
+ baseUrl;
180
+ provider = "gemini";
181
+ constructor(model, apiKey, baseUrl = GEMINI_EMBED_BASE) {
182
+ this.model = model;
183
+ this.apiKey = apiKey;
184
+ this.baseUrl = baseUrl;
185
+ }
186
+ async embed(texts, opts = {}) {
187
+ if (texts.length === 0)
188
+ return [];
189
+ const out = [];
190
+ for (const text of texts) {
191
+ const url = `${this.baseUrl}/models/${this.model}:embedContent?key=${encodeURIComponent(this.apiKey)}`;
192
+ const res = await fetch(url, {
193
+ method: "POST",
194
+ headers: { "content-type": "application/json" },
195
+ body: JSON.stringify({ content: { parts: [{ text }] } }),
196
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
197
+ });
198
+ if (!res.ok) {
199
+ const body = await res.text().catch(() => "");
200
+ throw new EmbedderError(`embedding request failed (gemini ${this.model}): ${res.status} ${body}`);
201
+ }
202
+ const json = (await res.json());
203
+ const values = json.embedding?.values;
204
+ if (!Array.isArray(values)) {
205
+ throw new EmbedderError(`gemini embedding response missing embedding.values (model ${this.model})`);
206
+ }
207
+ out.push(values);
208
+ }
209
+ return out;
210
+ }
211
+ }
212
+ export function createEmbedder(opts) {
213
+ const parsed = parseEmbedderModel(opts.model);
214
+ switch (parsed.providerId) {
215
+ case "mock":
216
+ return new MockEmbedder(parsed.modelId);
217
+ case "openai": {
218
+ const apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"];
219
+ if (apiKey === undefined || apiKey === "") {
220
+ throw new EmbedderError("openai embedder requires OPENAI_API_KEY");
221
+ }
222
+ return new OpenAILikeEmbedder("openai", parsed.modelId, opts.baseUrl ?? "https://api.openai.com", apiKey);
223
+ }
224
+ case "voyage": {
225
+ const apiKey = opts.apiKey ?? process.env["VOYAGE_API_KEY"];
226
+ if (apiKey === undefined || apiKey === "") {
227
+ throw new EmbedderError("voyage embedder requires VOYAGE_API_KEY");
228
+ }
229
+ return new OpenAILikeEmbedder("voyage", parsed.modelId, opts.baseUrl ?? "https://api.voyageai.com", apiKey);
230
+ }
231
+ case "cohere": {
232
+ const apiKey = opts.apiKey ?? process.env["COHERE_API_KEY"];
233
+ if (apiKey === undefined || apiKey === "") {
234
+ throw new EmbedderError("cohere embedder requires COHERE_API_KEY");
235
+ }
236
+ // Cohere's OpenAI-compatible surface lives under /compatibility —
237
+ // the bare host has no /v1/embeddings path (it 404s).
238
+ return new OpenAILikeEmbedder("cohere", parsed.modelId, opts.baseUrl ?? "https://api.cohere.ai/compatibility", apiKey);
239
+ }
240
+ case "gemini": {
241
+ // Empty-string env values count as unset so GOOGLE_API_KEY can take
242
+ // over when GEMINI_API_KEY="" (common in CI matrices).
243
+ const candidates = [
244
+ opts.apiKey,
245
+ process.env["GEMINI_API_KEY"],
246
+ process.env["GOOGLE_API_KEY"],
247
+ ];
248
+ const apiKey = candidates.find((k) => k !== undefined && k !== "");
249
+ if (apiKey === undefined) {
250
+ throw new EmbedderError("gemini embedder requires GEMINI_API_KEY (or GOOGLE_API_KEY)");
251
+ }
252
+ return new GeminiEmbedder(parsed.modelId, apiKey, opts.baseUrl ?? GEMINI_EMBED_BASE);
253
+ }
254
+ case "local":
255
+ return new OpenAILikeEmbedder("local", parsed.modelId, opts.baseUrl ?? parsed.baseUrl ?? "", opts.apiKey);
256
+ default: {
257
+ const exhaustive = parsed.providerId;
258
+ throw new EmbedderError(`unknown provider "${exhaustive}"`);
259
+ }
260
+ }
261
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/embedder",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Embedding provider — openai/voyage/cohere/local/mock backends with prefix-grammar selector",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,502 +0,0 @@
1
- import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
2
- import { EmbedderError, createEmbedder, parseEmbedderModel } from "./index";
3
-
4
- describe("parseEmbedderModel", () => {
5
- test("parses openai/<model>", () => {
6
- expect(parseEmbedderModel("openai/text-embedding-3-small")).toEqual({
7
- providerId: "openai",
8
- modelId: "text-embedding-3-small",
9
- });
10
- });
11
- test("parses voyage/<model>", () => {
12
- expect(parseEmbedderModel("voyage/voyage-3")).toEqual({
13
- providerId: "voyage",
14
- modelId: "voyage-3",
15
- });
16
- });
17
- test("parses cohere/<model>", () => {
18
- expect(parseEmbedderModel("cohere/embed-v3")).toEqual({
19
- providerId: "cohere",
20
- modelId: "embed-v3",
21
- });
22
- });
23
- test("parses local/<model>@<url>", () => {
24
- expect(parseEmbedderModel("local/nomic-embed@http://localhost:11434")).toEqual({
25
- providerId: "local",
26
- modelId: "nomic-embed",
27
- baseUrl: "http://localhost:11434",
28
- });
29
- });
30
- test("parses mock/deterministic", () => {
31
- expect(parseEmbedderModel("mock/det")).toEqual({
32
- providerId: "mock",
33
- modelId: "det",
34
- });
35
- });
36
- test("rejects unknown prefix", () => {
37
- expect(() => parseEmbedderModel("foo/bar")).toThrow(EmbedderError);
38
- });
39
- test("rejects local/ without @<url>", () => {
40
- expect(() => parseEmbedderModel("local/m")).toThrow(/local\/<model>/);
41
- });
42
- });
43
-
44
- describe("mock embedder (deterministic BoW)", () => {
45
- test("embed returns the requested number of vectors", async () => {
46
- const e = createEmbedder({ model: "mock/det" });
47
- const out = await e.embed(["alpha", "bravo", "charlie"]);
48
- expect(out.length).toBe(3);
49
- });
50
- test("each vector is 256-dim", async () => {
51
- const e = createEmbedder({ model: "mock/det" });
52
- const out = await e.embed(["hello world"]);
53
- expect(out[0]?.length).toBe(256);
54
- });
55
- test("identical input → identical output (deterministic)", async () => {
56
- const e = createEmbedder({ model: "mock/det" });
57
- const a = await e.embed(["how now brown cow"]);
58
- const b = await e.embed(["how now brown cow"]);
59
- expect(a[0]).toEqual(b[0] ?? []);
60
- });
61
- test("similar texts have higher cosine similarity than dissimilar ones (sanity)", async () => {
62
- const e = createEmbedder({ model: "mock/det" });
63
- const [a, b, c] = await e.embed([
64
- "the quick brown fox jumps",
65
- "the brown fox is quick",
66
- "purple snorkel velocity vortex",
67
- ]);
68
- const cos = (x?: number[], y?: number[]): number => {
69
- if (x === undefined || y === undefined) return 0;
70
- let s = 0;
71
- for (let i = 0; i < x.length; i += 1) s += (x[i] ?? 0) * (y[i] ?? 0);
72
- return s;
73
- };
74
- expect(cos(a, b)).toBeGreaterThan(cos(a, c));
75
- });
76
- test("empty input returns empty array (no network call)", async () => {
77
- const e = createEmbedder({ model: "mock/det" });
78
- expect(await e.embed([])).toEqual([]);
79
- });
80
- });
81
-
82
- describe("provider construction", () => {
83
- test("openai requires OPENAI_API_KEY", () => {
84
- const original = process.env["OPENAI_API_KEY"];
85
- process.env["OPENAI_API_KEY"] = "";
86
- try {
87
- expect(() => createEmbedder({ model: "openai/text-embedding-3-small" })).toThrow(
88
- /OPENAI_API_KEY/,
89
- );
90
- } finally {
91
- if (original !== undefined) process.env["OPENAI_API_KEY"] = original;
92
- else process.env["OPENAI_API_KEY"] = "";
93
- }
94
- });
95
-
96
- test("local/ accepts the parsed baseUrl", () => {
97
- const e = createEmbedder({ model: "local/x@http://localhost:11434" });
98
- expect(e.provider).toBe("local");
99
- expect(e.model).toBe("x");
100
- });
101
- });
102
-
103
- /**
104
- * Section 30 — embedder snapshot tests. Each provider's deterministic
105
- * mode (mock embedder) produces stable magnitudes per known input. The
106
- * production-path tests are gated on env vars and skipped in CI; the
107
- * deterministic snapshots here lock in the wire-shape expectations.
108
- */
109
- describe("Section 30 — embedder magnitude snapshots", () => {
110
- // Using the mock embedder as the snapshot source — production paths
111
- // (openai/voyage/cohere) are exercised by their respective providers
112
- // when env vars are set; the mock keeps CI stable.
113
- const FIXTURE_TEXTS = [
114
- "The mitochondria is the powerhouse of the cell.",
115
- "Lorem ipsum dolor sit amet.",
116
- "How many roads must a man walk down?",
117
- '10 PRINT "hello world"',
118
- "🔥 Some emoji 🚀",
119
- ];
120
-
121
- test("mock embedder produces stable magnitudes for a 5-text fixture", async () => {
122
- const e = createEmbedder({ model: "mock/det" });
123
- const vecs = await e.embed(FIXTURE_TEXTS);
124
- expect(vecs.length).toBe(5);
125
- const mags = vecs.map((v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0)));
126
- // All magnitudes positive + bounded (deterministic BoW emits unit-ish vectors).
127
- for (const m of mags) {
128
- expect(m).toBeGreaterThan(0);
129
- expect(m).toBeLessThan(100);
130
- }
131
- // Magnitude stability run-over-run.
132
- const e2 = createEmbedder({ model: "mock/det" });
133
- const vecs2 = await e2.embed(FIXTURE_TEXTS);
134
- const mags2 = vecs2.map((v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0)));
135
- for (let i = 0; i < mags.length; i++) {
136
- expect(Math.abs((mags[i] ?? 0) - (mags2[i] ?? 0))).toBeLessThan(1e-9);
137
- }
138
- });
139
-
140
- test("mock provider field reports correctly", () => {
141
- expect(createEmbedder({ model: "mock/d" }).provider).toBe("mock");
142
- });
143
-
144
- test("openai/voyage/cohere construct without API key throws fail-loud", () => {
145
- expect(() => createEmbedder({ model: "openai/text-embedding-3-small" })).toThrow();
146
- expect(() => createEmbedder({ model: "voyage/voyage-3" })).toThrow();
147
- expect(() => createEmbedder({ model: "cohere/embed-v3" })).toThrow();
148
- });
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 DELETED
@@ -1,323 +0,0 @@
1
- /**
2
- * Catalog R12 `embedder` — provider-agnostic text embedding.
3
- *
4
- * Mirrors `model-router`'s prefix-grammar:
5
- *
6
- * openai/text-embedding-3-small → OpenAI Embeddings API
7
- * voyage/voyage-3 → Voyage AI
8
- * cohere/embed-v3 → Cohere (OpenAI-compat endpoint)
9
- * gemini/gemini-embedding-001 → Google Generative Language REST API
10
- * local/<model>@<base-url> → OpenAI-compatible local server
11
- * mock/deterministic → in-process hashed BoW vectors (tests + smoke fallback)
12
- *
13
- * `embed(texts, opts?)` accepts up to 100 texts per call (provider rate
14
- * limit) and returns `number[][]` of equal length. Empty input returns
15
- * `[]` without making a network call.
16
- *
17
- * The mock backend produces a deterministic 256-dim vector based on a
18
- * hashed bag-of-words representation, suitable for the Section 21 smoke
19
- * test when `OPENAI_API_KEY` is unset.
20
- *
21
- * Layer R12. Pairs with `vector-store`, `tool-retrieve`, `pipeline-engine`.
22
- */
23
-
24
- import { createHash } from "node:crypto";
25
- import { CrewhausError } from "@crewhaus/errors";
26
-
27
- export type EmbedderProviderId = "openai" | "voyage" | "cohere" | "gemini" | "local" | "mock";
28
-
29
- export type ParsedEmbedderModel = {
30
- readonly providerId: EmbedderProviderId;
31
- readonly modelId: string;
32
- readonly baseUrl?: string;
33
- };
34
-
35
- export class EmbedderError extends CrewhausError {
36
- override readonly name = "EmbedderError";
37
- constructor(message: string, cause?: unknown) {
38
- super("config", message, cause);
39
- }
40
- }
41
-
42
- /**
43
- * Parse an embedder model string with the same prefix grammar
44
- * `model-router` uses for chat models.
45
- * openai/text-embedding-3-small
46
- * voyage/voyage-3
47
- * cohere/embed-v3
48
- * gemini/gemini-embedding-001
49
- * local/<model>@<base-url>
50
- * mock/<anything>
51
- */
52
- export function parseEmbedderModel(spec: string): ParsedEmbedderModel {
53
- if (typeof spec !== "string" || spec.length === 0) {
54
- throw new EmbedderError("embedder model must be a non-empty string");
55
- }
56
- if (spec.startsWith("openai/")) {
57
- return { providerId: "openai", modelId: spec.slice("openai/".length) };
58
- }
59
- if (spec.startsWith("voyage/")) {
60
- return { providerId: "voyage", modelId: spec.slice("voyage/".length) };
61
- }
62
- if (spec.startsWith("cohere/")) {
63
- return { providerId: "cohere", modelId: spec.slice("cohere/".length) };
64
- }
65
- if (spec.startsWith("gemini/")) {
66
- return { providerId: "gemini", modelId: spec.slice("gemini/".length) };
67
- }
68
- if (spec.startsWith("local/")) {
69
- const rest = spec.slice("local/".length);
70
- const at = rest.lastIndexOf("@");
71
- if (at === -1) {
72
- throw new EmbedderError(`local/ embedder must be local/<model>@<base-url> (got "${spec}")`);
73
- }
74
- const modelId = rest.slice(0, at);
75
- const baseUrl = rest.slice(at + 1);
76
- return { providerId: "local", modelId, baseUrl };
77
- }
78
- if (spec.startsWith("mock/")) {
79
- return { providerId: "mock", modelId: spec.slice("mock/".length) };
80
- }
81
- throw new EmbedderError(
82
- `unknown embedder prefix in "${spec}" — expected openai/, voyage/, cohere/, gemini/, local/<m>@<url>, or mock/`,
83
- );
84
- }
85
-
86
- export type EmbedOptions = {
87
- readonly batchSize?: number;
88
- readonly signal?: AbortSignal;
89
- };
90
-
91
- export interface Embedder {
92
- readonly model: string;
93
- readonly provider: EmbedderProviderId;
94
- embed(texts: ReadonlyArray<string>, opts?: EmbedOptions): Promise<number[][]>;
95
- }
96
-
97
- const DEFAULT_BATCH = 100;
98
-
99
- // ---------------------------------------------------------------------------
100
- // Mock backend — deterministic hashed bag-of-words.
101
- // ---------------------------------------------------------------------------
102
-
103
- const MOCK_DIM = 256;
104
-
105
- function hashedBow(text: string, dim: number): number[] {
106
- const out = new Array<number>(dim).fill(0);
107
- // Tokenise on word boundaries; lowercased; ASCII-only (good enough for hashed BoW).
108
- const tokens = text
109
- .toLowerCase()
110
- .normalize("NFKD")
111
- .replace(/[^a-z0-9]+/g, " ")
112
- .split(" ")
113
- .filter((t) => t.length > 0);
114
- for (const t of tokens) {
115
- const h = createHash("sha256").update(t).digest();
116
- const slot = h.readUInt32BE(0) % dim;
117
- const sign = (h.readUInt8(4) & 1) === 0 ? 1 : -1;
118
- out[slot] = (out[slot] ?? 0) + sign;
119
- }
120
- // L2-normalise so cosine similarity ≈ dot product.
121
- let norm = 0;
122
- for (const v of out) norm += v * v;
123
- norm = Math.sqrt(norm);
124
- if (norm === 0) return out;
125
- return out.map((v) => v / norm);
126
- }
127
-
128
- class MockEmbedder implements Embedder {
129
- readonly provider: EmbedderProviderId = "mock";
130
- constructor(public readonly model: string) {}
131
- async embed(texts: ReadonlyArray<string>): Promise<number[][]> {
132
- return texts.map((t) => hashedBow(t, MOCK_DIM));
133
- }
134
- }
135
-
136
- // ---------------------------------------------------------------------------
137
- // OpenAI / OpenAI-compatible (covers OpenAI cloud + local OpenAI-shaped
138
- // servers like Ollama, vLLM, llama.cpp).
139
- // ---------------------------------------------------------------------------
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
-
154
- class OpenAILikeEmbedder implements Embedder {
155
- readonly provider: EmbedderProviderId;
156
- private readonly baseUrl: string;
157
- constructor(
158
- provider: EmbedderProviderId,
159
- public readonly model: string,
160
- baseUrl: string,
161
- private readonly apiKey: string | undefined,
162
- ) {
163
- this.provider = provider;
164
- this.baseUrl = normaliseEmbeddingsBaseUrl(baseUrl);
165
- }
166
- async embed(texts: ReadonlyArray<string>, opts: EmbedOptions = {}): Promise<number[][]> {
167
- if (texts.length === 0) return [];
168
- const batchSize = opts.batchSize ?? DEFAULT_BATCH;
169
- const out: number[][] = [];
170
- for (let i = 0; i < texts.length; i += batchSize) {
171
- const batch = texts.slice(i, i + batchSize);
172
- const headers: Record<string, string> = { "content-type": "application/json" };
173
- if (this.apiKey !== undefined && this.apiKey.length > 0) {
174
- headers["authorization"] = `Bearer ${this.apiKey}`;
175
- }
176
- const res = await fetch(`${this.baseUrl}/v1/embeddings`, {
177
- method: "POST",
178
- headers,
179
- body: JSON.stringify({ model: this.model, input: batch }),
180
- ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
181
- });
182
- if (!res.ok) {
183
- const text = await res.text().catch(() => "");
184
- throw new EmbedderError(
185
- `embedding request failed (${this.provider} ${this.model}): ${res.status} ${text}`,
186
- );
187
- }
188
- const json = (await res.json()) as { data: Array<{ embedding: number[] }> };
189
- for (const row of json.data) out.push(row.embedding);
190
- }
191
- return out;
192
- }
193
- }
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
-
242
- // ---------------------------------------------------------------------------
243
- // Factory.
244
- // ---------------------------------------------------------------------------
245
-
246
- export type CreateEmbedderOptions = {
247
- readonly model: string;
248
- /** Override the API key; defaults to provider-specific env vars. */
249
- readonly apiKey?: string;
250
- /** For "local/" prefix; otherwise ignored. */
251
- readonly baseUrl?: string;
252
- };
253
-
254
- export function createEmbedder(opts: CreateEmbedderOptions): Embedder {
255
- const parsed = parseEmbedderModel(opts.model);
256
- switch (parsed.providerId) {
257
- case "mock":
258
- return new MockEmbedder(parsed.modelId);
259
- case "openai": {
260
- const apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"];
261
- if (apiKey === undefined || apiKey === "") {
262
- throw new EmbedderError("openai embedder requires OPENAI_API_KEY");
263
- }
264
- return new OpenAILikeEmbedder(
265
- "openai",
266
- parsed.modelId,
267
- opts.baseUrl ?? "https://api.openai.com",
268
- apiKey,
269
- );
270
- }
271
- case "voyage": {
272
- const apiKey = opts.apiKey ?? process.env["VOYAGE_API_KEY"];
273
- if (apiKey === undefined || apiKey === "") {
274
- throw new EmbedderError("voyage embedder requires VOYAGE_API_KEY");
275
- }
276
- return new OpenAILikeEmbedder(
277
- "voyage",
278
- parsed.modelId,
279
- opts.baseUrl ?? "https://api.voyageai.com",
280
- apiKey,
281
- );
282
- }
283
- case "cohere": {
284
- const apiKey = opts.apiKey ?? process.env["COHERE_API_KEY"];
285
- if (apiKey === undefined || apiKey === "") {
286
- throw new EmbedderError("cohere embedder requires COHERE_API_KEY");
287
- }
288
- // Cohere's OpenAI-compatible surface lives under /compatibility —
289
- // the bare host has no /v1/embeddings path (it 404s).
290
- return new OpenAILikeEmbedder(
291
- "cohere",
292
- parsed.modelId,
293
- opts.baseUrl ?? "https://api.cohere.ai/compatibility",
294
- apiKey,
295
- );
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
- }
311
- case "local":
312
- return new OpenAILikeEmbedder(
313
- "local",
314
- parsed.modelId,
315
- opts.baseUrl ?? parsed.baseUrl ?? "",
316
- opts.apiKey,
317
- );
318
- default: {
319
- const exhaustive: never = parsed.providerId;
320
- throw new EmbedderError(`unknown provider "${exhaustive}"`);
321
- }
322
- }
323
- }