@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 +51 -0
- package/README.md +31 -0
- package/dist/index.cjs +140 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +100 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +136 -0
- package/dist/index.js.map +1 -0
- package/package.json +26 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|