@melaya/runner 1.0.29 → 1.0.31
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/dist/connection.js +97 -56
- package/dist/modelLoader.d.ts +26 -0
- package/dist/modelLoader.js +216 -5
- package/dist/pythonEnv.js +1 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -117,38 +117,35 @@ export async function connect(opts) {
|
|
|
117
117
|
// Surface bootstrap progress to the FE so the launching
|
|
118
118
|
// animation reflects the real state instead of staying on a
|
|
119
119
|
// generic spinner during the 1-2 min first-time install.
|
|
120
|
+
// FLAT shape — server reads payload.run_id at top level
|
|
121
|
+
// (runnerNamespace.ts:202). My earlier nested {runId, payload:{}}
|
|
122
|
+
// shape was silently dropped by `if (!runId) return`.
|
|
120
123
|
socket.emit("runner:event", {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
label: `venv: ${msg}`,
|
|
129
|
-
status: "started",
|
|
130
|
-
},
|
|
124
|
+
run_id: payload.runId,
|
|
125
|
+
event_type: "pipeline_phase",
|
|
126
|
+
project: payload.project,
|
|
127
|
+
step: 1,
|
|
128
|
+
total: 5,
|
|
129
|
+
label: `venv: ${msg}`,
|
|
130
|
+
status: "started",
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
if (!envResult.ok) {
|
|
134
134
|
console.log(chalk.red(` ✗ python venv bootstrap failed: ${envResult.reason}`));
|
|
135
135
|
socket.emit("runner:event", {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
metadata: {},
|
|
150
|
-
timestamp: new Date().toISOString(),
|
|
151
|
-
},
|
|
136
|
+
run_id: payload.runId,
|
|
137
|
+
event_type: "agent_message",
|
|
138
|
+
project: payload.project,
|
|
139
|
+
replyId: `venv-${payload.runId}`,
|
|
140
|
+
replyName: "Runner",
|
|
141
|
+
replyRole: "system",
|
|
142
|
+
msg: {
|
|
143
|
+
id: `venv-${payload.runId}`,
|
|
144
|
+
name: "Runner",
|
|
145
|
+
role: "system",
|
|
146
|
+
content: [{ type: "text", text: `Python env bootstrap failed: ${envResult.reason}` }],
|
|
147
|
+
metadata: {},
|
|
148
|
+
timestamp: new Date().toISOString(),
|
|
152
149
|
},
|
|
153
150
|
});
|
|
154
151
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
@@ -178,20 +175,17 @@ export async function connect(opts) {
|
|
|
178
175
|
const { preflightModel } = await import("./modelLoader.js");
|
|
179
176
|
const onPreflightProgress = (msg) => {
|
|
180
177
|
console.log(chalk.gray(` [model] ${msg}`));
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
178
|
+
// Flat shape — server reads payload.run_id at top level
|
|
179
|
+
// (runnerNamespace.ts:202). Earlier nested {runId, payload:{}}
|
|
180
|
+
// was silently dropped by `if (!runId) return`.
|
|
184
181
|
socket.emit("runner:event", {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
label: msg,
|
|
193
|
-
status: "started",
|
|
194
|
-
},
|
|
182
|
+
run_id: payload.runId,
|
|
183
|
+
event_type: "pipeline_phase",
|
|
184
|
+
project: payload.project,
|
|
185
|
+
step: 1,
|
|
186
|
+
total: 5,
|
|
187
|
+
label: msg,
|
|
188
|
+
status: "started",
|
|
195
189
|
});
|
|
196
190
|
};
|
|
197
191
|
const preflight = await preflightModel(payload.configJson, onPreflightProgress);
|
|
@@ -199,22 +193,19 @@ export async function connect(opts) {
|
|
|
199
193
|
const detail = `${preflight.reason ?? "unknown"}${preflight.hint ? ` — ${preflight.hint}` : ""}`;
|
|
200
194
|
console.log(chalk.red(` ✗ model preflight failed: ${detail}`));
|
|
201
195
|
socket.emit("runner:event", {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
metadata: {},
|
|
216
|
-
timestamp: new Date().toISOString(),
|
|
217
|
-
},
|
|
196
|
+
run_id: payload.runId,
|
|
197
|
+
event_type: "agent_message",
|
|
198
|
+
project: payload.project,
|
|
199
|
+
replyId: `preflight-${payload.runId}`,
|
|
200
|
+
replyName: "Runner",
|
|
201
|
+
replyRole: "system",
|
|
202
|
+
msg: {
|
|
203
|
+
id: `preflight-${payload.runId}`,
|
|
204
|
+
name: "Runner",
|
|
205
|
+
role: "system",
|
|
206
|
+
content: [{ type: "text", text: `Model preflight failed: ${detail}` }],
|
|
207
|
+
metadata: {},
|
|
208
|
+
timestamp: new Date().toISOString(),
|
|
218
209
|
},
|
|
219
210
|
});
|
|
220
211
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
@@ -223,8 +214,18 @@ export async function connect(opts) {
|
|
|
223
214
|
// Build env vars for the subprocess
|
|
224
215
|
const relayUrl = `http://127.0.0.1:${relay.port}/events/relay/${relay.nonce}`;
|
|
225
216
|
const sharedDir = getSharedDir();
|
|
217
|
+
// Defense in depth: scrub any MEL_MODEL_* the operator may have
|
|
218
|
+
// exported in their shell before launching the runner. Without
|
|
219
|
+
// this, a stale leak (e.g. operator set MEL_MODEL_DISABLE_THINKING=1
|
|
220
|
+
// while debugging Qwen3, then ran a non-Qwen pipeline) would
|
|
221
|
+
// bypass the preflight gate and corrupt model.py's profile read.
|
|
222
|
+
const inherited = { ...process.env };
|
|
223
|
+
for (const k of Object.keys(inherited)) {
|
|
224
|
+
if (k.startsWith("MEL_MODEL_"))
|
|
225
|
+
delete inherited[k];
|
|
226
|
+
}
|
|
226
227
|
const env = {
|
|
227
|
-
...
|
|
228
|
+
...inherited,
|
|
228
229
|
PYTHONPATH: sharedDir,
|
|
229
230
|
PYTHONIOENCODING: "utf-8",
|
|
230
231
|
PYTHONUTF8: "1",
|
|
@@ -250,7 +251,47 @@ export async function connect(opts) {
|
|
|
250
251
|
MEL_LUMA_BROWSER_TOKEN: lumaBridge.token,
|
|
251
252
|
}
|
|
252
253
|
: {}),
|
|
254
|
+
// Per-model profile from preflight detection. Empty when
|
|
255
|
+
// preflight didn't classify (cloud-spawn or capable model with
|
|
256
|
+
// defaults intact). When set, shared/runtime/model.py reads
|
|
257
|
+
// these and overrides max_tokens / max_iters / thinking-disable
|
|
258
|
+
// for THIS model only (text-only / agentic-marginal). Run
|
|
259
|
+
// f9fd5e596e7f4c9a (qwen3-1.7b looped in <think>) was the
|
|
260
|
+
// canary that motivated this.
|
|
261
|
+
...(preflight.profile
|
|
262
|
+
? {
|
|
263
|
+
MEL_MODEL_TIER: preflight.profile.tier,
|
|
264
|
+
MEL_MODEL_FAMILY: preflight.profile.family ?? "",
|
|
265
|
+
MEL_MODEL_PARAMS_B: String(preflight.profile.paramsBillion ?? ""),
|
|
266
|
+
MEL_MODEL_MAX_TOKENS: String(preflight.profile.recommendedMaxTokens),
|
|
267
|
+
MEL_MODEL_MAX_ITERS: String(preflight.profile.recommendedMaxIters),
|
|
268
|
+
MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
|
|
269
|
+
}
|
|
270
|
+
: {}),
|
|
253
271
|
};
|
|
272
|
+
// Surface the tier to the chat panel as a system message so the
|
|
273
|
+
// operator sees WHY a small model is running with tighter caps.
|
|
274
|
+
if (preflight.profile && preflight.profile.tier !== "agentic-capable") {
|
|
275
|
+
socket.emit("runner:event", {
|
|
276
|
+
run_id: payload.runId,
|
|
277
|
+
event_type: "agent_message",
|
|
278
|
+
project: payload.project,
|
|
279
|
+
replyId: `model-tier-${payload.runId}`,
|
|
280
|
+
replyName: "Runner",
|
|
281
|
+
replyRole: "system",
|
|
282
|
+
msg: {
|
|
283
|
+
id: `model-tier-${payload.runId}`,
|
|
284
|
+
name: "Runner",
|
|
285
|
+
role: "system",
|
|
286
|
+
content: [{
|
|
287
|
+
type: "text",
|
|
288
|
+
text: `⚠ Model tier: **${preflight.profile.tier}** (${preflight.profile.id}, ${preflight.profile.paramsBillion ?? "?"}B). ${preflight.profile.reason}. Caps: max_tokens=${preflight.profile.recommendedMaxTokens}, max_iters=${preflight.profile.recommendedMaxIters}${preflight.profile.thinkingDefault === "off" ? ", thinking=off" : ""}. For full agentic capability use a ≥7B model or a cloud provider.`,
|
|
289
|
+
}],
|
|
290
|
+
metadata: { tier: preflight.profile.tier, params_b: preflight.profile.paramsBillion },
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
254
295
|
// Spawn Python subprocess (uses the bootstrapped venv python).
|
|
255
296
|
const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
|
|
256
297
|
cwd: runDir,
|
package/dist/modelLoader.d.ts
CHANGED
|
@@ -26,11 +26,36 @@
|
|
|
26
26
|
* 3. For ollama: GET /api/tags. If model not present → fail fast with
|
|
27
27
|
* `ollama pull <name>` instruction. If present, ollama auto-loads.
|
|
28
28
|
*/
|
|
29
|
+
export declare function classifyModel(id: string, lmstudioMeta?: LMStudioModelInfo): ModelProfile;
|
|
30
|
+
type LMStudioModelState = "loaded" | "not-loaded" | "loading" | string;
|
|
31
|
+
interface LMStudioModelInfo {
|
|
32
|
+
id: string;
|
|
33
|
+
state?: LMStudioModelState;
|
|
34
|
+
type?: string;
|
|
35
|
+
arch?: string;
|
|
36
|
+
params?: string | number;
|
|
37
|
+
max_context_length?: number;
|
|
38
|
+
}
|
|
39
|
+
export type ModelTier = "agentic-capable" | "agentic-marginal" | "text-only";
|
|
40
|
+
export interface ModelProfile {
|
|
41
|
+
schemaVersion: number;
|
|
42
|
+
id: string;
|
|
43
|
+
tier: ModelTier;
|
|
44
|
+
paramsBillion: number | null;
|
|
45
|
+
family: string | null;
|
|
46
|
+
supportsToolCalling: boolean;
|
|
47
|
+
thinkingDefault: "on" | "off" | "n/a";
|
|
48
|
+
recommendedMaxTokens: number;
|
|
49
|
+
recommendedMaxIters: number;
|
|
50
|
+
detectedAt: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}
|
|
29
53
|
export interface ModelPreflightResult {
|
|
30
54
|
ok: boolean;
|
|
31
55
|
reason?: string;
|
|
32
56
|
hint?: string;
|
|
33
57
|
loaded?: boolean;
|
|
58
|
+
profile?: ModelProfile;
|
|
34
59
|
}
|
|
35
60
|
export declare function preflightLmStudio(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
36
61
|
export declare function preflightOllama(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
@@ -40,3 +65,4 @@ export declare function preflightOllama(modelName: string, onProgress?: (msg: st
|
|
|
40
65
|
* non-local providers (cloud-spawn shouldn't reach the runner anyway).
|
|
41
66
|
*/
|
|
42
67
|
export declare function preflightModel(configJson: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
68
|
+
export {};
|
package/dist/modelLoader.js
CHANGED
|
@@ -27,6 +27,194 @@
|
|
|
27
27
|
* `ollama pull <name>` instruction. If present, ollama auto-loads.
|
|
28
28
|
*/
|
|
29
29
|
import chalk from "chalk";
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
31
|
+
import { join } from "path";
|
|
32
|
+
import { homedir } from "os";
|
|
33
|
+
// ── Model classification ───────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// Goal: detect when a local model is too small / weak to drive an
|
|
36
|
+
// agentic ReAct pipeline, BEFORE we spawn the python subprocess. The
|
|
37
|
+
// pipeline can then receive a tighter profile (max_tokens, max_iters,
|
|
38
|
+
// thinking-disable) so it fails fast or runs reliably instead of
|
|
39
|
+
// silently looping inside <think> tags forever (run f9fd5e596e7f4c9a
|
|
40
|
+
// canary: qwen3-1.7b produced 81 thinking blocks, 0 tool_use, 0 text).
|
|
41
|
+
//
|
|
42
|
+
// Classification is deterministic from (a) LM Studio metadata, (b)
|
|
43
|
+
// param-count regex on the model id, (c) a hard family table for known
|
|
44
|
+
// edge cases that don't match the regex. NO network probe today —
|
|
45
|
+
// keeps the preflight cheap; we lean on Layer 3 (runtime loop guard)
|
|
46
|
+
// to catch detection misses.
|
|
47
|
+
const PROFILE_DIR = join(homedir(), ".melaya-runner", "model-profiles");
|
|
48
|
+
// Models BELOW this param count are forced to `agentic-marginal` or
|
|
49
|
+
// `text-only` regardless of family. Above it, family table overrides.
|
|
50
|
+
const TIER_PARAM_THRESHOLDS = {
|
|
51
|
+
agenticCapable: 7.0, // 7B+ is the empirical floor for reliable tool_use
|
|
52
|
+
agenticMarginal: 4.0, // 4-7B can do simple tool_use, struggles with multi-step ReAct
|
|
53
|
+
// <4B is text-only — too small for reliable agentic work
|
|
54
|
+
};
|
|
55
|
+
// Family hints for models that don't expose param count cleanly via
|
|
56
|
+
// name OR are known special cases regardless of size. Match against
|
|
57
|
+
// the lowercased model id; first match wins. ORDER MATTERS — put
|
|
58
|
+
// MORE-SPECIFIC patterns first (e.g. qwen3-vl-4b before qwen3-4b),
|
|
59
|
+
// otherwise the looser pattern claims the id. All `\b...b\b` anchors
|
|
60
|
+
// use \b so `qwen3-14b` doesn't accidentally match `4b`.
|
|
61
|
+
const FAMILY_OVERRIDES = [
|
|
62
|
+
// Qwen3 family — thinking is on by default; for the small variants
|
|
63
|
+
// (which can't escape <think>) we need to disable it via
|
|
64
|
+
// chat_template_kwargs.enable_thinking=false. The vl-* and 8B+ keep
|
|
65
|
+
// thinking ON because they're capable enough to use it for ReAct.
|
|
66
|
+
// ── Qwen3 — VL (vision) variants FIRST (more specific than non-vl) ──
|
|
67
|
+
{ pattern: /qwen3.*vl.*\b4b\b/i, family: "qwen3", tier: "agentic-marginal", thinking: "on", reason: "qwen3-vl-4b: vision variant has stronger reasoning, marginal but keep thinking on" },
|
|
68
|
+
{ pattern: /qwen3.*vl.*\b8b\b/i, family: "qwen3", tier: "agentic-capable", thinking: "on", reason: "qwen3-vl-8b: agentic-capable, full thinking" },
|
|
69
|
+
// ── Qwen3 — capable (≥8B) BEFORE the smaller non-vl 4b pattern, so
|
|
70
|
+
// 14b / 32b don't get caught by the 4b regex's stem match. ──
|
|
71
|
+
{ pattern: /qwen3.*\b(8b|14b|32b)\b/i, family: "qwen3", tier: "agentic-capable", thinking: "on", reason: "qwen3 ≥8b: agentic-capable" },
|
|
72
|
+
// ── Qwen3 — small variants ──
|
|
73
|
+
{ pattern: /qwen3.*\b1\.7b\b/i, family: "qwen3", tier: "text-only", thinking: "off", reason: "qwen3-1.7b: param count below the agentic 7B floor; thinking disabled to prevent <think> loops" },
|
|
74
|
+
{ pattern: /qwen3.*\b0\.5b\b/i, family: "qwen3", tier: "text-only", thinking: "off", reason: "qwen3-0.5b: too small for agentic work" },
|
|
75
|
+
{ pattern: /qwen3.*\b4b\b/i, family: "qwen3", tier: "agentic-marginal", thinking: "off", reason: "qwen3-4b non-vl: marginal — thinking off, tighter caps" },
|
|
76
|
+
// ── DeepSeek-R1 — also emits <think> blocks. Capable variants keep
|
|
77
|
+
// thinking on; small distills get thinking off. R1's runtime
|
|
78
|
+
// accepts the same chat_template_kwargs.enable_thinking=false
|
|
79
|
+
// extra_body via the OpenAI-compat server (LM Studio / vLLM). ──
|
|
80
|
+
{ pattern: /deepseek.*r1.*\b1\.5b\b/i, family: "deepseek-r1", tier: "text-only", thinking: "off", reason: "deepseek-r1-distill-1.5b: too small for agentic, thinking off to prevent <think> loops" },
|
|
81
|
+
{ pattern: /deepseek.*r1.*\b7b\b/i, family: "deepseek-r1", tier: "agentic-marginal", thinking: "on", reason: "deepseek-r1-distill-7b: marginal, thinking on" },
|
|
82
|
+
{ pattern: /deepseek.*r1.*\b(8b|14b|32b|70b|671b)\b/i, family: "deepseek-r1", tier: "agentic-capable", thinking: "on", reason: "deepseek-r1-distill ≥8b: agentic-capable" },
|
|
83
|
+
// Gemma3 — small variants are text-completion oriented. Use \b
|
|
84
|
+
// anchors so gemma-3-12b doesn't collide with the 1b/2b rule via
|
|
85
|
+
// substring match (the previous unanchored pattern misclassified
|
|
86
|
+
// every gemma-N{1,2}b variant containing '1b' or '2b' as a substring).
|
|
87
|
+
{ pattern: /gemma-?3?.*\b(1b|2b)\b/i, family: "gemma", tier: "text-only", thinking: "n/a", reason: "gemma 1-2b: text-only, no reliable tool_use" },
|
|
88
|
+
{ pattern: /gemma-?3?.*\b4b\b/i, family: "gemma", tier: "agentic-marginal", thinking: "n/a", reason: "gemma 4b: marginal" },
|
|
89
|
+
{ pattern: /gemma-?3?.*\b(7b|9b|12b|21b|27b)\b/i, family: "gemma", tier: "agentic-capable", thinking: "n/a", reason: "gemma ≥7b: agentic-capable" },
|
|
90
|
+
// Llama3.2 small variants
|
|
91
|
+
{ pattern: /llama-?3\.?2.*\b(1b|3b)\b/i, family: "llama", tier: "text-only", thinking: "n/a", reason: "llama3.2 1-3b: text-only" },
|
|
92
|
+
// Phi-4 (14B) and Phi-4-mini (3.8B) — name has no \bNb\b token.
|
|
93
|
+
// Phi-3.5 mini (3.8B) covered separately.
|
|
94
|
+
{ pattern: /phi-?4(?!-?mini)/i, family: "phi", tier: "agentic-capable", thinking: "n/a", reason: "phi-4: 14B, agentic-capable" },
|
|
95
|
+
{ pattern: /phi-?4-?mini/i, family: "phi", tier: "agentic-marginal", thinking: "n/a", reason: "phi-4-mini: 3.8B, marginal" },
|
|
96
|
+
{ pattern: /phi-?3\.?5.*mini/i, family: "phi", tier: "agentic-marginal", thinking: "n/a", reason: "phi-3.5-mini: 3.8B, marginal" },
|
|
97
|
+
];
|
|
98
|
+
function _parseParamCount(id) {
|
|
99
|
+
// Match formats: "8b", "1.7b", "32B", "8x7b" (mixture-of-experts → use the active param count, e.g. mixtral-8x7b ≈ 12.9B active)
|
|
100
|
+
const m = id.match(/(\d+x)?(\d+(?:\.\d+)?)\s*[bB]\b/);
|
|
101
|
+
if (!m)
|
|
102
|
+
return null;
|
|
103
|
+
const moe = m[1];
|
|
104
|
+
const base = parseFloat(m[2]);
|
|
105
|
+
if (isNaN(base))
|
|
106
|
+
return null;
|
|
107
|
+
// For MoE, we approximate active params (rough — Mixtral 8x7B is ~12.9B active).
|
|
108
|
+
// Treat conservatively as 1.5× the base for the tier check.
|
|
109
|
+
return moe ? base * 1.5 : base;
|
|
110
|
+
}
|
|
111
|
+
function _classify(id, lmstudioMeta) {
|
|
112
|
+
const lower = id.toLowerCase();
|
|
113
|
+
// 1. Family override wins if matched.
|
|
114
|
+
for (const ov of FAMILY_OVERRIDES) {
|
|
115
|
+
if (ov.pattern.test(lower)) {
|
|
116
|
+
const params = _parseParamCount(id);
|
|
117
|
+
return _buildProfile(id, ov.tier, params, ov.family, ov.thinking, ov.reason);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// 2. LM Studio metadata if present.
|
|
121
|
+
let params = null;
|
|
122
|
+
if (lmstudioMeta?.params != null) {
|
|
123
|
+
const raw = String(lmstudioMeta.params);
|
|
124
|
+
const m = raw.match(/(\d+(?:\.\d+)?)/);
|
|
125
|
+
if (m) {
|
|
126
|
+
const n = parseFloat(m[1]);
|
|
127
|
+
// LM Studio reports either a raw count (e.g. 7000000000) or a
|
|
128
|
+
// human string ("7B"). Normalise to billions.
|
|
129
|
+
params = raw.toLowerCase().includes("b") ? n : (n > 1e6 ? n / 1e9 : n);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (params == null)
|
|
133
|
+
params = _parseParamCount(id);
|
|
134
|
+
// 3. Tier from param count.
|
|
135
|
+
let tier;
|
|
136
|
+
let reason;
|
|
137
|
+
if (params == null) {
|
|
138
|
+
tier = "agentic-marginal";
|
|
139
|
+
reason = `couldn't infer param count from "${id}" — defaulting to marginal (Layer 3 loop guard remains active)`;
|
|
140
|
+
}
|
|
141
|
+
else if (params >= TIER_PARAM_THRESHOLDS.agenticCapable) {
|
|
142
|
+
tier = "agentic-capable";
|
|
143
|
+
reason = `${params}B params ≥ ${TIER_PARAM_THRESHOLDS.agenticCapable}B threshold`;
|
|
144
|
+
}
|
|
145
|
+
else if (params >= TIER_PARAM_THRESHOLDS.agenticMarginal) {
|
|
146
|
+
tier = "agentic-marginal";
|
|
147
|
+
reason = `${params}B params: between ${TIER_PARAM_THRESHOLDS.agenticMarginal}B and ${TIER_PARAM_THRESHOLDS.agenticCapable}B — marginal, tighter caps applied`;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
tier = "text-only";
|
|
151
|
+
reason = `${params}B params < ${TIER_PARAM_THRESHOLDS.agenticMarginal}B threshold — text-only, abort if used for tool-using pipeline`;
|
|
152
|
+
}
|
|
153
|
+
return _buildProfile(id, tier, params, null, "n/a", reason);
|
|
154
|
+
}
|
|
155
|
+
function _buildProfile(id, tier, paramsBillion, family, thinkingDefault, reason) {
|
|
156
|
+
// Per-tier resource caps. Smaller models get tighter ceilings to
|
|
157
|
+
// bound runaway thinking loops.
|
|
158
|
+
const recommendedMaxTokens = tier === "agentic-capable" ? 8192
|
|
159
|
+
: tier === "agentic-marginal" ? 4096
|
|
160
|
+
: 2048;
|
|
161
|
+
const recommendedMaxIters = tier === "agentic-capable" ? 10
|
|
162
|
+
: tier === "agentic-marginal" ? 5
|
|
163
|
+
: 3;
|
|
164
|
+
return {
|
|
165
|
+
schemaVersion: PROFILE_SCHEMA_VERSION,
|
|
166
|
+
id,
|
|
167
|
+
tier,
|
|
168
|
+
paramsBillion,
|
|
169
|
+
family,
|
|
170
|
+
supportsToolCalling: tier !== "text-only",
|
|
171
|
+
thinkingDefault,
|
|
172
|
+
recommendedMaxTokens,
|
|
173
|
+
recommendedMaxIters,
|
|
174
|
+
detectedAt: new Date().toISOString(),
|
|
175
|
+
reason,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function _saveProfile(p) {
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(PROFILE_DIR, { recursive: true });
|
|
181
|
+
const safe = p.id.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
182
|
+
writeFileSync(join(PROFILE_DIR, `${safe}.json`), JSON.stringify(p, null, 2), "utf-8");
|
|
183
|
+
}
|
|
184
|
+
catch { /* best-effort — profile is regenerated on next preflight */ }
|
|
185
|
+
}
|
|
186
|
+
function _loadCachedProfile(id) {
|
|
187
|
+
try {
|
|
188
|
+
const safe = id.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
189
|
+
const p = join(PROFILE_DIR, `${safe}.json`);
|
|
190
|
+
if (!existsSync(p))
|
|
191
|
+
return null;
|
|
192
|
+
const parsed = JSON.parse(readFileSync(p, "utf-8"));
|
|
193
|
+
// Reject cached profiles produced by an older classifier — they
|
|
194
|
+
// may carry stale tiers (e.g. gemma-3-12b mis-tagged 'text-only'
|
|
195
|
+
// by the pre-v2 unanchored regex). Forces a fresh classification.
|
|
196
|
+
if ((parsed.schemaVersion ?? 1) !== PROFILE_SCHEMA_VERSION)
|
|
197
|
+
return null;
|
|
198
|
+
return parsed;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export function classifyModel(id, lmstudioMeta) {
|
|
205
|
+
// Cache hit if we've classified this id before. Cheap optimization
|
|
206
|
+
// to avoid redoing the regex/family walk on every spawn.
|
|
207
|
+
const cached = _loadCachedProfile(id);
|
|
208
|
+
if (cached && cached.id === id)
|
|
209
|
+
return cached;
|
|
210
|
+
const profile = _classify(id, lmstudioMeta);
|
|
211
|
+
_saveProfile(profile);
|
|
212
|
+
return profile;
|
|
213
|
+
}
|
|
214
|
+
// Bump whenever FAMILY_OVERRIDES, tier thresholds, or any classifier
|
|
215
|
+
// logic changes in a way that could re-tier an existing cached model.
|
|
216
|
+
// Cached profiles with a mismatched version are ignored and re-classified.
|
|
217
|
+
const PROFILE_SCHEMA_VERSION = 2;
|
|
30
218
|
const LMSTUDIO_BASE = process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234";
|
|
31
219
|
const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434";
|
|
32
220
|
// JIT-load timeout. 8B-class quantised models on M-series Macs typically
|
|
@@ -124,10 +312,17 @@ export async function preflightLmStudio(modelName, onProgress = () => { }) {
|
|
|
124
312
|
hint: `Model "${modelName}" not found in LM Studio. Open LM Studio → Search → download it. Available: ${available || "(none)"}`,
|
|
125
313
|
};
|
|
126
314
|
}
|
|
315
|
+
const profile = classifyModel(match.id, match);
|
|
316
|
+
if (profile.tier !== "agentic-capable") {
|
|
317
|
+
onProgress(`⚠ ${match.id} classified ${profile.tier} (${profile.reason})`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
onProgress(`✓ ${match.id} classified ${profile.tier} (${profile.paramsBillion ?? "?"}B)`);
|
|
321
|
+
}
|
|
127
322
|
const state = (match.state ?? "unknown");
|
|
128
323
|
if (state === "loaded") {
|
|
129
324
|
onProgress(`✓ ${match.id} already loaded in LM Studio`);
|
|
130
|
-
return { ok: true, loaded: true };
|
|
325
|
+
return { ok: true, loaded: true, profile };
|
|
131
326
|
}
|
|
132
327
|
onProgress(`Loading ${match.id} into LM Studio (this can take 30-120s for first load)…`);
|
|
133
328
|
const loaded = await jitLoadLmStudioModel(match.id, onProgress);
|
|
@@ -136,10 +331,11 @@ export async function preflightLmStudio(modelName, onProgress = () => { }) {
|
|
|
136
331
|
ok: false,
|
|
137
332
|
reason: "load_failed",
|
|
138
333
|
hint: `LM Studio could not load "${match.id}". Check available RAM and the LM Studio server log.`,
|
|
334
|
+
profile,
|
|
139
335
|
};
|
|
140
336
|
}
|
|
141
337
|
onProgress(`✓ ${match.id} loaded`);
|
|
142
|
-
return { ok: true, loaded: true };
|
|
338
|
+
return { ok: true, loaded: true, profile };
|
|
143
339
|
}
|
|
144
340
|
export async function preflightOllama(modelName, onProgress = () => { }) {
|
|
145
341
|
try {
|
|
@@ -160,8 +356,14 @@ export async function preflightOllama(modelName, onProgress = () => { }) {
|
|
|
160
356
|
hint: `Model "${modelName}" not pulled in Ollama. Run: ollama pull ${modelName}`,
|
|
161
357
|
};
|
|
162
358
|
}
|
|
163
|
-
|
|
164
|
-
|
|
359
|
+
const profile = classifyModel(modelName);
|
|
360
|
+
if (profile.tier !== "agentic-capable") {
|
|
361
|
+
onProgress(`⚠ ${modelName} classified ${profile.tier} (${profile.reason})`);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
onProgress(`✓ ${modelName} classified ${profile.tier} (${profile.paramsBillion ?? "?"}B)`);
|
|
365
|
+
}
|
|
366
|
+
return { ok: true, loaded: true, profile };
|
|
165
367
|
}
|
|
166
368
|
catch (e) {
|
|
167
369
|
return { ok: false, reason: "ollama_error", hint: e.message };
|
|
@@ -221,5 +423,14 @@ export async function preflightModel(configJson, onProgress = (m) => console.log
|
|
|
221
423
|
return { ok: true };
|
|
222
424
|
const results = await Promise.all(tasks);
|
|
223
425
|
const failed = results.find((r) => !r.ok);
|
|
224
|
-
|
|
426
|
+
if (failed)
|
|
427
|
+
return failed;
|
|
428
|
+
// Pick the most-restrictive profile across every model the pipeline
|
|
429
|
+
// uses — if ANY agent is on a text-only model, the whole pipeline
|
|
430
|
+
// gets the text-only caps. Agentic-capable < marginal < text-only
|
|
431
|
+
// (most restrictive last).
|
|
432
|
+
const tierRank = { "agentic-capable": 0, "agentic-marginal": 1, "text-only": 2 };
|
|
433
|
+
const profiles = results.map((r) => r.profile).filter((p) => !!p);
|
|
434
|
+
const worst = profiles.reduce((acc, p) => (!acc || tierRank[p.tier] > tierRank[acc.tier]) ? p : acc, undefined);
|
|
435
|
+
return { ok: true, loaded: true, profile: worst };
|
|
225
436
|
}
|
package/dist/pythonEnv.js
CHANGED