lynkr 9.1.2 → 9.1.4

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 (42) hide show
  1. package/README.md +21 -10
  2. package/package.json +3 -1
  3. package/scripts/build-knn-index.js +130 -0
  4. package/scripts/calibrate-thresholds.js +197 -0
  5. package/scripts/compare-policies.js +67 -0
  6. package/scripts/learn-output-ratios.js +162 -0
  7. package/scripts/refresh-pricing.js +122 -0
  8. package/scripts/run-routerarena.js +26 -0
  9. package/scripts/sample-regret.js +84 -0
  10. package/scripts/train-risk-classifier.js +191 -0
  11. package/src/api/middleware/budget-enforcer.js +60 -0
  12. package/src/api/middleware/load-shedding.js +11 -1
  13. package/src/api/middleware/tenant.js +21 -0
  14. package/src/api/router.js +19 -40
  15. package/src/budget/hierarchical-budget.js +159 -0
  16. package/src/cache/semantic.js +28 -2
  17. package/src/clients/databricks.js +59 -5
  18. package/src/config/index.js +239 -43
  19. package/src/context/toon.js +5 -4
  20. package/src/orchestrator/index.js +44 -6
  21. package/src/prompts/system.js +34 -6
  22. package/src/routing/bandit.js +246 -0
  23. package/src/routing/cascade.js +106 -0
  24. package/src/routing/complexity-analyzer.js +7 -15
  25. package/src/routing/confidence-scorer.js +121 -0
  26. package/src/routing/context-validator.js +71 -0
  27. package/src/routing/cost-optimizer.js +5 -2
  28. package/src/routing/deadline.js +52 -0
  29. package/src/routing/drift-monitor.js +113 -0
  30. package/src/routing/embedding-cache.js +77 -0
  31. package/src/routing/index.js +314 -5
  32. package/src/routing/knn-router.js +206 -0
  33. package/src/routing/latency-tracker.js +113 -71
  34. package/src/routing/model-tiers.js +156 -6
  35. package/src/routing/output-ratios.js +57 -0
  36. package/src/routing/regret-estimator.js +91 -0
  37. package/src/routing/reward-pipeline.js +62 -0
  38. package/src/routing/risk-classifier.js +130 -0
  39. package/src/routing/shadow-mode.js +77 -0
  40. package/src/routing/tenant-policy.js +96 -0
  41. package/src/routing/tokenizer.js +162 -0
  42. package/src/server.js +9 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Risk classifier (Phase 3.4).
3
+ *
4
+ * Replaces the regex-based risk-analyzer with a small logistic-regression
5
+ * model trained on TF-IDF of unigrams + bigrams. Bootstrap labels come from
6
+ * the existing regex matcher; subsequent training uses telemetry-flagged
7
+ * outcomes (set the request header `x-lynkr-risk-confirmed: true` to mark a
8
+ * request as truly risky for training).
9
+ *
10
+ * Falls back to the existing regex analyzer when no model artifact is present
11
+ * at data/risk-classifier.json. Model weights are JSON-serializable so they
12
+ * load fast and can be diffed in PRs.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const logger = require('../logger');
18
+ const { analyzeRisk: regexAnalyzeRisk } = require('./risk-analyzer');
19
+
20
+ const MODEL_PATH = path.join(__dirname, '../../data/risk-classifier.json');
21
+ const DECISION_THRESHOLD = 0.5;
22
+
23
+ let _model = null;
24
+ let _modelLoaded = false;
25
+
26
+ function _tokenize(text) {
27
+ if (!text || typeof text !== 'string') return [];
28
+ return text.toLowerCase().split(/[^a-z0-9_\-/.]+/).filter(Boolean);
29
+ }
30
+
31
+ function _features(text) {
32
+ const tokens = _tokenize(text);
33
+ const out = new Map();
34
+ for (let i = 0; i < tokens.length; i++) {
35
+ out.set(tokens[i], (out.get(tokens[i]) || 0) + 1);
36
+ if (i + 1 < tokens.length) {
37
+ const bigram = `${tokens[i]} ${tokens[i + 1]}`;
38
+ out.set(bigram, (out.get(bigram) || 0) + 1);
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function _loadModel() {
45
+ if (_modelLoaded) return _model;
46
+ _modelLoaded = true;
47
+ try {
48
+ if (!fs.existsSync(MODEL_PATH)) return null;
49
+ const raw = JSON.parse(fs.readFileSync(MODEL_PATH, 'utf8'));
50
+ if (!raw?.weights || !raw?.bias) return null;
51
+ _model = raw;
52
+ return _model;
53
+ } catch (err) {
54
+ logger.debug({ err: err.message }, '[RiskClassifier] Model load failed');
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function _sigmoid(z) {
60
+ if (z >= 0) return 1 / (1 + Math.exp(-z));
61
+ const ez = Math.exp(z);
62
+ return ez / (1 + ez);
63
+ }
64
+
65
+ function _predict(text, model) {
66
+ const feats = _features(text);
67
+ let z = model.bias;
68
+ for (const [tok, count] of feats) {
69
+ const w = model.weights[tok];
70
+ if (typeof w === 'number') z += w * count;
71
+ }
72
+ return _sigmoid(z);
73
+ }
74
+
75
+ /**
76
+ * Drop-in replacement for analyzeRisk(payload).
77
+ * Returns { level: 'low'|'medium'|'high', score, ...regexHits } so it's
78
+ * compatible with the existing telemetry pipeline.
79
+ */
80
+ function analyzeRisk(payload) {
81
+ // Always run the regex analyzer for hit details (kept for telemetry).
82
+ const regexResult = regexAnalyzeRisk(payload);
83
+
84
+ const model = _loadModel();
85
+ if (!model) return regexResult;
86
+
87
+ // Build the text we feed to the classifier: latest user message + tool defs + system fingerprint
88
+ let text = '';
89
+ if (Array.isArray(payload?.messages)) {
90
+ for (let i = payload.messages.length - 1; i >= 0; i--) {
91
+ const msg = payload.messages[i];
92
+ if (msg?.role === 'user') {
93
+ if (typeof msg.content === 'string') text = msg.content;
94
+ else if (Array.isArray(msg.content)) {
95
+ text = msg.content.filter(b => b?.type === 'text').map(b => b.text).join(' ');
96
+ }
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ if (typeof payload?.system === 'string') text += ' ' + payload.system;
102
+
103
+ const prob = _predict(text, model);
104
+ let level;
105
+ if (prob >= 0.75) level = 'high';
106
+ else if (prob >= DECISION_THRESHOLD) level = 'medium';
107
+ else level = 'low';
108
+
109
+ // Reconcile with regex: if classifier disagrees with regex by a lot, prefer the stricter signal.
110
+ // (We never want to *downgrade* a regex-flagged high-risk request silently.)
111
+ if (regexResult?.level === 'high' && level !== 'high') level = 'high';
112
+
113
+ return {
114
+ ...regexResult,
115
+ level,
116
+ score: prob,
117
+ classifierUsed: true,
118
+ };
119
+ }
120
+
121
+ function reloadModel() {
122
+ _modelLoaded = false;
123
+ _model = null;
124
+ }
125
+
126
+ module.exports = {
127
+ analyzeRisk,
128
+ reloadModel,
129
+ _internal: { _features, _predict },
130
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shadow-mode policy A/B testing (Phase 4.4).
3
+ *
4
+ * Lets us test a new routing policy against production without serving its
5
+ * decisions. The shadow policy runs alongside the active policy, makes its
6
+ * decision, and that decision is logged. A weekly comparison job
7
+ * (scripts/compare-policies.js) summarises agreement, cost delta, and (via
8
+ * the regret estimator) projected quality delta on the disagreed-on subset.
9
+ *
10
+ * Activation:
11
+ * - Set LYNKR_SHADOW_POLICY=<name> to enable
12
+ * - Implement and register policies via registerPolicy()
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const logger = require('../logger');
18
+
19
+ const LOG_PATH = path.join(__dirname, '../../data/shadow-decisions.jsonl');
20
+
21
+ const _registry = new Map();
22
+
23
+ function registerPolicy(name, fn) {
24
+ if (typeof fn !== 'function') throw new Error('Policy must be a function');
25
+ _registry.set(name, fn);
26
+ }
27
+
28
+ function isEnabled() {
29
+ return !!process.env.LYNKR_SHADOW_POLICY && _registry.has(process.env.LYNKR_SHADOW_POLICY);
30
+ }
31
+
32
+ function getShadowPolicy() {
33
+ if (!isEnabled()) return null;
34
+ return _registry.get(process.env.LYNKR_SHADOW_POLICY);
35
+ }
36
+
37
+ function _appendLog(entry) {
38
+ try {
39
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
40
+ fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n');
41
+ } catch (err) {
42
+ logger.debug({ err: err.message }, '[ShadowMode] Log append failed');
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Compare active and shadow decisions on the same payload, log the result.
48
+ * Does NOT change which decision is served — the caller uses activeDecision.
49
+ */
50
+ async function compareAndLog({ payload, activeDecision, shadowFn }) {
51
+ if (!shadowFn) return null;
52
+ let shadowDecision;
53
+ try {
54
+ shadowDecision = await shadowFn(payload);
55
+ } catch (err) {
56
+ logger.debug({ err: err.message }, '[ShadowMode] Shadow policy failed');
57
+ return null;
58
+ }
59
+ const agree = activeDecision.provider === shadowDecision?.provider
60
+ && activeDecision.model === shadowDecision?.model;
61
+ _appendLog({
62
+ timestamp: Date.now(),
63
+ policy: process.env.LYNKR_SHADOW_POLICY,
64
+ agree,
65
+ active: { provider: activeDecision.provider, model: activeDecision.model, tier: activeDecision.tier, score: activeDecision.score },
66
+ shadow: shadowDecision ? { provider: shadowDecision.provider, model: shadowDecision.model, tier: shadowDecision.tier, score: shadowDecision.score } : null,
67
+ });
68
+ return { agree, shadow: shadowDecision };
69
+ }
70
+
71
+ module.exports = {
72
+ registerPolicy,
73
+ isEnabled,
74
+ getShadowPolicy,
75
+ compareAndLog,
76
+ LOG_PATH,
77
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Per-tenant routing policy (Phase 6.1).
3
+ *
4
+ * Each tenant can override:
5
+ * - tier thresholds (which complexity scores map to which tiers)
6
+ * - reward weights (λ for cost, μ for latency in the bandit)
7
+ * - max acceptable latency
8
+ * - blocked models (never route to these)
9
+ *
10
+ * Tenant id is read from the `LYNKR_TENANT_ID` request header. Per-tenant
11
+ * configs live in data/tenants/<id>.json. Falls back to global config when
12
+ * the id is absent or the file doesn't exist.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const logger = require('../logger');
18
+
19
+ const TENANTS_DIR = path.join(__dirname, '../../data/tenants');
20
+ const _cache = new Map();
21
+ const RELOAD_INTERVAL_MS = 60_000;
22
+
23
+ function _loadTenant(tenantId) {
24
+ if (!tenantId) return null;
25
+ const cached = _cache.get(tenantId);
26
+ if (cached && Date.now() - cached.loadedAt < RELOAD_INTERVAL_MS) return cached.config;
27
+
28
+ const file = path.join(TENANTS_DIR, `${tenantId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
29
+ if (!fs.existsSync(file)) {
30
+ _cache.set(tenantId, { config: null, loadedAt: Date.now() });
31
+ return null;
32
+ }
33
+ try {
34
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
35
+ _cache.set(tenantId, { config: data, loadedAt: Date.now() });
36
+ return data;
37
+ } catch (err) {
38
+ logger.warn({ tenantId, err: err.message }, '[TenantPolicy] Load failed');
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function getPolicy(tenantId) {
44
+ const t = _loadTenant(tenantId);
45
+ if (!t) return null;
46
+ return {
47
+ tenantId,
48
+ tierRanges: t.tierRanges || null,
49
+ rewardWeights: t.rewardWeights || null,
50
+ maxLatencyMs: t.maxLatencyMs ?? null,
51
+ blockedModels: Array.isArray(t.blockedModels) ? new Set(t.blockedModels) : null,
52
+ preferredProviders: Array.isArray(t.preferredProviders) ? t.preferredProviders : null,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Apply tenant overrides to a routing decision after the main algorithm has
58
+ * produced one. Returns either the decision unchanged or a new decision
59
+ * respecting the tenant constraints.
60
+ */
61
+ function applyTenantOverrides(decision, tenantPolicy) {
62
+ if (!tenantPolicy || !decision) return decision;
63
+ // Blocked model → fall back to next-cheapest qualifying model in same tier
64
+ if (tenantPolicy.blockedModels && decision.model && tenantPolicy.blockedModels.has(decision.model)) {
65
+ const { getCostOptimizer } = require('./cost-optimizer');
66
+ const optimizer = getCostOptimizer();
67
+ const cheapest = optimizer.findCheapestForTier(decision.tier, tenantPolicy.preferredProviders || []);
68
+ if (cheapest && !tenantPolicy.blockedModels.has(cheapest.model)) {
69
+ return {
70
+ ...decision,
71
+ provider: cheapest.provider,
72
+ model: cheapest.model,
73
+ method: (decision.method || '') + '+tenant_override',
74
+ tenantOverride: { reason: 'blocked_model', tenantId: tenantPolicy.tenantId },
75
+ };
76
+ }
77
+ }
78
+ return decision;
79
+ }
80
+
81
+ function getTenantId(req) {
82
+ if (!req) return null;
83
+ const h = req.headers || req;
84
+ return (h['lynkr-tenant-id'] || h['LYNKR-Tenant-Id'] || h['x-tenant-id'] || null);
85
+ }
86
+
87
+ function reloadCache() {
88
+ _cache.clear();
89
+ }
90
+
91
+ module.exports = {
92
+ getPolicy,
93
+ getTenantId,
94
+ applyTenantOverrides,
95
+ reloadCache,
96
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Accurate token estimation using js-tiktoken.
3
+ *
4
+ * Replaces the chars/4 approximation across the routing path. Falls back to
5
+ * chars/4 if js-tiktoken is unavailable (graceful degradation — never throws).
6
+ *
7
+ * Phase 1.1 of the routing overhaul.
8
+ *
9
+ * @module routing/tokenizer
10
+ */
11
+
12
+ const logger = require('../logger');
13
+
14
+ let _tiktoken = null;
15
+ let _tiktokenLoaded = false;
16
+ const _encoderCache = new Map();
17
+
18
+ function _loadTiktoken() {
19
+ if (_tiktokenLoaded) return _tiktoken;
20
+ _tiktokenLoaded = true;
21
+ try {
22
+ _tiktoken = require('js-tiktoken');
23
+ } catch (err) {
24
+ logger.debug(
25
+ { err: err.message },
26
+ '[Tokenizer] js-tiktoken not available, falling back to chars/4'
27
+ );
28
+ _tiktoken = null;
29
+ }
30
+ return _tiktoken;
31
+ }
32
+
33
+ function _encodingForModel(model) {
34
+ if (!model || typeof model !== 'string') return 'cl100k_base';
35
+ const lower = model.toLowerCase();
36
+ // GPT-4o family + o-series use o200k_base
37
+ if (
38
+ lower.includes('gpt-4o') ||
39
+ lower.includes('gpt-4.1') ||
40
+ lower.includes('gpt-5') ||
41
+ lower.includes('o1') ||
42
+ lower.includes('o3') ||
43
+ lower.includes('o4')
44
+ ) {
45
+ return 'o200k_base';
46
+ }
47
+ // GPT-4 / GPT-3.5 / Anthropic / most others approximate well with cl100k_base
48
+ return 'cl100k_base';
49
+ }
50
+
51
+ function _getEncoder(model) {
52
+ const tiktoken = _loadTiktoken();
53
+ if (!tiktoken) return null;
54
+ const encName = _encodingForModel(model);
55
+ let cached = _encoderCache.get(encName);
56
+ if (cached) return cached;
57
+ try {
58
+ cached = tiktoken.getEncoding(encName);
59
+ _encoderCache.set(encName, cached);
60
+ return cached;
61
+ } catch (err) {
62
+ logger.debug(
63
+ { err: err.message, encoding: encName },
64
+ '[Tokenizer] Encoder load failed, using fallback'
65
+ );
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Count tokens in a single string.
72
+ * @param {string} text
73
+ * @param {string|null} model - optional model name for encoding selection
74
+ * @returns {number}
75
+ */
76
+ function countTokens(text, model = null) {
77
+ if (!text || typeof text !== 'string') return 0;
78
+ const encoder = _getEncoder(model);
79
+ if (!encoder) return Math.ceil(text.length / 4);
80
+ try {
81
+ return encoder.encode(text).length;
82
+ } catch (err) {
83
+ return Math.ceil(text.length / 4);
84
+ }
85
+ }
86
+
87
+ function _extractText(content) {
88
+ if (!content) return '';
89
+ if (typeof content === 'string') return content;
90
+ if (Array.isArray(content)) {
91
+ let combined = '';
92
+ for (const block of content) {
93
+ if (!block) continue;
94
+ if (typeof block === 'string') {
95
+ combined += block + ' ';
96
+ } else if (block.type === 'text' && block.text) {
97
+ combined += block.text + ' ';
98
+ } else if (typeof block.text === 'string') {
99
+ combined += block.text + ' ';
100
+ } else if (block.type === 'tool_use' && block.input) {
101
+ try {
102
+ combined += JSON.stringify(block.input) + ' ';
103
+ } catch {
104
+ // ignore non-serializable input
105
+ }
106
+ } else if (block.type === 'tool_result' && block.content) {
107
+ combined += _extractText(block.content) + ' ';
108
+ }
109
+ }
110
+ return combined;
111
+ }
112
+ return '';
113
+ }
114
+
115
+ function _imageTokenEstimate(content) {
116
+ if (!Array.isArray(content)) return 0;
117
+ let imageBase64Bytes = 0;
118
+ for (const block of content) {
119
+ if (block?.type === 'image' && block.source?.data) {
120
+ imageBase64Bytes += block.source.data.length;
121
+ }
122
+ }
123
+ // Rough heuristic mirroring previous behavior: ~1 token per 6 base64 chars
124
+ return Math.floor(imageBase64Bytes / 6);
125
+ }
126
+
127
+ /**
128
+ * Count tokens across a full Anthropic-format message array + optional system.
129
+ * @param {Array} messages
130
+ * @param {string|Array|null} system
131
+ * @param {string|null} model
132
+ * @returns {number}
133
+ */
134
+ function countMessagesTokens(messages = [], system = null, model = null) {
135
+ let total = 0;
136
+ if (system) {
137
+ total += countTokens(_extractText(system), model);
138
+ }
139
+ if (Array.isArray(messages)) {
140
+ for (const msg of messages) {
141
+ total += countTokens(_extractText(msg?.content), model);
142
+ total += _imageTokenEstimate(msg?.content);
143
+ }
144
+ // Per-message structural overhead (~4 tokens per message in both Anthropic and OpenAI)
145
+ total += messages.length * 4;
146
+ }
147
+ return total;
148
+ }
149
+
150
+ /**
151
+ * Count tokens from a full payload object (Anthropic-style with .messages, .system, .model).
152
+ */
153
+ function countPayloadTokens(payload, model = null) {
154
+ if (!payload) return 0;
155
+ return countMessagesTokens(payload.messages, payload.system, model || payload.model);
156
+ }
157
+
158
+ module.exports = {
159
+ countTokens,
160
+ countMessagesTokens,
161
+ countPayloadTokens,
162
+ };
package/src/server.js CHANGED
@@ -9,6 +9,8 @@ const { metricsMiddleware } = require("./api/middleware/metrics");
9
9
  const { requestLoggingMiddleware } = require("./api/middleware/request-logging");
10
10
  const { errorHandlingMiddleware, notFoundHandler } = require("./api/middleware/error-handling");
11
11
  const { loadSheddingMiddleware, initializeLoadShedder } = require("./api/middleware/load-shedding");
12
+ const { tenantMiddleware } = require("./api/middleware/tenant");
13
+ const { budgetEnforcer } = require("./api/middleware/budget-enforcer");
12
14
  const { livenessCheck, readinessCheck } = require("./api/health");
13
15
  const { getMetricsCollector } = require("./observability/metrics");
14
16
  const { getShutdownManager } = require("./server/shutdown");
@@ -90,6 +92,13 @@ function createApp() {
90
92
  app.use('/v1/messages', budgetMiddleware);
91
93
  }
92
94
 
95
+ // Phase 6.1 — per-tenant routing policies (LYNKR-Tenant-Id header).
96
+ // Runs before message handling so res.locals.tenantPolicy is populated.
97
+ app.use('/v1/messages', tenantMiddleware);
98
+
99
+ // Phase 6.2 — hierarchical budget enforcement (LYNKR_BUDGET_ENFORCER=false to disable).
100
+ app.use('/v1/messages', budgetEnforcer);
101
+
93
102
  // Health check endpoints
94
103
  app.get("/health/live", livenessCheck);
95
104
  app.get("/health/ready", readinessCheck);