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.
@@ -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 cloud patterns - always route to cloud regardless of score
96
- const FORCE_CLOUD_PATTERNS = [
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: 25, reason: 'force_cloud', pattern: pattern.source.slice(0, 30) };
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
+ };
@@ -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
- const modelSelection = selector.selectModel('COMPLEX', null);
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: 'COMPLEX',
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: 'COMPLEX',
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: 'COMPLEX',
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