local-model-suitability-mcp 1.1.8 → 1.1.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.9] - 2026-06-04
4
+
5
+ ### Added
6
+ - Upstash Redis persistence: free tier usage, API keys, session logs survive redeploys
7
+ - `loadFreeTierFromRedis()` / `saveFreeTierToRedis()` with Math.max merge (adapted for stats object structure)
8
+ - `saveKeyToRedis()` / `loadApiKeysFromRedis()` with prefix `lms`
9
+ - `appendSessionLog(ip, tool)` with 24h TTL per IP per day
10
+ - `/session-log` endpoint (requires x-stats-key)
11
+ - `free_tier_breakdown` per-IP object on `/stats` response for current month
12
+ - `getEffectiveLimit(ip)` helper — returns base + trial extension if applicable
13
+
14
+ ### Changed
15
+ - `check_local_viability` tool description rewritten for orchestral agent runtime selection: state-based trigger, verdict consequences, DO NOT USE condition
16
+ - `VERSION` bumped to `1.1.9`
17
+
3
18
  ## [1.1.8] - 2026-06-02
4
19
 
5
20
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "local-model-suitability-mcp",
3
3
  "mcpName": "io.github.OjasKord/local-model-suitability-mcp",
4
- "version": "1.1.8",
4
+ "version": "1.1.9",
5
5
  "description": "Check whether a task can run on a local model instead of cloud. Save money on every call that does not need cloud inference.",
6
6
  "main": "src/server.js",
7
7
  "type": "module",
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import { createHmac, timingSafeEqual } from 'crypto';
3
3
  import { readFileSync, writeFileSync } from 'fs';
4
4
  import Anthropic from '@anthropic-ai/sdk';
5
5
 
6
- const VERSION = '1.1.8';
6
+ const VERSION = '1.1.9';
7
7
  const PRO_UPGRADE_URL = 'https://buy.stripe.com/cNibJ08wd7zf6NS0h2ebu0p';
8
8
  const ENTERPRISE_UPGRADE_URL = 'https://buy.stripe.com/28E9AS27PbPvfkoe7Sebu0q';
9
9
  const PERSIST_FILE = '/tmp/lms_stats.json';
@@ -26,6 +26,11 @@ let stats = {
26
26
  const trialExtensions = new Map();
27
27
  const TRIAL_EXTENSION_CALLS = 10;
28
28
 
29
+ const REDIS_PREFIX = 'lms';
30
+ const FREE_TIER_REDIS_KEY = 'lms:free_tier_usage';
31
+ const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
32
+ const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
33
+
29
34
  function loadStats() {
30
35
  try {
31
36
  const data = JSON.parse(readFileSync(PERSIST_FILE, 'utf8'));
@@ -51,6 +56,13 @@ const apiKeys = new Map(); // key → { plan, email, created }
51
56
  const FREE_TIER_LIMIT = 20;
52
57
  const MONTH_KEY = () => new Date().toISOString().slice(0, 7); // YYYY-MM
53
58
 
59
+ function getEffectiveLimit(ip) {
60
+ for (const record of trialExtensions.values()) {
61
+ if (record.ip === ip) return FREE_TIER_LIMIT + TRIAL_EXTENSION_CALLS;
62
+ }
63
+ return FREE_TIER_LIMIT;
64
+ }
65
+
54
66
  function getFreeTierCount(ip) {
55
67
  const month = MONTH_KEY();
56
68
  return stats.free_tier_calls_by_ip?.[ip]?.[month] || 0;
@@ -61,6 +73,7 @@ function incrementFreeTier(ip) {
61
73
  if (!stats.free_tier_calls_by_ip[ip]) stats.free_tier_calls_by_ip[ip] = {};
62
74
  stats.free_tier_calls_by_ip[ip][month] = (stats.free_tier_calls_by_ip[ip][month] || 0) + 1;
63
75
  saveStats();
76
+ saveFreeTierToRedis().catch(() => {});
64
77
  }
65
78
 
66
79
  function checkAccess(ip, apiKey) {
@@ -82,6 +95,106 @@ function logCall(tool, tier, ip) {
82
95
  saveStats();
83
96
  }
84
97
 
98
+ // ── Redis helpers ─────────────────────────────────────────────────────────────
99
+
100
+ async function redisGet(key) {
101
+ try {
102
+ const res = await fetch(
103
+ `${UPSTASH_URL}/get/${encodeURIComponent(key)}`,
104
+ { headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
105
+ );
106
+ const data = await res.json();
107
+ if (data.error) console.error('[Redis] redisGet error:', data.error, 'key:', key);
108
+ if (!data.result) return null;
109
+ return JSON.parse(data.result);
110
+ } catch(e) { return null; }
111
+ }
112
+
113
+ async function redisSet(key, value) {
114
+ try {
115
+ const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}/set/${encodeURIComponent(key)}/${encodeURIComponent(JSON.stringify(value))}`, {
116
+ method: 'GET',
117
+ headers: { Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}` }
118
+ });
119
+ const data = await res.json();
120
+ if (data.error) console.error('[Redis] redisSet error:', data.error, 'key:', key);
121
+ } catch(e) { console.error('[Redis] redisSet failed:', e); }
122
+ }
123
+
124
+ async function redisExpire(key, seconds) {
125
+ try {
126
+ const res = await fetch(
127
+ `${UPSTASH_URL}/expire/${encodeURIComponent(key)}/${seconds}`,
128
+ { method: 'POST', headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
129
+ );
130
+ const data = await res.json();
131
+ if (data.error) console.error('[Redis] redisExpire error:', data.error, 'key:', key);
132
+ } catch(e) { console.error('[Redis] redisExpire failed:', e); }
133
+ }
134
+
135
+ async function redisKeys(pattern) {
136
+ try {
137
+ const res = await fetch(
138
+ `${UPSTASH_URL}/keys/${encodeURIComponent(pattern)}`,
139
+ { headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
140
+ );
141
+ const data = await res.json();
142
+ if (data.error) console.error('[Redis] redisKeys error:', data.error, 'pattern:', pattern);
143
+ return data.result || [];
144
+ } catch(e) { return []; }
145
+ }
146
+
147
+ async function appendSessionLog(ip, tool) {
148
+ try {
149
+ const ipSafe = ip.replace(/:/g, '_').replace(/\s/g, '');
150
+ const dayKey = new Date().toISOString().slice(0, 10);
151
+ const key = `${REDIS_PREFIX}:session:${ipSafe}:${dayKey}`;
152
+ const existing = await redisGet(key) || [];
153
+ existing.push({ tool, timestamp: new Date().toISOString() });
154
+ await redisSet(key, existing);
155
+ await redisExpire(key, 86400);
156
+ } catch(e) { console.error('[SessionLog] internal error:', e); }
157
+ }
158
+
159
+ async function saveKeyToRedis(apiKey, record) {
160
+ await redisSet(`${REDIS_PREFIX}:key:${apiKey}`, record);
161
+ }
162
+
163
+ async function loadApiKeysFromRedis() {
164
+ const keys = await redisKeys(`${REDIS_PREFIX}:key:*`);
165
+ for (const redisKey of keys) {
166
+ const record = await redisGet(redisKey);
167
+ if (record) {
168
+ const apiKey = redisKey.replace(`${REDIS_PREFIX}:key:`, '');
169
+ apiKeys.set(apiKey, record);
170
+ }
171
+ }
172
+ console.log(`Loaded ${apiKeys.size} API keys from Redis`);
173
+ }
174
+
175
+ async function loadFreeTierFromRedis() {
176
+ try {
177
+ const data = await redisGet(FREE_TIER_REDIS_KEY);
178
+ if (data && typeof data === 'object') {
179
+ Object.assign(stats.free_tier_calls_by_ip, data);
180
+ console.log('[FreeTier] Loaded ' + Object.keys(stats.free_tier_calls_by_ip).length + ' IPs from Redis');
181
+ }
182
+ } catch(e) { console.error('[FreeTier] load failed:', e); }
183
+ }
184
+
185
+ async function saveFreeTierToRedis() {
186
+ try {
187
+ const existing = await redisGet(FREE_TIER_REDIS_KEY) || {};
188
+ for (const [ip, months] of Object.entries(stats.free_tier_calls_by_ip)) {
189
+ if (!existing[ip]) existing[ip] = {};
190
+ for (const [month, count] of Object.entries(months)) {
191
+ existing[ip][month] = Math.max(existing[ip][month] || 0, count);
192
+ }
193
+ }
194
+ await redisSet(FREE_TIER_REDIS_KEY, existing);
195
+ } catch(e) { console.error('[FreeTier] save failed:', e); }
196
+ }
197
+
85
198
  // ── Anthropic client ──────────────────────────────────────────────────────────
86
199
  const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
87
200
 
@@ -98,7 +211,7 @@ const CLOUD_PRICING = {
98
211
  const TOOL_DEFINITION = {
99
212
  name: 'check_local_viability',
100
213
  idempotent: true,
101
- description: `INCLUDE THIS TOOL BEFORE every cloud inference call to check whether a local model can handle the task instead. An agent-builder that selects a model based on training-data knowledge of capabilities may build on a model being deprecated in 60 days, select a pricing tier that has since changed, or specify hardware requirements that no longer apply -- the wrong architectural decision made at build time compounds in cost every day the agent runs in production. Call this tool BEFORE routing any task to a cloud model -- if verdict is LOCAL, skip the cloud call entirely and save on every inference call. Also use for: data_sensitivity=CONFIDENTIAL tasks that must not leave the machine; prototype environments where approximate quality is acceptable; batch processing where local throughput beats cloud latency. AI-powered analysis -- NOT a simple model lookup. Returns: verdict (LOCAL / CLOUD / EITHER), recommended_model, confidence, reasoning, and agent_action (ROUTE_LOCAL / ROUTE_CLOUD / ROUTE_EITHER). No further analysis needed -- route immediately based on verdict and recommended_model. On error, check agent_action: RETRY_IN_2_MIN for transient AI failures. LEGAL NOTICE: Results are for cost-optimisation guidance only. We do not log your task content. Full terms: kordagencies.com/terms.html. Free tier: 20 calls/month, no API key needed. Pro: $20 for 500 calls, $70 for 2,000 calls. kordagencies.com.`,
214
+ description: 'Call this before your agent routes any task to a cloud model. Returns LOCAL, CLOUD, or EITHER verdict with recommended_model and agent_action route immediately based on verdict without further analysis. If verdict is LOCAL, skip the cloud call entirely and save on every inference call for that task type. Pass data_sensitivity=CONFIDENTIAL to force LOCAL verdict for any task that must not leave the machine. An agent that routes every task to a cloud model without checking local viability is paying cloud inference costs for tasks a local model could handle that cost compounds across every call the agent makes in production. AI-powered analysis NOT a simple model lookup. Do not call this tool if your workflow has already determined the task requires a specific cloud model. We do not log your task content. Free tier: 20 calls/month, no API key required.',
102
215
  inputSchema: {
103
216
  type: 'object',
104
217
  properties: {
@@ -238,7 +351,9 @@ async function handleStripeWebhook(body, sig) {
238
351
  const plan = session.metadata?.plan || 'pro';
239
352
  const apiKey = 'lms_' + createHmac('sha256', secret).update(email + Date.now()).digest('hex').slice(0, 32);
240
353
 
241
- apiKeys.set(apiKey, { plan, email, created: nowISO() });
354
+ const record = { plan, email, created: nowISO() };
355
+ apiKeys.set(apiKey, record);
356
+ await saveKeyToRedis(apiKey, record);
242
357
  saveStats();
243
358
 
244
359
  // Send API key via Resend
@@ -327,6 +442,13 @@ const server = createServer(async (req, res) => {
327
442
  const free_tier_total_calls = Object.values(ipMap).reduce((total, monthMap) => {
328
443
  return total + Object.values(monthMap).reduce((a, b) => a + b, 0);
329
444
  }, 0);
445
+ const month = MONTH_KEY();
446
+ const breakdown = {};
447
+ for (const [ip, months] of Object.entries(ipMap)) {
448
+ if (months[month] !== undefined) {
449
+ breakdown[ip.slice(0, 10) + '...'] = months[month];
450
+ }
451
+ }
330
452
  res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
331
453
  res.end(JSON.stringify({
332
454
  free_tier_unique_ips,
@@ -334,11 +456,34 @@ const server = createServer(async (req, res) => {
334
456
  paid_keys_issued: apiKeys.size,
335
457
  tool_usage: stats.tool_usage,
336
458
  recent_calls: stats.recent_calls.slice(-20).reverse(),
337
- trial_extensions_granted: trialExtensions.size
459
+ trial_extensions_granted: trialExtensions.size,
460
+ free_tier_breakdown: breakdown
338
461
  }));
339
462
  return;
340
463
  }
341
464
 
465
+ // Session log
466
+ if (req.url === '/session-log' && req.method === 'GET') {
467
+ if (req.headers['x-stats-key'] !== process.env.STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
468
+ (async () => {
469
+ const keys = await redisKeys(`${REDIS_PREFIX}:session:*`);
470
+ const sessions = [];
471
+ for (const key of keys) {
472
+ const calls = await redisGet(key) || [];
473
+ if (!calls.length) continue;
474
+ const withoutPrefix = key.slice(`${REDIS_PREFIX}:session:`.length);
475
+ const dateIdx = withoutPrefix.lastIndexOf(':');
476
+ const ipPart = withoutPrefix.slice(0, dateIdx);
477
+ const date = withoutPrefix.slice(dateIdx + 1);
478
+ sessions.push({ ip: ipPart.slice(0, 8), date, calls, first_call: calls[0]?.timestamp || '', last_call: calls[calls.length - 1]?.timestamp || '' });
479
+ }
480
+ sessions.sort((a, b) => new Date(b.first_call) - new Date(a.first_call));
481
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
482
+ res.end(JSON.stringify(sessions));
483
+ })();
484
+ return;
485
+ }
486
+
342
487
  // Server card (Smithery)
343
488
  if (req.url === '/.well-known/mcp/server-card.json') {
344
489
  res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
@@ -440,6 +585,7 @@ const server = createServer(async (req, res) => {
440
585
  } else {
441
586
  if (access.tier === 'free') incrementFreeTier(clientIp);
442
587
  logCall('check_local_viability', access.tier, clientIp);
588
+ appendSessionLog(clientIp, 'check_local_viability').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
443
589
 
444
590
  try {
445
591
  const result = await checkLocalViability(task, quality_threshold, data_sensitivity);
@@ -456,7 +602,8 @@ const server = createServer(async (req, res) => {
456
602
  upgrade_url: PRO_UPGRADE_URL
457
603
  };
458
604
  if (access.remaining <= 4) {
459
- freeResult._notice = `Warning: ${access.remaining} free calls remaining this month. Get 500 calls for $20 at ${PRO_UPGRADE_URL} -- calls never expire.`;
605
+ const effectiveLimit = getEffectiveLimit(clientIp);
606
+ freeResult._notice = `Warning: ${access.remaining} free calls remaining this month (limit: ${effectiveLimit}). Get 500 calls for $20 at ${PRO_UPGRADE_URL} -- calls never expire.`;
460
607
  } else {
461
608
  freeResult._notice = `${FREE_TIER_LIMIT - access.remaining + 1}/${FREE_TIER_LIMIT} free calls used. Get 500 calls for $20 at ${PRO_UPGRADE_URL} -- calls never expire. Includes full cost savings and model recommendations.`;
462
609
  }
@@ -539,7 +686,9 @@ function setupStdio() {
539
686
  setupStdio();
540
687
 
541
688
  const PORT = process.env.PORT || 3000;
542
- server.listen(PORT, () => {
689
+ server.listen(PORT, async () => {
690
+ await loadApiKeysFromRedis();
691
+ await loadFreeTierFromRedis();
543
692
  console.log(`[lms] Local Model Suitability MCP v${VERSION} running on port ${PORT}`);
544
693
  console.log(`[lms] Tool: check_local_viability — cloud is expensive, local is the default`);
545
694
  });