@crewhaus/embedder 0.1.0

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 ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/embedder",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Embedding provider — openai/voyage/cohere/local/mock backends with prefix-grammar selector",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/embedder"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/embedder#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, 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
+ });
package/src/index.ts ADDED
@@ -0,0 +1,240 @@
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
9
+ * local/<model>@<base-url> → OpenAI-compatible local server
10
+ * mock/deterministic → in-process hashed BoW vectors (tests + smoke fallback)
11
+ *
12
+ * `embed(texts, opts?)` accepts up to 100 texts per call (provider rate
13
+ * limit) and returns `number[][]` of equal length. Empty input returns
14
+ * `[]` without making a network call.
15
+ *
16
+ * The mock backend produces a deterministic 256-dim vector based on a
17
+ * hashed bag-of-words representation, suitable for the Section 21 smoke
18
+ * test when `OPENAI_API_KEY` is unset.
19
+ *
20
+ * Layer R12. Pairs with `vector-store`, `tool-retrieve`, `pipeline-engine`.
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { CrewhausError } from "@crewhaus/errors";
25
+
26
+ export type EmbedderProviderId = "openai" | "voyage" | "cohere" | "local" | "mock";
27
+
28
+ export type ParsedEmbedderModel = {
29
+ readonly providerId: EmbedderProviderId;
30
+ readonly modelId: string;
31
+ readonly baseUrl?: string;
32
+ };
33
+
34
+ export class EmbedderError extends CrewhausError {
35
+ override readonly name = "EmbedderError";
36
+ constructor(message: string, cause?: unknown) {
37
+ super("config", message, cause);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Parse an embedder model string with the same prefix grammar
43
+ * `model-router` uses for chat models.
44
+ * openai/text-embedding-3-small
45
+ * voyage/voyage-3
46
+ * cohere/embed-v3
47
+ * local/<model>@<base-url>
48
+ * mock/<anything>
49
+ */
50
+ export function parseEmbedderModel(spec: string): ParsedEmbedderModel {
51
+ if (typeof spec !== "string" || spec.length === 0) {
52
+ throw new EmbedderError("embedder model must be a non-empty string");
53
+ }
54
+ if (spec.startsWith("openai/")) {
55
+ return { providerId: "openai", modelId: spec.slice("openai/".length) };
56
+ }
57
+ if (spec.startsWith("voyage/")) {
58
+ return { providerId: "voyage", modelId: spec.slice("voyage/".length) };
59
+ }
60
+ if (spec.startsWith("cohere/")) {
61
+ return { providerId: "cohere", modelId: spec.slice("cohere/".length) };
62
+ }
63
+ if (spec.startsWith("local/")) {
64
+ const rest = spec.slice("local/".length);
65
+ const at = rest.lastIndexOf("@");
66
+ if (at === -1) {
67
+ throw new EmbedderError(`local/ embedder must be local/<model>@<base-url> (got "${spec}")`);
68
+ }
69
+ const modelId = rest.slice(0, at);
70
+ const baseUrl = rest.slice(at + 1);
71
+ return { providerId: "local", modelId, baseUrl };
72
+ }
73
+ if (spec.startsWith("mock/")) {
74
+ return { providerId: "mock", modelId: spec.slice("mock/".length) };
75
+ }
76
+ throw new EmbedderError(
77
+ `unknown embedder prefix in "${spec}" — expected openai/, voyage/, cohere/, local/<m>@<url>, or mock/`,
78
+ );
79
+ }
80
+
81
+ export type EmbedOptions = {
82
+ readonly batchSize?: number;
83
+ readonly signal?: AbortSignal;
84
+ };
85
+
86
+ export interface Embedder {
87
+ readonly model: string;
88
+ readonly provider: EmbedderProviderId;
89
+ embed(texts: ReadonlyArray<string>, opts?: EmbedOptions): Promise<number[][]>;
90
+ }
91
+
92
+ const DEFAULT_BATCH = 100;
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Mock backend — deterministic hashed bag-of-words.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const MOCK_DIM = 256;
99
+
100
+ function hashedBow(text: string, dim: number): number[] {
101
+ const out = new Array<number>(dim).fill(0);
102
+ // Tokenise on word boundaries; lowercased; ASCII-only (good enough for hashed BoW).
103
+ const tokens = text
104
+ .toLowerCase()
105
+ .normalize("NFKD")
106
+ .replace(/[^a-z0-9]+/g, " ")
107
+ .split(" ")
108
+ .filter((t) => t.length > 0);
109
+ for (const t of tokens) {
110
+ const h = createHash("sha256").update(t).digest();
111
+ const slot = h.readUInt32BE(0) % dim;
112
+ const sign = (h.readUInt8(4) & 1) === 0 ? 1 : -1;
113
+ out[slot] = (out[slot] ?? 0) + sign;
114
+ }
115
+ // L2-normalise so cosine similarity ≈ dot product.
116
+ let norm = 0;
117
+ for (const v of out) norm += v * v;
118
+ norm = Math.sqrt(norm);
119
+ if (norm === 0) return out;
120
+ return out.map((v) => v / norm);
121
+ }
122
+
123
+ class MockEmbedder implements Embedder {
124
+ readonly provider: EmbedderProviderId = "mock";
125
+ constructor(public readonly model: string) {}
126
+ async embed(texts: ReadonlyArray<string>): Promise<number[][]> {
127
+ return texts.map((t) => hashedBow(t, MOCK_DIM));
128
+ }
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // OpenAI / OpenAI-compatible (covers OpenAI cloud + local OpenAI-shaped
133
+ // servers like Ollama, vLLM, llama.cpp).
134
+ // ---------------------------------------------------------------------------
135
+
136
+ class OpenAILikeEmbedder implements Embedder {
137
+ readonly provider: EmbedderProviderId;
138
+ constructor(
139
+ provider: EmbedderProviderId,
140
+ public readonly model: string,
141
+ private readonly baseUrl: string,
142
+ private readonly apiKey: string | undefined,
143
+ ) {
144
+ this.provider = provider;
145
+ }
146
+ async embed(texts: ReadonlyArray<string>, opts: EmbedOptions = {}): Promise<number[][]> {
147
+ if (texts.length === 0) return [];
148
+ const batchSize = opts.batchSize ?? DEFAULT_BATCH;
149
+ const out: number[][] = [];
150
+ for (let i = 0; i < texts.length; i += batchSize) {
151
+ const batch = texts.slice(i, i + batchSize);
152
+ const headers: Record<string, string> = { "content-type": "application/json" };
153
+ if (this.apiKey !== undefined && this.apiKey.length > 0) {
154
+ headers["authorization"] = `Bearer ${this.apiKey}`;
155
+ }
156
+ const res = await fetch(`${this.baseUrl}/v1/embeddings`, {
157
+ method: "POST",
158
+ headers,
159
+ body: JSON.stringify({ model: this.model, input: batch }),
160
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
161
+ });
162
+ if (!res.ok) {
163
+ const text = await res.text().catch(() => "");
164
+ throw new EmbedderError(
165
+ `embedding request failed (${this.provider} ${this.model}): ${res.status} ${text}`,
166
+ );
167
+ }
168
+ const json = (await res.json()) as { data: Array<{ embedding: number[] }> };
169
+ for (const row of json.data) out.push(row.embedding);
170
+ }
171
+ return out;
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Factory.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export type CreateEmbedderOptions = {
180
+ readonly model: string;
181
+ /** Override the API key; defaults to provider-specific env vars. */
182
+ readonly apiKey?: string;
183
+ /** For "local/" prefix; otherwise ignored. */
184
+ readonly baseUrl?: string;
185
+ };
186
+
187
+ export function createEmbedder(opts: CreateEmbedderOptions): Embedder {
188
+ const parsed = parseEmbedderModel(opts.model);
189
+ switch (parsed.providerId) {
190
+ case "mock":
191
+ return new MockEmbedder(parsed.modelId);
192
+ case "openai": {
193
+ const apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"];
194
+ if (apiKey === undefined || apiKey === "") {
195
+ throw new EmbedderError("openai embedder requires OPENAI_API_KEY");
196
+ }
197
+ return new OpenAILikeEmbedder(
198
+ "openai",
199
+ parsed.modelId,
200
+ opts.baseUrl ?? "https://api.openai.com",
201
+ apiKey,
202
+ );
203
+ }
204
+ case "voyage": {
205
+ const apiKey = opts.apiKey ?? process.env["VOYAGE_API_KEY"];
206
+ if (apiKey === undefined || apiKey === "") {
207
+ throw new EmbedderError("voyage embedder requires VOYAGE_API_KEY");
208
+ }
209
+ return new OpenAILikeEmbedder(
210
+ "voyage",
211
+ parsed.modelId,
212
+ opts.baseUrl ?? "https://api.voyageai.com",
213
+ apiKey,
214
+ );
215
+ }
216
+ case "cohere": {
217
+ const apiKey = opts.apiKey ?? process.env["COHERE_API_KEY"];
218
+ if (apiKey === undefined || apiKey === "") {
219
+ throw new EmbedderError("cohere embedder requires COHERE_API_KEY");
220
+ }
221
+ return new OpenAILikeEmbedder(
222
+ "cohere",
223
+ parsed.modelId,
224
+ opts.baseUrl ?? "https://api.cohere.ai",
225
+ apiKey,
226
+ );
227
+ }
228
+ case "local":
229
+ return new OpenAILikeEmbedder(
230
+ "local",
231
+ parsed.modelId,
232
+ opts.baseUrl ?? parsed.baseUrl ?? "",
233
+ opts.apiKey,
234
+ );
235
+ default: {
236
+ const exhaustive: never = parsed.providerId;
237
+ throw new EmbedderError(`unknown provider "${exhaustive}"`);
238
+ }
239
+ }
240
+ }