lynkr 9.9.0 → 9.10.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.
- package/README.md +92 -23
- package/bin/cli.js +13 -7
- package/bin/lynkr-init.js +34 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +144 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +292 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/context/tool-result-compressor.js +883 -40
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +261 -0
- package/src/routing/index.js +70 -7
- package/src/routing/intent-score.js +132 -12
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +20 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -92,9 +92,23 @@ const ADVANCED_PATTERNS = {
|
|
|
92
92
|
},
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
// Force
|
|
96
|
-
|
|
95
|
+
// Force REASONING patterns - deterministic escalation to top tier (Claude in
|
|
96
|
+
// config B). Checked before force_cloud. Hardcoded constants (no env var).
|
|
97
|
+
// Security/governance asks route to the trusted Claude provider; deep
|
|
98
|
+
// reasoning asks route to extended thinking.
|
|
99
|
+
const FORCE_REASONING_PATTERNS = [
|
|
97
100
|
/\b(security\s+(audit|review|assessment)|penetration\s+test|vulnerability\s+scan)\b/i,
|
|
101
|
+
/\b(ultrathink|ultra[\s-]?think)\b/i,
|
|
102
|
+
/\b(think\s+(hard|deeply|carefully|step[\s-]by[\s-]step|through\s+this))/i,
|
|
103
|
+
/\b(prove|proof|formal\s+proof|verify|verification)\b/i,
|
|
104
|
+
/\b(from\s+first\s+principles)\b/i,
|
|
105
|
+
/\b(reason\s+(through|about|from)\s+(the|this))/i,
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// Force COMPLEX patterns - route to COMPLEX (GLM in config B) regardless of
|
|
109
|
+
// score. Architecture/refactor/code-review are substantial but not
|
|
110
|
+
// security-critical, so GLM is appropriate.
|
|
111
|
+
const FORCE_CLOUD_PATTERNS = [
|
|
98
112
|
/\b(architect(ure)?\s+(review|design|diagram)|system\s+design)\b/i,
|
|
99
113
|
/\b(refactor\s+(entire|whole|all|the\s+entire)|complete\s+rewrite)\b/i,
|
|
100
114
|
/\b(code\s+review|pr\s+review|pull\s+request\s+review)\b/i,
|
|
@@ -465,7 +479,13 @@ function scoreTools(payload) {
|
|
|
465
479
|
function scoreTaskType(content) {
|
|
466
480
|
const contentLower = content.toLowerCase();
|
|
467
481
|
|
|
468
|
-
// Check force patterns first
|
|
482
|
+
// Check force patterns first (priority: REASONING > cloud > local)
|
|
483
|
+
for (const pattern of FORCE_REASONING_PATTERNS) {
|
|
484
|
+
if (pattern.test(content)) {
|
|
485
|
+
return { score: 100, reason: 'force_reasoning', pattern: pattern.source.slice(0, 40) };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
469
489
|
for (const pattern of FORCE_LOCAL_PATTERNS) {
|
|
470
490
|
if (pattern.test(content)) {
|
|
471
491
|
return { score: 0, reason: 'force_local', pattern: 'greeting_or_simple' };
|
|
@@ -474,7 +494,7 @@ function scoreTaskType(content) {
|
|
|
474
494
|
|
|
475
495
|
for (const pattern of FORCE_CLOUD_PATTERNS) {
|
|
476
496
|
if (pattern.test(content)) {
|
|
477
|
-
return { score:
|
|
497
|
+
return { score: 75, reason: 'force_cloud', pattern: pattern.source.slice(0, 30) };
|
|
478
498
|
}
|
|
479
499
|
}
|
|
480
500
|
|
|
@@ -926,6 +946,20 @@ function shouldForceCloud(payload) {
|
|
|
926
946
|
return !!matched;
|
|
927
947
|
}
|
|
928
948
|
|
|
949
|
+
/**
|
|
950
|
+
* Quick check if request should be forced to REASONING tier (Claude in
|
|
951
|
+
* config B). Matches: ultrathink, think hard/deeply, prove/formal proof,
|
|
952
|
+
* from first principles, security audit.
|
|
953
|
+
*/
|
|
954
|
+
function shouldForceReasoning(payload) {
|
|
955
|
+
const content = extractContent(payload);
|
|
956
|
+
const matched = FORCE_REASONING_PATTERNS.find(pattern => pattern.test(content));
|
|
957
|
+
if (matched) {
|
|
958
|
+
logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_reasoning matched');
|
|
959
|
+
}
|
|
960
|
+
return !!matched;
|
|
961
|
+
}
|
|
962
|
+
|
|
929
963
|
// ============================================================================
|
|
930
964
|
// PHASE 4: Embeddings-Based Similarity (Optional Enhancement)
|
|
931
965
|
// ============================================================================
|
|
@@ -1041,6 +1075,7 @@ module.exports = {
|
|
|
1041
1075
|
// Quick checks
|
|
1042
1076
|
shouldForceLocal,
|
|
1043
1077
|
shouldForceCloud,
|
|
1078
|
+
shouldForceReasoning,
|
|
1044
1079
|
|
|
1045
1080
|
// Individual scoring (for testing/debugging)
|
|
1046
1081
|
scoreTokens,
|
|
@@ -1069,6 +1104,7 @@ module.exports = {
|
|
|
1069
1104
|
// Constants (for testing)
|
|
1070
1105
|
PATTERNS,
|
|
1071
1106
|
ADVANCED_PATTERNS,
|
|
1107
|
+
FORCE_REASONING_PATTERNS,
|
|
1072
1108
|
FORCE_CLOUD_PATTERNS,
|
|
1073
1109
|
FORCE_LOCAL_PATTERNS,
|
|
1074
1110
|
DIMENSION_WEIGHTS,
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Difficulty classifier — LLM-based 4-way classification of user prompts.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: The anchor-embedding scorer (intent-score.js) measures
|
|
5
|
+
* *topical* similarity to labeled difficulty exemplars. It confuses "list
|
|
6
|
+
* the exports from this file" (LOW difficulty, technical vocabulary) with
|
|
7
|
+
* hard problems. An LLM reading the actual sentence knows better.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN:
|
|
10
|
+
* - Uses whatever model is configured for the SIMPLE tier (fetched at call
|
|
11
|
+
* time via getModelTierSelector). When the user later swaps in a
|
|
12
|
+
* fine-tuned classifier as SIMPLE, this picks it up automatically.
|
|
13
|
+
* - Structured JSON output; parse failure → null → caller falls back.
|
|
14
|
+
* - Hard 2500ms timeout; on timeout → null → caller falls back.
|
|
15
|
+
* - LRU cache keyed by sha256(text.trim().toLowerCase()); capacity 500.
|
|
16
|
+
* - Skip conditions surface via classifyDifficulty returning null with
|
|
17
|
+
* reason=skipped: text.length<15, force-pattern matched, risk=high,
|
|
18
|
+
* cache hit is transparent (returns cached).
|
|
19
|
+
* - Hardcoded kill-switch CLASSIFIER_ENABLED — no env var per user policy.
|
|
20
|
+
*
|
|
21
|
+
* Failure modes are all null: anchor+lexical scoring already handles the
|
|
22
|
+
* fallback. This module never blocks routing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const logger = require('../logger');
|
|
27
|
+
|
|
28
|
+
const CLASSIFIER_ENABLED = true;
|
|
29
|
+
// Thinking models (minimax-m2.5) spend 1-4s in <thinking> before emitting
|
|
30
|
+
// JSON — 2.5s is not enough. Raise to 10s and rely on skip conditions +
|
|
31
|
+
// LRU cache to keep amortized latency low.
|
|
32
|
+
const TIMEOUT_MS = 10000;
|
|
33
|
+
const CACHE_CAPACITY = 500;
|
|
34
|
+
const MIN_TEXT_LENGTH = 15;
|
|
35
|
+
|
|
36
|
+
// Classifier model — decoupled from tier serving so SIMPLE tier can run a
|
|
37
|
+
// more capable model for real traffic while the classifier stays fast and
|
|
38
|
+
// cheap. Chosen 2026-07-19: qwen2.5:3b hit 87.3% hand-labeled accuracy at
|
|
39
|
+
// ~500ms warm latency, zero cost-critical over-routes. Replace with your
|
|
40
|
+
// fine-tuned classifier when you build one. Hardcoded per user directive
|
|
41
|
+
// (no env var).
|
|
42
|
+
const CLASSIFIER_PROVIDER = 'ollama';
|
|
43
|
+
const CLASSIFIER_MODEL = 'qwen2.5:3b';
|
|
44
|
+
|
|
45
|
+
const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
46
|
+
|
|
47
|
+
// One-shot classification prompt (v2). Kept in a const so drift is diffable.
|
|
48
|
+
// Difficulty framing (not intent) — matches config B routing goals.
|
|
49
|
+
//
|
|
50
|
+
// v2 (2026-07-21): added the follow-up rule, negative examples under
|
|
51
|
+
// REASONING, and casual-question SIMPLE examples after a live incident:
|
|
52
|
+
// qwen2.5:3b read "Who kills him ?" (a Doctor Doom plot question) as
|
|
53
|
+
// REASONING conf 1.0 — v1's REASONING examples were all formal-methods
|
|
54
|
+
// flavored and nothing said surface vocabulary isn't the signal. Baseline
|
|
55
|
+
// on data/difficulty-eval-followups.jsonl: 60% overall, 33% on SIMPLE,
|
|
56
|
+
// 3 SIMPLE→REASONING criticals.
|
|
57
|
+
const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the CURRENT user prompt into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
|
|
58
|
+
|
|
59
|
+
Tiers:
|
|
60
|
+
- SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups, and short conversational follow-up questions about people, stories, events, or everyday facts. Any tiny model handles.
|
|
61
|
+
examples: "hi", "ok thanks", "yes continue", "what time is it", "who is doctor doom?", "who kills him?", "why did he do that?", "and then what happened?", "does bleach kill mold?"
|
|
62
|
+
- MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
|
|
63
|
+
examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block", "verify the file exists before reading it"
|
|
64
|
+
- COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
|
|
65
|
+
examples: "architecture review of the orchestrator", "refactor the entire ingestion pipeline", "debug this complex race condition across three services"
|
|
66
|
+
- REASONING: formal proof, correctness verification, security audit, novel algorithm design, formal reasoning from first principles. Needs a frontier reasoning model.
|
|
67
|
+
examples: "prove the correctness of this lock-free queue", "security audit the auth middleware", "formally verify this state machine never deadlocks", "derive the optimal eviction policy and prove its competitive ratio"
|
|
68
|
+
NOT reasoning: casual questions that merely contain words like "prove", "kill", "verify", "audit" — "who kills him?" is SIMPLE, "prove me wrong lol" is SIMPLE, "can you verify the score?" is SIMPLE, "verify the file exists" is MEDIUM.
|
|
69
|
+
|
|
70
|
+
Rules:
|
|
71
|
+
- Judge the TASK the model must perform, not the vocabulary. Words like kill/prove/verify/audit/security do not make a prompt REASONING unless it demands formal or expert-level analysis.
|
|
72
|
+
- A short follow-up question (pronouns like he/she/it/that referring to the earlier conversation) about a casual topic is SIMPLE. A follow-up that extends a technical task inherits the difficulty of that task.
|
|
73
|
+
|
|
74
|
+
Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.0-1.0}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
// Short prompts are where context matters: "Who kills him ?" is
|
|
78
|
+
// unclassifiable in isolation but trivially SIMPLE next to its conversation.
|
|
79
|
+
// Long prompts self-describe, and contextualizing them would only shrink the
|
|
80
|
+
// LRU hit rate and grow latency.
|
|
81
|
+
const CONTEXT_MAX_TEXT_LENGTH = 40;
|
|
82
|
+
const CONTEXT_MAX_CHARS = 300;
|
|
83
|
+
|
|
84
|
+
function _buildPrompt(text, context) {
|
|
85
|
+
if (context) {
|
|
86
|
+
return `${CLASSIFY_PROMPT}
|
|
87
|
+
Conversation so far (context only — classify the CURRENT prompt, inheriting topic difficulty per the rules):
|
|
88
|
+
${context}
|
|
89
|
+
|
|
90
|
+
CURRENT user prompt: """${text}"""`;
|
|
91
|
+
}
|
|
92
|
+
return `${CLASSIFY_PROMPT}
|
|
93
|
+
User prompt: """${text}"""`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- LRU cache --------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
class LruCache {
|
|
99
|
+
constructor(capacity) {
|
|
100
|
+
this.capacity = capacity;
|
|
101
|
+
this.map = new Map();
|
|
102
|
+
}
|
|
103
|
+
get(key) {
|
|
104
|
+
if (!this.map.has(key)) return undefined;
|
|
105
|
+
const val = this.map.get(key);
|
|
106
|
+
this.map.delete(key);
|
|
107
|
+
this.map.set(key, val); // move to MRU
|
|
108
|
+
return val;
|
|
109
|
+
}
|
|
110
|
+
set(key, val) {
|
|
111
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
112
|
+
else if (this.map.size >= this.capacity) {
|
|
113
|
+
const oldest = this.map.keys().next().value;
|
|
114
|
+
this.map.delete(oldest);
|
|
115
|
+
}
|
|
116
|
+
this.map.set(key, val);
|
|
117
|
+
}
|
|
118
|
+
stats() { return { size: this.map.size, capacity: this.capacity }; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const _cache = new LruCache(CACHE_CAPACITY);
|
|
122
|
+
|
|
123
|
+
function _cacheKey(text) {
|
|
124
|
+
return crypto.createHash('sha256').update(text.trim().toLowerCase()).digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Ollama dispatch (only supported provider for the SIMPLE tier today) ----
|
|
128
|
+
|
|
129
|
+
async function _callOllama(text, context, opts) {
|
|
130
|
+
const config = require('../config');
|
|
131
|
+
|
|
132
|
+
// First cut supports ollama-family classifier only. Other providers can
|
|
133
|
+
// be wired later; today's classifier is qwen2.5:3b on ollama.
|
|
134
|
+
if (CLASSIFIER_PROVIDER !== 'ollama') {
|
|
135
|
+
logger.debug({ provider: CLASSIFIER_PROVIDER, model: CLASSIFIER_MODEL }, '[DifficultyClassifier] non-ollama classifier not yet supported');
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const endpoint = config.ollama?.endpoint || 'http://localhost:11434';
|
|
140
|
+
const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
|
|
141
|
+
const body = {
|
|
142
|
+
model: CLASSIFIER_MODEL,
|
|
143
|
+
messages: [{ role: 'user', content: _buildPrompt(text, context) }],
|
|
144
|
+
stream: false,
|
|
145
|
+
format: 'json',
|
|
146
|
+
// num_predict generous because thinking models (minimax-m2.5) burn
|
|
147
|
+
// budget in message.thinking before producing message.content JSON.
|
|
148
|
+
options: { temperature: 0, num_predict: 512 },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs || TIMEOUT_MS);
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(url, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'content-type': 'application/json' },
|
|
157
|
+
body: JSON.stringify(body),
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) return null;
|
|
161
|
+
const json = await res.json();
|
|
162
|
+
// Thinking models put output in message.thinking; regular in message.content.
|
|
163
|
+
// /api/generate variants use `response`. Try all three, biggest first.
|
|
164
|
+
const candidates = [
|
|
165
|
+
json?.message?.content,
|
|
166
|
+
json?.message?.thinking,
|
|
167
|
+
json?.response,
|
|
168
|
+
].filter(s => typeof s === 'string' && s.length > 0);
|
|
169
|
+
return candidates.join('\n') || null;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logger.debug({ err: err.message, textPreview: text.slice(0, 60) }, '[DifficultyClassifier] call failed');
|
|
172
|
+
return null;
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Parse & validate the model's JSON output -------------------------------
|
|
179
|
+
|
|
180
|
+
function _parseResult(raw) {
|
|
181
|
+
if (!raw) return null;
|
|
182
|
+
// Ollama with format:'json' usually gives clean JSON, but the model can
|
|
183
|
+
// still surround it with junk — extract the first { ... } span.
|
|
184
|
+
const match = raw.match(/\{[\s\S]*?\}/);
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
let obj;
|
|
187
|
+
try { obj = JSON.parse(match[0]); } catch { return null; }
|
|
188
|
+
const tier = String(obj.tier || '').toUpperCase().trim();
|
|
189
|
+
if (!VALID_TIERS.includes(tier)) return null;
|
|
190
|
+
const rawConf = Number(obj.confidence);
|
|
191
|
+
const confidence = Number.isFinite(rawConf) ? Math.max(0, Math.min(1, rawConf)) : 0.5;
|
|
192
|
+
return { tier, confidence };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Public API -------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classify difficulty of user text.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} text — cleaned user prompt
|
|
201
|
+
* @param {object} [opts]
|
|
202
|
+
* @param {number} [opts.timeoutMs] — override default 2500ms
|
|
203
|
+
* @param {boolean} [opts.forceMatched] — caller already matched a FORCE_* pattern; skip classifier
|
|
204
|
+
* @param {string} [opts.riskLevel] — 'high' | 'medium' | 'low'; skip when 'high'
|
|
205
|
+
* @param {string} [opts.context] — condensed prior conversation ("user asked:
|
|
206
|
+
* ... → assistant replied about: ..."); used only for short prompts, where
|
|
207
|
+
* a bare follow-up is unclassifiable in isolation
|
|
208
|
+
* @returns {Promise<{tier:string,confidence:number,source:'cache'|'model'}|null>}
|
|
209
|
+
* null when: disabled, skipped, model failure, parse failure, timeout.
|
|
210
|
+
*/
|
|
211
|
+
async function classifyDifficulty(text, opts = {}) {
|
|
212
|
+
if (!CLASSIFIER_ENABLED) return null;
|
|
213
|
+
if (typeof text !== 'string') return null;
|
|
214
|
+
const trimmed = text.trim();
|
|
215
|
+
if (trimmed.length < MIN_TEXT_LENGTH) return null;
|
|
216
|
+
if (opts.forceMatched) return null;
|
|
217
|
+
if (opts.riskLevel === 'high') return null;
|
|
218
|
+
|
|
219
|
+
const context =
|
|
220
|
+
typeof opts.context === 'string' && opts.context.trim() && trimmed.length <= CONTEXT_MAX_TEXT_LENGTH
|
|
221
|
+
? opts.context.trim().slice(0, CONTEXT_MAX_CHARS)
|
|
222
|
+
: null;
|
|
223
|
+
|
|
224
|
+
// Context participates in the cache key: the same follow-up text means
|
|
225
|
+
// different things in different conversations.
|
|
226
|
+
const key = _cacheKey(context ? `${trimmed}${context}` : trimmed);
|
|
227
|
+
const cached = _cache.get(key);
|
|
228
|
+
if (cached) return { ...cached, source: 'cache' };
|
|
229
|
+
|
|
230
|
+
const raw = await _callOllama(trimmed, context, opts);
|
|
231
|
+
const parsed = _parseResult(raw);
|
|
232
|
+
if (!parsed) return null;
|
|
233
|
+
_cache.set(key, parsed);
|
|
234
|
+
return { ...parsed, source: 'model' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Test helpers -----------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function _clearCacheForTests() { _cache.map.clear(); }
|
|
240
|
+
function _getCacheStats() { return _cache.stats(); }
|
|
241
|
+
|
|
242
|
+
// Exposed metadata for the bootstrap module — keeps model choice in one file
|
|
243
|
+
// so classifier-setup.js pulls exactly what the classifier will call.
|
|
244
|
+
const CLASSIFIER_MODEL_INFO = Object.freeze({
|
|
245
|
+
provider: CLASSIFIER_PROVIDER,
|
|
246
|
+
model: CLASSIFIER_MODEL,
|
|
247
|
+
endpoint: process.env.OLLAMA_ENDPOINT || 'http://localhost:11434',
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
classifyDifficulty,
|
|
252
|
+
CLASSIFIER_ENABLED,
|
|
253
|
+
CLASSIFIER_MODEL_INFO,
|
|
254
|
+
VALID_TIERS,
|
|
255
|
+
// internals for tests only
|
|
256
|
+
_parseResult,
|
|
257
|
+
_cacheKey,
|
|
258
|
+
_buildPrompt,
|
|
259
|
+
_clearCacheForTests,
|
|
260
|
+
_getCacheStats,
|
|
261
|
+
};
|
package/src/routing/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
analyzeComplexity,
|
|
15
15
|
shouldForceLocal,
|
|
16
16
|
shouldForceCloud,
|
|
17
|
+
shouldForceReasoning,
|
|
17
18
|
routingMetrics,
|
|
18
19
|
analyzeWithEmbeddings,
|
|
19
20
|
} = require('./complexity-analyzer');
|
|
@@ -583,6 +584,8 @@ function checkSessionPin(payload, options = {}) {
|
|
|
583
584
|
// the new_conversation guard for everyone after it.
|
|
584
585
|
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
585
586
|
promptTokensEst: pin.promptTokensEst,
|
|
587
|
+
// We're inside payloadHasToolHistory(payload) — session has committed.
|
|
588
|
+
hasToolHistory: true,
|
|
586
589
|
});
|
|
587
590
|
}
|
|
588
591
|
return { serve: true, pin, reason: 'tool_history', sessionId };
|
|
@@ -598,11 +601,30 @@ function checkSessionPin(payload, options = {}) {
|
|
|
598
601
|
sessionAffinity.setPin(sessionId, pin, {
|
|
599
602
|
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
600
603
|
promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
|
|
604
|
+
// setPin OR-merges with the existing pin's flag, so a plain-text turn
|
|
605
|
+
// between tool exchanges keeps the sticky-true property.
|
|
606
|
+
hasToolHistory: _payloadHasAnyToolBlocks(payload),
|
|
601
607
|
});
|
|
602
608
|
}
|
|
603
609
|
return { serve: true, pin, reason: 'guards_passed', sessionId };
|
|
604
610
|
}
|
|
605
611
|
|
|
612
|
+
/** Whole-payload scan for tool_use/tool_result blocks. Unlike
|
|
613
|
+
* sessionAffinity.payloadHasToolHistory (last message only), this checks
|
|
614
|
+
* every message so we correctly flag sessions where the last frame is a
|
|
615
|
+
* plain text turn but earlier turns carried tools. */
|
|
616
|
+
function _payloadHasAnyToolBlocks(payload) {
|
|
617
|
+
const msgs = payload?.messages;
|
|
618
|
+
if (!Array.isArray(msgs)) return false;
|
|
619
|
+
for (const m of msgs) {
|
|
620
|
+
if (!Array.isArray(m?.content)) continue;
|
|
621
|
+
for (const b of m.content) {
|
|
622
|
+
if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
606
628
|
/**
|
|
607
629
|
* Write-through helper: persist a fresh routing decision as the session's
|
|
608
630
|
* new pin. No-op when sessionId is missing or the decision has no provider.
|
|
@@ -641,7 +663,11 @@ function writeSessionPin(sessionId, decision, payload) {
|
|
|
641
663
|
return;
|
|
642
664
|
}
|
|
643
665
|
const promptTokensEst = _tryCountTokens(payload, decision.model);
|
|
644
|
-
sessionAffinity.setPin(sessionId, decision, {
|
|
666
|
+
sessionAffinity.setPin(sessionId, decision, {
|
|
667
|
+
messageCount,
|
|
668
|
+
promptTokensEst,
|
|
669
|
+
hasToolHistory: _payloadHasAnyToolBlocks(payload),
|
|
670
|
+
});
|
|
645
671
|
}
|
|
646
672
|
|
|
647
673
|
async function _determineProviderSmartInner(payload, options = {}) {
|
|
@@ -706,11 +732,14 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
706
732
|
if (risk?.level === 'high' && isFallbackEnabled()) {
|
|
707
733
|
try {
|
|
708
734
|
const selector = getModelTierSelector();
|
|
709
|
-
|
|
735
|
+
// Config B (local → GLM → Claude): high-risk requests route to the
|
|
736
|
+
// trusted provider (Claude) via REASONING, not the mid-tier GLM.
|
|
737
|
+
// Security/auth/middleware changes belong on the governance path.
|
|
738
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
710
739
|
const decision = {
|
|
711
740
|
provider: modelSelection.provider,
|
|
712
741
|
model: modelSelection.model,
|
|
713
|
-
tier: '
|
|
742
|
+
tier: 'REASONING',
|
|
714
743
|
method: 'risk',
|
|
715
744
|
reason: 'high_risk_forced_tier',
|
|
716
745
|
score: 100,
|
|
@@ -719,7 +748,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
719
748
|
escalations: [{
|
|
720
749
|
source: 'risk',
|
|
721
750
|
fromTier: null,
|
|
722
|
-
toTier: '
|
|
751
|
+
toTier: 'REASONING',
|
|
723
752
|
fromModel: null,
|
|
724
753
|
toModel: modelSelection.model,
|
|
725
754
|
}],
|
|
@@ -731,7 +760,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
731
760
|
};
|
|
732
761
|
routingMetrics.record(decision);
|
|
733
762
|
logger.debug({
|
|
734
|
-
tier: '
|
|
763
|
+
tier: 'REASONING',
|
|
735
764
|
provider: decision.provider,
|
|
736
765
|
instructionHits: risk.instructionHits,
|
|
737
766
|
pathHits: risk.pathHits,
|
|
@@ -785,6 +814,33 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
785
814
|
return decision;
|
|
786
815
|
}
|
|
787
816
|
|
|
817
|
+
// Force REASONING (Claude in config B) — checked before force_cloud.
|
|
818
|
+
// Matches ultrathink/prove/security-audit/first-principles. Deterministic
|
|
819
|
+
// routing to the top tier regardless of embedding score.
|
|
820
|
+
if (shouldForceReasoning(payload) && isFallbackEnabled() && config.modelTiers?.enabled) {
|
|
821
|
+
try {
|
|
822
|
+
const selector = getModelTierSelector();
|
|
823
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
824
|
+
const decision = {
|
|
825
|
+
provider: modelSelection.provider,
|
|
826
|
+
model: modelSelection.model,
|
|
827
|
+
tier: 'REASONING',
|
|
828
|
+
method: 'force',
|
|
829
|
+
reason: 'force_reasoning_pattern',
|
|
830
|
+
score: 100,
|
|
831
|
+
risk,
|
|
832
|
+
propensity: 1.0,
|
|
833
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
834
|
+
_queryEmbedding: queryEmbedding,
|
|
835
|
+
_queryText: queryText,
|
|
836
|
+
};
|
|
837
|
+
routingMetrics.record(decision);
|
|
838
|
+
return decision;
|
|
839
|
+
} catch (err) {
|
|
840
|
+
degradation.record('tier_select', err);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
788
844
|
if (shouldForceCloud(payload) && isFallbackEnabled()) {
|
|
789
845
|
// When tier routing is enabled, force-cloud means the COMPLEX tier's
|
|
790
846
|
// configured model — NOT getBestCloudProvider()'s credential-priority
|
|
@@ -1057,10 +1113,17 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1057
1113
|
|
|
1058
1114
|
// Phase 1.2 — cost-optimizer override.
|
|
1059
1115
|
// Only kick in when:
|
|
1060
|
-
// - feature flag enabled
|
|
1116
|
+
// - feature flag EXPLICITLY enabled with LYNKR_COST_OPTIMIZE=true (default OFF)
|
|
1061
1117
|
// - risk level is not high (high-risk keeps the explicitly-configured model)
|
|
1062
1118
|
// - the optimizer finds a meaningfully cheaper qualifying model
|
|
1063
|
-
|
|
1119
|
+
//
|
|
1120
|
+
// Default changed to OFF (2026-07-20): the optimizer silently overrides
|
|
1121
|
+
// explicit TIER_* config with the cheapest model in the tier pool, which
|
|
1122
|
+
// surprised operators who expected their tier settings to be authoritative
|
|
1123
|
+
// (e.g. TIER_REASONING=azure-anthropic:claude-opus-4.7 was being swapped
|
|
1124
|
+
// for openrouter:deepseek/deepseek-reasoner — which was also a typo that
|
|
1125
|
+
// 400'd upstream). Opt-in only.
|
|
1126
|
+
const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE === 'true'
|
|
1064
1127
|
&& config.routing?.costOptimize !== false;
|
|
1065
1128
|
if (costOptimizeEnabled && risk?.level !== 'high') {
|
|
1066
1129
|
try {
|