adaptive-memory-multi-model-router 1.9.5 → 2.0.1

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 (122) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +37 -0
  4. package/.github/PULL_REQUEST_TEMPLATE.md +44 -0
  5. package/.github/workflows/npm-stats-validation.yml +152 -0
  6. package/.github/workflows/pages.yml +37 -0
  7. package/CHANGELOG.md +122 -0
  8. package/CODE_OF_CONDUCT.md +128 -0
  9. package/CONTRIBUTING.md +110 -0
  10. package/LAUNCH-PAIN-DRIVEN.md +339 -0
  11. package/LAUNCH.md +575 -0
  12. package/POPULARITY_BOOSTERS.md +285 -0
  13. package/README.md +231 -300
  14. package/SECURITY.md +69 -0
  15. package/articles/CONTENT_STRUCTURE.md +292 -0
  16. package/articles/DEVTO_COST_GUIDE.md +473 -0
  17. package/articles/DEVTO_FINAL.md +416 -0
  18. package/articles/DEVTO_MULTI_PROVIDER.md +542 -0
  19. package/articles/DEVTO_V2_ANNOUNCEMENT.md +160 -0
  20. package/articles/HN_10X_BETTER.md +430 -0
  21. package/articles/HN_CHINESE_STYLE.md +308 -0
  22. package/articles/HN_FINAL.md +199 -0
  23. package/articles/HN_POSTED_VERSION.md +56 -0
  24. package/articles/HN_RESEARCH.md +364 -0
  25. package/articles/PAIN-DRIVEN-devto-v2.md +308 -0
  26. package/articles/PAIN-DRIVEN-devto-v3.md +268 -0
  27. package/articles/PAIN-DRIVEN-devto.md +242 -0
  28. package/articles/PAIN-DRIVEN-hackernews-v2.md +138 -0
  29. package/articles/PAIN-DRIVEN-hackernews-v3.md +151 -0
  30. package/articles/PAIN-DRIVEN-hackernews.md +131 -0
  31. package/articles/PAIN-DRIVEN-reddit-v2.md +301 -0
  32. package/articles/PAIN-DRIVEN-reddit-v3.md +236 -0
  33. package/articles/PAIN-DRIVEN-reddit.md +218 -0
  34. package/articles/PAIN-DRIVEN-twitter-v2.md +110 -0
  35. package/articles/PAIN-DRIVEN-twitter-v3.md +121 -0
  36. package/articles/PAIN-DRIVEN-twitter.md +120 -0
  37. package/articles/PORTKEY_VS_A3M.md +147 -0
  38. package/articles/REDDIT_FINAL.md +232 -0
  39. package/articles/TWITTER_FINAL.md +167 -0
  40. package/articles/WHY_10X_BETTER.md +261 -0
  41. package/articles/WHY_CHINESE_STYLE_BETTER.md +323 -0
  42. package/articles/hashnode-llm-cost-optimization.md +125 -0
  43. package/articles/medium-building-llm-router.md +205 -0
  44. package/articles/twitter-thread-cost-savings.md +98 -0
  45. package/articles/youtube-tutorial-script.md +262 -0
  46. package/assets/banner.svg +109 -0
  47. package/assets/logo.svg +68 -0
  48. package/assets/social-preview.svg +64 -0
  49. package/demo/demo-script.md +53 -0
  50. package/dist/analytics/costAnalytics.d.ts +77 -0
  51. package/dist/analytics/costAnalytics.d.ts.map +1 -0
  52. package/dist/analytics/costAnalytics.js +219 -0
  53. package/dist/analytics/costAnalytics.js.map +1 -0
  54. package/dist/cache/semanticCache.d.ts +62 -0
  55. package/dist/cache/semanticCache.d.ts.map +1 -0
  56. package/dist/cache/semanticCache.js +176 -0
  57. package/dist/cache/semanticCache.js.map +1 -0
  58. package/dist/cli.js +35 -0
  59. package/dist/index.d.ts +30 -722
  60. package/dist/index.js +76 -355
  61. package/dist/index.js.map +1 -1
  62. package/dist/integrations/langchainAdapter.d.ts +146 -0
  63. package/dist/integrations/langchainAdapter.d.ts.map +1 -0
  64. package/dist/integrations/langchainAdapter.js +731 -0
  65. package/dist/integrations/langchainAdapter.js.map +1 -0
  66. package/dist/integrations/oauth.d.ts +69 -0
  67. package/dist/integrations/oauth.d.ts.map +1 -0
  68. package/dist/integrations/oauth.js +225 -21
  69. package/dist/integrations/oauth.js.map +1 -0
  70. package/dist/memory/autoFetch.d.ts +39 -0
  71. package/dist/memory/autoFetch.d.ts.map +1 -0
  72. package/dist/memory/autoFetch.js +80 -88
  73. package/dist/memory/autoFetch.js.map +1 -0
  74. package/dist/memory/memoryTree.d.ts +76 -0
  75. package/dist/memory/memoryTree.d.ts.map +1 -0
  76. package/dist/memory/memoryTree.js +185 -130
  77. package/dist/memory/memoryTree.js.map +1 -0
  78. package/dist/memory/obsidianVault.d.ts +71 -0
  79. package/dist/memory/obsidianVault.d.ts.map +1 -0
  80. package/dist/memory/obsidianVault.js +207 -22
  81. package/dist/memory/obsidianVault.js.map +1 -0
  82. package/dist/providers/providerConfig.d.ts +49 -0
  83. package/dist/providers/providerConfig.d.ts.map +1 -0
  84. package/dist/providers/providerConfig.js +806 -401
  85. package/dist/providers/providerConfig.js.map +1 -0
  86. package/dist/security/guardrails.d.ts +76 -0
  87. package/dist/security/guardrails.d.ts.map +1 -0
  88. package/dist/security/guardrails.js +479 -0
  89. package/dist/security/guardrails.js.map +1 -0
  90. package/dist/server/dashboard.d.ts +58 -0
  91. package/dist/server/dashboard.d.ts.map +1 -0
  92. package/dist/server/dashboard.js +553 -0
  93. package/dist/server/dashboard.js.map +1 -0
  94. package/dist/server/modelMapper.d.ts +43 -0
  95. package/dist/server/modelMapper.d.ts.map +1 -0
  96. package/dist/server/modelMapper.js +154 -0
  97. package/dist/server/modelMapper.js.map +1 -0
  98. package/dist/server/proxyServer.d.ts +41 -0
  99. package/dist/server/proxyServer.d.ts.map +1 -0
  100. package/dist/server/proxyServer.js +932 -0
  101. package/dist/server/proxyServer.js.map +1 -0
  102. package/dist/skills/__tests__/skill_manager.test.d.ts +2 -0
  103. package/dist/skills/__tests__/skill_manager.test.d.ts.map +1 -0
  104. package/dist/skills/__tests__/skill_manager.test.js +268 -0
  105. package/dist/skills/__tests__/skill_manager.test.js.map +1 -0
  106. package/docs-site/index.html +347 -0
  107. package/package.json +53 -7
  108. package/playground/README.md +51 -0
  109. package/playground/codesandbox.json +12 -0
  110. package/playground/index.js +39 -0
  111. package/scripts/update-npm-badges.js +158 -0
  112. package/src/analytics/costAnalytics.ts +304 -0
  113. package/src/cache/semanticCache.ts +221 -0
  114. package/src/index.ts +99 -0
  115. package/src/integrations/langchainAdapter.ts +955 -0
  116. package/src/providers/providerConfig.ts +923 -0
  117. package/src/security/guardrails.ts +585 -0
  118. package/src/server/dashboard.ts +610 -0
  119. package/src/server/modelMapper.ts +182 -0
  120. package/src/server/proxyServer.ts +1105 -0
  121. package/src/types/langchain.d.ts +83 -0
  122. package/tsconfig.build.json +20 -0
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NPM Stats Badge Updater
4
+ *
5
+ * Updates README.md with current NPM download statistics
6
+ * Run: node scripts/update-npm-badges.js
7
+ */
8
+
9
+ const https = require('https');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const PACKAGE_NAME = 'adaptive-memory-multi-model-router';
14
+
15
+ function fetchNPMStats(period) {
16
+ return new Promise((resolve, reject) => {
17
+ const url = `https://api.npmjs.org/downloads/point/${period}/${PACKAGE_NAME}`;
18
+ https.get(url, (res) => {
19
+ let data = '';
20
+ res.on('data', chunk => data += chunk);
21
+ res.on('end', () => {
22
+ try {
23
+ const parsed = JSON.parse(data);
24
+ resolve(parsed);
25
+ } catch (e) {
26
+ reject(e);
27
+ }
28
+ });
29
+ }).on('error', reject);
30
+ });
31
+ }
32
+
33
+ function generateBadge(label, value, color) {
34
+ // Use shields.io for dynamic badges
35
+ const encodedLabel = encodeURIComponent(label);
36
+ const encodedValue = encodeURIComponent(value.toString());
37
+ return `https://img.shields.io/badge/${encodedLabel}-${encodedValue}-${color}?logo=npm`;
38
+ }
39
+
40
+ function formatNumber(num) {
41
+ if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
42
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'k';
43
+ return num.toString();
44
+ }
45
+
46
+ async function updateBadges() {
47
+ console.log('📊 Fetching NPM Statistics...\n');
48
+
49
+ try {
50
+ // Fetch stats
51
+ const [day, week, month] = await Promise.all([
52
+ fetchNPMStats('last-day'),
53
+ fetchNPMStats('last-week'),
54
+ fetchNPMStats('last-month'),
55
+ ]);
56
+
57
+ console.log('Current Statistics:');
58
+ console.log('─────────────────────────────────────────────────────────────');
59
+ console.log(` Daily: ${day.downloads.toLocaleString().padStart(6)} downloads`);
60
+ console.log(` Weekly: ${week.downloads.toLocaleString().padStart(6)} downloads`);
61
+ console.log(` Monthly: ${month.downloads.toLocaleString().padStart(6)} downloads`);
62
+ console.log();
63
+
64
+ // Generate badge URLs
65
+ const badges = {
66
+ daily: generateBadge('downloads/day', formatNumber(day.downloads), 'blue'),
67
+ weekly: generateBadge('downloads/week', formatNumber(week.downloads), 'green'),
68
+ monthly: generateBadge('downloads/month', formatNumber(month.downloads), 'orange'),
69
+ version: `https://img.shields.io/npm/v/${PACKAGE_NAME}?logo=npm`,
70
+ license: `https://img.shields.io/npm/l/${PACKAGE_NAME}?color=blue`,
71
+ tests: `https://img.shields.io/badge/tests-33%20passing-brightgreen`,
72
+ };
73
+
74
+ // Read README
75
+ const readmePath = path.join(__dirname, '..', 'README.md');
76
+ let readme = fs.readFileSync(readmePath, 'utf8');
77
+
78
+ // Update badges section
79
+ const badgeSection = `<!-- BADGES_START -->
80
+ <p align="center">
81
+ <a href="https://www.npmjs.com/package/${PACKAGE_NAME}">
82
+ <img src="${badges.version}" alt="NPM Version">
83
+ </a>
84
+ <a href="https://www.npmjs.com/package/${PACKAGE_NAME}">
85
+ <img src="${badges.daily}" alt="Daily Downloads">
86
+ </a>
87
+ <a href="https://www.npmjs.com/package/${PACKAGE_NAME}">
88
+ <img src="${badges.weekly}" alt="Weekly Downloads">
89
+ </a>
90
+ <a href="https://www.npmjs.com/package/${PACKAGE_NAME}">
91
+ <img src="${badges.monthly}" alt="Monthly Downloads">
92
+ </a>
93
+ <a href="https://github.com/Das-rebel/${PACKAGE_NAME}/blob/main/LICENSE">
94
+ <img src="${badges.license}" alt="License">
95
+ </a>
96
+ <img src="${badges.tests}" alt="Tests">
97
+ </p>
98
+ <!-- BADGES_END -->`;
99
+
100
+ // Replace existing badge section or add at top
101
+ if (readme.includes('<!-- BADGES_START -->')) {
102
+ readme = readme.replace(
103
+ /<!-- BADGES_START -->[\s\S]*?<!-- BADGES_END -->/,
104
+ badgeSection
105
+ );
106
+ } else {
107
+ // Add after title
108
+ readme = readme.replace(
109
+ /^(# .*$)/m,
110
+ `$1\n\n${badgeSection}`
111
+ );
112
+ }
113
+
114
+ // Update stats section in README
115
+ const statsSection = `<!-- STATS_START -->
116
+ ## 📊 Download Statistics
117
+
118
+ | Period | Downloads | Trend |
119
+ |--------|-----------|-------|
120
+ | Daily | ${day.downloads.toLocaleString()} | 📈 |
121
+ | Weekly | ${week.downloads.toLocaleString()} | 📈 |
122
+ | Monthly | ${month.downloads.toLocaleString()} | 📈 |
123
+
124
+ *Last updated: ${new Date().toISOString().split('T')[0]}*
125
+ <!-- STATS_END -->`;
126
+
127
+ if (readme.includes('<!-- STATS_START -->')) {
128
+ readme = readme.replace(
129
+ /<!-- STATS_START -->[\s\S]*?<!-- STATS_END -->/,
130
+ statsSection
131
+ );
132
+ }
133
+
134
+ // Write updated README
135
+ fs.writeFileSync(readmePath, readme);
136
+
137
+ console.log('✅ README badges updated successfully!');
138
+ console.log();
139
+ console.log('Generated Badges:');
140
+ console.log('─────────────────────────────────────────────────────────────');
141
+ console.log(` Version: ${badges.version}`);
142
+ console.log(` Daily: ${badges.daily}`);
143
+ console.log(` Weekly: ${badges.weekly}`);
144
+ console.log(` Monthly: ${badges.monthly}`);
145
+ console.log();
146
+
147
+ } catch (error) {
148
+ console.error('❌ Error updating badges:', error.message);
149
+ process.exit(1);
150
+ }
151
+ }
152
+
153
+ // Run if called directly
154
+ if (require.main === module) {
155
+ updateBadges();
156
+ }
157
+
158
+ module.exports = { updateBadges, fetchNPMStats };
@@ -0,0 +1,304 @@
1
+ /**
2
+ * A3M Router - Cost Analytics
3
+ *
4
+ * Advanced cost tracking and analytics:
5
+ * - Record every request with full metadata
6
+ * - Real-time savings vs single premium provider
7
+ * - Monthly/yearly projections
8
+ * - Breakdown by provider and query type
9
+ * - Export to JSON or CSV
10
+ * - In-memory storage with auto-rotation
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface CostRecord {
18
+ timestamp: Date;
19
+ provider: string;
20
+ model: string;
21
+ inputTokens: number;
22
+ outputTokens: number;
23
+ cost: number;
24
+ latency: number;
25
+ queryType: 'simple' | 'code' | 'summary' | 'complex';
26
+ cached: boolean;
27
+ }
28
+
29
+ export interface CostSummary {
30
+ totalCost: number;
31
+ totalRequests: number;
32
+ totalInputTokens: number;
33
+ totalOutputTokens: number;
34
+ avgLatency: number;
35
+ cacheHitRate: number;
36
+ period: { start: Date; end: Date };
37
+ }
38
+
39
+ export interface ProviderStats {
40
+ totalCost: number;
41
+ totalRequests: number;
42
+ avgLatency: number;
43
+ totalInputTokens: number;
44
+ totalOutputTokens: number;
45
+ models: Record<string, { cost: number; requests: number }>;
46
+ }
47
+
48
+ export interface QueryTypeStats {
49
+ totalCost: number;
50
+ totalRequests: number;
51
+ avgLatency: number;
52
+ cachedCount: number;
53
+ cacheHitRate: number;
54
+ }
55
+
56
+ export interface SavingsReport {
57
+ totalSaved: number;
58
+ percentageSaved: number;
59
+ byQueryType: Record<string, { saved: number; percentage: number }>;
60
+ projectedMonthlySavings: number;
61
+ projectedYearlySavings: number;
62
+ }
63
+
64
+ // Model costs per 1M tokens (USD) for baseline comparison
65
+ const BASELINE_COSTS: Record<string, { input: number; output: number }> = {
66
+ 'gpt-4o': { input: 2.50, output: 10.00 },
67
+ 'gpt-4-turbo': { input: 10.00, output: 30.00 },
68
+ 'claude-3.5-sonnet': { input: 3.00, output: 15.00 },
69
+ 'claude-3-opus': { input: 15.00, output: 75.00 },
70
+ 'claude-3-haiku': { input: 0.25, output: 1.25 },
71
+ 'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
72
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00 },
73
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30 },
74
+ 'groq/llama-3.3-70b': { input: 0.59, output: 0.79 },
75
+ 'groq/llama-3.1-8b': { input: 0.05, output: 0.08 },
76
+ 'cerebras/llama-3.3-70b': { input: 0.10, output: 0.10 },
77
+ 'mistral-large': { input: 2.00, output: 6.00 },
78
+ 'mistral-small': { input: 0.20, output: 0.60 },
79
+ };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // CostAnalytics
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export class CostAnalytics {
86
+ private records: CostRecord[] = [];
87
+ private maxRecords: number;
88
+
89
+ constructor(maxRecords: number = 10000) {
90
+ this.maxRecords = maxRecords;
91
+ }
92
+
93
+ // ---- Record ----
94
+
95
+ record(data: Omit<CostRecord, 'timestamp'>): void {
96
+ const entry: CostRecord = { ...data, timestamp: new Date() };
97
+ this.records.push(entry);
98
+
99
+ // Auto-rotate
100
+ if (this.records.length > this.maxRecords) {
101
+ this.records = this.records.slice(-this.maxRecords);
102
+ }
103
+ }
104
+
105
+ // ---- Summary ----
106
+
107
+ getSummary(period: 'hour' | 'day' | 'week' | 'month' = 'day'): CostSummary {
108
+ const filtered = this.filterByPeriod(period);
109
+ if (filtered.length === 0) {
110
+ const now = new Date();
111
+ return {
112
+ totalCost: 0, totalRequests: 0, totalInputTokens: 0, totalOutputTokens: 0,
113
+ avgLatency: 0, cacheHitRate: 0,
114
+ period: { start: now, end: now },
115
+ };
116
+ }
117
+
118
+ const totalCost = sum(filtered, (r) => r.cost);
119
+ const cached = filtered.filter((r) => r.cached).length;
120
+ const start = filtered[0].timestamp;
121
+ const end = filtered[filtered.length - 1].timestamp;
122
+
123
+ return {
124
+ totalCost: round(totalCost),
125
+ totalRequests: filtered.length,
126
+ totalInputTokens: sum(filtered, (r) => r.inputTokens),
127
+ totalOutputTokens: sum(filtered, (r) => r.outputTokens),
128
+ avgLatency: round(sum(filtered, (r) => r.latency) / filtered.length),
129
+ cacheHitRate: round(cached / filtered.length),
130
+ period: { start, end },
131
+ };
132
+ }
133
+
134
+ // ---- By provider ----
135
+
136
+ getByProvider(): Record<string, ProviderStats> {
137
+ const result: Record<string, ProviderStats> = {};
138
+ for (const r of this.records) {
139
+ if (!result[r.provider]) {
140
+ result[r.provider] = {
141
+ totalCost: 0, totalRequests: 0, avgLatency: 0,
142
+ totalInputTokens: 0, totalOutputTokens: 0, models: {},
143
+ };
144
+ }
145
+ const p = result[r.provider];
146
+ p.totalCost += r.cost;
147
+ p.totalRequests++;
148
+ p.avgLatency += r.latency;
149
+ p.totalInputTokens += r.inputTokens;
150
+ p.totalOutputTokens += r.outputTokens;
151
+
152
+ if (!p.models[r.model]) {
153
+ p.models[r.model] = { cost: 0, requests: 0 };
154
+ }
155
+ p.models[r.model].cost += r.cost;
156
+ p.models[r.model].requests++;
157
+ }
158
+
159
+ for (const p of Object.values(result)) {
160
+ if (p.totalRequests > 0) {
161
+ p.avgLatency = round(p.avgLatency / p.totalRequests);
162
+ }
163
+ p.totalCost = round(p.totalCost);
164
+ }
165
+ return result;
166
+ }
167
+
168
+ // ---- By query type ----
169
+
170
+ getByQueryType(): Record<string, QueryTypeStats> {
171
+ const result: Record<string, QueryTypeStats> = {};
172
+ for (const r of this.records) {
173
+ if (!result[r.queryType]) {
174
+ result[r.queryType] = {
175
+ totalCost: 0, totalRequests: 0, avgLatency: 0, cachedCount: 0, cacheHitRate: 0,
176
+ };
177
+ }
178
+ const q = result[r.queryType];
179
+ q.totalCost += r.cost;
180
+ q.totalRequests++;
181
+ q.avgLatency += r.latency;
182
+ if (r.cached) q.cachedCount++;
183
+ }
184
+
185
+ for (const q of Object.values(result)) {
186
+ if (q.totalRequests > 0) {
187
+ q.avgLatency = round(q.avgLatency / q.totalRequests);
188
+ q.cacheHitRate = round(q.cachedCount / q.totalRequests);
189
+ }
190
+ q.totalCost = round(q.totalCost);
191
+ }
192
+ return result;
193
+ }
194
+
195
+ // ---- Savings ----
196
+
197
+ getSavings(baselineProvider: string = 'gpt-4o'): SavingsReport {
198
+ const baseline = BASELINE_COSTS[baselineProvider] || { input: 2.50, output: 10.00 };
199
+ let totalActual = 0;
200
+ let totalBaseline = 0;
201
+ const byQueryType: Record<string, { actual: number; baseline: number }> = {};
202
+
203
+ for (const r of this.records) {
204
+ const actualCost = r.cost;
205
+ const baselineCost =
206
+ (r.inputTokens / 1_000_000) * baseline.input +
207
+ (r.outputTokens / 1_000_000) * baseline.output;
208
+
209
+ totalActual += actualCost;
210
+ totalBaseline += baselineCost;
211
+
212
+ if (!byQueryType[r.queryType]) {
213
+ byQueryType[r.queryType] = { actual: 0, baseline: 0 };
214
+ }
215
+ byQueryType[r.queryType].actual += actualCost;
216
+ byQueryType[r.queryType].baseline += baselineCost;
217
+ }
218
+
219
+ const totalSaved = totalBaseline - totalActual;
220
+ const percentageSaved = totalBaseline > 0 ? (totalSaved / totalBaseline) * 100 : 0;
221
+
222
+ // Project based on days of data we have
223
+ const daysCovered = this.getDaysCovered();
224
+ const dailySavings = daysCovered > 0 ? totalSaved / daysCovered : 0;
225
+
226
+ const queryTypeSavings: Record<string, { saved: number; percentage: number }> = {};
227
+ for (const [qt, data] of Object.entries(byQueryType)) {
228
+ const saved = data.baseline - data.actual;
229
+ queryTypeSavings[qt] = {
230
+ saved: round(saved),
231
+ percentage: data.baseline > 0 ? round((saved / data.baseline) * 100) : 0,
232
+ };
233
+ }
234
+
235
+ return {
236
+ totalSaved: round(totalSaved),
237
+ percentageSaved: round(percentageSaved),
238
+ byQueryType: queryTypeSavings,
239
+ projectedMonthlySavings: round(dailySavings * 30),
240
+ projectedYearlySavings: round(dailySavings * 365),
241
+ };
242
+ }
243
+
244
+ // ---- Export ----
245
+
246
+ export(format: 'json' | 'csv' = 'json'): string {
247
+ if (format === 'csv') {
248
+ const header = 'timestamp,provider,model,inputTokens,outputTokens,cost,latency,queryType,cached';
249
+ const rows = this.records.map((r) =>
250
+ `${r.timestamp.toISOString()},${r.provider},${r.model},${r.inputTokens},${r.outputTokens},${r.cost},${r.latency},${r.queryType},${r.cached}`
251
+ );
252
+ return [header, ...rows].join('\n');
253
+ }
254
+ return JSON.stringify(this.records, null, 2);
255
+ }
256
+
257
+ // ---- Reset ----
258
+
259
+ reset(): void {
260
+ this.records = [];
261
+ }
262
+
263
+ // ---- Internals ----
264
+
265
+ private filterByPeriod(period: 'hour' | 'day' | 'week' | 'month'): CostRecord[] {
266
+ const now = Date.now();
267
+ const ms: Record<string, number> = {
268
+ hour: 3600_000,
269
+ day: 86400_000,
270
+ week: 604800_000,
271
+ month: 2592000_000,
272
+ };
273
+ const cutoff = now - (ms[period] || ms.day);
274
+ return this.records.filter((r) => r.timestamp.getTime() >= cutoff);
275
+ }
276
+
277
+ private getDaysCovered(): number {
278
+ if (this.records.length < 2) return 1;
279
+ const first = this.records[0].timestamp.getTime();
280
+ const last = this.records[this.records.length - 1].timestamp.getTime();
281
+ return Math.max(1, (last - first) / 86400_000);
282
+ }
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function sum(arr: CostRecord[], fn: (r: CostRecord) => number): number {
290
+ return arr.reduce((acc, r) => acc + fn(r), 0);
291
+ }
292
+
293
+ function round(n: number, decimals: number = 4): number {
294
+ const factor = Math.pow(10, decimals);
295
+ return Math.round(n * factor) / factor;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Convenience factory
300
+ // ---------------------------------------------------------------------------
301
+
302
+ export function createCostAnalytics(maxRecords?: number): CostAnalytics {
303
+ return new CostAnalytics(maxRecords);
304
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * A3M Router - Semantic Cache
3
+ *
4
+ * Stores previous query->response pairs and returns cached responses
5
+ * for semantically similar queries using character n-gram Jaccard similarity.
6
+ *
7
+ * No external embedding API needed. Trigram overlap catches paraphrases like:
8
+ * "What is Python?" ≈ "Tell me about Python" ≈ "Explain Python"
9
+ * "Write a sort fn" ≈ "Create a sorting fn" ≈ "How to sort an array"
10
+ */
11
+
12
+ // ============================================================
13
+ // Types
14
+ // ============================================================
15
+
16
+ export interface CachedResponse {
17
+ query: string;
18
+ response: string;
19
+ metadata?: any;
20
+ cachedAt: number;
21
+ hitCount: number;
22
+ }
23
+
24
+ export interface SemanticCacheOptions {
25
+ maxSize?: number; // Max entries (default: 1000)
26
+ similarityThreshold?: number; // 0-1, min similarity for hit (default: 0.92)
27
+ ttl?: number; // TTL in ms (default: 3600000 = 1 hour)
28
+ }
29
+
30
+ interface CacheEntry extends CachedResponse {
31
+ trigrams: Set<string>;
32
+ expiresAt: number;
33
+ }
34
+
35
+ export interface SemanticCacheStats {
36
+ hits: number;
37
+ misses: number;
38
+ hitRate: number;
39
+ size: number;
40
+ }
41
+
42
+ // ============================================================
43
+ // N-gram utilities
44
+ // ============================================================
45
+
46
+ /**
47
+ * Normalize text: lowercase, collapse whitespace, strip punctuation edges.
48
+ */
49
+ function normalize(text: string): string {
50
+ return text
51
+ .toLowerCase()
52
+ .replace(/\s+/g, " ")
53
+ .trim();
54
+ }
55
+
56
+ /**
57
+ * Extract character trigrams from text.
58
+ * Pads with spaces so short words still produce trigrams.
59
+ */
60
+ function extractTrigrams(text: string): Set<string> {
61
+ const normalized = " " + normalize(text) + " ";
62
+ const trigrams = new Set<string>();
63
+ for (let i = 0; i <= normalized.length - 3; i++) {
64
+ trigrams.add(normalized.substring(i, i + 3));
65
+ }
66
+ return trigrams;
67
+ }
68
+
69
+ /**
70
+ * Compute Jaccard similarity between two sets.
71
+ * |A ∩ B| / |A ∪ B|
72
+ */
73
+ function jaccard(a: Set<string>, b: Set<string>): number {
74
+ if (a.size === 0 && b.size === 0) return 1.0;
75
+ let intersection = 0;
76
+ for (const item of a) {
77
+ if (b.has(item)) intersection++;
78
+ }
79
+ const union = a.size + b.size - intersection;
80
+ return union === 0 ? 0 : intersection / union;
81
+ }
82
+
83
+ // ============================================================
84
+ // Semantic Cache
85
+ // ============================================================
86
+
87
+ export class SemanticCache {
88
+ private entries: CacheEntry[] = [];
89
+ private maxSize: number;
90
+ private similarityThreshold: number;
91
+ private ttl: number;
92
+ private hits = 0;
93
+ private misses = 0;
94
+
95
+ constructor(options?: SemanticCacheOptions) {
96
+ this.maxSize = options?.maxSize ?? 1000;
97
+ this.similarityThreshold = options?.similarityThreshold ?? 0.92;
98
+ this.ttl = options?.ttl ?? 3600000; // 1 hour
99
+ }
100
+
101
+ /**
102
+ * Get cached response for a semantically similar query.
103
+ * Returns the best match above the similarity threshold, or null.
104
+ */
105
+ async get(query: string): Promise<CachedResponse | null> {
106
+ const now = Date.now();
107
+ const queryTrigrams = extractTrigrams(query);
108
+
109
+ let bestEntry: CacheEntry | null = null;
110
+ let bestScore = 0;
111
+
112
+ for (const entry of this.entries) {
113
+ // Skip expired
114
+ if (now > entry.expiresAt) continue;
115
+
116
+ const score = jaccard(queryTrigrams, entry.trigrams);
117
+ if (score > bestScore) {
118
+ bestScore = score;
119
+ bestEntry = entry;
120
+ }
121
+ }
122
+
123
+ if (bestEntry && bestScore >= this.similarityThreshold) {
124
+ this.hits++;
125
+ bestEntry.hitCount++;
126
+ return {
127
+ query: bestEntry.query,
128
+ response: bestEntry.response,
129
+ metadata: bestEntry.metadata,
130
+ cachedAt: bestEntry.cachedAt,
131
+ hitCount: bestEntry.hitCount,
132
+ };
133
+ }
134
+
135
+ this.misses++;
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Store a query->response pair in the cache.
141
+ */
142
+ async set(query: string, response: string, metadata?: any): Promise<void> {
143
+ const now = Date.now();
144
+
145
+ // Evict expired entries first
146
+ this.evictExpired();
147
+
148
+ // Evict oldest if at capacity
149
+ if (this.entries.length >= this.maxSize) {
150
+ this.evictOldest();
151
+ }
152
+
153
+ // Check if an exact-match entry already exists and update it
154
+ const normalized = normalize(query);
155
+ const existing = this.entries.find(
156
+ (e) => normalize(e.query) === normalized && now <= e.expiresAt
157
+ );
158
+ if (existing) {
159
+ existing.response = response;
160
+ existing.metadata = metadata;
161
+ existing.cachedAt = now;
162
+ existing.expiresAt = now + this.ttl;
163
+ existing.trigrams = extractTrigrams(query);
164
+ return;
165
+ }
166
+
167
+ this.entries.push({
168
+ query,
169
+ response,
170
+ metadata,
171
+ cachedAt: now,
172
+ expiresAt: now + this.ttl,
173
+ hitCount: 0,
174
+ trigrams: extractTrigrams(query),
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Clear all cache entries.
180
+ */
181
+ clear(): void {
182
+ this.entries = [];
183
+ }
184
+
185
+ /**
186
+ * Get cache statistics.
187
+ */
188
+ getStats(): SemanticCacheStats {
189
+ const total = this.hits + this.misses;
190
+ return {
191
+ hits: this.hits,
192
+ misses: this.misses,
193
+ hitRate: total > 0 ? this.hits / total : 0,
194
+ size: this.entries.length,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Purge expired entries.
200
+ */
201
+ private evictExpired(): void {
202
+ const now = Date.now();
203
+ this.entries = this.entries.filter((e) => now <= e.expiresAt);
204
+ }
205
+
206
+ /**
207
+ * Evict the oldest (by cachedAt) entry.
208
+ */
209
+ private evictOldest(): void {
210
+ if (this.entries.length === 0) return;
211
+ let oldestIdx = 0;
212
+ let oldestTime = Infinity;
213
+ for (let i = 0; i < this.entries.length; i++) {
214
+ if (this.entries[i].cachedAt < oldestTime) {
215
+ oldestTime = this.entries[i].cachedAt;
216
+ oldestIdx = i;
217
+ }
218
+ }
219
+ this.entries.splice(oldestIdx, 1);
220
+ }
221
+ }