lynkr 9.9.0 → 9.9.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.
- package/README.md +42 -14
- package/bin/cli.js +11 -7
- package/bin/lynkr-init.js +30 -0
- 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 +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/router.js +17 -0
- package/src/context/tool-result-compressor.js +883 -40
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/index.js +35 -4
- package/src/routing/intent-score.js +114 -12
- package/src/server.js +20 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifier bootstrap — verifies ollama is installed and the classifier
|
|
3
|
+
* model is pulled + warm. Called from:
|
|
4
|
+
* - `lynkr init` (interactive, prompts user for install, blocks on completion)
|
|
5
|
+
* - server boot (non-blocking, logs warnings, never errors out)
|
|
6
|
+
*
|
|
7
|
+
* Ollama install is intentionally NOT auto-executed. Silent `curl | sh`
|
|
8
|
+
* during npm install is a supply-chain footgun; we detect and instruct
|
|
9
|
+
* instead.
|
|
10
|
+
*
|
|
11
|
+
* "Later" work per user directive:
|
|
12
|
+
* - Fine-tune qwen2.5:3b on labeled classification data (needs LoRA infra)
|
|
13
|
+
* - Canary verification against known-good prompts on every startup
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
// Reads the classifier model constant from difficulty-classifier.js so this
|
|
20
|
+
// module and the classifier stay in lock-step.
|
|
21
|
+
const { CLASSIFIER_MODEL_INFO } = require('./difficulty-classifier');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check whether the `ollama` CLI is on PATH.
|
|
25
|
+
* @returns {Promise<{installed: boolean, version?: string}>}
|
|
26
|
+
*/
|
|
27
|
+
function detectOllama() {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const proc = spawn('ollama', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
30
|
+
let out = '';
|
|
31
|
+
proc.stdout?.on('data', (d) => { out += d.toString(); });
|
|
32
|
+
proc.on('error', () => resolve({ installed: false }));
|
|
33
|
+
proc.on('close', (code) => {
|
|
34
|
+
if (code === 0) {
|
|
35
|
+
resolve({ installed: true, version: out.trim() });
|
|
36
|
+
} else {
|
|
37
|
+
resolve({ installed: false });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check whether a specific ollama model is present locally.
|
|
45
|
+
* @param {string} model
|
|
46
|
+
* @returns {Promise<boolean>}
|
|
47
|
+
*/
|
|
48
|
+
function isModelPulled(model) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const proc = spawn('ollama', ['list'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
|
+
let out = '';
|
|
52
|
+
proc.stdout?.on('data', (d) => { out += d.toString(); });
|
|
53
|
+
proc.on('error', () => resolve(false));
|
|
54
|
+
proc.on('close', () => {
|
|
55
|
+
// ollama list prints "NAME ID SIZE MODIFIED" — grep by exact model name
|
|
56
|
+
const rows = out.split('\n').slice(1);
|
|
57
|
+
resolve(rows.some((line) => line.startsWith(model + ' ') || line.split(/\s+/)[0] === model));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pull a model, streaming progress to stdout.
|
|
64
|
+
* @param {string} model
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
function pullModel(model) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const proc = spawn('ollama', ['pull', model], { stdio: 'inherit' });
|
|
70
|
+
proc.on('error', reject);
|
|
71
|
+
proc.on('close', (code) => {
|
|
72
|
+
if (code === 0) resolve();
|
|
73
|
+
else reject(new Error(`ollama pull exited with code ${code}`));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Warm the model — one dummy inference so first real call isn't cold.
|
|
80
|
+
* Best-effort; failure doesn't block.
|
|
81
|
+
* @param {string} model
|
|
82
|
+
*/
|
|
83
|
+
async function warmModel(model, endpoint = 'http://localhost:11434') {
|
|
84
|
+
try {
|
|
85
|
+
const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timer = setTimeout(() => controller.abort(), 60000);
|
|
88
|
+
const res = await fetch(url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model,
|
|
93
|
+
messages: [{ role: 'user', content: 'ok' }],
|
|
94
|
+
stream: false,
|
|
95
|
+
options: { num_predict: 3 },
|
|
96
|
+
}),
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
return res.ok;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Instructions to print when ollama is missing. */
|
|
107
|
+
function installInstructions() {
|
|
108
|
+
const plat = os.platform();
|
|
109
|
+
const lines = ['Ollama is required for the difficulty classifier and SIMPLE-tier serving.'];
|
|
110
|
+
if (plat === 'darwin') {
|
|
111
|
+
lines.push('Install on macOS:');
|
|
112
|
+
lines.push(' brew install ollama');
|
|
113
|
+
lines.push(' # or download: https://ollama.com/download');
|
|
114
|
+
} else if (plat === 'linux') {
|
|
115
|
+
lines.push('Install on Linux:');
|
|
116
|
+
lines.push(' curl -fsSL https://ollama.com/install.sh | sh');
|
|
117
|
+
} else if (plat === 'win32') {
|
|
118
|
+
lines.push('Install on Windows:');
|
|
119
|
+
lines.push(' Download the installer from https://ollama.com/download/windows');
|
|
120
|
+
} else {
|
|
121
|
+
lines.push('Install from https://ollama.com/download');
|
|
122
|
+
}
|
|
123
|
+
lines.push('After installing, re-run `lynkr init` (or restart the server).');
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Full bootstrap flow with prompts.
|
|
129
|
+
* @param {object} opts
|
|
130
|
+
* @param {'interactive'|'boot'} opts.mode — interactive blocks on failure, boot warns and continues
|
|
131
|
+
* @param {function} [opts.log] — logger function (defaults to console)
|
|
132
|
+
* @param {function} [opts.warn] — warn function
|
|
133
|
+
* @param {function} [opts.prompt] — async prompt(text)→string for interactive install/pull confirmations
|
|
134
|
+
* @returns {Promise<{ready: boolean, ollama: boolean, model: boolean, reason?: string}>}
|
|
135
|
+
*/
|
|
136
|
+
async function ensureClassifierReady(opts = {}) {
|
|
137
|
+
const mode = opts.mode || 'boot';
|
|
138
|
+
const log = opts.log || ((msg) => console.log(msg));
|
|
139
|
+
const warn = opts.warn || ((msg) => console.warn(msg));
|
|
140
|
+
const { provider, model, endpoint } = CLASSIFIER_MODEL_INFO;
|
|
141
|
+
|
|
142
|
+
if (provider !== 'ollama') {
|
|
143
|
+
// Non-ollama providers not supported today — bail cleanly.
|
|
144
|
+
warn(`Classifier provider "${provider}" is not yet auto-provisioned. Ensure ${model} is reachable manually.`);
|
|
145
|
+
return { ready: false, ollama: false, model: false, reason: 'non_ollama_provider' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 1. Ollama detection
|
|
149
|
+
const ollama = await detectOllama();
|
|
150
|
+
if (!ollama.installed) {
|
|
151
|
+
const msg = installInstructions();
|
|
152
|
+
if (mode === 'interactive') {
|
|
153
|
+
log('');
|
|
154
|
+
log('⚠ Ollama not found on PATH.');
|
|
155
|
+
log('');
|
|
156
|
+
log(msg);
|
|
157
|
+
log('');
|
|
158
|
+
} else {
|
|
159
|
+
warn(`[classifier-setup] Ollama not installed — classifier disabled. ${installInstructions().split('\n')[0]}`);
|
|
160
|
+
}
|
|
161
|
+
return { ready: false, ollama: false, model: false, reason: 'ollama_missing' };
|
|
162
|
+
}
|
|
163
|
+
if (mode === 'interactive') log(`✓ Ollama detected (${ollama.version || 'unknown version'}).`);
|
|
164
|
+
|
|
165
|
+
// 2. Model pull
|
|
166
|
+
const hasModel = await isModelPulled(model);
|
|
167
|
+
if (!hasModel) {
|
|
168
|
+
if (mode === 'interactive') {
|
|
169
|
+
log('');
|
|
170
|
+
log(`Classifier model "${model}" not present locally.`);
|
|
171
|
+
const yes = opts.prompt ? await opts.prompt(`Pull ${model} now? [Y/n] `) : 'y';
|
|
172
|
+
if (yes.trim().toLowerCase().startsWith('n')) {
|
|
173
|
+
warn(`Skipped pull — classifier will be disabled until you run: ollama pull ${model}`);
|
|
174
|
+
return { ready: false, ollama: true, model: false, reason: 'pull_declined' };
|
|
175
|
+
}
|
|
176
|
+
log(`Pulling ${model} (this can take a few minutes on first run)...`);
|
|
177
|
+
try {
|
|
178
|
+
await pullModel(model);
|
|
179
|
+
log(`✓ Model ${model} pulled.`);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
warn(`✗ Pull failed: ${err.message}`);
|
|
182
|
+
return { ready: false, ollama: true, model: false, reason: 'pull_failed' };
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// Boot mode — don't auto-pull. Log a clear instruction and continue.
|
|
186
|
+
warn(`[classifier-setup] Classifier model ${model} not pulled. Run: ollama pull ${model}. Classifier will fall back to anchor-only scoring until then.`);
|
|
187
|
+
return { ready: false, ollama: true, model: false, reason: 'model_missing' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. Warm-up
|
|
192
|
+
const warmed = await warmModel(model, endpoint);
|
|
193
|
+
if (mode === 'interactive') {
|
|
194
|
+
log(warmed ? `✓ Model warmed (first classification will be fast).` : `⚠ Warm-up call failed — the first classification may be slow.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { ready: true, ollama: true, model: true, warmed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
detectOllama,
|
|
202
|
+
isModelPulled,
|
|
203
|
+
pullModel,
|
|
204
|
+
warmModel,
|
|
205
|
+
installInstructions,
|
|
206
|
+
ensureClassifierReady,
|
|
207
|
+
};
|
|
@@ -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,219 @@
|
|
|
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. Kept in a const so drift is diffable.
|
|
48
|
+
// Difficulty framing (not intent) — matches config B routing goals.
|
|
49
|
+
const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the user prompt below into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
|
|
50
|
+
|
|
51
|
+
Tiers:
|
|
52
|
+
- SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups. Any tiny model handles.
|
|
53
|
+
examples: "hi", "ok thanks", "yes continue", "what time is it"
|
|
54
|
+
- MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
|
|
55
|
+
examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block"
|
|
56
|
+
- COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
|
|
57
|
+
examples: "architecture review of the orchestrator", "refactor the entire ingestion pipeline", "debug this complex race condition across three services"
|
|
58
|
+
- REASONING: formal proof, correctness verification, security audit, novel algorithm design, formal reasoning from first principles. Needs a frontier reasoning model.
|
|
59
|
+
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"
|
|
60
|
+
|
|
61
|
+
Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.0-1.0}
|
|
62
|
+
|
|
63
|
+
User prompt: `;
|
|
64
|
+
|
|
65
|
+
// --- LRU cache --------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
class LruCache {
|
|
68
|
+
constructor(capacity) {
|
|
69
|
+
this.capacity = capacity;
|
|
70
|
+
this.map = new Map();
|
|
71
|
+
}
|
|
72
|
+
get(key) {
|
|
73
|
+
if (!this.map.has(key)) return undefined;
|
|
74
|
+
const val = this.map.get(key);
|
|
75
|
+
this.map.delete(key);
|
|
76
|
+
this.map.set(key, val); // move to MRU
|
|
77
|
+
return val;
|
|
78
|
+
}
|
|
79
|
+
set(key, val) {
|
|
80
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
81
|
+
else if (this.map.size >= this.capacity) {
|
|
82
|
+
const oldest = this.map.keys().next().value;
|
|
83
|
+
this.map.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
this.map.set(key, val);
|
|
86
|
+
}
|
|
87
|
+
stats() { return { size: this.map.size, capacity: this.capacity }; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const _cache = new LruCache(CACHE_CAPACITY);
|
|
91
|
+
|
|
92
|
+
function _cacheKey(text) {
|
|
93
|
+
return crypto.createHash('sha256').update(text.trim().toLowerCase()).digest('hex');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Ollama dispatch (only supported provider for the SIMPLE tier today) ----
|
|
97
|
+
|
|
98
|
+
async function _callOllama(text, opts) {
|
|
99
|
+
const config = require('../config');
|
|
100
|
+
|
|
101
|
+
// First cut supports ollama-family classifier only. Other providers can
|
|
102
|
+
// be wired later; today's classifier is qwen2.5:3b on ollama.
|
|
103
|
+
if (CLASSIFIER_PROVIDER !== 'ollama') {
|
|
104
|
+
logger.debug({ provider: CLASSIFIER_PROVIDER, model: CLASSIFIER_MODEL }, '[DifficultyClassifier] non-ollama classifier not yet supported');
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const endpoint = config.ollama?.endpoint || 'http://localhost:11434';
|
|
109
|
+
const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
|
|
110
|
+
const body = {
|
|
111
|
+
model: CLASSIFIER_MODEL,
|
|
112
|
+
messages: [{ role: 'user', content: CLASSIFY_PROMPT + '"""' + text + '"""' }],
|
|
113
|
+
stream: false,
|
|
114
|
+
format: 'json',
|
|
115
|
+
// num_predict generous because thinking models (minimax-m2.5) burn
|
|
116
|
+
// budget in message.thinking before producing message.content JSON.
|
|
117
|
+
options: { temperature: 0, num_predict: 512 },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs || TIMEOUT_MS);
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
signal: controller.signal,
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) return null;
|
|
130
|
+
const json = await res.json();
|
|
131
|
+
// Thinking models put output in message.thinking; regular in message.content.
|
|
132
|
+
// /api/generate variants use `response`. Try all three, biggest first.
|
|
133
|
+
const candidates = [
|
|
134
|
+
json?.message?.content,
|
|
135
|
+
json?.message?.thinking,
|
|
136
|
+
json?.response,
|
|
137
|
+
].filter(s => typeof s === 'string' && s.length > 0);
|
|
138
|
+
return candidates.join('\n') || null;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logger.debug({ err: err.message, textPreview: text.slice(0, 60) }, '[DifficultyClassifier] call failed');
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Parse & validate the model's JSON output -------------------------------
|
|
148
|
+
|
|
149
|
+
function _parseResult(raw) {
|
|
150
|
+
if (!raw) return null;
|
|
151
|
+
// Ollama with format:'json' usually gives clean JSON, but the model can
|
|
152
|
+
// still surround it with junk — extract the first { ... } span.
|
|
153
|
+
const match = raw.match(/\{[\s\S]*?\}/);
|
|
154
|
+
if (!match) return null;
|
|
155
|
+
let obj;
|
|
156
|
+
try { obj = JSON.parse(match[0]); } catch { return null; }
|
|
157
|
+
const tier = String(obj.tier || '').toUpperCase().trim();
|
|
158
|
+
if (!VALID_TIERS.includes(tier)) return null;
|
|
159
|
+
const rawConf = Number(obj.confidence);
|
|
160
|
+
const confidence = Number.isFinite(rawConf) ? Math.max(0, Math.min(1, rawConf)) : 0.5;
|
|
161
|
+
return { tier, confidence };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Public API -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify difficulty of user text.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} text — cleaned user prompt
|
|
170
|
+
* @param {object} [opts]
|
|
171
|
+
* @param {number} [opts.timeoutMs] — override default 2500ms
|
|
172
|
+
* @param {boolean} [opts.forceMatched] — caller already matched a FORCE_* pattern; skip classifier
|
|
173
|
+
* @param {string} [opts.riskLevel] — 'high' | 'medium' | 'low'; skip when 'high'
|
|
174
|
+
* @returns {Promise<{tier:string,confidence:number,source:'cache'|'model'}|null>}
|
|
175
|
+
* null when: disabled, skipped, model failure, parse failure, timeout.
|
|
176
|
+
*/
|
|
177
|
+
async function classifyDifficulty(text, opts = {}) {
|
|
178
|
+
if (!CLASSIFIER_ENABLED) return null;
|
|
179
|
+
if (typeof text !== 'string') return null;
|
|
180
|
+
const trimmed = text.trim();
|
|
181
|
+
if (trimmed.length < MIN_TEXT_LENGTH) return null;
|
|
182
|
+
if (opts.forceMatched) return null;
|
|
183
|
+
if (opts.riskLevel === 'high') return null;
|
|
184
|
+
|
|
185
|
+
const key = _cacheKey(trimmed);
|
|
186
|
+
const cached = _cache.get(key);
|
|
187
|
+
if (cached) return { ...cached, source: 'cache' };
|
|
188
|
+
|
|
189
|
+
const raw = await _callOllama(trimmed, opts);
|
|
190
|
+
const parsed = _parseResult(raw);
|
|
191
|
+
if (!parsed) return null;
|
|
192
|
+
_cache.set(key, parsed);
|
|
193
|
+
return { ...parsed, source: 'model' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Test helpers -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function _clearCacheForTests() { _cache.map.clear(); }
|
|
199
|
+
function _getCacheStats() { return _cache.stats(); }
|
|
200
|
+
|
|
201
|
+
// Exposed metadata for the bootstrap module — keeps model choice in one file
|
|
202
|
+
// so classifier-setup.js pulls exactly what the classifier will call.
|
|
203
|
+
const CLASSIFIER_MODEL_INFO = Object.freeze({
|
|
204
|
+
provider: CLASSIFIER_PROVIDER,
|
|
205
|
+
model: CLASSIFIER_MODEL,
|
|
206
|
+
endpoint: process.env.OLLAMA_ENDPOINT || 'http://localhost:11434',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
classifyDifficulty,
|
|
211
|
+
CLASSIFIER_ENABLED,
|
|
212
|
+
CLASSIFIER_MODEL_INFO,
|
|
213
|
+
VALID_TIERS,
|
|
214
|
+
// internals for tests only
|
|
215
|
+
_parseResult,
|
|
216
|
+
_cacheKey,
|
|
217
|
+
_clearCacheForTests,
|
|
218
|
+
_getCacheStats,
|
|
219
|
+
};
|
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');
|
|
@@ -706,11 +707,14 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
706
707
|
if (risk?.level === 'high' && isFallbackEnabled()) {
|
|
707
708
|
try {
|
|
708
709
|
const selector = getModelTierSelector();
|
|
709
|
-
|
|
710
|
+
// Config B (local → GLM → Claude): high-risk requests route to the
|
|
711
|
+
// trusted provider (Claude) via REASONING, not the mid-tier GLM.
|
|
712
|
+
// Security/auth/middleware changes belong on the governance path.
|
|
713
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
710
714
|
const decision = {
|
|
711
715
|
provider: modelSelection.provider,
|
|
712
716
|
model: modelSelection.model,
|
|
713
|
-
tier: '
|
|
717
|
+
tier: 'REASONING',
|
|
714
718
|
method: 'risk',
|
|
715
719
|
reason: 'high_risk_forced_tier',
|
|
716
720
|
score: 100,
|
|
@@ -719,7 +723,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
719
723
|
escalations: [{
|
|
720
724
|
source: 'risk',
|
|
721
725
|
fromTier: null,
|
|
722
|
-
toTier: '
|
|
726
|
+
toTier: 'REASONING',
|
|
723
727
|
fromModel: null,
|
|
724
728
|
toModel: modelSelection.model,
|
|
725
729
|
}],
|
|
@@ -731,7 +735,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
731
735
|
};
|
|
732
736
|
routingMetrics.record(decision);
|
|
733
737
|
logger.debug({
|
|
734
|
-
tier: '
|
|
738
|
+
tier: 'REASONING',
|
|
735
739
|
provider: decision.provider,
|
|
736
740
|
instructionHits: risk.instructionHits,
|
|
737
741
|
pathHits: risk.pathHits,
|
|
@@ -785,6 +789,33 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
785
789
|
return decision;
|
|
786
790
|
}
|
|
787
791
|
|
|
792
|
+
// Force REASONING (Claude in config B) — checked before force_cloud.
|
|
793
|
+
// Matches ultrathink/prove/security-audit/first-principles. Deterministic
|
|
794
|
+
// routing to the top tier regardless of embedding score.
|
|
795
|
+
if (shouldForceReasoning(payload) && isFallbackEnabled() && config.modelTiers?.enabled) {
|
|
796
|
+
try {
|
|
797
|
+
const selector = getModelTierSelector();
|
|
798
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
799
|
+
const decision = {
|
|
800
|
+
provider: modelSelection.provider,
|
|
801
|
+
model: modelSelection.model,
|
|
802
|
+
tier: 'REASONING',
|
|
803
|
+
method: 'force',
|
|
804
|
+
reason: 'force_reasoning_pattern',
|
|
805
|
+
score: 100,
|
|
806
|
+
risk,
|
|
807
|
+
propensity: 1.0,
|
|
808
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
809
|
+
_queryEmbedding: queryEmbedding,
|
|
810
|
+
_queryText: queryText,
|
|
811
|
+
};
|
|
812
|
+
routingMetrics.record(decision);
|
|
813
|
+
return decision;
|
|
814
|
+
} catch (err) {
|
|
815
|
+
degradation.record('tier_select', err);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
788
819
|
if (shouldForceCloud(payload) && isFallbackEnabled()) {
|
|
789
820
|
// When tier routing is enabled, force-cloud means the COMPLEX tier's
|
|
790
821
|
// configured model — NOT getBestCloudProvider()'s credential-priority
|