semantic-router-ts 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,426 @@
1
+ // src/encoders/base.ts
2
+ var BaseEncoder = class {
3
+ initialized = false;
4
+ /**
5
+ * Encode multiple texts in batch.
6
+ * Default implementation calls encode() for each, but subclasses
7
+ * can override for more efficient batch processing.
8
+ */
9
+ async encodeBatch(texts) {
10
+ return Promise.all(texts.map((text) => this.encode(text)));
11
+ }
12
+ /**
13
+ * Normalize an embedding vector to unit length.
14
+ */
15
+ normalize(embedding) {
16
+ const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0));
17
+ if (norm === 0) return embedding;
18
+ return embedding.map((val) => val / norm);
19
+ }
20
+ };
21
+
22
+ // src/encoders/local.ts
23
+ var pipeline = null;
24
+ var loadPromise = null;
25
+ async function loadTransformers() {
26
+ if (pipeline) return;
27
+ if (loadPromise) {
28
+ await loadPromise;
29
+ return;
30
+ }
31
+ loadPromise = (async () => {
32
+ try {
33
+ const transformers = await import("@xenova/transformers");
34
+ pipeline = transformers.pipeline;
35
+ } catch {
36
+ throw new Error(
37
+ "LocalEncoder requires @xenova/transformers. Install it with: npm install @xenova/transformers"
38
+ );
39
+ }
40
+ })();
41
+ await loadPromise;
42
+ }
43
+ var LocalEncoder = class _LocalEncoder extends BaseEncoder {
44
+ name = "LocalEncoder";
45
+ dimensions;
46
+ model;
47
+ embedder = null;
48
+ initPromise = null;
49
+ shouldNormalize;
50
+ // Model -> dimensions mapping
51
+ static MODEL_DIMENSIONS = {
52
+ "Xenova/all-MiniLM-L6-v2": 384,
53
+ "Xenova/all-mpnet-base-v2": 768,
54
+ "Xenova/bge-small-en-v1.5": 384,
55
+ "Xenova/bge-base-en-v1.5": 768
56
+ };
57
+ constructor(config = {}) {
58
+ super();
59
+ this.model = config.model || "Xenova/all-MiniLM-L6-v2";
60
+ this.dimensions = _LocalEncoder.MODEL_DIMENSIONS[this.model] || 384;
61
+ this.shouldNormalize = config.normalize ?? true;
62
+ }
63
+ async ensureInitialized() {
64
+ if (this.initialized && this.embedder) return;
65
+ if (this.initPromise) {
66
+ await this.initPromise;
67
+ return;
68
+ }
69
+ this.initPromise = this.initialize();
70
+ await this.initPromise;
71
+ }
72
+ async initialize() {
73
+ await loadTransformers();
74
+ console.log(`[LocalEncoder] Loading model: ${this.model}...`);
75
+ const start = Date.now();
76
+ this.embedder = await pipeline("feature-extraction", this.model);
77
+ this.initialized = true;
78
+ console.log(`[LocalEncoder] Model loaded in ${Date.now() - start}ms`);
79
+ }
80
+ async encode(text) {
81
+ await this.ensureInitialized();
82
+ const result = await this.embedder(text, {
83
+ pooling: "mean",
84
+ normalize: this.shouldNormalize
85
+ });
86
+ return Array.from(result.data);
87
+ }
88
+ async encodeBatch(texts) {
89
+ await this.ensureInitialized();
90
+ const results = await Promise.all(
91
+ texts.map(async (text) => {
92
+ const result = await this.embedder(text, {
93
+ pooling: "mean",
94
+ normalize: this.shouldNormalize
95
+ });
96
+ return Array.from(result.data);
97
+ })
98
+ );
99
+ return results;
100
+ }
101
+ };
102
+
103
+ // src/encoders/openai.ts
104
+ var OpenAIClass = null;
105
+ async function loadOpenAI() {
106
+ if (OpenAIClass) return;
107
+ try {
108
+ const openaiModule = await import("openai");
109
+ OpenAIClass = openaiModule.default || openaiModule.OpenAI;
110
+ } catch {
111
+ throw new Error(
112
+ "OpenAIEncoder requires openai package. Install it with: npm install openai"
113
+ );
114
+ }
115
+ }
116
+ var OpenAIEncoder = class _OpenAIEncoder extends BaseEncoder {
117
+ name = "OpenAIEncoder";
118
+ dimensions;
119
+ model;
120
+ apiKey;
121
+ client = null;
122
+ // Model -> default dimensions mapping
123
+ static MODEL_DIMENSIONS = {
124
+ "text-embedding-3-small": 1536,
125
+ "text-embedding-3-large": 3072,
126
+ "text-embedding-ada-002": 1536
127
+ };
128
+ constructor(config = {}) {
129
+ super();
130
+ this.model = config.model || "text-embedding-3-small";
131
+ this.dimensions = config.dimensions || _OpenAIEncoder.MODEL_DIMENSIONS[this.model] || 1536;
132
+ this.apiKey = config.apiKey || process.env.OPENAI_API_KEY || "";
133
+ if (!this.apiKey) {
134
+ console.warn("[OpenAIEncoder] No API key provided. Set OPENAI_API_KEY env var or pass apiKey config.");
135
+ }
136
+ }
137
+ async ensureClient() {
138
+ if (this.client) return;
139
+ await loadOpenAI();
140
+ this.client = new OpenAIClass({ apiKey: this.apiKey });
141
+ this.initialized = true;
142
+ }
143
+ async encode(text) {
144
+ const results = await this.encodeBatch([text]);
145
+ return results[0];
146
+ }
147
+ async encodeBatch(texts) {
148
+ await this.ensureClient();
149
+ const response = await this.client.embeddings.create({
150
+ model: this.model,
151
+ input: texts,
152
+ ...this.model.includes("3-") && { dimensions: this.dimensions }
153
+ });
154
+ const sorted = response.data.sort((a, b) => a.index - b.index);
155
+ return sorted.map((item) => item.embedding);
156
+ }
157
+ };
158
+
159
+ // src/index/local.ts
160
+ var LocalIndex = class {
161
+ vectors = [];
162
+ ready = false;
163
+ async add(embeddings, routes, utterances) {
164
+ if (embeddings.length !== routes.length || routes.length !== utterances.length) {
165
+ throw new Error("Embeddings, routes, and utterances must have same length");
166
+ }
167
+ for (let i = 0; i < embeddings.length; i++) {
168
+ this.vectors.push({
169
+ route: routes[i],
170
+ utterance: utterances[i],
171
+ embedding: embeddings[i]
172
+ });
173
+ }
174
+ this.ready = true;
175
+ }
176
+ async query(embedding, topK) {
177
+ if (this.vectors.length === 0) {
178
+ return [];
179
+ }
180
+ const scored = this.vectors.map((vector) => ({
181
+ route: vector.route,
182
+ utterance: vector.utterance,
183
+ score: this.cosineSimilarity(embedding, vector.embedding)
184
+ }));
185
+ scored.sort((a, b) => b.score - a.score);
186
+ return scored.slice(0, topK);
187
+ }
188
+ async clear() {
189
+ this.vectors = [];
190
+ this.ready = false;
191
+ }
192
+ isReady() {
193
+ return this.ready;
194
+ }
195
+ /**
196
+ * Get number of stored vectors
197
+ */
198
+ get size() {
199
+ return this.vectors.length;
200
+ }
201
+ /**
202
+ * Cosine similarity between two vectors
203
+ */
204
+ cosineSimilarity(a, b) {
205
+ if (a.length !== b.length) return 0;
206
+ let dotProduct = 0;
207
+ let normA = 0;
208
+ let normB = 0;
209
+ for (let i = 0; i < a.length; i++) {
210
+ dotProduct += a[i] * b[i];
211
+ normA += a[i] * a[i];
212
+ normB += b[i] * b[i];
213
+ }
214
+ if (normA === 0 || normB === 0) return 0;
215
+ return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
216
+ }
217
+ };
218
+
219
+ // src/router.ts
220
+ var SemanticRouter = class {
221
+ routes = [];
222
+ encoder;
223
+ index;
224
+ llm;
225
+ // Configuration
226
+ topK;
227
+ aggregation;
228
+ threshold;
229
+ // State
230
+ initialized = false;
231
+ initPromise = null;
232
+ constructor(config = {}) {
233
+ this.routes = config.routes || [];
234
+ this.encoder = config.encoder || new LocalEncoder();
235
+ this.index = new LocalIndex();
236
+ this.llm = config.llm;
237
+ this.topK = config.topK ?? 5;
238
+ this.aggregation = config.aggregation ?? "mean";
239
+ this.threshold = config.threshold ?? 0.4;
240
+ }
241
+ /**
242
+ * Initialize the router by encoding all routes.
243
+ * Safe to call multiple times.
244
+ */
245
+ async initialize() {
246
+ if (this.initialized) return;
247
+ if (this.initPromise) {
248
+ await this.initPromise;
249
+ return;
250
+ }
251
+ this.initPromise = this.doInitialize();
252
+ await this.initPromise;
253
+ }
254
+ async doInitialize() {
255
+ if (this.routes.length === 0) {
256
+ console.warn("[SemanticRouter] No routes configured");
257
+ this.initialized = true;
258
+ return;
259
+ }
260
+ console.log(`[SemanticRouter] Encoding ${this.routes.length} routes...`);
261
+ const start = Date.now();
262
+ const allUtterances = [];
263
+ const allRouteNames = [];
264
+ for (const route of this.routes) {
265
+ for (const utterance of route.utterances) {
266
+ allUtterances.push(utterance);
267
+ allRouteNames.push(route.name);
268
+ }
269
+ }
270
+ const embeddings = await this.encoder.encodeBatch(allUtterances);
271
+ await this.index.add(embeddings, allRouteNames, allUtterances);
272
+ this.initialized = true;
273
+ console.log(
274
+ `[SemanticRouter] Initialized in ${Date.now() - start}ms (${allUtterances.length} utterances from ${this.routes.length} routes)`
275
+ );
276
+ }
277
+ /**
278
+ * Route a query to the best matching route.
279
+ */
280
+ async route(query) {
281
+ if (!this.initialized) {
282
+ await this.initialize();
283
+ }
284
+ const queryEmbedding = await this.encoder.encode(query);
285
+ const matches = await this.index.query(queryEmbedding, this.topK);
286
+ if (matches.length === 0) {
287
+ return this.noMatch();
288
+ }
289
+ const routeScores = this.aggregateScores(matches);
290
+ routeScores.sort((a, b) => b.score - a.score);
291
+ const topMatch = routeScores[0];
292
+ if (topMatch.score < this.threshold) {
293
+ if (this.llm) {
294
+ return await this.llmFallback(query, routeScores);
295
+ }
296
+ return this.noMatch(routeScores);
297
+ }
298
+ const matchedRoute = this.routes.find((r) => r.name === topMatch.name);
299
+ return {
300
+ route: matchedRoute || null,
301
+ name: topMatch.name,
302
+ confidence: topMatch.score,
303
+ scores: routeScores
304
+ };
305
+ }
306
+ /**
307
+ * Shorthand to get just the route name
308
+ */
309
+ async classify(query) {
310
+ const result = await this.route(query);
311
+ return result.name;
312
+ }
313
+ /**
314
+ * Add a route dynamically
315
+ */
316
+ async addRoute(route) {
317
+ this.routes = this.routes.filter((r) => r.name !== route.name);
318
+ this.routes.push(route);
319
+ const embeddings = await this.encoder.encodeBatch(route.utterances);
320
+ const routeNames = route.utterances.map(() => route.name);
321
+ await this.index.add(embeddings, routeNames, route.utterances);
322
+ console.log(`[SemanticRouter] Added route: ${route.name} (${route.utterances.length} utterances)`);
323
+ }
324
+ /**
325
+ * Aggregate scores from multiple matches by route
326
+ */
327
+ aggregateScores(matches) {
328
+ const scoresByRoute = /* @__PURE__ */ new Map();
329
+ for (const match of matches) {
330
+ const scores = scoresByRoute.get(match.route) || [];
331
+ scores.push(match.score);
332
+ scoresByRoute.set(match.route, scores);
333
+ }
334
+ const aggregated = [];
335
+ for (const [name, scores] of scoresByRoute) {
336
+ let score;
337
+ switch (this.aggregation) {
338
+ case "max":
339
+ score = Math.max(...scores);
340
+ break;
341
+ case "sum":
342
+ score = scores.reduce((a, b) => a + b, 0);
343
+ break;
344
+ case "mean":
345
+ default:
346
+ score = scores.reduce((a, b) => a + b, 0) / scores.length;
347
+ break;
348
+ }
349
+ aggregated.push({ name, score });
350
+ }
351
+ return aggregated;
352
+ }
353
+ /**
354
+ * Use LLM to classify when similarity is low
355
+ */
356
+ async llmFallback(query, scores) {
357
+ if (!this.llm) {
358
+ return this.noMatch(scores);
359
+ }
360
+ console.log("[SemanticRouter] Using LLM fallback for low-confidence match");
361
+ const routeDescriptions = this.routes.map((r) => `- ${r.name}: ${r.description || r.utterances[0]}`).join("\n");
362
+ const prompt = `Classify the user's intent into exactly ONE of these routes:
363
+
364
+ ${routeDescriptions}
365
+
366
+ User query: "${query}"
367
+
368
+ Respond with ONLY the route name, nothing else.`;
369
+ try {
370
+ const response = await this.llm.generate(prompt);
371
+ const routeName = response.trim().toLowerCase();
372
+ const matchedRoute = this.routes.find(
373
+ (r) => r.name.toLowerCase() === routeName
374
+ );
375
+ if (matchedRoute) {
376
+ console.log(`[SemanticRouter] LLM classified as: ${matchedRoute.name}`);
377
+ return {
378
+ route: matchedRoute,
379
+ name: matchedRoute.name,
380
+ confidence: 0.7,
381
+ // LLM confidence estimate
382
+ scores
383
+ };
384
+ }
385
+ } catch (error) {
386
+ console.warn("[SemanticRouter] LLM fallback failed:", error);
387
+ }
388
+ return this.noMatch(scores);
389
+ }
390
+ /**
391
+ * Return a no-match result
392
+ */
393
+ noMatch(scores) {
394
+ return {
395
+ route: null,
396
+ name: null,
397
+ confidence: 0,
398
+ scores
399
+ };
400
+ }
401
+ /**
402
+ * Get router statistics
403
+ */
404
+ getStats() {
405
+ return {
406
+ routeCount: this.routes.length,
407
+ routes: this.routes.map((r) => r.name),
408
+ encoder: this.encoder.name,
409
+ threshold: this.threshold,
410
+ initialized: this.initialized
411
+ };
412
+ }
413
+ /**
414
+ * Check if router is ready
415
+ */
416
+ isReady() {
417
+ return this.initialized;
418
+ }
419
+ };
420
+ export {
421
+ BaseEncoder,
422
+ LocalEncoder,
423
+ LocalIndex,
424
+ OpenAIEncoder,
425
+ SemanticRouter
426
+ };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "semantic-router-ts",
3
+ "version": "1.0.0",
4
+ "description": "Superfast semantic routing for LLMs and AI agents - TypeScript port of aurelio-labs/semantic-router",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
22
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "lint": "eslint src --ext .ts",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "semantic",
30
+ "router",
31
+ "llm",
32
+ "ai",
33
+ "embeddings",
34
+ "vector",
35
+ "similarity",
36
+ "agents",
37
+ "intent",
38
+ "classification"
39
+ ],
40
+ "author": "Nilesh",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/nilesh/semantic-router-ts"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.0.0",
48
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
49
+ "@typescript-eslint/parser": "^8.0.0",
50
+ "eslint": "^9.0.0",
51
+ "tsup": "^8.0.0",
52
+ "typescript": "^5.7.0",
53
+ "vitest": "^2.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@xenova/transformers": "^2.0.0",
57
+ "openai": "^4.0.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@xenova/transformers": {
61
+ "optional": true
62
+ },
63
+ "openai": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "engines": {
68
+ "node": ">=18.0.0"
69
+ }
70
+ }