@betterdb/semantic-cache 0.1.0 → 0.2.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.
Files changed (41) hide show
  1. package/README.md +211 -128
  2. package/dist/SemanticCache.d.ts +85 -5
  3. package/dist/SemanticCache.js +689 -47
  4. package/dist/adapters/ai.js +6 -1
  5. package/dist/adapters/anthropic.d.ts +32 -0
  6. package/dist/adapters/anthropic.js +94 -0
  7. package/dist/adapters/langchain.js +6 -1
  8. package/dist/adapters/langgraph.d.ts +104 -0
  9. package/dist/adapters/langgraph.js +271 -0
  10. package/dist/adapters/llamaindex.d.ts +32 -0
  11. package/dist/adapters/llamaindex.js +76 -0
  12. package/dist/adapters/openai-responses.d.ts +31 -0
  13. package/dist/adapters/openai-responses.js +112 -0
  14. package/dist/adapters/openai.d.ts +42 -0
  15. package/dist/adapters/openai.js +97 -0
  16. package/dist/analytics.d.ts +24 -0
  17. package/dist/analytics.js +116 -0
  18. package/dist/cluster.d.ts +10 -0
  19. package/dist/cluster.js +43 -0
  20. package/dist/defaultCostTable.d.ts +11 -0
  21. package/dist/defaultCostTable.js +1976 -0
  22. package/dist/embed/bedrock.d.ts +32 -0
  23. package/dist/embed/bedrock.js +109 -0
  24. package/dist/embed/cohere.d.ts +34 -0
  25. package/dist/embed/cohere.js +37 -0
  26. package/dist/embed/ollama.d.ts +30 -0
  27. package/dist/embed/ollama.js +24 -0
  28. package/dist/embed/openai.d.ts +31 -0
  29. package/dist/embed/openai.js +66 -0
  30. package/dist/embed/voyage.d.ts +31 -0
  31. package/dist/embed/voyage.js +32 -0
  32. package/dist/index.d.ts +6 -1
  33. package/dist/index.js +11 -1
  34. package/dist/normalizer.d.ts +68 -0
  35. package/dist/normalizer.js +102 -0
  36. package/dist/telemetry.d.ts +3 -0
  37. package/dist/telemetry.js +18 -0
  38. package/dist/types.d.ts +107 -7
  39. package/dist/utils.d.ts +58 -0
  40. package/dist/utils.js +30 -0
  41. package/package.json +81 -6
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prepareSemanticParams = prepareSemanticParams;
4
+ const normalizer_1 = require("../normalizer");
5
+ async function normalizeResponsesPart(part, normalizer) {
6
+ const t = part.type;
7
+ if (t === 'input_text' || t === 'output_text') {
8
+ return { type: 'text', text: part.text ?? '' };
9
+ }
10
+ if (t === 'input_image') {
11
+ const fileId = part.file_id;
12
+ const imageUrl = part.image_url;
13
+ const detail = part.detail;
14
+ let source;
15
+ let mediaType = 'image/*';
16
+ if (fileId) {
17
+ source = { type: 'fileId', fileId, provider: 'openai' };
18
+ }
19
+ else if (imageUrl) {
20
+ if (imageUrl.startsWith('data:')) {
21
+ const semi = imageUrl.indexOf(';');
22
+ if (semi > 5)
23
+ mediaType = imageUrl.slice(5, semi);
24
+ source = { type: 'base64', data: imageUrl };
25
+ }
26
+ else {
27
+ source = { type: 'url', url: imageUrl };
28
+ }
29
+ }
30
+ else {
31
+ return null;
32
+ }
33
+ const ref = await normalizer({ kind: 'image', source });
34
+ const block = { type: 'binary', kind: 'image', mediaType, ref };
35
+ if (detail)
36
+ block.detail = detail;
37
+ return block;
38
+ }
39
+ if (t === 'input_file') {
40
+ const fileId = part.file_id;
41
+ const fileData = part.file_data;
42
+ const fileUrl = part.file_url;
43
+ const filename = part.filename;
44
+ let source;
45
+ let mediaType = 'application/octet-stream';
46
+ if (fileId) {
47
+ source = { type: 'fileId', fileId, provider: 'openai' };
48
+ }
49
+ else if (fileData) {
50
+ if (fileData.startsWith('data:')) {
51
+ const semi = fileData.indexOf(';');
52
+ if (semi > 5)
53
+ mediaType = fileData.slice(5, semi);
54
+ }
55
+ source = { type: 'base64', data: fileData };
56
+ }
57
+ else if (fileUrl) {
58
+ source = { type: 'url', url: fileUrl };
59
+ }
60
+ else {
61
+ return null;
62
+ }
63
+ const ref = await normalizer({ kind: 'document', source });
64
+ const block = { type: 'binary', kind: 'document', mediaType, ref };
65
+ if (filename)
66
+ block.filename = filename;
67
+ return block;
68
+ }
69
+ return null;
70
+ }
71
+ /**
72
+ * Extract semantic cache params from OpenAI Responses API request params.
73
+ *
74
+ * Extracts the last user input text (or the instructions if no user input exists)
75
+ * for semantic similarity matching.
76
+ */
77
+ async function prepareSemanticParams(params, opts) {
78
+ const normalizer = opts?.normalizer ?? normalizer_1.defaultNormalizer;
79
+ const p = params;
80
+ if (typeof p.input === 'string') {
81
+ return { text: p.input, model: p.model };
82
+ }
83
+ if (Array.isArray(p.input)) {
84
+ // Find last user/message input item
85
+ const userItems = p.input.filter((item) => item.role === 'user');
86
+ const lastUser = userItems[userItems.length - 1];
87
+ if (lastUser) {
88
+ const content = lastUser.content;
89
+ if (typeof content === 'string') {
90
+ return { text: content, model: p.model };
91
+ }
92
+ if (Array.isArray(content)) {
93
+ const blocks = [];
94
+ for (const part of content) {
95
+ const block = await normalizeResponsesPart(part, normalizer);
96
+ if (block)
97
+ blocks.push(block);
98
+ }
99
+ const text = blocks
100
+ .filter((b) => b.type === 'text')
101
+ .map((b) => b.text)
102
+ .join(' ');
103
+ return { text, blocks, model: p.model };
104
+ }
105
+ }
106
+ }
107
+ // Fall back to instructions
108
+ if (p.instructions) {
109
+ return { text: p.instructions, model: p.model };
110
+ }
111
+ return { text: '', model: p.model };
112
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * OpenAI Chat Completions adapter for @betterdb/semantic-cache.
3
+ *
4
+ * Extracts the text to embed from OpenAI Chat Completions request params.
5
+ * Semantic caching keys on the last user message's text content because that
6
+ * is the actual query that changes between requests. Caching on the full
7
+ * conversation history would produce almost no hits in practice since each
8
+ * conversation turn is unique. Use the filter option on check() to scope
9
+ * results to a specific model or conversation context if needed.
10
+ *
11
+ * Usage:
12
+ * import { prepareSemanticParams } from '@betterdb/semantic-cache/openai';
13
+ * const { text, model } = await prepareSemanticParams(params);
14
+ * const result = await cache.check(text, { filter: `@model:{${model}}` });
15
+ */
16
+ import type { ChatCompletionCreateParams } from 'openai/resources/chat/completions';
17
+ import type { BinaryBlock, TextBlock } from '../utils';
18
+ import type { BinaryNormalizer } from '../normalizer';
19
+ export interface OpenAISemanticPrepareOptions {
20
+ /** Binary content normalizer. Default: passthrough. */
21
+ normalizer?: BinaryNormalizer;
22
+ }
23
+ export interface SemanticParams {
24
+ /**
25
+ * The extracted text to embed. Pass to cache.check(text) or cache.store(text, response).
26
+ */
27
+ text: string;
28
+ /**
29
+ * Content blocks extracted from the last user message.
30
+ * Present when the message contains multi-part content (text + images/files).
31
+ * Pass to cache.check(blocks) for binary-aware cache lookups.
32
+ */
33
+ blocks?: (TextBlock | BinaryBlock)[];
34
+ /** Model name from the request params. Use as a TAG filter if desired. */
35
+ model?: string;
36
+ }
37
+ /**
38
+ * Extract semantic cache params from OpenAI Chat Completions request params.
39
+ *
40
+ * Extracts the last user message for semantic similarity matching.
41
+ */
42
+ export declare function prepareSemanticParams(params: ChatCompletionCreateParams, opts?: OpenAISemanticPrepareOptions): Promise<SemanticParams>;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prepareSemanticParams = prepareSemanticParams;
4
+ const normalizer_1 = require("../normalizer");
5
+ async function normalizeContentPart(part, normalizer) {
6
+ if (part.type === 'text') {
7
+ return { type: 'text', text: part.text };
8
+ }
9
+ if (part.type === 'image_url') {
10
+ const url = part.image_url.url;
11
+ let source;
12
+ let mediaType = 'image/*';
13
+ if (url.startsWith('data:')) {
14
+ const semi = url.indexOf(';');
15
+ if (semi > 5)
16
+ mediaType = url.slice(5, semi);
17
+ source = { type: 'base64', data: url };
18
+ }
19
+ else {
20
+ source = { type: 'url', url };
21
+ }
22
+ const ref = await normalizer({ kind: 'image', source });
23
+ const block = { type: 'binary', kind: 'image', mediaType, ref };
24
+ if (part.image_url.detail)
25
+ block.detail = part.image_url.detail;
26
+ return block;
27
+ }
28
+ if (part.type === 'input_audio') {
29
+ const ref = await normalizer({
30
+ kind: 'audio',
31
+ source: { type: 'base64', data: part.input_audio.data },
32
+ });
33
+ return {
34
+ type: 'binary',
35
+ kind: 'audio',
36
+ mediaType: `audio/${part.input_audio.format}`,
37
+ ref,
38
+ };
39
+ }
40
+ if (part.type === 'file') {
41
+ const { file_id, file_data, filename } = part.file;
42
+ let source;
43
+ let mediaType = 'application/octet-stream';
44
+ if (file_id) {
45
+ source = { type: 'fileId', fileId: file_id, provider: 'openai' };
46
+ }
47
+ else if (file_data) {
48
+ if (file_data.startsWith('data:')) {
49
+ const semi = file_data.indexOf(';');
50
+ if (semi > 5)
51
+ mediaType = file_data.slice(5, semi);
52
+ }
53
+ source = { type: 'base64', data: file_data };
54
+ }
55
+ else {
56
+ return null;
57
+ }
58
+ const ref = await normalizer({ kind: 'document', source });
59
+ const block = { type: 'binary', kind: 'document', mediaType, ref };
60
+ if (filename)
61
+ block.filename = filename;
62
+ return block;
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * Extract semantic cache params from OpenAI Chat Completions request params.
68
+ *
69
+ * Extracts the last user message for semantic similarity matching.
70
+ */
71
+ async function prepareSemanticParams(params, opts) {
72
+ const normalizer = opts?.normalizer ?? normalizer_1.defaultNormalizer;
73
+ // Find last user message
74
+ const userMessages = params.messages.filter((m) => m.role === 'user');
75
+ if (userMessages.length === 0) {
76
+ return { text: '', model: params.model };
77
+ }
78
+ const lastUser = userMessages[userMessages.length - 1];
79
+ const content = lastUser.content;
80
+ if (typeof content === 'string') {
81
+ return { text: content, model: params.model };
82
+ }
83
+ if (Array.isArray(content)) {
84
+ const blocks = [];
85
+ for (const part of content) {
86
+ const block = await normalizeContentPart(part, normalizer);
87
+ if (block)
88
+ blocks.push(block);
89
+ }
90
+ const text = blocks
91
+ .filter((b) => b.type === 'text')
92
+ .map((b) => b.text)
93
+ .join(' ');
94
+ return { text, blocks, model: params.model };
95
+ }
96
+ return { text: '', model: params.model };
97
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Product analytics module for semantic-cache.
3
+ *
4
+ * Uses posthog-node as an optional peer dependency with a noop fallback.
5
+ * Instance identity is a UUID persisted in Valkey.
6
+ */
7
+ export interface ValkeyLike {
8
+ get(key: string): Promise<string | null>;
9
+ set(key: string, value: string): Promise<unknown>;
10
+ }
11
+ export interface Analytics {
12
+ init(client: ValkeyLike, name: string, configProps?: Record<string, unknown>): Promise<void>;
13
+ capture(event: string, properties?: Record<string, unknown>): void;
14
+ shutdown(): Promise<void>;
15
+ }
16
+ export interface AnalyticsOptions {
17
+ apiKey?: string;
18
+ host?: string;
19
+ disabled?: boolean;
20
+ /** Interval in ms for periodic stats snapshots. Default: 300_000 (5 min). 0 to disable. */
21
+ statsIntervalMs?: number;
22
+ }
23
+ export declare const NOOP_ANALYTICS: Analytics;
24
+ export declare function createAnalytics(opts?: AnalyticsOptions): Promise<Analytics>;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ /**
3
+ * Product analytics module for semantic-cache.
4
+ *
5
+ * Uses posthog-node as an optional peer dependency with a noop fallback.
6
+ * Instance identity is a UUID persisted in Valkey.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.NOOP_ANALYTICS = void 0;
43
+ exports.createAnalytics = createAnalytics;
44
+ const EVENT_PREFIX = 'semantic_cache:';
45
+ // Build-time placeholders — replaced by scripts/inject-telemetry-defaults.mjs
46
+ // When the placeholder is NOT replaced, the startsWith('__') guard treats it as unset.
47
+ const BAKED_POSTHOG_API_KEY = '__BETTERDB_POSTHOG_API_KEY__';
48
+ const BAKED_POSTHOG_HOST = '__BETTERDB_POSTHOG_HOST__';
49
+ exports.NOOP_ANALYTICS = {
50
+ async init() { },
51
+ capture() { },
52
+ async shutdown() { },
53
+ };
54
+ function isTelemetryOptedOut() {
55
+ const val = process.env.BETTERDB_TELEMETRY;
56
+ return val !== undefined && ['false', '0', 'no', 'off'].includes(val.toLowerCase());
57
+ }
58
+ class PostHogAnalytics {
59
+ posthog;
60
+ distinctId = '';
61
+ constructor(posthog) {
62
+ this.posthog = posthog;
63
+ }
64
+ async init(client, name, configProps) {
65
+ const idKey = `${name}:__instance_id`;
66
+ let id = await client.get(idKey);
67
+ if (!id) {
68
+ id = crypto.randomUUID();
69
+ await client.set(idKey, id);
70
+ }
71
+ this.distinctId = id;
72
+ this.capture('cache_init', configProps);
73
+ }
74
+ capture(event, properties) {
75
+ try {
76
+ this.posthog.capture({
77
+ distinctId: this.distinctId,
78
+ event: `${EVENT_PREFIX}${event}`,
79
+ properties,
80
+ });
81
+ }
82
+ catch {
83
+ // never throw from analytics
84
+ }
85
+ }
86
+ async shutdown() {
87
+ try {
88
+ await this.posthog.shutdown();
89
+ }
90
+ catch {
91
+ // swallow
92
+ }
93
+ }
94
+ }
95
+ async function createAnalytics(opts) {
96
+ if (opts?.disabled || isTelemetryOptedOut()) {
97
+ return exports.NOOP_ANALYTICS;
98
+ }
99
+ // Key resolution: opts.apiKey → BETTERDB_POSTHOG_API_KEY env var → baked wheel value
100
+ const bakedKey = BAKED_POSTHOG_API_KEY.startsWith('__') ? undefined : BAKED_POSTHOG_API_KEY;
101
+ const apiKey = opts?.apiKey ?? process.env.BETTERDB_POSTHOG_API_KEY ?? bakedKey;
102
+ if (!apiKey) {
103
+ return exports.NOOP_ANALYTICS;
104
+ }
105
+ const bakedHost = BAKED_POSTHOG_HOST.startsWith('__') ? undefined : BAKED_POSTHOG_HOST;
106
+ const host = opts?.host ?? process.env.BETTERDB_POSTHOG_HOST ?? bakedHost;
107
+ try {
108
+ // @ts-ignore — posthog-node is an optional peer dep
109
+ const { PostHog } = await Promise.resolve().then(() => __importStar(require('posthog-node')));
110
+ const posthog = new PostHog(apiKey, { host, flushAt: 20, flushInterval: 10_000 });
111
+ return new PostHogAnalytics(posthog);
112
+ }
113
+ catch {
114
+ return exports.NOOP_ANALYTICS;
115
+ }
116
+ }
@@ -0,0 +1,10 @@
1
+ import type Valkey from 'iovalkey';
2
+ /**
3
+ * Perform a SCAN across all master nodes if the client is a Cluster instance,
4
+ * or on the single client if standalone. Calls `onKeys` with each batch of
5
+ * matched keys. The caller handles what to do with them (GET, DEL, etc.).
6
+ *
7
+ * This is the single place in the codebase that handles the cluster vs
8
+ * standalone SCAN divergence.
9
+ */
10
+ export declare function clusterScan(client: Valkey, pattern: string, onKeys: (keys: string[], nodeClient: Valkey) => Promise<void>, count?: number): Promise<void>;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clusterScan = clusterScan;
4
+ const iovalkey_1 = require("iovalkey");
5
+ const errors_1 = require("./errors");
6
+ function getMasterNodes(client) {
7
+ if (!(client instanceof iovalkey_1.Cluster))
8
+ return [client];
9
+ // Cast needed: TypeScript can't narrow Valkey & Cluster because both classes
10
+ // share a private `reconnectTimeout` field, collapsing the intersection to never.
11
+ return client.nodes('master');
12
+ }
13
+ /**
14
+ * Perform a SCAN across all master nodes if the client is a Cluster instance,
15
+ * or on the single client if standalone. Calls `onKeys` with each batch of
16
+ * matched keys. The caller handles what to do with them (GET, DEL, etc.).
17
+ *
18
+ * This is the single place in the codebase that handles the cluster vs
19
+ * standalone SCAN divergence.
20
+ */
21
+ async function clusterScan(client, pattern, onKeys, count = 100) {
22
+ const nodes = getMasterNodes(client);
23
+ if (nodes.length === 0) {
24
+ throw new errors_1.ValkeyCommandError('SCAN', new Error('cluster has no master nodes visible'));
25
+ }
26
+ for (const nodeClient of nodes) {
27
+ let cursor = '0';
28
+ do {
29
+ let scanResult;
30
+ try {
31
+ scanResult = await nodeClient.scan(cursor, 'MATCH', pattern, 'COUNT', count);
32
+ }
33
+ catch (err) {
34
+ throw new errors_1.ValkeyCommandError('SCAN', err);
35
+ }
36
+ cursor = scanResult[0];
37
+ const keys = scanResult[1];
38
+ if (keys.length > 0) {
39
+ await onKeys(keys, nodeClient);
40
+ }
41
+ } while (cursor !== '0');
42
+ }
43
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * AUTO-GENERATED. Do not edit by hand.
3
+ * Source: https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json
4
+ * Commit: 26fcbc9
5
+ * Fetched: 2026-04-21T17:10:19.295Z
6
+ * Entries: 1971
7
+ *
8
+ * Regenerate: pnpm --filter @betterdb/semantic-cache update:pricing
9
+ */
10
+ import type { ModelCost } from './types';
11
+ export declare const DEFAULT_COST_TABLE: Record<string, ModelCost>;