lynkr 9.0.1 → 9.1.2
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/README.md +70 -21
- package/bin/cli.js +34 -4
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/index.js +7 -3
- package/install.sh +3 -3
- package/lynkr-skill.tar.gz +0 -0
- package/native/Cargo.toml +26 -0
- package/native/index.js +29 -0
- package/native/lynkr-native.node +0 -0
- package/native/src/lib.rs +321 -0
- package/package.json +6 -5
- package/public/dashboard.html +665 -0
- package/src/api/files-multipart.js +30 -0
- package/src/api/files-router.js +81 -0
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/openai-router.js +353 -301
- package/src/api/router.js +275 -40
- package/src/cache/prompt.js +13 -0
- package/src/clients/databricks.js +42 -18
- package/src/clients/ollama-utils.js +21 -17
- package/src/clients/openai-format.js +50 -10
- package/src/clients/openrouter-utils.js +42 -37
- package/src/clients/prompt-cache-injection.js +140 -0
- package/src/clients/provider-capabilities.js +41 -0
- package/src/clients/responses-format.js +8 -7
- package/src/clients/standard-tools.js +1 -1
- package/src/clients/xml-tool-extractor.js +307 -0
- package/src/cluster.js +82 -0
- package/src/config/index.js +16 -0
- package/src/context/distill.js +15 -0
- package/src/context/tool-result-compressor.js +563 -0
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/extractor.js +22 -0
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +163 -204
- package/src/orchestrator/preflight.js +188 -0
- package/src/routing/index.js +64 -32
- package/src/routing/interaction.js +183 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/telemetry.js +47 -2
- package/src/server.js +15 -0
- package/src/stores/file-store.js +104 -0
- package/src/stores/response-store.js +25 -0
- package/src/tools/index.js +1 -1
- package/src/tools/smart-selection.js +11 -2
- package/src/tools/web.js +1 -1
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
- package/.lynkr/telemetry.db +0 -0
- package/.lynkr/telemetry.db-shm +0 -0
- package/.lynkr/telemetry.db-wal +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight Checks
|
|
3
|
+
*
|
|
4
|
+
* Runs user-supplied commands before invoking the model. If they all
|
|
5
|
+
* exit 0, the work is already done — we skip the LLM call entirely
|
|
6
|
+
* and return a synthetic "preflight_satisfied" response at zero cost.
|
|
7
|
+
*
|
|
8
|
+
* Typical use case: a fix-the-failing-test request that arrives after
|
|
9
|
+
* the test already passes (CI lag, retry-after-fix, idempotent agent
|
|
10
|
+
* retries).
|
|
11
|
+
*
|
|
12
|
+
* The request opts in by including a top-level `preflight_commands`
|
|
13
|
+
* array on the Anthropic-format payload, e.g.:
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "model": "...",
|
|
17
|
+
* "messages": [...],
|
|
18
|
+
* "preflight_commands": ["pnpm test -- user-service"]
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Disabled by default — gated on LYNKR_PREFLIGHT_ENABLED=true. The
|
|
22
|
+
* commands run with the same permissions as the Lynkr server, so
|
|
23
|
+
* operators should only enable this on workspaces where that is OK.
|
|
24
|
+
*
|
|
25
|
+
* @module orchestrator/preflight
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { spawnSync } = require('child_process');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const config = require('../config');
|
|
31
|
+
const logger = require('../logger');
|
|
32
|
+
|
|
33
|
+
const MAX_COMMANDS = 10;
|
|
34
|
+
const MAX_OUTPUT_BYTES = 4000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract the preflight command list from a request payload.
|
|
38
|
+
* Accepts either `preflight_commands` (Lynkr-specific) or
|
|
39
|
+
* `metadata.lynkr_preflight_commands` (for clients that strip unknown
|
|
40
|
+
* top-level fields).
|
|
41
|
+
*
|
|
42
|
+
* @param {object} payload
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
function extractCommands(payload) {
|
|
46
|
+
if (!payload) return [];
|
|
47
|
+
const raw =
|
|
48
|
+
payload.preflight_commands ||
|
|
49
|
+
payload.metadata?.lynkr_preflight_commands ||
|
|
50
|
+
[];
|
|
51
|
+
if (!Array.isArray(raw)) return [];
|
|
52
|
+
return raw
|
|
53
|
+
.filter(cmd => typeof cmd === 'string' && cmd.trim().length > 0)
|
|
54
|
+
.slice(0, MAX_COMMANDS);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the workspace path for command execution. Falls back to
|
|
59
|
+
* process.cwd() if no workspace is supplied (the caller should usually
|
|
60
|
+
* pass one explicitly).
|
|
61
|
+
*
|
|
62
|
+
* @param {string|null|undefined} cwd
|
|
63
|
+
* @returns {string|null} absolute path, or null if invalid
|
|
64
|
+
*/
|
|
65
|
+
function resolveCwd(cwd) {
|
|
66
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
67
|
+
if (!path.isAbsolute(cwd)) return null;
|
|
68
|
+
return cwd;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run a single command, returning a structured result.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} command
|
|
75
|
+
* @param {string} cwd
|
|
76
|
+
* @param {number} timeoutMs
|
|
77
|
+
* @returns {{ command: string, exit_code: number|null, stdout: string, stderr: string, timed_out: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
function runCommand(command, cwd, timeoutMs) {
|
|
80
|
+
const result = spawnSync(command, {
|
|
81
|
+
cwd,
|
|
82
|
+
shell: true,
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
command,
|
|
89
|
+
exit_code: result.status,
|
|
90
|
+
stdout: (result.stdout || '').slice(-MAX_OUTPUT_BYTES),
|
|
91
|
+
stderr: (result.stderr || '').slice(-MAX_OUTPUT_BYTES),
|
|
92
|
+
timed_out: result.signal === 'SIGTERM',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Try the preflight pass. Returns null when preflight should be
|
|
98
|
+
* skipped (disabled, no commands, missing cwd). Returns a result
|
|
99
|
+
* object otherwise.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} args
|
|
102
|
+
* @param {object} args.payload - Anthropic-format request payload
|
|
103
|
+
* @param {string} [args.cwd] - Workspace cwd (absolute path)
|
|
104
|
+
* @returns {null | {
|
|
105
|
+
* satisfied: boolean,
|
|
106
|
+
* results: object[],
|
|
107
|
+
* failedCommand: string|null,
|
|
108
|
+
* reason: string,
|
|
109
|
+
* }}
|
|
110
|
+
*/
|
|
111
|
+
function tryPreflight({ payload, cwd }) {
|
|
112
|
+
if (!config.routing?.preflightEnabled) return null;
|
|
113
|
+
const commands = extractCommands(payload);
|
|
114
|
+
if (commands.length === 0) return null;
|
|
115
|
+
const workspaceCwd = resolveCwd(cwd);
|
|
116
|
+
if (!workspaceCwd) {
|
|
117
|
+
logger.debug({ cwd }, '[Preflight] No valid cwd, skipping');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timeoutMs = config.routing?.preflightTimeoutMs || 120000;
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const command of commands) {
|
|
124
|
+
const r = runCommand(command, workspaceCwd, timeoutMs);
|
|
125
|
+
results.push(r);
|
|
126
|
+
if (r.exit_code !== 0) {
|
|
127
|
+
return {
|
|
128
|
+
satisfied: false,
|
|
129
|
+
results,
|
|
130
|
+
failedCommand: command,
|
|
131
|
+
reason: r.timed_out
|
|
132
|
+
? `Preflight command timed out: ${command}`
|
|
133
|
+
: `Preflight command exited ${r.exit_code}: ${command}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
satisfied: true,
|
|
139
|
+
results,
|
|
140
|
+
failedCommand: null,
|
|
141
|
+
reason: 'All preflight commands passed.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a synthetic "preflight satisfied" Anthropic Message response
|
|
147
|
+
* that processMessage can return without hitting the model.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} args
|
|
150
|
+
* @param {string} args.model
|
|
151
|
+
* @param {object} args.preflightResult
|
|
152
|
+
* @returns {object} The full processMessage return value.
|
|
153
|
+
*/
|
|
154
|
+
function buildSatisfiedResponse({ model, preflightResult }) {
|
|
155
|
+
const summary = `Preflight satisfied — work appears already complete (${preflightResult.results.length} command${preflightResult.results.length === 1 ? '' : 's'} passed).`;
|
|
156
|
+
return {
|
|
157
|
+
response: {
|
|
158
|
+
json: {
|
|
159
|
+
id: `msg_preflight_${Date.now()}`,
|
|
160
|
+
type: 'message',
|
|
161
|
+
role: 'assistant',
|
|
162
|
+
content: [{ type: 'text', text: summary }],
|
|
163
|
+
model,
|
|
164
|
+
stop_reason: 'end_turn',
|
|
165
|
+
stop_sequence: null,
|
|
166
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
167
|
+
lynkr_preflight: {
|
|
168
|
+
satisfied: true,
|
|
169
|
+
reason: preflightResult.reason,
|
|
170
|
+
results: preflightResult.results,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
ok: true,
|
|
174
|
+
status: 200,
|
|
175
|
+
},
|
|
176
|
+
steps: 0,
|
|
177
|
+
durationMs: 0,
|
|
178
|
+
terminationReason: 'preflight_satisfied',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
tryPreflight,
|
|
184
|
+
buildSatisfiedResponse,
|
|
185
|
+
extractCommands,
|
|
186
|
+
// Exposed for tests
|
|
187
|
+
resolveCwd,
|
|
188
|
+
};
|
package/src/routing/index.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
const { getAgenticDetector, AGENT_TYPES } = require('./agentic-detector');
|
|
23
23
|
const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
|
|
24
24
|
const { getCostOptimizer } = require('./cost-optimizer');
|
|
25
|
+
const { analyzeRisk } = require('./risk-analyzer');
|
|
25
26
|
|
|
26
27
|
// Telemetry modules
|
|
27
28
|
const telemetry = require('./telemetry');
|
|
@@ -97,6 +98,18 @@ function getBestLocalProvider() {
|
|
|
97
98
|
async function determineProviderSmart(payload, options = {}) {
|
|
98
99
|
const primaryProvider = config.modelProvider?.type ?? 'databricks';
|
|
99
100
|
|
|
101
|
+
// Risk analysis runs orthogonally to complexity. We compute it once
|
|
102
|
+
// up-front so it can short-circuit force_local and feed the tier
|
|
103
|
+
// selector below. Even when tier routing is disabled we still surface
|
|
104
|
+
// the signal for telemetry.
|
|
105
|
+
let risk = null;
|
|
106
|
+
try {
|
|
107
|
+
risk = analyzeRisk(payload);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logger.debug({ err: err.message }, '[Routing] Risk analysis failed, ignoring');
|
|
110
|
+
risk = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// If tier routing is disabled, use static configuration
|
|
101
114
|
if (!config.modelTiers?.enabled) {
|
|
102
115
|
return {
|
|
@@ -104,9 +117,39 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
104
117
|
model: null,
|
|
105
118
|
method: 'static',
|
|
106
119
|
reason: 'tier_routing_disabled',
|
|
120
|
+
risk,
|
|
107
121
|
};
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
// High-risk requests jump straight to COMPLEX and skip the rest of
|
|
125
|
+
// the analysis. This is independent of complexity score — a one-line
|
|
126
|
+
// edit to auth/middleware.ts should never go to a local model.
|
|
127
|
+
if (risk?.level === 'high' && isFallbackEnabled()) {
|
|
128
|
+
try {
|
|
129
|
+
const selector = getModelTierSelector();
|
|
130
|
+
const modelSelection = selector.selectModel('COMPLEX', null);
|
|
131
|
+
const decision = {
|
|
132
|
+
provider: modelSelection.provider,
|
|
133
|
+
model: modelSelection.model,
|
|
134
|
+
tier: 'COMPLEX',
|
|
135
|
+
method: 'risk',
|
|
136
|
+
reason: 'high_risk_forced_tier',
|
|
137
|
+
score: 100,
|
|
138
|
+
risk,
|
|
139
|
+
};
|
|
140
|
+
routingMetrics.record(decision);
|
|
141
|
+
logger.debug({
|
|
142
|
+
tier: 'COMPLEX',
|
|
143
|
+
provider: decision.provider,
|
|
144
|
+
instructionHits: risk.instructionHits,
|
|
145
|
+
pathHits: risk.pathHits,
|
|
146
|
+
}, '[Routing] High risk → forcing tier');
|
|
147
|
+
return decision;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.debug({ err: err.message }, '[Routing] Risk-forced tier selection failed, falling through');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
// Quick check for force patterns
|
|
111
154
|
if (shouldForceLocal(payload)) {
|
|
112
155
|
// When tier routing is enabled, respect TIER_SIMPLE instead of blindly choosing local
|
|
@@ -121,6 +164,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
121
164
|
method: 'force',
|
|
122
165
|
reason: 'force_local_pattern',
|
|
123
166
|
score: 0,
|
|
167
|
+
risk,
|
|
124
168
|
};
|
|
125
169
|
routingMetrics.record(decision);
|
|
126
170
|
return decision;
|
|
@@ -135,6 +179,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
135
179
|
method: 'force',
|
|
136
180
|
reason: 'force_local_pattern',
|
|
137
181
|
score: 0,
|
|
182
|
+
risk,
|
|
138
183
|
};
|
|
139
184
|
routingMetrics.record(decision);
|
|
140
185
|
return decision;
|
|
@@ -148,6 +193,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
148
193
|
method: 'force',
|
|
149
194
|
reason: 'force_cloud_pattern',
|
|
150
195
|
score: 100,
|
|
196
|
+
risk,
|
|
151
197
|
};
|
|
152
198
|
routingMetrics.record(decision);
|
|
153
199
|
return decision;
|
|
@@ -201,6 +247,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
201
247
|
reason: 'autonomous_workflow',
|
|
202
248
|
score: analysis.score,
|
|
203
249
|
agenticResult,
|
|
250
|
+
risk,
|
|
204
251
|
};
|
|
205
252
|
routingMetrics.record(decision);
|
|
206
253
|
return decision;
|
|
@@ -247,37 +294,8 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
247
294
|
selectedModel = modelSelection.model;
|
|
248
295
|
logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
|
|
249
296
|
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
if (config.routing?.costOptimization && tier) {
|
|
253
|
-
try {
|
|
254
|
-
const optimizer = getCostOptimizer();
|
|
255
|
-
const availableProviders = [provider];
|
|
256
|
-
|
|
257
|
-
// Also consider local provider if not already selected
|
|
258
|
-
const localProvider = getBestLocalProvider();
|
|
259
|
-
if (localProvider !== provider) {
|
|
260
|
-
availableProviders.push(localProvider);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const cheapest = optimizer.findCheapestForTier(tier, availableProviders);
|
|
264
|
-
if (cheapest && cheapest.provider !== provider) {
|
|
265
|
-
logger.debug({
|
|
266
|
-
from: provider,
|
|
267
|
-
to: cheapest.provider,
|
|
268
|
-
tier,
|
|
269
|
-
savings: `${cheapest.model} is cheaper`,
|
|
270
|
-
}, '[Routing] Cost optimization: switching provider');
|
|
271
|
-
|
|
272
|
-
provider = cheapest.provider;
|
|
273
|
-
selectedModel = cheapest.model;
|
|
274
|
-
costOptimized = true;
|
|
275
|
-
method = 'cost_optimized';
|
|
276
|
-
}
|
|
277
|
-
} catch (err) {
|
|
278
|
-
logger.debug({ err: err.message }, 'Cost optimization failed');
|
|
279
|
-
}
|
|
280
|
-
}
|
|
297
|
+
// TIER_* env vars are the final word — no cost optimization override.
|
|
298
|
+
// The user explicitly configured provider:model per tier; respect that.
|
|
281
299
|
|
|
282
300
|
const decision = {
|
|
283
301
|
provider,
|
|
@@ -291,7 +309,8 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
291
309
|
analysis,
|
|
292
310
|
embeddingsResult,
|
|
293
311
|
agenticResult,
|
|
294
|
-
costOptimized,
|
|
312
|
+
costOptimized: false,
|
|
313
|
+
risk,
|
|
295
314
|
};
|
|
296
315
|
|
|
297
316
|
// Phase 3: Record metrics
|
|
@@ -351,6 +370,18 @@ function getRoutingHeaders(decision) {
|
|
|
351
370
|
headers['X-Lynkr-Cost-Optimized'] = 'true';
|
|
352
371
|
}
|
|
353
372
|
|
|
373
|
+
if (decision.risk?.level) {
|
|
374
|
+
headers['X-Lynkr-Risk'] = decision.risk.level;
|
|
375
|
+
const hits = Array.from(new Set([
|
|
376
|
+
...(decision.risk.instructionHits || []),
|
|
377
|
+
...(decision.risk.pathHits || []),
|
|
378
|
+
]));
|
|
379
|
+
if (hits.length > 0) {
|
|
380
|
+
// Header values are ASCII-only; comma-join the first few hits.
|
|
381
|
+
headers['X-Lynkr-Risk-Hits'] = hits.slice(0, 8).join(',');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
354
385
|
return headers;
|
|
355
386
|
}
|
|
356
387
|
|
|
@@ -379,6 +410,7 @@ module.exports = {
|
|
|
379
410
|
|
|
380
411
|
// Re-export analyzer for direct access
|
|
381
412
|
analyzeComplexity: require('./complexity-analyzer').analyzeComplexity,
|
|
413
|
+
analyzeRisk,
|
|
382
414
|
|
|
383
415
|
// Intelligent routing modules
|
|
384
416
|
getAgenticDetector,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing Interaction Block
|
|
3
|
+
*
|
|
4
|
+
* Builds an "interaction" block that explains, in plain text, what
|
|
5
|
+
* Lynkr decided to do with a request — which tier, which provider,
|
|
6
|
+
* why it routed there, and what (if anything) the user should do next.
|
|
7
|
+
*
|
|
8
|
+
* Lynkr already surfaces this information via X-Lynkr-* response
|
|
9
|
+
* headers, but headers are invisible to most users in Claude Code /
|
|
10
|
+
* Cursor / Codex. The interaction block lives in the response body
|
|
11
|
+
* so it shows up alongside the model's reply when the visible-routing
|
|
12
|
+
* env flag is on (LYNKR_VISIBLE_ROUTING=true).
|
|
13
|
+
*
|
|
14
|
+
* @module routing/interaction
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rough estimate of cost savings vs always-COMPLEX baseline. Not
|
|
19
|
+
* invoice-grade, just a reproducible number for users to glance at.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null} tier
|
|
22
|
+
* @param {string|null} provider
|
|
23
|
+
* @returns {number} 0-100
|
|
24
|
+
*/
|
|
25
|
+
function estimateSavingsPercent(tier, provider) {
|
|
26
|
+
if (!tier) return 0;
|
|
27
|
+
const t = tier.toUpperCase();
|
|
28
|
+
// Local providers carry the same savings band as their tier.
|
|
29
|
+
const isLocal = provider && ['ollama', 'llamacpp', 'lmstudio'].includes(provider);
|
|
30
|
+
if (t === 'SIMPLE') return isLocal ? 100 : 70;
|
|
31
|
+
if (t === 'MEDIUM') return isLocal ? 90 : 45;
|
|
32
|
+
if (t === 'COMPLEX') return 10;
|
|
33
|
+
if (t === 'REASONING') return 0;
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Choose a mode label that describes what happened.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} decision
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function modeFor(decision) {
|
|
44
|
+
if (decision.method === 'risk') return 'risk_forced_tier';
|
|
45
|
+
if (decision.method === 'agentic') return 'agentic_workflow';
|
|
46
|
+
if (decision.method === 'force' && decision.reason === 'force_local_pattern') return 'force_local';
|
|
47
|
+
if (decision.method === 'force' && decision.reason === 'force_cloud_pattern') return 'force_cloud';
|
|
48
|
+
if (decision.method === 'static') return 'static';
|
|
49
|
+
return 'tier_routed';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Produce a one-line, terminal-friendly route label, e.g.
|
|
54
|
+
* "[Lynkr] tier=COMPLEX provider=databricks risk=high score=78"
|
|
55
|
+
*
|
|
56
|
+
* @param {object} decision
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function routeLabel(decision) {
|
|
60
|
+
const parts = ['[Lynkr]'];
|
|
61
|
+
if (decision.tier) parts.push(`tier=${decision.tier}`);
|
|
62
|
+
if (decision.provider) parts.push(`provider=${decision.provider}`);
|
|
63
|
+
if (decision.model) parts.push(`model=${decision.model}`);
|
|
64
|
+
if (decision.risk?.level) parts.push(`risk=${decision.risk.level}`);
|
|
65
|
+
if (typeof decision.score === 'number') parts.push(`score=${decision.score}`);
|
|
66
|
+
return parts.join(' ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Headline + next_step are model-facing prose. We keep them terse so
|
|
71
|
+
* they don't pollute the user's view when the model echoes them back.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} decision
|
|
74
|
+
* @returns {{ headline: string, next_step: string }}
|
|
75
|
+
*/
|
|
76
|
+
function copyFor(decision) {
|
|
77
|
+
const mode = modeFor(decision);
|
|
78
|
+
if (mode === 'risk_forced_tier') {
|
|
79
|
+
return {
|
|
80
|
+
headline: `Lynkr routed to ${decision.tier} tier because the request touches a protected domain.`,
|
|
81
|
+
next_step: 'Review the response carefully — sensitive logic was involved.',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (mode === 'agentic_workflow') {
|
|
85
|
+
return {
|
|
86
|
+
headline: `Lynkr detected an agentic workflow and routed to ${decision.provider || decision.tier}.`,
|
|
87
|
+
next_step: 'No action needed — autonomous workflows always use cloud providers.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (mode === 'force_local') {
|
|
91
|
+
return {
|
|
92
|
+
headline: 'Lynkr routed to the local tier (greeting or trivial request).',
|
|
93
|
+
next_step: 'No action needed.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (mode === 'force_cloud') {
|
|
97
|
+
return {
|
|
98
|
+
headline: `Lynkr forced cloud routing (${decision.provider || 'cloud'}) for this request.`,
|
|
99
|
+
next_step: 'No action needed.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (mode === 'static') {
|
|
103
|
+
return {
|
|
104
|
+
headline: `Lynkr used the static provider ${decision.provider}.`,
|
|
105
|
+
next_step: 'Tier routing is disabled — set TIER_* env vars to enable.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
headline: `Lynkr routed to the ${decision.tier || 'default'} tier (${decision.provider || 'unknown'}).`,
|
|
110
|
+
next_step: 'No action needed.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the full interaction block.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} decision - The routing decision (from determineProviderSmart
|
|
118
|
+
* or the pre-route in api/router.js). Must at least have `provider`; ideally
|
|
119
|
+
* includes `tier`, `model`, `method`, `reason`, `score`, and `risk`.
|
|
120
|
+
* @returns {object}
|
|
121
|
+
*/
|
|
122
|
+
function buildInteractionBlock(decision) {
|
|
123
|
+
if (!decision || typeof decision !== 'object') return null;
|
|
124
|
+
const { headline, next_step } = copyFor(decision);
|
|
125
|
+
return {
|
|
126
|
+
tool: 'lynkr.route',
|
|
127
|
+
mode: modeFor(decision),
|
|
128
|
+
headline,
|
|
129
|
+
route_label: routeLabel(decision),
|
|
130
|
+
reason: decision.reason || 'unspecified',
|
|
131
|
+
tier: decision.tier || null,
|
|
132
|
+
provider: decision.provider || null,
|
|
133
|
+
model: decision.model || null,
|
|
134
|
+
risk: decision.risk?.level || 'low',
|
|
135
|
+
risk_hits: Array.from(new Set([
|
|
136
|
+
...(decision.risk?.instructionHits || []),
|
|
137
|
+
...(decision.risk?.pathHits || []),
|
|
138
|
+
])),
|
|
139
|
+
complexity_score: typeof decision.score === 'number' ? decision.score : null,
|
|
140
|
+
estimated_savings_percent: estimateSavingsPercent(decision.tier, decision.provider),
|
|
141
|
+
next_step,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Attach an interaction block to an Anthropic-format response body.
|
|
147
|
+
* Mutates and returns the body.
|
|
148
|
+
*
|
|
149
|
+
* Anthropic clients ignore unknown top-level fields, so this is safe.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} body
|
|
152
|
+
* @param {object} interaction
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
function attachToAnthropicResponse(body, interaction) {
|
|
156
|
+
if (!body || !interaction) return body;
|
|
157
|
+
body.lynkr_interaction = interaction;
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Attach an interaction block to an OpenAI chat-completions response.
|
|
163
|
+
* Mutates and returns the body.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} body
|
|
166
|
+
* @param {object} interaction
|
|
167
|
+
* @returns {object}
|
|
168
|
+
*/
|
|
169
|
+
function attachToOpenAIResponse(body, interaction) {
|
|
170
|
+
if (!body || !interaction) return body;
|
|
171
|
+
body.lynkr_interaction = interaction;
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
buildInteractionBlock,
|
|
177
|
+
attachToAnthropicResponse,
|
|
178
|
+
attachToOpenAIResponse,
|
|
179
|
+
// Exposed for tests
|
|
180
|
+
estimateSavingsPercent,
|
|
181
|
+
modeFor,
|
|
182
|
+
routeLabel,
|
|
183
|
+
};
|