@lacspace/llm-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/LICENSE ADDED
@@ -0,0 +1,51 @@
1
+ Lacspace Free Licence
2
+ Version 1.0, August 2026
3
+
4
+ Copyright (c) 2026 Lacspace
5
+
6
+ PREAMBLE
7
+
8
+ This software is published by Lacspace under the Lacspace Free Licence — a free,
9
+ permissive licence that lets you use this software for any purpose, including in
10
+ commercial products and services, at no cost. It grants the same freedoms as
11
+ common permissive open-source licences; the only condition is that this notice
12
+ travels with the software. The canonical, always-current text of this licence is
13
+ maintained at https://lacspace.com/licenses/lacspace-free-1.0
14
+
15
+ GRANT OF RIGHTS
16
+
17
+ Permission is hereby granted, free of charge, to any person or organisation
18
+ obtaining a copy of this software and its associated documentation and data files
19
+ (the "Software"), to deal in the Software without restriction, including without
20
+ limitation the rights to use, copy, modify, merge, publish, distribute,
21
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the
22
+ Software is furnished to do so, subject to the conditions below. These rights are
23
+ granted for any purpose, personal or commercial, and are perpetual, worldwide,
24
+ non-exclusive, and royalty-free.
25
+
26
+ CONDITIONS
27
+
28
+ The above copyright notice, this permission notice, and the name of this licence
29
+ ("Lacspace Free Licence") shall be included in all copies or substantial portions
30
+ of the Software.
31
+
32
+ TRADEMARKS
33
+
34
+ This licence does not grant permission to use the trade names, trademarks, service
35
+ marks, logos, or product names of Lacspace, except as required to reproduce the
36
+ notice above or to describe the origin of the Software in a truthful manner.
37
+
38
+ DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
42
+ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
43
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
44
+ AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
45
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
46
+
47
+ ---
48
+
49
+ The Lacspace Free Licence is a source-available, permissive licence and is not (as
50
+ of this version) an OSI-approved licence. In substance it grants the same freedoms
51
+ as the MIT Licence. Learn more at https://lacspace.com/licenses
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @lacspace/llm-cache
2
+
3
+ **Never pay twice for the same LLM call.** A content-hash cache keyed by `(model family, prompt version, normalized input)`, so identical requests, retries after a 429, and repeated rewrites are served from cache. Pluggable async store (in-memory LRU built in; Mongo/Redis/KV via a tiny interface), TTL, stale-if-error, and a `wrap()` memoizer. Zero dependencies, isomorphic.
4
+
5
+ ```bash
6
+ npm i @lacspace/llm-cache
7
+ ```
8
+
9
+ ```ts
10
+ import { createLlmCache, memoryStore } from "@lacspace/llm-cache";
11
+
12
+ const cache = createLlmCache({ store: memoryStore(), ttlMs: 24 * 3600_000, promptVersion: "v3" });
13
+
14
+ const pack = await cache.wrap(
15
+ { input: condensedSources, model: "gemini", variant: { lang: "ne" } },
16
+ () => callGemini(condensedSources), // only runs on a miss
17
+ { staleIfError: true }, // 429? serve the last good result
18
+ );
19
+
20
+ cache.stats(); // { hits, misses, sets }
21
+ ```
22
+
23
+ - **Stable key** across runtimes via a wide (128-bit) dependency-free content hash; whitespace-normalized input by default.
24
+ - **`wrap(input, fn)`** memoizes an async call: the retry that a rate-limit forces you into costs nothing.
25
+ - **Pluggable store** — implement `{ get, set, delete }` (sync or async) over Mongo/Redis/KV to share the cache across PM2 processes or hosts; `kvStore`-style adapters are trivial.
26
+ - **TTL + stale-if-error** — expire results, but fall back to the last good one when the provider is down.
27
+
28
+ Bump `promptVersion` when your template changes and old results retire automatically. Exports `contentHash`, `memoryStore`, and the `CacheStore` interface.
29
+
30
+ ## Licence
31
+ [Lacspace Free Licence v1.0](https://developer.lacspace.com/licenses/lacspace-free-1.0) — free for personal and commercial use.
package/dist/index.cjs ADDED
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ // src/hash.ts
4
+ var P = 1099511628211n;
5
+ var MASK = (1n << 64n) - 1n;
6
+ function fnv(input, basis) {
7
+ let h = basis;
8
+ for (let i = 0; i < input.length; i++) {
9
+ h ^= BigInt(input.charCodeAt(i));
10
+ h = h * P & MASK;
11
+ }
12
+ return h;
13
+ }
14
+ function hex16(n) {
15
+ return n.toString(16).padStart(16, "0");
16
+ }
17
+ function contentHash(input) {
18
+ return hex16(fnv(input, 14695981039346656037n)) + hex16(fnv(input, 1469598103934665603n));
19
+ }
20
+
21
+ // src/stores.ts
22
+ function memoryStore(options = {}) {
23
+ const max = options.maxEntries ?? 1e3;
24
+ const map = /* @__PURE__ */ new Map();
25
+ const evictExpired = (now) => {
26
+ for (const [k, v] of map) if (v.expiresAt !== null && v.expiresAt <= now) map.delete(k);
27
+ };
28
+ return {
29
+ get(key) {
30
+ const rec = map.get(key);
31
+ if (!rec) return null;
32
+ if (rec.expiresAt !== null && rec.expiresAt <= Date.now()) {
33
+ map.delete(key);
34
+ return null;
35
+ }
36
+ map.delete(key);
37
+ map.set(key, rec);
38
+ return rec;
39
+ },
40
+ set(key, rec) {
41
+ const now = Date.now();
42
+ evictExpired(now);
43
+ map.delete(key);
44
+ map.set(key, rec);
45
+ while (map.size > max) {
46
+ const oldest = map.keys().next().value;
47
+ if (oldest === void 0) break;
48
+ map.delete(oldest);
49
+ }
50
+ },
51
+ delete(key) {
52
+ map.delete(key);
53
+ },
54
+ size() {
55
+ return map.size;
56
+ },
57
+ clear() {
58
+ map.clear();
59
+ }
60
+ };
61
+ }
62
+
63
+ // src/index.ts
64
+ var defaultNormalize = (s) => s.replace(/\s+/g, " ").trim();
65
+ function variantString(v) {
66
+ if (v === void 0) return "";
67
+ if (typeof v === "string") return v;
68
+ return JSON.stringify(v, Object.keys(v).sort());
69
+ }
70
+ function createLlmCache(options = {}) {
71
+ const store = options.store ?? memoryStore();
72
+ const ns = options.namespace ?? "llm";
73
+ const normalize = options.normalize ?? defaultNormalize;
74
+ let hits = 0;
75
+ let misses = 0;
76
+ let sets = 0;
77
+ const key = (input) => {
78
+ const parts = [
79
+ ns,
80
+ input.model ?? "",
81
+ input.promptVersion ?? options.promptVersion ?? "",
82
+ variantString(input.variant),
83
+ normalize(input.input ?? "")
84
+ ].join("\0");
85
+ return `${ns}:${contentHash(parts)}`;
86
+ };
87
+ const get = async (input) => {
88
+ const k = key(input);
89
+ const rec = await store.get(k);
90
+ if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {
91
+ hits++;
92
+ return { hit: true, value: rec.value, key: k, record: rec };
93
+ }
94
+ misses++;
95
+ return { hit: false, value: null, key: k, record: null };
96
+ };
97
+ const setAt = async (k, value, ttlMs, meta) => {
98
+ const now = Date.now();
99
+ const ttl = ttlMs ?? options.ttlMs ?? 0;
100
+ const record = { value, storedAt: now, expiresAt: ttl > 0 ? now + ttl : null, meta };
101
+ await store.set(k, record);
102
+ sets++;
103
+ };
104
+ return {
105
+ key,
106
+ get,
107
+ async set(input, value, opts) {
108
+ await setAt(key(input), value, opts?.ttlMs, opts?.meta);
109
+ },
110
+ async delete(input) {
111
+ await store.delete(key(input));
112
+ },
113
+ async wrap(input, fn, opts) {
114
+ const k = key(input);
115
+ const rec = await store.get(k);
116
+ if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {
117
+ hits++;
118
+ return rec.value;
119
+ }
120
+ misses++;
121
+ try {
122
+ const value = await fn();
123
+ await setAt(k, value, opts?.ttlMs, opts?.meta);
124
+ return value;
125
+ } catch (err) {
126
+ if ((opts?.staleIfError ?? false) && rec) return rec.value;
127
+ throw err;
128
+ }
129
+ },
130
+ stats() {
131
+ return { hits, misses, sets };
132
+ }
133
+ };
134
+ }
135
+
136
+ exports.contentHash = contentHash;
137
+ exports.createLlmCache = createLlmCache;
138
+ exports.memoryStore = memoryStore;
139
+ //# sourceMappingURL=index.cjs.map
140
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hash.ts","../src/stores.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["// A wide, dependency-free content hash. Two independent FNV-1a passes (64-bit\n// each via BigInt, different offset bases) concatenated to 32 hex chars, so the\n// collision space is ~2^128 — ample for a cache key without pulling in crypto.\nconst P = 1099511628211n;\nconst MASK = (1n << 64n) - 1n;\n\nfunction fnv(input: string, basis: bigint): bigint {\n let h = basis;\n for (let i = 0; i < input.length; i++) {\n h ^= BigInt(input.charCodeAt(i));\n h = (h * P) & MASK;\n }\n return h;\n}\n\nfunction hex16(n: bigint): string {\n return n.toString(16).padStart(16, \"0\");\n}\n\n/** Stable 128-bit hex digest of a string. Deterministic across runtimes. */\nexport function contentHash(input: string): string {\n return hex16(fnv(input, 14695981039346656037n)) + hex16(fnv(input, 1469598103934665603n));\n}\n","import type { CacheRecord, CacheStore } from \"./types.js\";\n\n/**\n * In-memory store with TTL and an LRU cap. Good for a single process; for\n * multi-process (PM2) or cross-host sharing, pass a Mongo/Redis-backed store\n * that implements the same {@link CacheStore} interface.\n */\nexport interface MemoryStore extends CacheStore {\n get(key: string): CacheRecord | null;\n set(key: string, record: CacheRecord): void;\n delete(key: string): void;\n size(): number;\n clear(): void;\n}\n\nexport function memoryStore(options: { maxEntries?: number } = {}): MemoryStore {\n const max = options.maxEntries ?? 1000;\n const map = new Map<string, CacheRecord>();\n\n const evictExpired = (now: number) => {\n for (const [k, v] of map) if (v.expiresAt !== null && v.expiresAt <= now) map.delete(k);\n };\n\n return {\n get(key) {\n const rec = map.get(key);\n if (!rec) return null;\n if (rec.expiresAt !== null && rec.expiresAt <= Date.now()) {\n map.delete(key);\n return null;\n }\n // LRU touch\n map.delete(key);\n map.set(key, rec);\n return rec;\n },\n set(key, rec) {\n const now = Date.now();\n evictExpired(now);\n map.delete(key);\n map.set(key, rec);\n while (map.size > max) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) break;\n map.delete(oldest);\n }\n },\n delete(key) {\n map.delete(key);\n },\n size() {\n return map.size;\n },\n clear() {\n map.clear();\n },\n };\n}\n","import { contentHash } from \"./hash.js\";\nimport { memoryStore } from \"./stores.js\";\nimport type { CacheRecord, CacheStore } from \"./types.js\";\n\nexport { contentHash } from \"./hash.js\";\nexport { memoryStore } from \"./stores.js\";\nexport type { MemoryStore } from \"./stores.js\";\nexport type { CacheRecord, CacheStore } from \"./types.js\";\n\n/** The dimensions that make one LLM request identical to another. */\nexport interface CacheKeyInput {\n /** The prompt / input text (or a stable stringification of your messages). */\n input: string;\n /** Model family — different families should not share results (e.g. \"gemini\", \"groq-llama\"). */\n model?: string;\n /** Bump when your prompt template changes so old results are not reused. */\n promptVersion?: string;\n /** Extra key parts (temperature, tools, language…) that change the output. */\n variant?: string | Record<string, unknown>;\n}\n\nexport interface LlmCacheOptions {\n /** Where records live. Default: in-memory LRU (1000 entries). */\n store?: CacheStore;\n /** Default TTL in ms. Omit or 0 for no expiry. */\n ttlMs?: number;\n /** Global prompt version, overridable per call. */\n promptVersion?: string;\n /** Namespace prefix, so multiple caches can share one store. */\n namespace?: string;\n /** Normalize the input before hashing. Default: trim + collapse whitespace. */\n normalize?: (input: string) => string;\n}\n\nexport interface CacheGetResult<T> {\n hit: boolean;\n value: T | null;\n key: string;\n record: CacheRecord<T> | null;\n}\n\nexport interface LlmCache {\n /** Compute the content-hash key for an input (no store access). */\n key(input: CacheKeyInput): string;\n /** Look up a cached value. */\n get<T = unknown>(input: CacheKeyInput): Promise<CacheGetResult<T>>;\n /** Store a value, with an optional per-call TTL and metadata. */\n set<T = unknown>(input: CacheKeyInput, value: T, opts?: { ttlMs?: number; meta?: Record<string, unknown> }): Promise<void>;\n /** Remove one entry. */\n delete(input: CacheKeyInput): Promise<void>;\n /**\n * Memoize an async LLM call: returns the cached value on a hit, otherwise runs\n * `fn`, stores the result, and returns it. On a `fn` error, an unexpired\n * record is returned if present (stale-if-error), else the error rethrows.\n */\n wrap<T>(input: CacheKeyInput, fn: () => Promise<T>, opts?: { ttlMs?: number; meta?: Record<string, unknown>; staleIfError?: boolean }): Promise<T>;\n /** Hit/miss counters since creation. */\n stats(): { hits: number; misses: number; sets: number };\n}\n\nconst defaultNormalize = (s: string): string => s.replace(/\\s+/g, \" \").trim();\n\nfunction variantString(v: CacheKeyInput[\"variant\"]): string {\n if (v === undefined) return \"\";\n if (typeof v === \"string\") return v;\n // Stable stringify: sort keys.\n return JSON.stringify(v, Object.keys(v).sort());\n}\n\nexport function createLlmCache(options: LlmCacheOptions = {}): LlmCache {\n const store = options.store ?? memoryStore();\n const ns = options.namespace ?? \"llm\";\n const normalize = options.normalize ?? defaultNormalize;\n let hits = 0;\n let misses = 0;\n let sets = 0;\n\n const key = (input: CacheKeyInput): string => {\n const parts = [\n ns,\n input.model ?? \"\",\n input.promptVersion ?? options.promptVersion ?? \"\",\n variantString(input.variant),\n normalize(input.input ?? \"\"),\n ].join(\"\\u0000\");\n return `${ns}:${contentHash(parts)}`;\n };\n\n const get = async <T>(input: CacheKeyInput): Promise<CacheGetResult<T>> => {\n const k = key(input);\n const rec = (await store.get(k)) as CacheRecord<T> | null;\n if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {\n hits++;\n return { hit: true, value: rec.value, key: k, record: rec };\n }\n misses++;\n return { hit: false, value: null, key: k, record: null };\n };\n\n const setAt = async <T>(k: string, value: T, ttlMs: number | undefined, meta?: Record<string, unknown>): Promise<void> => {\n const now = Date.now();\n const ttl = ttlMs ?? options.ttlMs ?? 0;\n const record: CacheRecord<T> = { value, storedAt: now, expiresAt: ttl > 0 ? now + ttl : null, meta };\n await store.set(k, record as CacheRecord);\n sets++;\n };\n\n return {\n key,\n get,\n async set(input, value, opts) {\n await setAt(key(input), value, opts?.ttlMs, opts?.meta);\n },\n async delete(input) {\n await store.delete(key(input));\n },\n async wrap(input, fn, opts) {\n const k = key(input);\n const rec = (await store.get(k)) as CacheRecord | null;\n if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {\n hits++;\n return rec.value as Awaited<ReturnType<typeof fn>>;\n }\n misses++;\n try {\n const value = await fn();\n await setAt(k, value, opts?.ttlMs, opts?.meta);\n return value;\n } catch (err) {\n if ((opts?.staleIfError ?? false) && rec) return rec.value as Awaited<ReturnType<typeof fn>>;\n throw err;\n }\n },\n stats() {\n return { hits, misses, sets };\n },\n };\n}\n"]}
@@ -0,0 +1,100 @@
1
+ /** A stored cache record. `value` is whatever the LLM call returned (JSON-serializable). */
2
+ interface CacheRecord<T = unknown> {
3
+ value: T;
4
+ /** Epoch ms when this expires, or null for no expiry. */
5
+ expiresAt: number | null;
6
+ /** Epoch ms when it was written. */
7
+ storedAt: number;
8
+ /** Optional metadata the caller attached (e.g. model, tokens). */
9
+ meta?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * A pluggable cache store. All methods may be sync or async. Implement this over
13
+ * Mongo, Redis, Cloudflare KV, etc. to share the cache across processes/hosts.
14
+ */
15
+ interface CacheStore {
16
+ get(key: string): CacheRecord | null | Promise<CacheRecord | null>;
17
+ set(key: string, record: CacheRecord): void | Promise<void>;
18
+ delete(key: string): void | Promise<void>;
19
+ }
20
+
21
+ /** Stable 128-bit hex digest of a string. Deterministic across runtimes. */
22
+ declare function contentHash(input: string): string;
23
+
24
+ /**
25
+ * In-memory store with TTL and an LRU cap. Good for a single process; for
26
+ * multi-process (PM2) or cross-host sharing, pass a Mongo/Redis-backed store
27
+ * that implements the same {@link CacheStore} interface.
28
+ */
29
+ interface MemoryStore extends CacheStore {
30
+ get(key: string): CacheRecord | null;
31
+ set(key: string, record: CacheRecord): void;
32
+ delete(key: string): void;
33
+ size(): number;
34
+ clear(): void;
35
+ }
36
+ declare function memoryStore(options?: {
37
+ maxEntries?: number;
38
+ }): MemoryStore;
39
+
40
+ /** The dimensions that make one LLM request identical to another. */
41
+ interface CacheKeyInput {
42
+ /** The prompt / input text (or a stable stringification of your messages). */
43
+ input: string;
44
+ /** Model family — different families should not share results (e.g. "gemini", "groq-llama"). */
45
+ model?: string;
46
+ /** Bump when your prompt template changes so old results are not reused. */
47
+ promptVersion?: string;
48
+ /** Extra key parts (temperature, tools, language…) that change the output. */
49
+ variant?: string | Record<string, unknown>;
50
+ }
51
+ interface LlmCacheOptions {
52
+ /** Where records live. Default: in-memory LRU (1000 entries). */
53
+ store?: CacheStore;
54
+ /** Default TTL in ms. Omit or 0 for no expiry. */
55
+ ttlMs?: number;
56
+ /** Global prompt version, overridable per call. */
57
+ promptVersion?: string;
58
+ /** Namespace prefix, so multiple caches can share one store. */
59
+ namespace?: string;
60
+ /** Normalize the input before hashing. Default: trim + collapse whitespace. */
61
+ normalize?: (input: string) => string;
62
+ }
63
+ interface CacheGetResult<T> {
64
+ hit: boolean;
65
+ value: T | null;
66
+ key: string;
67
+ record: CacheRecord<T> | null;
68
+ }
69
+ interface LlmCache {
70
+ /** Compute the content-hash key for an input (no store access). */
71
+ key(input: CacheKeyInput): string;
72
+ /** Look up a cached value. */
73
+ get<T = unknown>(input: CacheKeyInput): Promise<CacheGetResult<T>>;
74
+ /** Store a value, with an optional per-call TTL and metadata. */
75
+ set<T = unknown>(input: CacheKeyInput, value: T, opts?: {
76
+ ttlMs?: number;
77
+ meta?: Record<string, unknown>;
78
+ }): Promise<void>;
79
+ /** Remove one entry. */
80
+ delete(input: CacheKeyInput): Promise<void>;
81
+ /**
82
+ * Memoize an async LLM call: returns the cached value on a hit, otherwise runs
83
+ * `fn`, stores the result, and returns it. On a `fn` error, an unexpired
84
+ * record is returned if present (stale-if-error), else the error rethrows.
85
+ */
86
+ wrap<T>(input: CacheKeyInput, fn: () => Promise<T>, opts?: {
87
+ ttlMs?: number;
88
+ meta?: Record<string, unknown>;
89
+ staleIfError?: boolean;
90
+ }): Promise<T>;
91
+ /** Hit/miss counters since creation. */
92
+ stats(): {
93
+ hits: number;
94
+ misses: number;
95
+ sets: number;
96
+ };
97
+ }
98
+ declare function createLlmCache(options?: LlmCacheOptions): LlmCache;
99
+
100
+ export { type CacheGetResult, type CacheKeyInput, type CacheRecord, type CacheStore, type LlmCache, type LlmCacheOptions, type MemoryStore, contentHash, createLlmCache, memoryStore };
@@ -0,0 +1,100 @@
1
+ /** A stored cache record. `value` is whatever the LLM call returned (JSON-serializable). */
2
+ interface CacheRecord<T = unknown> {
3
+ value: T;
4
+ /** Epoch ms when this expires, or null for no expiry. */
5
+ expiresAt: number | null;
6
+ /** Epoch ms when it was written. */
7
+ storedAt: number;
8
+ /** Optional metadata the caller attached (e.g. model, tokens). */
9
+ meta?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * A pluggable cache store. All methods may be sync or async. Implement this over
13
+ * Mongo, Redis, Cloudflare KV, etc. to share the cache across processes/hosts.
14
+ */
15
+ interface CacheStore {
16
+ get(key: string): CacheRecord | null | Promise<CacheRecord | null>;
17
+ set(key: string, record: CacheRecord): void | Promise<void>;
18
+ delete(key: string): void | Promise<void>;
19
+ }
20
+
21
+ /** Stable 128-bit hex digest of a string. Deterministic across runtimes. */
22
+ declare function contentHash(input: string): string;
23
+
24
+ /**
25
+ * In-memory store with TTL and an LRU cap. Good for a single process; for
26
+ * multi-process (PM2) or cross-host sharing, pass a Mongo/Redis-backed store
27
+ * that implements the same {@link CacheStore} interface.
28
+ */
29
+ interface MemoryStore extends CacheStore {
30
+ get(key: string): CacheRecord | null;
31
+ set(key: string, record: CacheRecord): void;
32
+ delete(key: string): void;
33
+ size(): number;
34
+ clear(): void;
35
+ }
36
+ declare function memoryStore(options?: {
37
+ maxEntries?: number;
38
+ }): MemoryStore;
39
+
40
+ /** The dimensions that make one LLM request identical to another. */
41
+ interface CacheKeyInput {
42
+ /** The prompt / input text (or a stable stringification of your messages). */
43
+ input: string;
44
+ /** Model family — different families should not share results (e.g. "gemini", "groq-llama"). */
45
+ model?: string;
46
+ /** Bump when your prompt template changes so old results are not reused. */
47
+ promptVersion?: string;
48
+ /** Extra key parts (temperature, tools, language…) that change the output. */
49
+ variant?: string | Record<string, unknown>;
50
+ }
51
+ interface LlmCacheOptions {
52
+ /** Where records live. Default: in-memory LRU (1000 entries). */
53
+ store?: CacheStore;
54
+ /** Default TTL in ms. Omit or 0 for no expiry. */
55
+ ttlMs?: number;
56
+ /** Global prompt version, overridable per call. */
57
+ promptVersion?: string;
58
+ /** Namespace prefix, so multiple caches can share one store. */
59
+ namespace?: string;
60
+ /** Normalize the input before hashing. Default: trim + collapse whitespace. */
61
+ normalize?: (input: string) => string;
62
+ }
63
+ interface CacheGetResult<T> {
64
+ hit: boolean;
65
+ value: T | null;
66
+ key: string;
67
+ record: CacheRecord<T> | null;
68
+ }
69
+ interface LlmCache {
70
+ /** Compute the content-hash key for an input (no store access). */
71
+ key(input: CacheKeyInput): string;
72
+ /** Look up a cached value. */
73
+ get<T = unknown>(input: CacheKeyInput): Promise<CacheGetResult<T>>;
74
+ /** Store a value, with an optional per-call TTL and metadata. */
75
+ set<T = unknown>(input: CacheKeyInput, value: T, opts?: {
76
+ ttlMs?: number;
77
+ meta?: Record<string, unknown>;
78
+ }): Promise<void>;
79
+ /** Remove one entry. */
80
+ delete(input: CacheKeyInput): Promise<void>;
81
+ /**
82
+ * Memoize an async LLM call: returns the cached value on a hit, otherwise runs
83
+ * `fn`, stores the result, and returns it. On a `fn` error, an unexpired
84
+ * record is returned if present (stale-if-error), else the error rethrows.
85
+ */
86
+ wrap<T>(input: CacheKeyInput, fn: () => Promise<T>, opts?: {
87
+ ttlMs?: number;
88
+ meta?: Record<string, unknown>;
89
+ staleIfError?: boolean;
90
+ }): Promise<T>;
91
+ /** Hit/miss counters since creation. */
92
+ stats(): {
93
+ hits: number;
94
+ misses: number;
95
+ sets: number;
96
+ };
97
+ }
98
+ declare function createLlmCache(options?: LlmCacheOptions): LlmCache;
99
+
100
+ export { type CacheGetResult, type CacheKeyInput, type CacheRecord, type CacheStore, type LlmCache, type LlmCacheOptions, type MemoryStore, contentHash, createLlmCache, memoryStore };
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ // src/hash.ts
2
+ var P = 1099511628211n;
3
+ var MASK = (1n << 64n) - 1n;
4
+ function fnv(input, basis) {
5
+ let h = basis;
6
+ for (let i = 0; i < input.length; i++) {
7
+ h ^= BigInt(input.charCodeAt(i));
8
+ h = h * P & MASK;
9
+ }
10
+ return h;
11
+ }
12
+ function hex16(n) {
13
+ return n.toString(16).padStart(16, "0");
14
+ }
15
+ function contentHash(input) {
16
+ return hex16(fnv(input, 14695981039346656037n)) + hex16(fnv(input, 1469598103934665603n));
17
+ }
18
+
19
+ // src/stores.ts
20
+ function memoryStore(options = {}) {
21
+ const max = options.maxEntries ?? 1e3;
22
+ const map = /* @__PURE__ */ new Map();
23
+ const evictExpired = (now) => {
24
+ for (const [k, v] of map) if (v.expiresAt !== null && v.expiresAt <= now) map.delete(k);
25
+ };
26
+ return {
27
+ get(key) {
28
+ const rec = map.get(key);
29
+ if (!rec) return null;
30
+ if (rec.expiresAt !== null && rec.expiresAt <= Date.now()) {
31
+ map.delete(key);
32
+ return null;
33
+ }
34
+ map.delete(key);
35
+ map.set(key, rec);
36
+ return rec;
37
+ },
38
+ set(key, rec) {
39
+ const now = Date.now();
40
+ evictExpired(now);
41
+ map.delete(key);
42
+ map.set(key, rec);
43
+ while (map.size > max) {
44
+ const oldest = map.keys().next().value;
45
+ if (oldest === void 0) break;
46
+ map.delete(oldest);
47
+ }
48
+ },
49
+ delete(key) {
50
+ map.delete(key);
51
+ },
52
+ size() {
53
+ return map.size;
54
+ },
55
+ clear() {
56
+ map.clear();
57
+ }
58
+ };
59
+ }
60
+
61
+ // src/index.ts
62
+ var defaultNormalize = (s) => s.replace(/\s+/g, " ").trim();
63
+ function variantString(v) {
64
+ if (v === void 0) return "";
65
+ if (typeof v === "string") return v;
66
+ return JSON.stringify(v, Object.keys(v).sort());
67
+ }
68
+ function createLlmCache(options = {}) {
69
+ const store = options.store ?? memoryStore();
70
+ const ns = options.namespace ?? "llm";
71
+ const normalize = options.normalize ?? defaultNormalize;
72
+ let hits = 0;
73
+ let misses = 0;
74
+ let sets = 0;
75
+ const key = (input) => {
76
+ const parts = [
77
+ ns,
78
+ input.model ?? "",
79
+ input.promptVersion ?? options.promptVersion ?? "",
80
+ variantString(input.variant),
81
+ normalize(input.input ?? "")
82
+ ].join("\0");
83
+ return `${ns}:${contentHash(parts)}`;
84
+ };
85
+ const get = async (input) => {
86
+ const k = key(input);
87
+ const rec = await store.get(k);
88
+ if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {
89
+ hits++;
90
+ return { hit: true, value: rec.value, key: k, record: rec };
91
+ }
92
+ misses++;
93
+ return { hit: false, value: null, key: k, record: null };
94
+ };
95
+ const setAt = async (k, value, ttlMs, meta) => {
96
+ const now = Date.now();
97
+ const ttl = ttlMs ?? options.ttlMs ?? 0;
98
+ const record = { value, storedAt: now, expiresAt: ttl > 0 ? now + ttl : null, meta };
99
+ await store.set(k, record);
100
+ sets++;
101
+ };
102
+ return {
103
+ key,
104
+ get,
105
+ async set(input, value, opts) {
106
+ await setAt(key(input), value, opts?.ttlMs, opts?.meta);
107
+ },
108
+ async delete(input) {
109
+ await store.delete(key(input));
110
+ },
111
+ async wrap(input, fn, opts) {
112
+ const k = key(input);
113
+ const rec = await store.get(k);
114
+ if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {
115
+ hits++;
116
+ return rec.value;
117
+ }
118
+ misses++;
119
+ try {
120
+ const value = await fn();
121
+ await setAt(k, value, opts?.ttlMs, opts?.meta);
122
+ return value;
123
+ } catch (err) {
124
+ if ((opts?.staleIfError ?? false) && rec) return rec.value;
125
+ throw err;
126
+ }
127
+ },
128
+ stats() {
129
+ return { hits, misses, sets };
130
+ }
131
+ };
132
+ }
133
+
134
+ export { contentHash, createLlmCache, memoryStore };
135
+ //# sourceMappingURL=index.js.map
136
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hash.ts","../src/stores.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// A wide, dependency-free content hash. Two independent FNV-1a passes (64-bit\n// each via BigInt, different offset bases) concatenated to 32 hex chars, so the\n// collision space is ~2^128 — ample for a cache key without pulling in crypto.\nconst P = 1099511628211n;\nconst MASK = (1n << 64n) - 1n;\n\nfunction fnv(input: string, basis: bigint): bigint {\n let h = basis;\n for (let i = 0; i < input.length; i++) {\n h ^= BigInt(input.charCodeAt(i));\n h = (h * P) & MASK;\n }\n return h;\n}\n\nfunction hex16(n: bigint): string {\n return n.toString(16).padStart(16, \"0\");\n}\n\n/** Stable 128-bit hex digest of a string. Deterministic across runtimes. */\nexport function contentHash(input: string): string {\n return hex16(fnv(input, 14695981039346656037n)) + hex16(fnv(input, 1469598103934665603n));\n}\n","import type { CacheRecord, CacheStore } from \"./types.js\";\n\n/**\n * In-memory store with TTL and an LRU cap. Good for a single process; for\n * multi-process (PM2) or cross-host sharing, pass a Mongo/Redis-backed store\n * that implements the same {@link CacheStore} interface.\n */\nexport interface MemoryStore extends CacheStore {\n get(key: string): CacheRecord | null;\n set(key: string, record: CacheRecord): void;\n delete(key: string): void;\n size(): number;\n clear(): void;\n}\n\nexport function memoryStore(options: { maxEntries?: number } = {}): MemoryStore {\n const max = options.maxEntries ?? 1000;\n const map = new Map<string, CacheRecord>();\n\n const evictExpired = (now: number) => {\n for (const [k, v] of map) if (v.expiresAt !== null && v.expiresAt <= now) map.delete(k);\n };\n\n return {\n get(key) {\n const rec = map.get(key);\n if (!rec) return null;\n if (rec.expiresAt !== null && rec.expiresAt <= Date.now()) {\n map.delete(key);\n return null;\n }\n // LRU touch\n map.delete(key);\n map.set(key, rec);\n return rec;\n },\n set(key, rec) {\n const now = Date.now();\n evictExpired(now);\n map.delete(key);\n map.set(key, rec);\n while (map.size > max) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) break;\n map.delete(oldest);\n }\n },\n delete(key) {\n map.delete(key);\n },\n size() {\n return map.size;\n },\n clear() {\n map.clear();\n },\n };\n}\n","import { contentHash } from \"./hash.js\";\nimport { memoryStore } from \"./stores.js\";\nimport type { CacheRecord, CacheStore } from \"./types.js\";\n\nexport { contentHash } from \"./hash.js\";\nexport { memoryStore } from \"./stores.js\";\nexport type { MemoryStore } from \"./stores.js\";\nexport type { CacheRecord, CacheStore } from \"./types.js\";\n\n/** The dimensions that make one LLM request identical to another. */\nexport interface CacheKeyInput {\n /** The prompt / input text (or a stable stringification of your messages). */\n input: string;\n /** Model family — different families should not share results (e.g. \"gemini\", \"groq-llama\"). */\n model?: string;\n /** Bump when your prompt template changes so old results are not reused. */\n promptVersion?: string;\n /** Extra key parts (temperature, tools, language…) that change the output. */\n variant?: string | Record<string, unknown>;\n}\n\nexport interface LlmCacheOptions {\n /** Where records live. Default: in-memory LRU (1000 entries). */\n store?: CacheStore;\n /** Default TTL in ms. Omit or 0 for no expiry. */\n ttlMs?: number;\n /** Global prompt version, overridable per call. */\n promptVersion?: string;\n /** Namespace prefix, so multiple caches can share one store. */\n namespace?: string;\n /** Normalize the input before hashing. Default: trim + collapse whitespace. */\n normalize?: (input: string) => string;\n}\n\nexport interface CacheGetResult<T> {\n hit: boolean;\n value: T | null;\n key: string;\n record: CacheRecord<T> | null;\n}\n\nexport interface LlmCache {\n /** Compute the content-hash key for an input (no store access). */\n key(input: CacheKeyInput): string;\n /** Look up a cached value. */\n get<T = unknown>(input: CacheKeyInput): Promise<CacheGetResult<T>>;\n /** Store a value, with an optional per-call TTL and metadata. */\n set<T = unknown>(input: CacheKeyInput, value: T, opts?: { ttlMs?: number; meta?: Record<string, unknown> }): Promise<void>;\n /** Remove one entry. */\n delete(input: CacheKeyInput): Promise<void>;\n /**\n * Memoize an async LLM call: returns the cached value on a hit, otherwise runs\n * `fn`, stores the result, and returns it. On a `fn` error, an unexpired\n * record is returned if present (stale-if-error), else the error rethrows.\n */\n wrap<T>(input: CacheKeyInput, fn: () => Promise<T>, opts?: { ttlMs?: number; meta?: Record<string, unknown>; staleIfError?: boolean }): Promise<T>;\n /** Hit/miss counters since creation. */\n stats(): { hits: number; misses: number; sets: number };\n}\n\nconst defaultNormalize = (s: string): string => s.replace(/\\s+/g, \" \").trim();\n\nfunction variantString(v: CacheKeyInput[\"variant\"]): string {\n if (v === undefined) return \"\";\n if (typeof v === \"string\") return v;\n // Stable stringify: sort keys.\n return JSON.stringify(v, Object.keys(v).sort());\n}\n\nexport function createLlmCache(options: LlmCacheOptions = {}): LlmCache {\n const store = options.store ?? memoryStore();\n const ns = options.namespace ?? \"llm\";\n const normalize = options.normalize ?? defaultNormalize;\n let hits = 0;\n let misses = 0;\n let sets = 0;\n\n const key = (input: CacheKeyInput): string => {\n const parts = [\n ns,\n input.model ?? \"\",\n input.promptVersion ?? options.promptVersion ?? \"\",\n variantString(input.variant),\n normalize(input.input ?? \"\"),\n ].join(\"\\u0000\");\n return `${ns}:${contentHash(parts)}`;\n };\n\n const get = async <T>(input: CacheKeyInput): Promise<CacheGetResult<T>> => {\n const k = key(input);\n const rec = (await store.get(k)) as CacheRecord<T> | null;\n if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {\n hits++;\n return { hit: true, value: rec.value, key: k, record: rec };\n }\n misses++;\n return { hit: false, value: null, key: k, record: null };\n };\n\n const setAt = async <T>(k: string, value: T, ttlMs: number | undefined, meta?: Record<string, unknown>): Promise<void> => {\n const now = Date.now();\n const ttl = ttlMs ?? options.ttlMs ?? 0;\n const record: CacheRecord<T> = { value, storedAt: now, expiresAt: ttl > 0 ? now + ttl : null, meta };\n await store.set(k, record as CacheRecord);\n sets++;\n };\n\n return {\n key,\n get,\n async set(input, value, opts) {\n await setAt(key(input), value, opts?.ttlMs, opts?.meta);\n },\n async delete(input) {\n await store.delete(key(input));\n },\n async wrap(input, fn, opts) {\n const k = key(input);\n const rec = (await store.get(k)) as CacheRecord | null;\n if (rec && (rec.expiresAt === null || rec.expiresAt > Date.now())) {\n hits++;\n return rec.value as Awaited<ReturnType<typeof fn>>;\n }\n misses++;\n try {\n const value = await fn();\n await setAt(k, value, opts?.ttlMs, opts?.meta);\n return value;\n } catch (err) {\n if ((opts?.staleIfError ?? false) && rec) return rec.value as Awaited<ReturnType<typeof fn>>;\n throw err;\n }\n },\n stats() {\n return { hits, misses, sets };\n },\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@lacspace/llm-cache",
3
+ "version": "1.0.0",
4
+ "description": "A content-hash cache for LLM calls — key by (model family, prompt version, normalized input) so identical requests, retries after a 429, and repeated rewrites never pay twice. Pluggable async store (in-memory LRU built in, Mongo/Redis/KV via a tiny adapter interface), TTL, stale-if-error, and a wrap() memoizer. Zero-dependency, isomorphic, typed.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
12
+ "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
13
+ }
14
+ },
15
+ "files": ["dist"],
16
+ "sideEffects": false,
17
+ "scripts": { "build": "tsup", "prepublishOnly": "npm run build" },
18
+ "keywords": ["llm-cache", "cache", "memoize", "content-hash", "prompt-cache", "ttl", "lru", "openai", "gemini", "groq", "rate-limit", "429", "zero-dependency", "isomorphic", "typescript"],
19
+ "author": "Lacspace <contact@lacspace.com>",
20
+ "license": "SEE LICENSE IN LICENSE",
21
+ "homepage": "https://developer.lacspace.com/packages/llm-cache",
22
+ "repository": { "type": "git", "url": "git+https://github.com/lacspace/npm-packages.git", "directory": "llm-cache" },
23
+ "bugs": { "url": "https://github.com/lacspace/npm-packages/issues" },
24
+ "engines": { "node": ">=18" },
25
+ "publishConfig": { "access": "public" }
26
+ }