next-semantic-cache 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/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # next-semantic-cache
2
+
3
+ ### Drop-in local semantic caching for the Vercel AI SDK.
4
+
5
+ **Cut your OpenAI / Anthropic bill. Slash latency to ~50 ms. Compute embeddings for free — locally, on every request.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/next-semantic-cache?color=6366f1&style=flat-square)](https://www.npmjs.com/package/next-semantic-cache)
8
+ [![license](https://img.shields.io/npm/l/next-semantic-cache?color=22c55e&style=flat-square)](./LICENSE)
9
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/next-semantic-cache?color=f59e0b&style=flat-square)](https://bundlephobia.com/package/next-semantic-cache)
10
+
11
+ ---
12
+
13
+ ## Why next-semantic-cache?
14
+
15
+ Every time a user asks *"What is the return policy?"* your app pays for a full GPT-4o call — even when you answered the same question 200 times yesterday. That is wasted money and wasted latency.
16
+
17
+ `next-semantic-cache` sits **between your app and the LLM** as a Vercel AI SDK middleware. It:
18
+
19
+ 1. **Embeds** the incoming prompt using `all-MiniLM-L6-v2` — a 22 MB model that runs locally inside your serverless function. **Zero embedding API cost.**
20
+ 2. **Searches** your vector store for a semantically equivalent previous prompt (cosine similarity ≥ threshold).
21
+ 3. **Returns** the cached response in **~50 ms** if a match is found — no LLM call, no token spend.
22
+ 4. **Saves** every new LLM response asynchronously for future cache hits — without adding latency to the current request.
23
+
24
+ The result: **identical answers are served instantly, and you only pay the LLM for genuinely novel questions.**
25
+
26
+ ---
27
+
28
+ ## How it works
29
+
30
+ ```
31
+ Incoming prompt
32
+
33
+
34
+ ┌─────────────────────────────┐
35
+ │ transformParams() │ ← embed prompt locally (all-MiniLM-L6-v2)
36
+ │ vector search in Redis │ ← cosine similarity ≥ threshold?
37
+ └────────────┬────────────────┘
38
+
39
+ ┌──────┴──────┐
40
+ │ HIT (~50ms) │ NO LLM call. Return cached string immediately.
41
+ └─────────────┘
42
+
43
+ ┌──────┴──────┐
44
+ │ MISS │ Call real LLM → stream response to user
45
+ └──────┬──────┘
46
+
47
+ ┌─────────────────┐
48
+ │ fire-and-forget │ save(vector, response) → Redis, with optional TTL
49
+ └─────────────────┘
50
+ ```
51
+
52
+ > **Key insight:** Embeddings are computed with `@huggingface/transformers` using quantised ONNX weights loaded **once per cold start** as a singleton. Warm requests pay ~2–5 ms for the embedding step — everything else is a Redis RTT.
53
+
54
+ ---
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ npm install next-semantic-cache @upstash/redis uuid ai
60
+ npm install -D @types/uuid
61
+ ```
62
+
63
+ Set your Upstash credentials in `.env.local`:
64
+
65
+ ```bash
66
+ UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
67
+ UPSTASH_REDIS_REST_TOKEN=your-token-here
68
+ ```
69
+
70
+ > **Upstash requirement:** Your Redis database must have **RediSearch / Redis Stack** enabled. All new Upstash databases have this by default. For self-hosted Redis, use `redis-stack` or load the `redisearch` module.
71
+
72
+ ---
73
+
74
+ ## Quick start
75
+
76
+ ```ts
77
+ import { withSemanticCache, RedisVectorAdapter } from "next-semantic-cache";
78
+ import { openai } from "@ai-sdk/openai";
79
+
80
+ const adapter = new RedisVectorAdapter({
81
+ redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
82
+ redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
83
+ ttlSeconds: 86_400, // cache entries expire after 24 hours
84
+ });
85
+
86
+ // Wrap your model once. Use it everywhere.
87
+ export const cachedModel = withSemanticCache(openai("gpt-4o"), {
88
+ adapter,
89
+ threshold: 0.92, // cosine similarity — tune to taste
90
+ });
91
+ ```
92
+
93
+ That's it. Pass `cachedModel` to `streamText`, `generateText`, or `generateObject` exactly as you would with the unwrapped model.
94
+
95
+ ---
96
+
97
+ ## Next.js Edge function example
98
+
99
+ ```ts
100
+ // app/api/chat/route.ts
101
+ import { withSemanticCache, RedisVectorAdapter } from "next-semantic-cache";
102
+ import { openai } from "@ai-sdk/openai";
103
+ import { streamText } from "ai";
104
+
105
+ export const runtime = "edge"; // ← works in Edge runtime
106
+
107
+ // ── Build adapter & cached model outside the handler ─────────────────────
108
+ // On warm lambdas this is reused across requests. The embedding model
109
+ // singleton is loaded once and stays in memory between invocations.
110
+ const adapter = new RedisVectorAdapter({
111
+ redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
112
+ redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
113
+ ttlSeconds: 60 * 60 * 24 * 7, // 7-day TTL — FAQ answers stay fresh
114
+ connectionTimeoutMs: 1_500, // fail fast; fall back to LLM
115
+ });
116
+
117
+ const cachedModel = withSemanticCache(openai("gpt-4o"), {
118
+ adapter,
119
+ threshold: 0.92,
120
+ debug: process.env.NODE_ENV === "development",
121
+ });
122
+
123
+ // ── Edge handler ──────────────────────────────────────────────────────────
124
+ export async function POST(req: Request) {
125
+ const { messages } = await req.json();
126
+
127
+ // Drop-in replacement — the API is identical to the unwrapped model.
128
+ const result = streamText({
129
+ model: cachedModel,
130
+ messages,
131
+ system: "You are a helpful customer support assistant.",
132
+ });
133
+
134
+ return result.toDataStreamResponse();
135
+ }
136
+ ```
137
+
138
+ A cache hit response includes `providerMetadata.semanticCache.hit = true` so you can log it or expose it in your analytics:
139
+
140
+ ```ts
141
+ const result = await generateText({ model: cachedModel, prompt });
142
+
143
+ if (result.experimental_providerMetadata?.semanticCache?.hit) {
144
+ console.log("Served from cache — 0 tokens billed.");
145
+ }
146
+ ```
147
+
148
+ ---
149
+
150
+ ## API reference
151
+
152
+ ### `withSemanticCache(model, options)`
153
+
154
+ The primary entry point. Returns the wrapped model — fully compatible with all Vercel AI SDK functions.
155
+
156
+ ```ts
157
+ function withSemanticCache<M extends LanguageModelV1>(
158
+ model: M,
159
+ options: SemanticCacheOptions
160
+ ): LanguageModelV1
161
+ ```
162
+
163
+ | Option | Type | Default | Description |
164
+ |---|---|---|---|
165
+ | `adapter` | `VectorStoreAdapter` | **required** | Storage backend. Use the built-in `RedisVectorAdapter` or bring your own. |
166
+ | `threshold` | `number` | `0.92` | Cosine similarity cutoff in `[0, 1]`. Higher = stricter matching. |
167
+ | `debug` | `boolean` | `false` | Log cache hits/misses to `console.debug`. |
168
+
169
+ ---
170
+
171
+ ### `RedisVectorAdapter`
172
+
173
+ Built-in adapter for Upstash Redis (and any Redis Stack instance).
174
+
175
+ ```ts
176
+ const adapter = new RedisVectorAdapter(options);
177
+ ```
178
+
179
+ | Option | Type | Default | Description |
180
+ |---|---|---|---|
181
+ | `redisUrl` | `string` | — | Upstash REST endpoint URL. |
182
+ | `redisToken` | `string` | — | Upstash REST API token. |
183
+ | `client` | `Redis` | — | Pass an existing `@upstash/redis` client instead of `url` + `token`. |
184
+ | `ttlSeconds` | `number` | `0` (no expiry) | Seconds until a cache entry is automatically deleted by Redis. |
185
+ | `connectionTimeoutMs` | `number` | `2000` | Max ms to wait for Redis before treating the call as failed and falling back to the LLM. |
186
+
187
+ ```ts
188
+ // Flush all cache entries (testing / manual invalidation)
189
+ const deleted = await adapter.flush();
190
+ console.log(`Removed ${deleted} entries from the semantic cache.`);
191
+ ```
192
+
193
+ ---
194
+
195
+ ### `VectorStoreAdapter` interface
196
+
197
+ Bring your own storage backend by implementing two methods:
198
+
199
+ ```ts
200
+ export interface VectorStoreAdapter {
201
+ /**
202
+ * Return the cached response string if a similar prompt exists,
203
+ * or null if no match exceeds the threshold.
204
+ */
205
+ search(vector: number[], threshold: number): Promise<string | null>;
206
+
207
+ /**
208
+ * Persist the prompt embedding alongside its LLM response.
209
+ */
210
+ save(promptVector: number[], response: string): Promise<void>;
211
+ }
212
+ ```
213
+
214
+ #### Example — in-memory adapter (for unit testing)
215
+
216
+ ```ts
217
+ import type { VectorStoreAdapter } from "next-semantic-cache";
218
+
219
+ class InMemoryAdapter implements VectorStoreAdapter {
220
+ private store: Array<{ vector: number[]; response: string }> = [];
221
+
222
+ private cosineSimilarity(a: number[], b: number[]): number {
223
+ const dot = a.reduce((sum, v, i) => sum + v * b[i], 0);
224
+ const magA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
225
+ const magB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
226
+ return dot / (magA * magB);
227
+ }
228
+
229
+ async search(vector: number[], threshold: number) {
230
+ for (const entry of this.store) {
231
+ if (this.cosineSimilarity(vector, entry.vector) >= threshold) {
232
+ return entry.response;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ async save(promptVector: number[], response: string) {
239
+ this.store.push({ vector: promptVector, response });
240
+ }
241
+ }
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Architecture notes
247
+
248
+ ### Singleton embedding model
249
+
250
+ `@huggingface/transformers` loads `Xenova/all-MiniLM-L6-v2` (quantised q8 ONNX, ~23 MB) **once per process**. The pipeline instance is held in a module-level variable and reused on every subsequent request. Cold-start cost: ~400–800 ms. Warm embedding cost: ~2–5 ms.
251
+
252
+ ### Middleware interception flow
253
+
254
+ The middleware implements two hooks from the Vercel AI SDK's `LanguageModelV1Middleware` spec:
255
+
256
+ - **`transformParams`** — runs synchronously *before* the LLM call. Embeds the prompt and attaches a `__semanticCacheHit` sentinel to `providerMetadata` on a hit.
257
+ - **`wrapGenerate`** — reads the sentinel. If present, returns the cached result and **never calls `doGenerate()`**. On a miss it calls `doGenerate()` and fire-and-forgets the `save()`.
258
+
259
+ ### Failure isolation
260
+
261
+ If Redis is unreachable or times out:
262
+ - `search()` returns `null` → the middleware calls the real LLM transparently.
263
+ - `save()` logs the error and returns — the LLM response has already been streamed to the user.
264
+
265
+ **Redis failures are completely invisible to your users.**
266
+
267
+ ---
268
+
269
+ ## Threshold tuning guide
270
+
271
+ | Threshold | Behaviour | Use-case |
272
+ |---|---|---|
273
+ | **0.98** | Near-identical phrasing only | Deduplicate exact retries |
274
+ | **0.92** *(default)* | Catches paraphrases & minor rewording | General-purpose FAQ / support bots |
275
+ | **0.88** | Broad semantic matching | High-traffic apps where some imprecision is acceptable |
276
+ | **0.80** | Very aggressive | Only for tightly constrained, single-domain prompts |
277
+
278
+ ---
279
+
280
+ ## FAQ
281
+
282
+ **Does it work in the Vercel Edge Runtime?**
283
+ Yes. `@huggingface/transformers` with ONNX backend runs in Edge (V8 isolate) environments. The `@upstash/redis` client uses the REST API so there are no raw TCP sockets.
284
+
285
+ **Can I use a different LLM — Anthropic Claude, Google Gemini, etc.?**
286
+ Yes. `withSemanticCache` accepts any `LanguageModelV1`-compatible model. Swap `openai(...)` for `anthropic(...)`, `google(...)`, or any community provider.
287
+
288
+ **Does it support streaming?**
289
+ Cache misses stream normally via the underlying model. Cache hits return a non-streaming synthetic result — the text is complete and the SDK delivers it as a resolved promise. To surface hits as streams, extend `buildCachedGenerateResult` to use `wrapStream` instead.
290
+
291
+ **Does it work with `generateObject` / structured output?**
292
+ The middleware hooks into `wrapGenerate`, which also underlies `generateObject`. Cached responses are raw text; if your object-generation prompt is highly templated and structurally identical across calls, hits will work correctly. For variable schema inputs, raise `threshold` to ≥ 0.97.
293
+
294
+ **Will it slow down my first request on a cold start?**
295
+ The embedding model loads in ~400–800 ms on the first invocation. Vercel pre-warms functions in production, reducing the impact. You can also explicitly warm the pipeline by calling `getEmbeddingPipeline()` from a `warmup` route.
296
+
297
+ ---
298
+
299
+ ## License
300
+
301
+ MIT © 2026 — contributions welcome.
302
+
303
+ > **Install deps:** `npm i next-semantic-cache @upstash/redis uuid ai`
@@ -0,0 +1,94 @@
1
+ /**
2
+ * next-semantic-cache — index.ts
3
+ *
4
+ * Public API surface for the package.
5
+ *
6
+ * The single exported helper, `withSemanticCache`, is a thin convenience
7
+ * wrapper around `wrapLanguageModel` from the Vercel AI SDK. It handles
8
+ * all middleware wiring so callers can go from zero to cached in one line.
9
+ *
10
+ * Usage:
11
+ * import { withSemanticCache } from "next-semantic-cache";
12
+ * import { RedisVectorAdapter } from "next-semantic-cache/adapters/redis";
13
+ *
14
+ * const model = withSemanticCache(openai("gpt-4o"), {
15
+ * adapter: new RedisVectorAdapter({ ... }),
16
+ * threshold: 0.92,
17
+ * });
18
+ */
19
+ import { type LanguageModel } from "ai";
20
+ import { SemanticCacheMiddleware, type VectorStoreAdapter, type SemanticCacheMiddlewareOptions } from "./semantic-cache-middleware";
21
+ export type { VectorStoreAdapter, SemanticCacheMiddlewareOptions };
22
+ export { SemanticCacheMiddleware };
23
+ export { RedisVectorAdapter } from "./redis-vector-adapter";
24
+ /**
25
+ * Configuration for {@link withSemanticCache}.
26
+ */
27
+ export interface SemanticCacheOptions {
28
+ /**
29
+ * A concrete {@link VectorStoreAdapter} implementation.
30
+ *
31
+ * The package ships with {@link RedisVectorAdapter} for Upstash / Redis
32
+ * Stack out of the box. You can also supply your own implementation —
33
+ * any class that satisfies the two-method interface will work.
34
+ */
35
+ adapter: VectorStoreAdapter;
36
+ /**
37
+ * Cosine-similarity threshold in [0, 1].
38
+ *
39
+ * Prompts whose nearest cached neighbour exceeds this similarity are
40
+ * served from cache. Tune this for your use-case:
41
+ * • 0.95 — very strict; only near-identical phrasings hit the cache.
42
+ * • 0.90 — balanced; catches paraphrases and minor rewording.
43
+ * • 0.85 — aggressive; may serve slightly off-topic cached answers.
44
+ *
45
+ * @default 0.92
46
+ */
47
+ threshold?: number;
48
+ /**
49
+ * Emit cache hit/miss debug logs to `console.debug`.
50
+ * Safe to enable in development; disable in production.
51
+ * @default false
52
+ */
53
+ debug?: boolean;
54
+ }
55
+ /**
56
+ * Wraps any Vercel AI SDK {@link LanguageModelV1} with local semantic caching.
57
+ *
58
+ * Embeddings are computed **locally** using `all-MiniLM-L6-v2` via
59
+ * `@huggingface/transformers` — no third-party embedding API is called.
60
+ * Cache lookups return in ~50 ms; LLM calls are made only on a true miss.
61
+ *
62
+ * @param model - Any Vercel AI SDK model (OpenAI, Anthropic, Gemini, …).
63
+ * @param options - Adapter, similarity threshold, and debug flag.
64
+ * @returns The same model type, now augmented with semantic caching.
65
+ *
66
+ * @example — Next.js Edge route
67
+ * ```ts
68
+ * import { withSemanticCache, RedisVectorAdapter } from "next-semantic-cache";
69
+ * import { openai } from "@ai-sdk/openai";
70
+ * import { streamText } from "ai";
71
+ *
72
+ * const adapter = new RedisVectorAdapter({
73
+ * redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
74
+ * redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
75
+ * ttlSeconds: 86_400,
76
+ * });
77
+ *
78
+ * // Build the cached model once — reused across requests on warm lambdas.
79
+ * const cachedModel = withSemanticCache(openai("gpt-4o"), {
80
+ * adapter,
81
+ * threshold: 0.92,
82
+ * debug: process.env.NODE_ENV === "development",
83
+ * });
84
+ *
85
+ * export const runtime = "edge";
86
+ *
87
+ * export async function POST(req: Request) {
88
+ * const { prompt } = await req.json();
89
+ * const result = streamText({ model: cachedModel, prompt });
90
+ * return result.toDataStreamResponse();
91
+ * }
92
+ * ```
93
+ */
94
+ export declare function withSemanticCache<M extends LanguageModel>(model: M, options: SemanticCacheOptions): M;
package/dist/index.js ADDED
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ /**
3
+ * next-semantic-cache — index.ts
4
+ *
5
+ * Public API surface for the package.
6
+ *
7
+ * The single exported helper, `withSemanticCache`, is a thin convenience
8
+ * wrapper around `wrapLanguageModel` from the Vercel AI SDK. It handles
9
+ * all middleware wiring so callers can go from zero to cached in one line.
10
+ *
11
+ * Usage:
12
+ * import { withSemanticCache } from "next-semantic-cache";
13
+ * import { RedisVectorAdapter } from "next-semantic-cache/adapters/redis";
14
+ *
15
+ * const model = withSemanticCache(openai("gpt-4o"), {
16
+ * adapter: new RedisVectorAdapter({ ... }),
17
+ * threshold: 0.92,
18
+ * });
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.RedisVectorAdapter = exports.SemanticCacheMiddleware = void 0;
22
+ exports.withSemanticCache = withSemanticCache;
23
+ const ai_1 = require("ai");
24
+ const semantic_cache_middleware_1 = require("./semantic-cache-middleware");
25
+ Object.defineProperty(exports, "SemanticCacheMiddleware", { enumerable: true, get: function () { return semantic_cache_middleware_1.SemanticCacheMiddleware; } });
26
+ var redis_vector_adapter_1 = require("./redis-vector-adapter");
27
+ Object.defineProperty(exports, "RedisVectorAdapter", { enumerable: true, get: function () { return redis_vector_adapter_1.RedisVectorAdapter; } });
28
+ // ---------------------------------------------------------------------------
29
+ // withSemanticCache
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Wraps any Vercel AI SDK {@link LanguageModelV1} with local semantic caching.
33
+ *
34
+ * Embeddings are computed **locally** using `all-MiniLM-L6-v2` via
35
+ * `@huggingface/transformers` — no third-party embedding API is called.
36
+ * Cache lookups return in ~50 ms; LLM calls are made only on a true miss.
37
+ *
38
+ * @param model - Any Vercel AI SDK model (OpenAI, Anthropic, Gemini, …).
39
+ * @param options - Adapter, similarity threshold, and debug flag.
40
+ * @returns The same model type, now augmented with semantic caching.
41
+ *
42
+ * @example — Next.js Edge route
43
+ * ```ts
44
+ * import { withSemanticCache, RedisVectorAdapter } from "next-semantic-cache";
45
+ * import { openai } from "@ai-sdk/openai";
46
+ * import { streamText } from "ai";
47
+ *
48
+ * const adapter = new RedisVectorAdapter({
49
+ * redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
50
+ * redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
51
+ * ttlSeconds: 86_400,
52
+ * });
53
+ *
54
+ * // Build the cached model once — reused across requests on warm lambdas.
55
+ * const cachedModel = withSemanticCache(openai("gpt-4o"), {
56
+ * adapter,
57
+ * threshold: 0.92,
58
+ * debug: process.env.NODE_ENV === "development",
59
+ * });
60
+ *
61
+ * export const runtime = "edge";
62
+ *
63
+ * export async function POST(req: Request) {
64
+ * const { prompt } = await req.json();
65
+ * const result = streamText({ model: cachedModel, prompt });
66
+ * return result.toDataStreamResponse();
67
+ * }
68
+ * ```
69
+ */
70
+ function withSemanticCache(model, options) {
71
+ const { adapter, threshold = 0.92, debug = false } = options;
72
+ // Build the middleware with the caller-supplied adapter and threshold.
73
+ const middleware = (0, semantic_cache_middleware_1.SemanticCacheMiddleware)({
74
+ vectorStore: adapter,
75
+ similarityThreshold: threshold,
76
+ debug,
77
+ });
78
+ // wrapLanguageModel injects the middleware into the model's call chain.
79
+ // The returned value is structurally identical to the original model and
80
+ // can be passed to any Vercel AI SDK function (streamText, generateText …).
81
+ return (0, ai_1.wrapLanguageModel)({ model: model, middleware });
82
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * semantic-cache-middleware.ts
3
+ *
4
+ * A Vercel AI SDK LanguageModelV4Middleware that short-circuits LLM calls
5
+ * by performing semantic similarity lookups against a pluggable vector store.
6
+ *
7
+ * Pipeline:
8
+ * 1. transformParams — embed the incoming prompt.
9
+ * 2. Cache hit — return a synthetic LanguageModelV4GenerateResult immediately.
10
+ * 3. Cache miss — fall through to wrapGenerate; store result asynchronously.
11
+ *
12
+ * Dependencies:
13
+ * npm i @huggingface/transformers ai
14
+ * npm i -D @types/node
15
+ */
16
+ import { type LanguageModelMiddleware } from "ai";
17
+ export type LanguageModelV4Middleware = LanguageModelMiddleware;
18
+ /**
19
+ * Implement this interface to connect any vector database
20
+ * (e.g. Pinecone, Weaviate, pgvector, an in-memory store, etc.).
21
+ */
22
+ export interface VectorStoreAdapter {
23
+ /**
24
+ * Search for a cached response whose embedding is within `threshold`
25
+ * cosine distance of `vector`.
26
+ *
27
+ * @param vector - The embedding of the current prompt (float32 array).
28
+ * @param threshold - Similarity threshold in [0, 1]. A value of 0.92 is a
29
+ * reasonable starting point for sentence-level deduplication.
30
+ * @returns The cached LLM response string, or `null` on a miss.
31
+ */
32
+ search(vector: number[], threshold: number): Promise<string | null>;
33
+ /**
34
+ * Persist a prompt embedding alongside the LLM response it produced.
35
+ *
36
+ * @param promptVector - The embedding that was used for the cache miss.
37
+ * @param response - The raw text returned by the LLM.
38
+ */
39
+ save(promptVector: number[], response: string): Promise<void>;
40
+ }
41
+ export interface SemanticCacheMiddlewareOptions {
42
+ /**
43
+ * Your VectorStoreAdapter implementation.
44
+ * The middleware is entirely storage-agnostic — bring your own backend.
45
+ */
46
+ vectorStore: VectorStoreAdapter;
47
+ /**
48
+ * Cosine-similarity threshold in [0, 1].
49
+ * Prompts whose nearest neighbour exceeds this value are considered cache hits.
50
+ * @default 0.92
51
+ */
52
+ similarityThreshold?: number;
53
+ /**
54
+ * When `true`, logs cache hits/misses to `console.debug`.
55
+ * Disable in production or pipe to your own logger.
56
+ * @default false
57
+ */
58
+ debug?: boolean;
59
+ }
60
+ /**
61
+ * Creates a LanguageModelV4Middleware instance that intercepts LLM calls and
62
+ * serves semantically equivalent responses from a vector cache.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * import { wrapLanguageModel } from "ai";
67
+ * import { SemanticCacheMiddleware } from "./semantic-cache-middleware";
68
+ * import { MyPgVectorStore } from "./stores/pg-vector";
69
+ *
70
+ * const model = wrapLanguageModel({
71
+ * model: openai("gpt-4o"),
72
+ * middleware: SemanticCacheMiddleware({
73
+ * vectorStore: new MyPgVectorStore(),
74
+ * similarityThreshold: 0.92,
75
+ * debug: process.env.NODE_ENV !== "production",
76
+ * }),
77
+ * });
78
+ * ```
79
+ */
80
+ export declare function SemanticCacheMiddleware(options: SemanticCacheMiddlewareOptions): LanguageModelV4Middleware;
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ /**
3
+ * semantic-cache-middleware.ts
4
+ *
5
+ * A Vercel AI SDK LanguageModelV4Middleware that short-circuits LLM calls
6
+ * by performing semantic similarity lookups against a pluggable vector store.
7
+ *
8
+ * Pipeline:
9
+ * 1. transformParams — embed the incoming prompt.
10
+ * 2. Cache hit — return a synthetic LanguageModelV4GenerateResult immediately.
11
+ * 3. Cache miss — fall through to wrapGenerate; store result asynchronously.
12
+ *
13
+ * Dependencies:
14
+ * npm i @huggingface/transformers ai
15
+ * npm i -D @types/node
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.SemanticCacheMiddleware = SemanticCacheMiddleware;
52
+ /**
53
+ * Internal singleton state — one pipeline instance shared across all
54
+ * middleware invocations in the same Node.js process / serverless warm start.
55
+ */
56
+ let _embeddingPipeline = null;
57
+ /** Name of the SBERT-compatible model used for prompt embeddings. */
58
+ const EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
59
+ /**
60
+ * Returns (and lazily initialises) the singleton feature-extraction pipeline.
61
+ * Subsequent calls are O(1) — the model is loaded only once per cold start.
62
+ *
63
+ * Thread-safety note: in a single-threaded Node.js runtime this is safe.
64
+ * In environments with true concurrency, wrap the init block with a mutex.
65
+ */
66
+ async function getEmbeddingPipeline() {
67
+ if (_embeddingPipeline !== null) {
68
+ return _embeddingPipeline;
69
+ }
70
+ // Dynamic import keeps the heavy transformer runtime tree-shakeable when
71
+ // the middleware is used in environments that bundle conditionally.
72
+ const { pipeline } = await Promise.resolve().then(() => __importStar(require("@huggingface/transformers")));
73
+ const rawPipeline = await pipeline("feature-extraction", EMBEDDING_MODEL, {
74
+ // Quantised ONNX weights — smaller download, same retrieval quality.
75
+ dtype: "q8",
76
+ // Suppress verbose download progress in production logs.
77
+ progress_callback: undefined,
78
+ });
79
+ // Cast to our internal type; the raw callable matches the signature.
80
+ _embeddingPipeline = rawPipeline;
81
+ return _embeddingPipeline;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // 3. Prompt serialisation helper
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * Collapses a LanguageModelV1 prompt (an array of messages) into a single
88
+ * plain-text string suitable for embedding.
89
+ *
90
+ * Only the most recent *user* message is embedded by default, which keeps
91
+ * the cache key stable across varying system-prompt versions. Override this
92
+ * function if you need full-conversation hashing.
93
+ */
94
+ function extractPromptText(prompt) {
95
+ if (!Array.isArray(prompt)) {
96
+ return "";
97
+ }
98
+ const userMessages = prompt
99
+ .filter((m) => m && typeof m === "object" && m.role === "user")
100
+ .flatMap((m) => {
101
+ const content = m.content;
102
+ if (!Array.isArray(content)) {
103
+ return [];
104
+ }
105
+ return content
106
+ .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
107
+ .map((part) => part.text);
108
+ });
109
+ // Use the last user turn as the cache key.
110
+ return userMessages.at(-1) ?? "";
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // 4. Embedding generator
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * Generates a normalised float32 embedding vector for `text`.
117
+ * Uses mean pooling over all token embeddings (standard for MiniLM).
118
+ */
119
+ async function generateEmbedding(text) {
120
+ const pipe = await getEmbeddingPipeline();
121
+ // `output` has shape [1, seq_len, hidden_dim] as a Tensor;
122
+ // the `pooling: "mean"` + `normalize: true` options collapse it to [1, 384].
123
+ const output = await pipe(text, {
124
+ pooling: "mean",
125
+ normalize: true,
126
+ });
127
+ // Flatten to a plain JS array for portability across vector store clients.
128
+ return Array.from(output.data);
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // 5. Synthetic response builder
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * Constructs a minimal LanguageModelV1GenerateResult-shaped object that the
135
+ * Vercel AI SDK accepts as a short-circuited response.
136
+ *
137
+ * The SDK's LanguageModelV4GenerateResult is structurally compatible with V1
138
+ * at the fields we populate here. Update field names as the SDK evolves.
139
+ */
140
+ function buildCachedGenerateResult(cachedText) {
141
+ return {
142
+ /** Passthrough fields required by the SDK result contract. */
143
+ rawCall: {
144
+ rawPrompt: null,
145
+ rawSettings: {},
146
+ },
147
+ /** Signal to the caller that generation ended normally. */
148
+ finishReason: "stop",
149
+ /** Zero usage — no tokens were consumed from the provider. */
150
+ usage: {
151
+ promptTokens: 0,
152
+ completionTokens: 0,
153
+ },
154
+ /** The cached LLM response returned verbatim. */
155
+ text: cachedText,
156
+ /** Content field for LanguageModelV4GenerateResult compatibility. */
157
+ content: [
158
+ {
159
+ type: "text",
160
+ text: cachedText,
161
+ },
162
+ ],
163
+ /** Tool-call fields are undefined for pure-text cached responses. */
164
+ toolCalls: undefined,
165
+ toolResults: undefined,
166
+ /** Warnings field for LanguageModelV4GenerateResult compatibility. */
167
+ warnings: undefined,
168
+ /** Custom metadata so downstream consumers can detect cache hits. */
169
+ providerMetadata: {
170
+ semanticCache: {
171
+ hit: true,
172
+ model: EMBEDDING_MODEL,
173
+ },
174
+ },
175
+ };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // 7. Internal sentinel key
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * The key used to smuggle a cache-hit value through `providerMetadata`
182
+ * from `transformParams` into `wrapGenerate`.
183
+ * Prefixed with `__` to minimise collision risk with real provider keys.
184
+ */
185
+ const CACHE_HIT_SENTINEL = "__semanticCacheHit";
186
+ // ---------------------------------------------------------------------------
187
+ // 8. SemanticCacheMiddleware factory
188
+ // ---------------------------------------------------------------------------
189
+ /**
190
+ * Creates a LanguageModelV4Middleware instance that intercepts LLM calls and
191
+ * serves semantically equivalent responses from a vector cache.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * import { wrapLanguageModel } from "ai";
196
+ * import { SemanticCacheMiddleware } from "./semantic-cache-middleware";
197
+ * import { MyPgVectorStore } from "./stores/pg-vector";
198
+ *
199
+ * const model = wrapLanguageModel({
200
+ * model: openai("gpt-4o"),
201
+ * middleware: SemanticCacheMiddleware({
202
+ * vectorStore: new MyPgVectorStore(),
203
+ * similarityThreshold: 0.92,
204
+ * debug: process.env.NODE_ENV !== "production",
205
+ * }),
206
+ * });
207
+ * ```
208
+ */
209
+ function SemanticCacheMiddleware(options) {
210
+ const { vectorStore, similarityThreshold = 0.92, debug = false } = options;
211
+ // -------------------------------------------------------------------------
212
+ // Ephemeral per-request state: thread the prompt vector from
213
+ // transformParams → wrapGenerate without mutating the SDK params object.
214
+ //
215
+ // ⚠️ This Map is process-scoped. In high-concurrency environments consider
216
+ // AsyncLocalStorage to ensure true per-request isolation.
217
+ // -------------------------------------------------------------------------
218
+ const _promptVectorByText = new Map();
219
+ /** Scoped logger — no-ops unless `debug` is true. */
220
+ const log = (...args) => {
221
+ if (debug)
222
+ console.debug("[SemanticCache]", ...args);
223
+ };
224
+ // =========================================================================
225
+ // transformParams
226
+ //
227
+ // Runs BEFORE the LLM call. Generates a prompt embedding and probes the
228
+ // vector store. On a hit, attaches a sentinel to providerMetadata so that
229
+ // wrapGenerate can short-circuit without calling the model.
230
+ // =========================================================================
231
+ const transformParams = async ({ params, }) => {
232
+ const promptText = extractPromptText(params.prompt);
233
+ if (!promptText) {
234
+ // Nothing to embed; pass params through unchanged.
235
+ log("Empty prompt text — skipping semantic cache lookup.");
236
+ return params;
237
+ }
238
+ // ------------------------------------------------------------------
239
+ // Generate the embedding for the incoming prompt.
240
+ // This is the only I/O-bound step in the hot path (beyond the cache
241
+ // search itself); keep `similarityThreshold` high to minimise false
242
+ // positives and unnecessary LLM bypasses.
243
+ // ------------------------------------------------------------------
244
+ const promptVector = await generateEmbedding(promptText);
245
+ // Stash the vector keyed by prompt text so wrapGenerate can access it
246
+ // on a cache miss without re-embedding.
247
+ _promptVectorByText.set(promptText, promptVector);
248
+ // Probe the vector store.
249
+ const cachedResponse = await vectorStore.search(promptVector, similarityThreshold);
250
+ if (cachedResponse !== null) {
251
+ // ── Cache HIT ─────────────────────────────────────────────────
252
+ log(`Cache HIT — "${promptText.slice(0, 72)}…"`);
253
+ // Attach the cached value to providerMetadata. wrapGenerate reads
254
+ // this sentinel and returns the synthetic result without calling doGenerate.
255
+ return {
256
+ ...params,
257
+ providerMetadata: {
258
+ ...(params.providerMetadata ?? {}),
259
+ [CACHE_HIT_SENTINEL]: cachedResponse,
260
+ },
261
+ };
262
+ }
263
+ // ── Cache MISS ────────────────────────────────────────────────────
264
+ log(`Cache MISS — "${promptText.slice(0, 72)}…"`);
265
+ // Return params unmodified; wrapGenerate will call the real LLM.
266
+ return params;
267
+ };
268
+ // =========================================================================
269
+ // wrapGenerate
270
+ //
271
+ // Wraps model.doGenerate.
272
+ // • Cache HIT → returns a synthetic result; doGenerate is NEVER called.
273
+ // • Cache MISS → calls doGenerate, then fire-and-forgets the save.
274
+ // =========================================================================
275
+ const wrapGenerate = async ({ doGenerate, params, }) => {
276
+ // ── Cache HIT path ────────────────────────────────────────────────
277
+ const cacheHitValue = (params.providerMetadata ?? {})[CACHE_HIT_SENTINEL];
278
+ if (typeof cacheHitValue === "string") {
279
+ log("Short-circuiting LLM call — serving cached response.");
280
+ // Return the fabricated result; the real model is never invoked.
281
+ return buildCachedGenerateResult(cacheHitValue);
282
+ }
283
+ // ── Cache MISS path ───────────────────────────────────────────────
284
+ // Delegate to the real model.
285
+ const result = await doGenerate();
286
+ // Persist asynchronously — we must NOT block the response path.
287
+ const promptText = extractPromptText(params.prompt);
288
+ const promptVector = _promptVectorByText.get(promptText);
289
+ // SDK v4 uses `text` directly; fall back to content array for compatibility.
290
+ const responseText = result.text ??
291
+ (result.content?.find((c) => c.type === "text")?.text ??
292
+ "");
293
+ if (promptVector && responseText) {
294
+ vectorStore
295
+ .save(promptVector, responseText)
296
+ .then(() => {
297
+ log(`Saved embedding — "${promptText.slice(0, 72)}…"`);
298
+ // Remove the ephemeral vector; the cache is now the source of truth.
299
+ _promptVectorByText.delete(promptText);
300
+ })
301
+ .catch((err) => {
302
+ console.error("[SemanticCache] Failed to persist to vector store:", err);
303
+ });
304
+ }
305
+ return result;
306
+ };
307
+ return { transformParams, wrapGenerate };
308
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "next-semantic-cache",
3
+ "version": "1.0.0",
4
+ "description": "**Cut your OpenAI / Anthropic bill. Slash latency to ~50 ms. Compute embeddings for free — locally, on every request.**",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc"
9
+ },
10
+ "files": [
11
+ "dist/index.js",
12
+ "dist/index.d.ts",
13
+ "dist/semantic-cache-middleware.js",
14
+ "dist/semantic-cache-middleware.d.ts"
15
+ ],
16
+ "keywords": [],
17
+ "author": "Prateek Chaturvedi",
18
+ "license": "ISC",
19
+ "type": "commonjs",
20
+ "dependencies": {
21
+ "@ai-sdk/provider": "^4.0.2",
22
+ "@huggingface/transformers": "^4.2.0",
23
+ "@upstash/redis": "^1.38.0",
24
+ "ai": "^7.0.16",
25
+ "uuid": "^14.0.1"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^26.1.0",
29
+ "@types/uuid": "^10.0.0",
30
+ "typescript": "^6.0.3"
31
+ }
32
+ }