@houtini/lm 2.13.1 → 3.1.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 +16 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +741 -295
- package/dist/index.js.map +1 -1
- package/dist/inference-lock.d.ts +39 -0
- package/dist/inference-lock.js +155 -0
- package/dist/inference-lock.js.map +1 -0
- package/dist/model-cache.d.ts +27 -10
- package/dist/model-cache.js +239 -241
- package/dist/model-cache.js.map +1 -1
- package/dist/suppress-experimental-warnings.d.ts +0 -0
- package/dist/suppress-experimental-warnings.js +17 -0
- package/dist/suppress-experimental-warnings.js.map +1 -0
- package/package.json +4 -6
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
* Connects to LM Studio (or any OpenAI-compatible endpoint) and exposes
|
|
6
6
|
* chat, custom prompts, code tasks, and model discovery as MCP tools.
|
|
7
7
|
*/
|
|
8
|
+
// Must be first: registers the node:sqlite experimental-warning filter BEFORE
|
|
9
|
+
// the model-cache import (which pulls in node:sqlite and would emit the warning
|
|
10
|
+
// at import time, i.e. before any later-registered handler could catch it).
|
|
11
|
+
import './suppress-experimental-warnings.js';
|
|
8
12
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
14
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
11
15
|
import { profileModelsAtStartup, getCachedProfile, toModelProfile as cachedToProfile, getHFEnrichmentLine, getPromptHints, getThinkingSupport, recordPerformance, getAllPerformance, getLifetimeTotals, recordPrefillSample, getPrefillSamples, fitPrefillLinear, } from './model-cache.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
16
|
+
import { acquireInferenceLock } from './inference-lock.js';
|
|
17
|
+
import { readFile, stat, realpath } from 'node:fs/promises';
|
|
18
|
+
import { realpathSync } from 'node:fs';
|
|
19
|
+
import { isAbsolute, basename, resolve, sep } from 'node:path';
|
|
14
20
|
// Env var naming: HOUTINI_LM_* is the preferred namespace now that we
|
|
15
21
|
// support more than just LM Studio. The legacy LM_STUDIO_* names remain
|
|
16
22
|
// accepted indefinitely so existing users don't need to change anything.
|
|
@@ -34,7 +40,78 @@ const SOFT_TIMEOUT_MS = 300_000; // 5 min — progress notifications reset MCP c
|
|
|
34
40
|
const READ_CHUNK_TIMEOUT_MS = 30_000; // max wait for a single SSE chunk mid-stream
|
|
35
41
|
const PREFILL_TIMEOUT_MS = 180_000; // max wait for the FIRST chunk — prompt prefill on slow hardware with big inputs can legitimately take 1-2 min
|
|
36
42
|
const PREFILL_KEEPALIVE_MS = 10_000; // fire a progress notification every N ms while waiting for prefill to finish
|
|
43
|
+
const STREAM_PROGRESS_THROTTLE_MS = 500; // min gap between per-delta streaming progress pings — decoupled from token rate so a fast model can't flood stdio
|
|
37
44
|
const FALLBACK_CONTEXT_LENGTH = parseInt(process.env.HOUTINI_LM_CONTEXT_WINDOW || process.env.LM_CONTEXT_WINDOW || '100000', 10);
|
|
45
|
+
// ── code_task_files read guards ──────────────────────────────────────
|
|
46
|
+
// Per-file size cap (default 10 MB) — files are read fully into memory before
|
|
47
|
+
// the token estimator runs, so an unbounded read risks memory exhaustion.
|
|
48
|
+
const MAX_FILE_BYTES = Math.max(1, parseInt(process.env.HOUTINI_LM_MAX_FILE_MB || '10', 10)) * 1024 * 1024;
|
|
49
|
+
// Optional allowlist of directory roots that code_task_files may read from.
|
|
50
|
+
// Unset = current behaviour (any absolute path). When set (colon- or
|
|
51
|
+
// comma-separated), reads are confined to these roots AFTER symlink resolution,
|
|
52
|
+
// so a prompt-injected call can't escape to ~/.ssh/id_rsa via a planted symlink.
|
|
53
|
+
// Roots are realpath'd (not just resolved) so they compare correctly against the
|
|
54
|
+
// realpath'd file below — otherwise a root under a symlinked ancestor (e.g. macOS
|
|
55
|
+
// /tmp → /private/tmp) would never match and every confined read would fail. A
|
|
56
|
+
// non-existent root falls back to resolve(), which simply won't match anything.
|
|
57
|
+
const FILE_ROOTS = (process.env.HOUTINI_LM_FILE_ROOTS || '')
|
|
58
|
+
.split(/[:,]/)
|
|
59
|
+
.map((r) => r.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((r) => { try {
|
|
62
|
+
return realpathSync(resolve(r));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return resolve(r);
|
|
66
|
+
} });
|
|
67
|
+
/**
|
|
68
|
+
* Read a file for code_task_files with size and (optional) root confinement.
|
|
69
|
+
* Resolves symlinks first so neither the allowlist check nor the caller can be
|
|
70
|
+
* tricked by a symlink pointing outside an allowed root. Throws on violation;
|
|
71
|
+
* the caller turns the throw into an inline "READ FAILED" section.
|
|
72
|
+
*/
|
|
73
|
+
async function readGuardedFile(p) {
|
|
74
|
+
const real = await realpath(p);
|
|
75
|
+
if (FILE_ROOTS.length > 0) {
|
|
76
|
+
const ok = FILE_ROOTS.some((root) => real === root || real.startsWith(root + sep));
|
|
77
|
+
if (!ok)
|
|
78
|
+
throw new Error('path is outside the allowed roots (HOUTINI_LM_FILE_ROOTS)');
|
|
79
|
+
}
|
|
80
|
+
const info = await stat(real);
|
|
81
|
+
if (!info.isFile())
|
|
82
|
+
throw new Error('not a regular file');
|
|
83
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
84
|
+
throw new Error(`file is ${(info.size / 1024 / 1024).toFixed(1)} MB, over the ${(MAX_FILE_BYTES / 1024 / 1024).toFixed(0)} MB limit (set HOUTINI_LM_MAX_FILE_MB to raise)`);
|
|
85
|
+
}
|
|
86
|
+
return readFile(real, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Redact secrets embedded in an endpoint URL before echoing it to the client
|
|
90
|
+
* or logs. Strips userinfo (`user:pass@`) and common secret query params
|
|
91
|
+
* (`api_key`, `token`, …). Returns the input unchanged when it parses to no
|
|
92
|
+
* secret, so the common `http://localhost:1234` case is displayed verbatim.
|
|
93
|
+
*/
|
|
94
|
+
function redactUrl(raw) {
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(raw);
|
|
97
|
+
let hadSecret = false;
|
|
98
|
+
if (u.username || u.password) {
|
|
99
|
+
u.username = '';
|
|
100
|
+
u.password = '';
|
|
101
|
+
hadSecret = true;
|
|
102
|
+
}
|
|
103
|
+
for (const key of ['api_key', 'apikey', 'key', 'token', 'password', 'access_token']) {
|
|
104
|
+
if (u.searchParams.has(key)) {
|
|
105
|
+
u.searchParams.set(key, '***');
|
|
106
|
+
hadSecret = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return hadSecret ? u.toString() : raw;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return raw;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
38
115
|
// ── Session-level token accounting ───────────────────────────────────
|
|
39
116
|
// Tracks cumulative tokens offloaded to the local LLM across all calls
|
|
40
117
|
// in this session. Shown in every response footer so Claude can reason
|
|
@@ -85,6 +162,57 @@ async function hydrateLifetimeFromDb() {
|
|
|
85
162
|
process.stderr.write(`[houtini-lm] Lifetime hydration failed (stats will build from this session): ${err}\n`);
|
|
86
163
|
}
|
|
87
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Compose the system prompt sent to the local model. Guarantees a non-empty,
|
|
167
|
+
* directionally-productive instruction on EVERY call — the previous per-handler
|
|
168
|
+
* merge sent no system message at all when the caller passed none and the model
|
|
169
|
+
* family had no outputConstraint (Llama/Nemotron/Granite/gpt-oss/unknown).
|
|
170
|
+
* Layers, in order:
|
|
171
|
+
* base — persona (+ task, for code tasks); always present
|
|
172
|
+
* grounding — universal anti-hallucination line (the trust lever for the QA loop)
|
|
173
|
+
* format — JSON-only when a json_schema is set (which SUPPRESSES the
|
|
174
|
+
* markdown guidance that would otherwise contradict it);
|
|
175
|
+
* otherwise the task-appropriate format line plus any per-family
|
|
176
|
+
* constraint.
|
|
177
|
+
* Compact by design — every token here is prefill the estimator and latency pay for.
|
|
178
|
+
*/
|
|
179
|
+
function buildSystemPrompt(opts) {
|
|
180
|
+
const layers = [opts.base.trim()];
|
|
181
|
+
layers.push('Base your answer only on the information provided in this conversation. ' +
|
|
182
|
+
'If it is insufficient to answer correctly, say what is missing rather than guessing.');
|
|
183
|
+
if (opts.structuredOutput) {
|
|
184
|
+
layers.push('Return only valid JSON conforming to the requested schema — no prose, no markdown, no code fences.');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
if (opts.formatLine && opts.formatLine.trim())
|
|
188
|
+
layers.push(opts.formatLine.trim());
|
|
189
|
+
if (opts.modelConstraint && opts.modelConstraint.trim())
|
|
190
|
+
layers.push(opts.modelConstraint.trim());
|
|
191
|
+
}
|
|
192
|
+
return layers.join('\n\n');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Generation throughput in tokens/sec, isolating decode time from prefill.
|
|
196
|
+
* `generationMs` spans connect + prefill + decode; dividing by the whole span
|
|
197
|
+
* makes a large-prompt call look many times slower than the model actually
|
|
198
|
+
* decodes, and that pessimistic number is what the footer, the first-call
|
|
199
|
+
* benchmark, and the persisted per-model stats report back to the orchestrator.
|
|
200
|
+
* Subtract TTFT so the denominator is the decode window. Returns null when it
|
|
201
|
+
* can't be measured.
|
|
202
|
+
*
|
|
203
|
+
* Known limitation (tracked separately): for thinking models TTFT is time to
|
|
204
|
+
* first *visible* token, so reasoning time is excluded from the denominator
|
|
205
|
+
* while reasoning tokens remain in completion_tokens — resolving that needs the
|
|
206
|
+
* TTFT-vs-reasoning fix, out of scope for this change.
|
|
207
|
+
*/
|
|
208
|
+
function computeTokPerSec(resp) {
|
|
209
|
+
if (!resp.usage)
|
|
210
|
+
return null;
|
|
211
|
+
const decodeMs = resp.generationMs - (resp.ttftMs ?? 0);
|
|
212
|
+
if (decodeMs <= 50)
|
|
213
|
+
return null;
|
|
214
|
+
return resp.usage.completion_tokens / (decodeMs / 1000);
|
|
215
|
+
}
|
|
88
216
|
function recordUsage(resp) {
|
|
89
217
|
session.calls++;
|
|
90
218
|
const promptTokens = resp.usage?.prompt_tokens ?? 0;
|
|
@@ -101,9 +229,7 @@ function recordUsage(resp) {
|
|
|
101
229
|
session.completionTokens += est;
|
|
102
230
|
}
|
|
103
231
|
// Tok/s used by both session and lifetime stats
|
|
104
|
-
const tokPerSec = resp
|
|
105
|
-
? (resp.usage.completion_tokens / (resp.generationMs / 1000))
|
|
106
|
-
: 0;
|
|
232
|
+
const tokPerSec = computeTokPerSec(resp) ?? 0;
|
|
107
233
|
// Session per-model (unchanged behaviour)
|
|
108
234
|
if (resp.model) {
|
|
109
235
|
const existing = session.modelStats.get(resp.model) || { calls: 0, ttftCalls: 0, perfCalls: 0, totalTtftMs: 0, totalTokPerSec: 0 };
|
|
@@ -205,13 +331,24 @@ function apiHeaders() {
|
|
|
205
331
|
}
|
|
206
332
|
// ── Request semaphore ────────────────────────────────────────────────
|
|
207
333
|
// Most local LLM servers run a single model and queue parallel requests,
|
|
208
|
-
// which stacks timeouts and wastes the
|
|
209
|
-
//
|
|
334
|
+
// which stacks timeouts and wastes the budget. This semaphore ensures only one
|
|
335
|
+
// inference call runs at a time; others wait in line.
|
|
336
|
+
// Global override: HOUTINI_LM_SERIALISE=0 (or false/no/off) turns OFF inference
|
|
337
|
+
// serialisation entirely — both the in-process semaphore and the cross-process
|
|
338
|
+
// file lock. The right setting for backends that batch requests natively (vLLM,
|
|
339
|
+
// TGI, SGLang), where one-at-a-time only throttles throughput. Default on, which
|
|
340
|
+
// suits a single-model LM Studio / Ollama host contending for one GPU.
|
|
341
|
+
const SERIALISE_INFERENCE = !/^(0|false|no|off)$/i.test(process.env.HOUTINI_LM_SERIALISE || '');
|
|
342
|
+
/** Serialise inference for the current backend? Combines the env override with
|
|
343
|
+
* the provider profile (OpenRouter and other parallel-friendly backends off). */
|
|
344
|
+
function shouldSerialiseInference() {
|
|
345
|
+
return SERIALISE_INFERENCE && getProviderProfile().serialiseInference;
|
|
346
|
+
}
|
|
210
347
|
let inferenceLock = Promise.resolve();
|
|
211
348
|
function withInferenceLock(fn) {
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
if (!
|
|
349
|
+
// Skip when serialisation is off (env override) or the backend is
|
|
350
|
+
// parallel-friendly (OpenRouter etc.) — serialising there just throttles us.
|
|
351
|
+
if (!shouldSerialiseInference())
|
|
215
352
|
return fn();
|
|
216
353
|
let release;
|
|
217
354
|
const next = new Promise((resolve) => { release = resolve; });
|
|
@@ -482,9 +619,12 @@ async function fetchWithRetry(url, options, timeoutMs, retries) {
|
|
|
482
619
|
await res.body?.cancel();
|
|
483
620
|
}
|
|
484
621
|
catch { /* ignore */ }
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
622
|
+
// Exponential base capped at 10s, but honour an explicit server
|
|
623
|
+
// Retry-After (up to 60s) even when larger. Jitter UPWARD only
|
|
624
|
+
// (target..1.5×) so we never retry before the server-mandated delay.
|
|
625
|
+
const base = Math.min(400 * (attempt + 1), 10_000);
|
|
626
|
+
const target = Math.max(base, Math.min(retryAfter ?? 0, 60_000));
|
|
627
|
+
const delay = Math.round(target * (1 + Math.random() * 0.5));
|
|
488
628
|
await new Promise((r) => setTimeout(r, delay));
|
|
489
629
|
continue;
|
|
490
630
|
}
|
|
@@ -492,10 +632,15 @@ async function fetchWithRetry(url, options, timeoutMs, retries) {
|
|
|
492
632
|
}
|
|
493
633
|
catch (e) {
|
|
494
634
|
lastErr = e;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
635
|
+
// A timeout abort can mean the server already received the POST and began
|
|
636
|
+
// a (billed) generation — re-POSTing /v1/chat/completions would duplicate
|
|
637
|
+
// it. Only retry errors that indicate the request never reached the
|
|
638
|
+
// server (connection refused/reset); never on our own abort.
|
|
639
|
+
const isAbort = e instanceof Error && e.name === 'AbortError';
|
|
640
|
+
if (isAbort || attempt >= retries)
|
|
641
|
+
break;
|
|
642
|
+
const delay = Math.round(400 * (attempt + 1) * (1 + Math.random() * 0.5));
|
|
643
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
499
644
|
}
|
|
500
645
|
}
|
|
501
646
|
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
@@ -517,16 +662,96 @@ async function timedRead(reader, timeoutMs) {
|
|
|
517
662
|
}
|
|
518
663
|
}
|
|
519
664
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
|
|
525
|
-
|
|
665
|
+
* Extract and RANGE-VALIDATE optional sampling params from tool args. Out-of-range,
|
|
666
|
+
* NaN, or wrong-type values are dropped (undefined) rather than forwarded — the
|
|
667
|
+
* backend then applies its own default. This also closes the earlier gap where an
|
|
668
|
+
* unvalidated max_tokens/temperature could reach the upstream request.
|
|
669
|
+
*/
|
|
670
|
+
function extractSamplingParams(args) {
|
|
671
|
+
const range = (v, min, max) => {
|
|
672
|
+
const n = typeof v === 'number' ? v : NaN;
|
|
673
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : undefined;
|
|
674
|
+
};
|
|
675
|
+
const stopRaw = args.stop;
|
|
676
|
+
const stop = typeof stopRaw === 'string'
|
|
677
|
+
? stopRaw
|
|
678
|
+
: Array.isArray(stopRaw)
|
|
679
|
+
? stopRaw.filter((s) => typeof s === 'string').slice(0, 4)
|
|
680
|
+
: undefined;
|
|
681
|
+
return {
|
|
682
|
+
seed: Number.isInteger(args.seed) ? args.seed : undefined,
|
|
683
|
+
stop: stop && stop.length ? stop : undefined,
|
|
684
|
+
topP: range(args.top_p, 0, 1),
|
|
685
|
+
topK: range(args.top_k, 1, 100_000),
|
|
686
|
+
repeatPenalty: range(args.repeat_penalty, 0, 2),
|
|
687
|
+
frequencyPenalty: range(args.frequency_penalty, -2, 2),
|
|
688
|
+
presencePenalty: range(args.presence_penalty, -2, 2),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** Clamp a caller-supplied temperature to a sane range, or undefined if unusable. */
|
|
692
|
+
function validTemperature(v) {
|
|
693
|
+
const n = typeof v === 'number' ? v : NaN;
|
|
694
|
+
return Number.isFinite(n) && n >= 0 && n <= 2 ? n : undefined;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Minimum caller-supplied max_tokens the server will honour. Values below this
|
|
698
|
+
* are discarded so the dynamic context-based budget (25% of the model's context
|
|
699
|
+
* window) applies instead — MCP clients habitually pass tiny caps like 256 that
|
|
700
|
+
* strangle reasoning models. Set HOUTINI_LM_MIN_TOKENS=0 to honour any value
|
|
701
|
+
* (e.g. deliberate micro-chunking on slow hardware), or a different floor.
|
|
526
702
|
*/
|
|
703
|
+
const MIN_MAX_TOKENS = (() => {
|
|
704
|
+
const v = Number(process.env.HOUTINI_LM_MIN_TOKENS);
|
|
705
|
+
return Number.isFinite(v) && v >= 0 ? Math.floor(v) : 4096;
|
|
706
|
+
})();
|
|
707
|
+
/** Clamp a caller-supplied max_tokens to a positive sane range, or undefined. */
|
|
708
|
+
function validMaxTokens(v) {
|
|
709
|
+
const n = typeof v === 'number' ? v : NaN;
|
|
710
|
+
if (!Number.isInteger(n) || n <= 0 || n > 1_000_000)
|
|
711
|
+
return undefined;
|
|
712
|
+
if (n < MIN_MAX_TOKENS) {
|
|
713
|
+
process.stderr.write(`[houtini-lm] max_tokens=${n} is below the ${MIN_MAX_TOKENS} floor — ignoring it and using the dynamic context-based budget (HOUTINI_LM_MIN_TOKENS=0 to allow)\n`);
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
return n;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Build an OpenAI response_format from the tool's json_schema input. Accepts
|
|
720
|
+
* BOTH the documented wrapper `{ name, schema, strict }` and a bare JSON Schema
|
|
721
|
+
* (which the description invites) — the latter previously produced undefined
|
|
722
|
+
* name/schema and silently unconstrained output.
|
|
723
|
+
*/
|
|
724
|
+
function toResponseFormat(js) {
|
|
725
|
+
if (!js || typeof js !== 'object')
|
|
726
|
+
return undefined;
|
|
727
|
+
const obj = js;
|
|
728
|
+
const hasWrapper = !!obj.schema && typeof obj.schema === 'object';
|
|
729
|
+
const schema = (hasWrapper ? obj.schema : obj);
|
|
730
|
+
const name = hasWrapper && typeof obj.name === 'string' ? obj.name : 'response';
|
|
731
|
+
const strict = hasWrapper && typeof obj.strict === 'boolean' ? obj.strict : true;
|
|
732
|
+
return { type: 'json_schema', json_schema: { name, strict, schema } };
|
|
733
|
+
}
|
|
527
734
|
async function chatCompletionStreaming(messages, options = {}) {
|
|
528
735
|
return withInferenceLock(() => chatCompletionStreamingInner(messages, options));
|
|
529
736
|
}
|
|
737
|
+
/**
|
|
738
|
+
* Pull a human-readable message out of an OpenAI-style mid-stream error
|
|
739
|
+
* payload (`data: {"error":{...}}`). Returns undefined when the chunk carries
|
|
740
|
+
* no error, so callers can treat a truthy result as "the backend failed".
|
|
741
|
+
*/
|
|
742
|
+
function extractStreamError(json) {
|
|
743
|
+
if (!json || typeof json !== 'object')
|
|
744
|
+
return undefined;
|
|
745
|
+
const err = json.error;
|
|
746
|
+
if (!err)
|
|
747
|
+
return undefined;
|
|
748
|
+
if (typeof err === 'string')
|
|
749
|
+
return err;
|
|
750
|
+
if (typeof err === 'object' && typeof err.message === 'string') {
|
|
751
|
+
return err.message;
|
|
752
|
+
}
|
|
753
|
+
return JSON.stringify(err);
|
|
754
|
+
}
|
|
530
755
|
/** Get the first loaded model's info for context-aware defaults. */
|
|
531
756
|
async function getActiveModel() {
|
|
532
757
|
try {
|
|
@@ -557,14 +782,30 @@ async function chatCompletionStreamingInner(messages, options = {}) {
|
|
|
557
782
|
}
|
|
558
783
|
// Derive max_tokens from the model's actual context window when not explicitly set.
|
|
559
784
|
// Uses 25% of context as a generous output budget (e.g. 262K context → 65K output).
|
|
560
|
-
let
|
|
561
|
-
|
|
785
|
+
let contextLen;
|
|
786
|
+
{
|
|
562
787
|
const activeModel = await resolveActive();
|
|
563
|
-
if (activeModel)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
788
|
+
if (activeModel)
|
|
789
|
+
contextLen = getContextLength(activeModel);
|
|
790
|
+
}
|
|
791
|
+
let effectiveMaxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
792
|
+
if (!options.maxTokens && contextLen) {
|
|
793
|
+
effectiveMaxTokens = Math.floor(contextLen * 0.25);
|
|
567
794
|
}
|
|
795
|
+
// Never request more output than the context window can hold alongside the
|
|
796
|
+
// prompt — vLLM (and strict OpenAI backends) reject prompt+max_tokens >
|
|
797
|
+
// context with a 400 instead of clamping. Conservative prompt estimate:
|
|
798
|
+
// 1 token ≈ 3 chars, plus per-message overhead.
|
|
799
|
+
const promptChars = messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : JSON.stringify(m.content ?? '').length), 0);
|
|
800
|
+
const capToContext = (requested) => {
|
|
801
|
+
if (!contextLen)
|
|
802
|
+
return requested;
|
|
803
|
+
const cap = contextLen - (Math.ceil(promptChars / 3) + 64 * messages.length + 512);
|
|
804
|
+
// If the prompt alone (over)fills the context, don't mangle the request —
|
|
805
|
+
// let the backend report the real overflow.
|
|
806
|
+
return cap > 0 ? Math.min(requested, cap) : requested;
|
|
807
|
+
};
|
|
808
|
+
effectiveMaxTokens = capToContext(effectiveMaxTokens);
|
|
568
809
|
const body = {
|
|
569
810
|
messages,
|
|
570
811
|
temperature: options.temperature ?? DEFAULT_TEMPERATURE,
|
|
@@ -582,6 +823,26 @@ async function chatCompletionStreamingInner(messages, options = {}) {
|
|
|
582
823
|
if (options.responseFormat) {
|
|
583
824
|
body.response_format = options.responseFormat;
|
|
584
825
|
}
|
|
826
|
+
// Optional sampling controls — forwarded only when the caller set them (already
|
|
827
|
+
// range-validated in extractSamplingParams). Backends ignore unknown fields, so
|
|
828
|
+
// sending e.g. top_k to one that doesn't support it is harmless.
|
|
829
|
+
const s = options.sampling;
|
|
830
|
+
if (s) {
|
|
831
|
+
if (s.seed !== undefined)
|
|
832
|
+
body.seed = s.seed;
|
|
833
|
+
if (s.stop !== undefined)
|
|
834
|
+
body.stop = s.stop;
|
|
835
|
+
if (s.topP !== undefined)
|
|
836
|
+
body.top_p = s.topP;
|
|
837
|
+
if (s.topK !== undefined)
|
|
838
|
+
body.top_k = s.topK;
|
|
839
|
+
if (s.repeatPenalty !== undefined)
|
|
840
|
+
body.repeat_penalty = s.repeatPenalty;
|
|
841
|
+
if (s.frequencyPenalty !== undefined)
|
|
842
|
+
body.frequency_penalty = s.frequencyPenalty;
|
|
843
|
+
if (s.presencePenalty !== undefined)
|
|
844
|
+
body.presence_penalty = s.presencePenalty;
|
|
845
|
+
}
|
|
585
846
|
// Handle thinking/reasoning models.
|
|
586
847
|
// Some models (Gemma 4, Qwen3, DeepSeek R1, Nemotron, gpt-oss) have extended
|
|
587
848
|
// thinking that consumes part of the max_tokens budget for invisible reasoning
|
|
@@ -609,7 +870,7 @@ async function chatCompletionStreamingInner(messages, options = {}) {
|
|
|
609
870
|
// guarantee the model generates less of it.
|
|
610
871
|
body.reasoning = { exclude: true };
|
|
611
872
|
const beforeInflation = effectiveMaxTokens;
|
|
612
|
-
const inflated = Math.max(beforeInflation * 4, beforeInflation + 2000);
|
|
873
|
+
const inflated = capToContext(Math.max(beforeInflation * 4, beforeInflation + 2000));
|
|
613
874
|
body.max_tokens = inflated;
|
|
614
875
|
body.max_completion_tokens = inflated;
|
|
615
876
|
process.stderr.write(`[houtini-lm] OpenRouter model ${modelId || '(unspecified)'}: reasoning.exclude=true, max_tokens inflated ${beforeInflation} → ${inflated}\n`);
|
|
@@ -625,40 +886,20 @@ async function chatCompletionStreamingInner(messages, options = {}) {
|
|
|
625
886
|
// Inflation uses effectiveMaxTokens (the context-aware value), not
|
|
626
887
|
// DEFAULT_MAX_TOKENS — otherwise big-context models get sized down.
|
|
627
888
|
const beforeInflation = effectiveMaxTokens;
|
|
628
|
-
const inflated = Math.max(beforeInflation * 4, beforeInflation + 2000);
|
|
889
|
+
const inflated = capToContext(Math.max(beforeInflation * 4, beforeInflation + 2000));
|
|
629
890
|
body.max_tokens = inflated;
|
|
630
891
|
body.max_completion_tokens = inflated;
|
|
631
892
|
process.stderr.write(`[houtini-lm] Thinking model ${modelId}: reasoning_effort=${reasoningValue ?? '(omitted)'}, enable_thinking=false, max_tokens inflated ${beforeInflation} → ${inflated}\n`);
|
|
632
893
|
}
|
|
633
894
|
}
|
|
634
895
|
const startTime = Date.now();
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
if (!res.body) {
|
|
643
|
-
throw new Error('Response body is null — streaming not supported by endpoint');
|
|
644
|
-
}
|
|
645
|
-
const reader = res.body.getReader();
|
|
646
|
-
const decoder = new TextDecoder();
|
|
647
|
-
let content = '';
|
|
648
|
-
let reasoning = '';
|
|
896
|
+
// Progress-notification plumbing lifted ABOVE the fetch so we can keep the
|
|
897
|
+
// MCP client's request timeout alive during the HTTP handshake with the
|
|
898
|
+
// upstream LLM. On slow backends (big prompt, heavy prefill, cold model)
|
|
899
|
+
// the POST to /v1/chat/completions can sit for 30-60+ seconds before the
|
|
900
|
+
// response headers flush — that window used to be silent and would trip
|
|
901
|
+
// the client's 60s default timeout before any SSE chunk reached us.
|
|
649
902
|
let progressSeq = 0;
|
|
650
|
-
let model = '';
|
|
651
|
-
let usage;
|
|
652
|
-
let finishReason = '';
|
|
653
|
-
let truncated = false;
|
|
654
|
-
let prefillStall = false;
|
|
655
|
-
let buffer = '';
|
|
656
|
-
let ttftMs;
|
|
657
|
-
let firstChunkReceived = false;
|
|
658
|
-
// Prefill keep-alive — /v1/chat/completions gives no SSE events during
|
|
659
|
-
// prompt processing, so the MCP client clock ticks uninterrupted on a slow
|
|
660
|
-
// backend with a big input. Fire a progress notification every 10s until
|
|
661
|
-
// the first chunk arrives to keep the client from timing out at 60s.
|
|
662
903
|
const sendProgress = (message) => {
|
|
663
904
|
if (options.progressToken === undefined)
|
|
664
905
|
return;
|
|
@@ -672,190 +913,323 @@ async function chatCompletionStreamingInner(messages, options = {}) {
|
|
|
672
913
|
},
|
|
673
914
|
}).catch(() => { });
|
|
674
915
|
};
|
|
675
|
-
|
|
676
|
-
|
|
916
|
+
// Throttled variant for the per-delta streaming updates. Streaming fires a
|
|
917
|
+
// progress event on every content/reasoning chunk; on a fast model (e.g.
|
|
918
|
+
// 145 tok/s) that would be ~145 JSON-RPC notifications/sec over stdio,
|
|
919
|
+
// flooding the transport and the client's notification handler. Gate those
|
|
920
|
+
// on a time interval so the notification rate is decoupled from the token
|
|
921
|
+
// rate — slow models still update often, fast models emit at most one ping
|
|
922
|
+
// per interval. The immediate connect ping and the interval keepalives below
|
|
923
|
+
// bypass this deliberately.
|
|
924
|
+
let lastStreamProgressMs = 0;
|
|
925
|
+
const sendStreamProgress = (message) => {
|
|
926
|
+
const now = Date.now();
|
|
927
|
+
if (now - lastStreamProgressMs < STREAM_PROGRESS_THROTTLE_MS)
|
|
677
928
|
return;
|
|
678
|
-
|
|
679
|
-
sendProgress(
|
|
680
|
-
}
|
|
929
|
+
lastStreamProgressMs = now;
|
|
930
|
+
sendProgress(message);
|
|
931
|
+
};
|
|
932
|
+
// Ping once immediately — resets the client's timeout clock as soon as the
|
|
933
|
+
// tool call is acknowledged server-side, regardless of how long prefill or
|
|
934
|
+
// the upstream handshake takes.
|
|
935
|
+
sendProgress('Connecting to model...');
|
|
936
|
+
// Cross-process inference serialisation (local single-model backends only).
|
|
937
|
+
// Acquire BEFORE the request so only one process at a time drives the model;
|
|
938
|
+
// the in-process semaphore already gates same-process calls, so this only ever
|
|
939
|
+
// contends across processes. Keepalive via sendProgress during the wait so a
|
|
940
|
+
// queued call doesn't sit silent past the client's request timeout. Fail-open:
|
|
941
|
+
// the lock module returns a no-op release on error or after the wait cap.
|
|
942
|
+
const canKeepalive = options.progressToken !== undefined;
|
|
943
|
+
const releaseInferenceLock = shouldSerialiseInference()
|
|
944
|
+
? await acquireInferenceLock({
|
|
945
|
+
onWait: canKeepalive
|
|
946
|
+
? (waitedMs) => sendProgress(`Waiting for the local model — another request is running (${(waitedMs / 1000).toFixed(0)}s)`)
|
|
947
|
+
: undefined,
|
|
948
|
+
// Without a progressToken we can't keep the client alive during a long
|
|
949
|
+
// wait, so fail open well before the typical ~60s client request timeout
|
|
950
|
+
// rather than sit silent in the queue and get aborted.
|
|
951
|
+
maxWaitMs: canKeepalive ? undefined : 45_000,
|
|
952
|
+
})
|
|
953
|
+
: () => { };
|
|
681
954
|
try {
|
|
682
|
-
while
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
955
|
+
// Pre-fetch heartbeat — keep the client alive while we wait for the
|
|
956
|
+
// upstream LLM to return response headers. Cleared once fetch resolves.
|
|
957
|
+
const preFetchTimer = setInterval(() => {
|
|
958
|
+
const waitedMs = Date.now() - startTime;
|
|
959
|
+
sendProgress(`Connecting to model... (${(waitedMs / 1000).toFixed(0)}s)`);
|
|
960
|
+
}, PREFILL_KEEPALIVE_MS);
|
|
961
|
+
let res;
|
|
962
|
+
try {
|
|
963
|
+
res = profile.retryOnRateLimit
|
|
964
|
+
? await fetchWithRetry(`${LM_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) }, INFERENCE_CONNECT_TIMEOUT_MS, 2)
|
|
965
|
+
: await fetchWithTimeout(`${LM_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) }, INFERENCE_CONNECT_TIMEOUT_MS);
|
|
966
|
+
}
|
|
967
|
+
finally {
|
|
968
|
+
clearInterval(preFetchTimer);
|
|
969
|
+
}
|
|
970
|
+
if (!res.ok) {
|
|
971
|
+
const text = await res.text().catch(() => '');
|
|
972
|
+
throw new Error(`LM Studio API error ${res.status}: ${text}`);
|
|
973
|
+
}
|
|
974
|
+
if (!res.body) {
|
|
975
|
+
throw new Error('Response body is null — streaming not supported by endpoint');
|
|
976
|
+
}
|
|
977
|
+
const reader = res.body.getReader();
|
|
978
|
+
const decoder = new TextDecoder();
|
|
979
|
+
let content = '';
|
|
980
|
+
let reasoning = '';
|
|
981
|
+
let model = '';
|
|
982
|
+
let usage;
|
|
983
|
+
let finishReason = '';
|
|
984
|
+
let truncated = false;
|
|
985
|
+
let prefillStall = false;
|
|
986
|
+
let buffer = '';
|
|
987
|
+
let ttftMs;
|
|
988
|
+
let firstChunkReceived = false;
|
|
989
|
+
// Backends (OpenRouter, vLLM, llama.cpp) can emit an error object mid-stream
|
|
990
|
+
// — `data: {"error":{...}}` — then close the connection normally. Without
|
|
991
|
+
// capturing it, the loop parses the payload, matches no field, and returns
|
|
992
|
+
// the partial/empty content as a clean success. Track it so we can surface
|
|
993
|
+
// the real cause instead of silently corrupting the result.
|
|
994
|
+
let streamError;
|
|
995
|
+
// Prefill keep-alive — even after HTTP headers flush, the first SSE chunk
|
|
996
|
+
// can still lag while the model finishes prompt processing. Fire a progress
|
|
997
|
+
// notification every 10s until the first chunk arrives.
|
|
998
|
+
const keepAliveTimer = setInterval(() => {
|
|
999
|
+
if (firstChunkReceived)
|
|
1000
|
+
return;
|
|
1001
|
+
const waitedMs = Date.now() - startTime;
|
|
1002
|
+
sendProgress(`Waiting for model... (${(waitedMs / 1000).toFixed(0)}s, still in prefill)`);
|
|
1003
|
+
}, PREFILL_KEEPALIVE_MS);
|
|
1004
|
+
try {
|
|
1005
|
+
while (true) {
|
|
1006
|
+
// Check soft timeout before each read
|
|
1007
|
+
const elapsed = Date.now() - startTime;
|
|
1008
|
+
if (elapsed > SOFT_TIMEOUT_MS) {
|
|
1009
|
+
truncated = true;
|
|
1010
|
+
process.stderr.write(`[houtini-lm] Soft timeout at ${elapsed}ms, returning ${content.length} chars of partial content\n`);
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
// Split prefill vs mid-stream timeouts. Prefill on slow hardware with
|
|
1014
|
+
// a 7k-token input can legitimately take 1-2 min; mid-stream stalls
|
|
1015
|
+
// should surface much faster. Track firstChunkReceived to switch.
|
|
1016
|
+
const remaining = SOFT_TIMEOUT_MS - elapsed;
|
|
1017
|
+
const perChunkCeiling = firstChunkReceived ? READ_CHUNK_TIMEOUT_MS : PREFILL_TIMEOUT_MS;
|
|
1018
|
+
const chunkTimeout = Math.min(perChunkCeiling, remaining);
|
|
1019
|
+
const result = await timedRead(reader, chunkTimeout);
|
|
1020
|
+
if (result === 'timeout') {
|
|
1021
|
+
truncated = true;
|
|
1022
|
+
prefillStall = !firstChunkReceived;
|
|
1023
|
+
process.stderr.write(`[houtini-lm] ${prefillStall ? 'Prefill' : 'Mid-stream'} timeout, returning ${content.length} chars of partial content\n`);
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
if (result.done)
|
|
1027
|
+
break;
|
|
1028
|
+
if (!firstChunkReceived) {
|
|
1029
|
+
firstChunkReceived = true;
|
|
1030
|
+
}
|
|
1031
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
1032
|
+
// Parse SSE lines
|
|
1033
|
+
const lines = buffer.split('\n');
|
|
1034
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
1035
|
+
for (const line of lines) {
|
|
1036
|
+
const trimmed = line.trim();
|
|
1037
|
+
if (!trimmed || trimmed === 'data: [DONE]')
|
|
1038
|
+
continue;
|
|
1039
|
+
if (!trimmed.startsWith('data: '))
|
|
1040
|
+
continue;
|
|
1041
|
+
try {
|
|
1042
|
+
const json = JSON.parse(trimmed.slice(6));
|
|
1043
|
+
// Mid-stream error from the backend — capture the cause and stop.
|
|
1044
|
+
// The connection usually closes normally right after, so without
|
|
1045
|
+
// this the partial result would be returned as a success.
|
|
1046
|
+
const errMsg = extractStreamError(json);
|
|
1047
|
+
if (errMsg) {
|
|
1048
|
+
streamError = errMsg;
|
|
1049
|
+
truncated = true;
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
if (json.model)
|
|
1053
|
+
model = json.model;
|
|
1054
|
+
const delta = json.choices?.[0]?.delta;
|
|
1055
|
+
// Reasoning channel. Two field names exist in the wild:
|
|
1056
|
+
// - LM Studio (dev toggle "Separate reasoning_content"), DeepSeek R1,
|
|
1057
|
+
// Nemotron → delta.reasoning_content
|
|
1058
|
+
// - Ollama OpenAI-compat (all thinking models) → delta.reasoning
|
|
1059
|
+
// We capture both so runtime detection and safety-net fallback fire
|
|
1060
|
+
// regardless of backend. An Ollama qwen3:4b at low max_tokens
|
|
1061
|
+
// previously looked like a broken model — silent empty content —
|
|
1062
|
+
// because this channel was dropped.
|
|
1063
|
+
const reasoningChunk = (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0)
|
|
1064
|
+
? delta.reasoning_content
|
|
1065
|
+
: (typeof delta?.reasoning === 'string' && delta.reasoning.length > 0)
|
|
1066
|
+
? delta.reasoning
|
|
1067
|
+
: '';
|
|
1068
|
+
if (reasoningChunk) {
|
|
1069
|
+
// TTFT = time to first token of ANY channel (true prefill end). For
|
|
1070
|
+
// thinking models the first token is reasoning, not content; setting
|
|
1071
|
+
// TTFT here keeps the prefill estimator from mistaking the whole
|
|
1072
|
+
// reasoning phase for prefill (which caused spurious code_task_files
|
|
1073
|
+
// refusals) and keeps the decode-window tok/s denominator consistent.
|
|
1074
|
+
if (ttftMs === undefined)
|
|
1075
|
+
ttftMs = Date.now() - startTime;
|
|
1076
|
+
reasoning += reasoningChunk;
|
|
1077
|
+
sendStreamProgress(`Thinking... (${reasoning.length} chars of reasoning)`);
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof delta?.content === 'string' && delta.content.length > 0) {
|
|
1080
|
+
if (ttftMs === undefined)
|
|
1081
|
+
ttftMs = Date.now() - startTime;
|
|
1082
|
+
content += delta.content;
|
|
1083
|
+
sendStreamProgress(`Streaming... ${content.length} chars`);
|
|
1084
|
+
}
|
|
1085
|
+
const reason = json.choices?.[0]?.finish_reason;
|
|
1086
|
+
if (reason)
|
|
1087
|
+
finishReason = reason;
|
|
1088
|
+
// Some endpoints include usage in the final streaming chunk
|
|
1089
|
+
if (json.usage)
|
|
1090
|
+
usage = json.usage;
|
|
739
1091
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
ttftMs = Date.now() - startTime;
|
|
743
|
-
content += delta.content;
|
|
744
|
-
sendProgress(`Streaming... ${content.length} chars`);
|
|
1092
|
+
catch {
|
|
1093
|
+
// Skip unparseable chunks (partial JSON, comments, etc.)
|
|
745
1094
|
}
|
|
746
|
-
const reason = json.choices?.[0]?.finish_reason;
|
|
747
|
-
if (reason)
|
|
748
|
-
finishReason = reason;
|
|
749
|
-
// Some endpoints include usage in the final streaming chunk
|
|
750
|
-
if (json.usage)
|
|
751
|
-
usage = json.usage;
|
|
752
|
-
}
|
|
753
|
-
catch {
|
|
754
|
-
// Skip unparseable chunks (partial JSON, comments, etc.)
|
|
755
1095
|
}
|
|
1096
|
+
// A mid-stream error breaks the inner line loop; also stop reading here
|
|
1097
|
+
// rather than waiting out the per-chunk timeout on a connection the
|
|
1098
|
+
// backend is about to close.
|
|
1099
|
+
if (streamError)
|
|
1100
|
+
break;
|
|
756
1101
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1102
|
+
// Flush remaining buffer — the usage chunk often arrives in the final SSE
|
|
1103
|
+
// message and may not have a trailing newline, leaving it stranded in buffer.
|
|
1104
|
+
if (buffer.trim()) {
|
|
1105
|
+
const trimmed = buffer.trim();
|
|
1106
|
+
if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
|
|
1107
|
+
try {
|
|
1108
|
+
const json = JSON.parse(trimmed.slice(6));
|
|
1109
|
+
const errMsg = extractStreamError(json);
|
|
1110
|
+
if (errMsg) {
|
|
1111
|
+
streamError = errMsg;
|
|
1112
|
+
truncated = true;
|
|
1113
|
+
}
|
|
1114
|
+
if (json.model)
|
|
1115
|
+
model = json.model;
|
|
1116
|
+
const delta = json.choices?.[0]?.delta;
|
|
1117
|
+
const finalReasoningChunk = (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0)
|
|
1118
|
+
? delta.reasoning_content
|
|
1119
|
+
: (typeof delta?.reasoning === 'string' && delta.reasoning.length > 0)
|
|
1120
|
+
? delta.reasoning
|
|
1121
|
+
: '';
|
|
1122
|
+
if (finalReasoningChunk) {
|
|
1123
|
+
if (ttftMs === undefined)
|
|
1124
|
+
ttftMs = Date.now() - startTime;
|
|
1125
|
+
reasoning += finalReasoningChunk;
|
|
1126
|
+
}
|
|
1127
|
+
if (typeof delta?.content === 'string' && delta.content.length > 0) {
|
|
1128
|
+
if (ttftMs === undefined)
|
|
1129
|
+
ttftMs = Date.now() - startTime;
|
|
1130
|
+
content += delta.content;
|
|
1131
|
+
}
|
|
1132
|
+
const reason = json.choices?.[0]?.finish_reason;
|
|
1133
|
+
if (reason)
|
|
1134
|
+
finishReason = reason;
|
|
1135
|
+
if (json.usage)
|
|
1136
|
+
usage = json.usage;
|
|
775
1137
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
content += delta.content;
|
|
1138
|
+
catch (e) {
|
|
1139
|
+
// Incomplete JSON in final buffer — log for diagnostics
|
|
1140
|
+
process.stderr.write(`[houtini-lm] Unflushed buffer parse failed (${buffer.length} bytes): ${e}\n`);
|
|
780
1141
|
}
|
|
781
|
-
const reason = json.choices?.[0]?.finish_reason;
|
|
782
|
-
if (reason)
|
|
783
|
-
finishReason = reason;
|
|
784
|
-
if (json.usage)
|
|
785
|
-
usage = json.usage;
|
|
786
|
-
}
|
|
787
|
-
catch (e) {
|
|
788
|
-
// Incomplete JSON in final buffer — log for diagnostics
|
|
789
|
-
process.stderr.write(`[houtini-lm] Unflushed buffer parse failed (${buffer.length} bytes): ${e}\n`);
|
|
790
1142
|
}
|
|
791
1143
|
}
|
|
792
1144
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1145
|
+
finally {
|
|
1146
|
+
clearInterval(keepAliveTimer);
|
|
1147
|
+
// Best-effort cancel with a short timeout — cancel() can hang if the upstream
|
|
1148
|
+
// connection is wedged, so we race it against a 500ms timer. This frees the
|
|
1149
|
+
// underlying socket sooner on abrupt client disconnects without blocking the
|
|
1150
|
+
// tool response path.
|
|
1151
|
+
try {
|
|
1152
|
+
await Promise.race([
|
|
1153
|
+
reader.cancel().catch(() => { }),
|
|
1154
|
+
new Promise((resolve) => setTimeout(resolve, 500)),
|
|
1155
|
+
]);
|
|
1156
|
+
}
|
|
1157
|
+
catch { /* never propagate cleanup errors */ }
|
|
1158
|
+
try {
|
|
1159
|
+
reader.releaseLock();
|
|
1160
|
+
}
|
|
1161
|
+
catch { /* already released */ }
|
|
805
1162
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1163
|
+
const generationMs = Date.now() - startTime;
|
|
1164
|
+
// Backend failed mid-stream and produced nothing usable — surface the real
|
|
1165
|
+
// cause as an error rather than returning an empty "success" the orchestrator
|
|
1166
|
+
// would treat as a valid (empty) answer. When partial content did arrive we
|
|
1167
|
+
// keep it but carry streamError through so the footer flags it.
|
|
1168
|
+
if (streamError && !content.trim() && !reasoning.trim()) {
|
|
1169
|
+
throw new Error(`Upstream model error mid-stream: ${streamError}`);
|
|
809
1170
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
let reasoningFallback = false;
|
|
832
|
-
if (!cleanContent) {
|
|
833
|
-
if (content.trim()) {
|
|
834
|
-
thinkStripFallback = true;
|
|
835
|
-
cleanContent = content.trim();
|
|
1171
|
+
// Strip <think>...</think> reasoning blocks from models that always emit them
|
|
1172
|
+
// inline on the content channel (e.g. GLM Flash, Ollama Qwen3). Claude doesn't
|
|
1173
|
+
// need the model's internal reasoning. Handle three shapes:
|
|
1174
|
+
// 1. Balanced <think>...</think> — GLM Flash, Nemotron normal case
|
|
1175
|
+
// 2. Orphan opener <think>... (truncated)
|
|
1176
|
+
// 3. Orphan closer ...</think> — Ollama Qwen3 streams reasoning directly
|
|
1177
|
+
// on the content channel and terminates with a bare </think> before the
|
|
1178
|
+
// real answer. Strip everything up to and including the first closer.
|
|
1179
|
+
let cleanContent = content.replace(/<think>[\s\S]*?<\/think>\s*/g, ''); // closed blocks
|
|
1180
|
+
cleanContent = cleanContent.replace(/^<think>\s*/, ''); // orphaned opening tag
|
|
1181
|
+
// Orphaned closing tag: reasoning streamed on the content channel then a bare
|
|
1182
|
+
// </think> before the answer (Ollama Qwen3). Strip up to the first closer ONLY
|
|
1183
|
+
// for models KNOWN to emit think blocks (per the cached profile). For any other
|
|
1184
|
+
// model, leave it: a real answer that merely quotes "</think>" would otherwise
|
|
1185
|
+
// be truncated — and a leaked reasoning prefix (visible, recoverable) is a far
|
|
1186
|
+
// safer failure than deleting answer text (silent, unrecoverable). The earlier
|
|
1187
|
+
// backtick heuristic was lose-lose (leaked reasoning containing code, still
|
|
1188
|
+
// deleted answers with an unquoted literal); the profile flag is the right signal.
|
|
1189
|
+
const thinkProfile = await getThinkingSupport(modelId).catch(() => null);
|
|
1190
|
+
if (thinkProfile?.emitsThinkBlocks && cleanContent.includes('</think>')) {
|
|
1191
|
+
cleanContent = cleanContent.replace(/^[\s\S]*?<\/think>\s*/, '');
|
|
836
1192
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1193
|
+
cleanContent = cleanContent.trim();
|
|
1194
|
+
// Safety nets for empty visible output. Try in order:
|
|
1195
|
+
// 1. thinkStripFallback: stripping <think> left nothing, but raw content had text
|
|
1196
|
+
// 2. reasoningFallback: no visible content AT ALL, but reasoning_content was streamed
|
|
1197
|
+
// (this is the Nemotron/DeepSeek-R1/LM-Studio-dev-toggle case — previously
|
|
1198
|
+
// produced silent empty bodies because reasoning was discarded)
|
|
1199
|
+
let thinkStripFallback = false;
|
|
1200
|
+
let reasoningFallback = false;
|
|
1201
|
+
if (!cleanContent) {
|
|
1202
|
+
if (content.trim()) {
|
|
1203
|
+
thinkStripFallback = true;
|
|
1204
|
+
cleanContent = content.trim();
|
|
1205
|
+
}
|
|
1206
|
+
else if (reasoning.trim()) {
|
|
1207
|
+
reasoningFallback = true;
|
|
1208
|
+
cleanContent =
|
|
1209
|
+
'[No visible output — the model spent its entire output budget on reasoning_content before emitting any content. ' +
|
|
1210
|
+
'Raw reasoning below so you can see what it was doing:]\n\n' +
|
|
1211
|
+
reasoning.trim();
|
|
1212
|
+
}
|
|
843
1213
|
}
|
|
1214
|
+
return {
|
|
1215
|
+
content: cleanContent,
|
|
1216
|
+
rawContent: content,
|
|
1217
|
+
reasoningContent: reasoning || undefined,
|
|
1218
|
+
model,
|
|
1219
|
+
usage,
|
|
1220
|
+
finishReason,
|
|
1221
|
+
truncated,
|
|
1222
|
+
ttftMs,
|
|
1223
|
+
generationMs,
|
|
1224
|
+
thinkStripFallback,
|
|
1225
|
+
reasoningFallback,
|
|
1226
|
+
prefillStall,
|
|
1227
|
+
streamError,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
finally {
|
|
1231
|
+
releaseInferenceLock();
|
|
844
1232
|
}
|
|
845
|
-
return {
|
|
846
|
-
content: cleanContent,
|
|
847
|
-
rawContent: content,
|
|
848
|
-
reasoningContent: reasoning || undefined,
|
|
849
|
-
model,
|
|
850
|
-
usage,
|
|
851
|
-
finishReason,
|
|
852
|
-
truncated,
|
|
853
|
-
ttftMs,
|
|
854
|
-
generationMs,
|
|
855
|
-
thinkStripFallback,
|
|
856
|
-
reasoningFallback,
|
|
857
|
-
prefillStall,
|
|
858
|
-
};
|
|
859
1233
|
}
|
|
860
1234
|
let detectedBackend = null;
|
|
861
1235
|
function getBackend() {
|
|
@@ -1021,7 +1395,11 @@ const PREFILL_WARN_THRESHOLD_SEC = 25;
|
|
|
1021
1395
|
*/
|
|
1022
1396
|
async function estimatePrefill(inputChars, modelId) {
|
|
1023
1397
|
const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN);
|
|
1024
|
-
// 1. Linear fit over recent samples (preferred).
|
|
1398
|
+
// 1. Linear fit over recent samples (preferred); 2. ratio fallback from the
|
|
1399
|
+
// SAME samples when there aren't enough for a fit. Both use the per-call
|
|
1400
|
+
// (promptTokens, ttft) pairs, so the ratio can't mix populations the way the
|
|
1401
|
+
// old aggregate did (avg prompt tokens over all calls vs avg TTFT over only
|
|
1402
|
+
// TTFT-bearing calls), which skewed the rate and mis-fired the refusal guard.
|
|
1025
1403
|
try {
|
|
1026
1404
|
const samples = await getPrefillSamples(modelId);
|
|
1027
1405
|
const fit = fitPrefillLinear(samples);
|
|
@@ -1034,24 +1412,22 @@ async function estimatePrefill(inputChars, modelId) {
|
|
|
1034
1412
|
fit,
|
|
1035
1413
|
};
|
|
1036
1414
|
}
|
|
1415
|
+
if (samples.length >= 2) {
|
|
1416
|
+
const sumPrompt = samples.reduce((a, s) => a + s.promptTokens, 0);
|
|
1417
|
+
const sumTtftMs = samples.reduce((a, s) => a + s.ttftMs, 0);
|
|
1418
|
+
if (sumPrompt > 0 && sumTtftMs > 0) {
|
|
1419
|
+
const prefillTokPerSec = sumPrompt / (sumTtftMs / 1000);
|
|
1420
|
+
return {
|
|
1421
|
+
inputTokens,
|
|
1422
|
+
estimatedSeconds: inputTokens / prefillTokPerSec,
|
|
1423
|
+
basis: 'ratio',
|
|
1424
|
+
prefillTokPerSec,
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1037
1428
|
}
|
|
1038
1429
|
catch {
|
|
1039
|
-
// Sample fetch failed — fall through to
|
|
1040
|
-
}
|
|
1041
|
-
// 2. Ratio fallback — uses aggregate stats already in memory.
|
|
1042
|
-
const stats = lifetime.modelStats.get(modelId);
|
|
1043
|
-
if (stats && stats.ttftCalls >= 2 && stats.totalTtftMs > 0 && stats.totalPromptTokens > 0) {
|
|
1044
|
-
const avgPromptTokens = stats.totalPromptTokens / stats.calls;
|
|
1045
|
-
const avgTtftSec = (stats.totalTtftMs / stats.ttftCalls) / 1000;
|
|
1046
|
-
if (avgTtftSec > 0) {
|
|
1047
|
-
const prefillTokPerSec = avgPromptTokens / avgTtftSec;
|
|
1048
|
-
return {
|
|
1049
|
-
inputTokens,
|
|
1050
|
-
estimatedSeconds: inputTokens / prefillTokPerSec,
|
|
1051
|
-
basis: 'ratio',
|
|
1052
|
-
prefillTokPerSec,
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1430
|
+
// Sample fetch failed — fall through to the conservative default.
|
|
1055
1431
|
}
|
|
1056
1432
|
// 3. Conservative default for unknown model/hardware.
|
|
1057
1433
|
return {
|
|
@@ -1130,9 +1506,7 @@ async function routeToModel(taskType, override) {
|
|
|
1130
1506
|
function assessQuality(resp, rawContent) {
|
|
1131
1507
|
const hadThinkBlocks = /<think>/.test(rawContent);
|
|
1132
1508
|
const estimated = !resp.usage && resp.content.length > 0;
|
|
1133
|
-
const tokPerSec = resp
|
|
1134
|
-
? resp.usage.completion_tokens / (resp.generationMs / 1000)
|
|
1135
|
-
: null;
|
|
1509
|
+
const tokPerSec = computeTokPerSec(resp);
|
|
1136
1510
|
return {
|
|
1137
1511
|
truncated: resp.truncated,
|
|
1138
1512
|
prefillStall: resp.prefillStall ?? false,
|
|
@@ -1162,6 +1536,10 @@ function formatQualityLine(quality) {
|
|
|
1162
1536
|
flags.push('tokens-estimated');
|
|
1163
1537
|
if (quality.finishReason === 'length')
|
|
1164
1538
|
flags.push('hit-max-tokens');
|
|
1539
|
+
// content_filter is a REFUSAL, not a truncation — the orchestrator should
|
|
1540
|
+
// handle it differently (don't retry with a bigger budget). Surface distinctly.
|
|
1541
|
+
if (quality.finishReason === 'content_filter')
|
|
1542
|
+
flags.push('CONTENT-FILTERED (model refused/blocked — not a length cut)');
|
|
1165
1543
|
if (flags.length === 0)
|
|
1166
1544
|
return '';
|
|
1167
1545
|
return `Quality: ${flags.join(', ')}`;
|
|
@@ -1193,6 +1571,12 @@ function formatFooter(resp, extra) {
|
|
|
1193
1571
|
else {
|
|
1194
1572
|
parts.push(`${resp.usage.prompt_tokens}→${resp.usage.completion_tokens} tokens`);
|
|
1195
1573
|
}
|
|
1574
|
+
// Prefix-cache (KV reuse) hits — a strong "this delegation was nearly free"
|
|
1575
|
+
// signal for the orchestrator when it re-sends shared context.
|
|
1576
|
+
const cached = resp.usage.prompt_tokens_details?.cached_tokens;
|
|
1577
|
+
if (typeof cached === 'number' && cached > 0) {
|
|
1578
|
+
parts.push(`${cached} prompt tokens cached`);
|
|
1579
|
+
}
|
|
1196
1580
|
}
|
|
1197
1581
|
else if (resp.content.length > 0) {
|
|
1198
1582
|
// Estimate when usage is missing (truncated responses where final SSE chunk was lost)
|
|
@@ -1204,8 +1588,9 @@ function formatFooter(resp, extra) {
|
|
|
1204
1588
|
if (resp.ttftMs !== undefined)
|
|
1205
1589
|
perfParts.push(`TTFT: ${resp.ttftMs}ms`);
|
|
1206
1590
|
let tokPerSec = 0;
|
|
1207
|
-
|
|
1208
|
-
|
|
1591
|
+
const measuredTokPerSec = computeTokPerSec(resp);
|
|
1592
|
+
if (measuredTokPerSec !== null) {
|
|
1593
|
+
tokPerSec = measuredTokPerSec;
|
|
1209
1594
|
perfParts.push(`${tokPerSec.toFixed(1)} tok/s`);
|
|
1210
1595
|
}
|
|
1211
1596
|
if (resp.generationMs)
|
|
@@ -1219,7 +1604,9 @@ function formatFooter(resp, extra) {
|
|
|
1219
1604
|
const qualityLine = formatQualityLine(quality);
|
|
1220
1605
|
if (qualityLine)
|
|
1221
1606
|
parts.push(qualityLine);
|
|
1222
|
-
if (resp.
|
|
1607
|
+
if (resp.streamError)
|
|
1608
|
+
parts.push(`⚠ UPSTREAM ERROR (partial result — backend reported: ${resp.streamError})`);
|
|
1609
|
+
else if (resp.truncated)
|
|
1223
1610
|
parts.push('⚠ TRUNCATED (soft timeout — partial result)');
|
|
1224
1611
|
const benchmarkLine = isFirstBenchmarkedCall(resp.model, tokPerSec)
|
|
1225
1612
|
? `📊 First measured call on ${resp.model}: ${tokPerSec.toFixed(1)} tok/s${resp.ttftMs !== undefined ? `, ${resp.ttftMs}ms to first token` : ''} — use this to gauge whether to delegate longer tasks.`
|
|
@@ -1239,6 +1626,17 @@ function formatFooter(resp, extra) {
|
|
|
1239
1626
|
return lines.join('\n');
|
|
1240
1627
|
}
|
|
1241
1628
|
// ── MCP Tool definitions ─────────────────────────────────────────────
|
|
1629
|
+
// Optional sampling controls shared by the inference tools. Out-of-range values
|
|
1630
|
+
// are dropped server-side (extractSamplingParams), so the backend default applies.
|
|
1631
|
+
const SAMPLING_PROPS = {
|
|
1632
|
+
seed: { type: 'integer', description: 'Deterministic sampling seed — same seed + same prompt → reproducible output. Useful for testing.' },
|
|
1633
|
+
stop: { type: ['string', 'array'], items: { type: 'string' }, description: 'Stop sequence(s) — generation halts when one is produced (up to 4).' },
|
|
1634
|
+
top_p: { type: 'number', description: 'Nucleus sampling 0–1 (e.g. 0.9). Lower = more focused. Alternative to temperature.' },
|
|
1635
|
+
top_k: { type: 'integer', description: 'Sample only from the top-K tokens (e.g. 40). 0/omitted = disabled.' },
|
|
1636
|
+
repeat_penalty: { type: 'number', description: 'Penalise repetition, 0–2 (1 = off, ~1.1 typical).' },
|
|
1637
|
+
frequency_penalty: { type: 'number', description: 'OpenAI-style frequency penalty, -2 to 2.' },
|
|
1638
|
+
presence_penalty: { type: 'number', description: 'OpenAI-style presence penalty, -2 to 2.' },
|
|
1639
|
+
};
|
|
1242
1640
|
const TOOLS = [
|
|
1243
1641
|
{
|
|
1244
1642
|
name: 'chat',
|
|
@@ -1256,7 +1654,8 @@ const TOOLS = [
|
|
|
1256
1654
|
'(1) Send COMPLETE context — the local LLM cannot read files.\n' +
|
|
1257
1655
|
'(2) Be explicit about output format ("respond as a JSON array", "return only the function").\n' +
|
|
1258
1656
|
'(3) Specific system persona beats generic — "Senior TypeScript dev" not "helpful assistant".\n' +
|
|
1259
|
-
'(4) State constraints — "no preamble", "reference line numbers", "max 5 bullets".\n
|
|
1657
|
+
'(4) State constraints — "no preamble", "reference line numbers", "max 5 bullets".\n' +
|
|
1658
|
+
'(5) Leave max_tokens UNSET — the server sizes the budget from the model\'s real context window. Tiny caps like 256 waste the model: reasoning burns the budget before any visible output.\n\n' +
|
|
1260
1659
|
'Routing picks the best loaded model automatically. Call `discover` to see what is loaded and, after the first real call, its measured speed. The footer shows cumulative tokens kept in the user\'s quota.',
|
|
1261
1660
|
inputSchema: {
|
|
1262
1661
|
type: 'object',
|
|
@@ -1275,7 +1674,7 @@ const TOOLS = [
|
|
|
1275
1674
|
},
|
|
1276
1675
|
max_tokens: {
|
|
1277
1676
|
type: 'number',
|
|
1278
|
-
description: '
|
|
1677
|
+
description: 'Response token budget. OMIT THIS — when omitted the server checks the live model\'s context window and allocates 25% of it (e.g. ~32,000 tokens on a 128k-context model), which is right for almost every call. Small caps like 256/512/1024 strangle reasoning models (hidden thinking burns the budget before visible output), so values below 4,096 are IGNORED and the dynamic budget applies. Only set this to raise the ceiling for very long outputs.',
|
|
1279
1678
|
},
|
|
1280
1679
|
json_schema: {
|
|
1281
1680
|
type: 'object',
|
|
@@ -1285,6 +1684,7 @@ const TOOLS = [
|
|
|
1285
1684
|
type: 'string',
|
|
1286
1685
|
description: 'Optional: pin to a specific model id (e.g. "nvidia/nemotron-3-nano-30b-a3b:free" on OpenRouter, "qwen.qwen3-coder-30b-a3b-instruct" on LM Studio). When set, overrides automatic routing. Useful on providers with many models where auto-routing picks poorly.',
|
|
1287
1686
|
},
|
|
1687
|
+
...SAMPLING_PROPS,
|
|
1288
1688
|
},
|
|
1289
1689
|
required: ['message'],
|
|
1290
1690
|
},
|
|
@@ -1325,7 +1725,7 @@ const TOOLS = [
|
|
|
1325
1725
|
},
|
|
1326
1726
|
max_tokens: {
|
|
1327
1727
|
type: 'number',
|
|
1328
|
-
description: '
|
|
1728
|
+
description: 'Response token budget. OMIT THIS — the server sizes it from the live model\'s context window (25%, e.g. ~32,000 on a 128k-context model). Values below 4,096 are IGNORED (tiny caps strangle reasoning models) and the dynamic budget applies. Only set to raise the ceiling.',
|
|
1329
1729
|
},
|
|
1330
1730
|
json_schema: {
|
|
1331
1731
|
type: 'object',
|
|
@@ -1335,6 +1735,7 @@ const TOOLS = [
|
|
|
1335
1735
|
type: 'string',
|
|
1336
1736
|
description: 'Optional: pin to a specific model id. When set, overrides automatic routing.',
|
|
1337
1737
|
},
|
|
1738
|
+
...SAMPLING_PROPS,
|
|
1338
1739
|
},
|
|
1339
1740
|
required: ['instruction'],
|
|
1340
1741
|
},
|
|
@@ -1371,12 +1772,13 @@ const TOOLS = [
|
|
|
1371
1772
|
},
|
|
1372
1773
|
max_tokens: {
|
|
1373
1774
|
type: 'number',
|
|
1374
|
-
description: '
|
|
1775
|
+
description: 'Response token budget. OMIT THIS — the server sizes it from the live model\'s context window (25%, e.g. ~32,000 on a 128k-context model). Values below 4,096 are IGNORED (tiny caps strangle reasoning models) and the dynamic budget applies. Only set to raise the ceiling.',
|
|
1375
1776
|
},
|
|
1376
1777
|
model: {
|
|
1377
1778
|
type: 'string',
|
|
1378
1779
|
description: 'Optional: pin to a specific model id. When set, overrides automatic routing.',
|
|
1379
1780
|
},
|
|
1781
|
+
...SAMPLING_PROPS,
|
|
1380
1782
|
},
|
|
1381
1783
|
required: ['code', 'task'],
|
|
1382
1784
|
},
|
|
@@ -1414,12 +1816,13 @@ const TOOLS = [
|
|
|
1414
1816
|
},
|
|
1415
1817
|
max_tokens: {
|
|
1416
1818
|
type: 'number',
|
|
1417
|
-
description: '
|
|
1819
|
+
description: 'Response token budget. OMIT THIS — the server sizes it from the live model\'s context window (25%). Values below 4,096 are IGNORED and the dynamic budget applies. Only set to raise the ceiling.',
|
|
1418
1820
|
},
|
|
1419
1821
|
model: {
|
|
1420
1822
|
type: 'string',
|
|
1421
1823
|
description: 'Optional: pin to a specific model id. When set, overrides automatic routing.',
|
|
1422
1824
|
},
|
|
1825
|
+
...SAMPLING_PROPS,
|
|
1423
1826
|
},
|
|
1424
1827
|
required: ['paths', 'task'],
|
|
1425
1828
|
},
|
|
@@ -1486,7 +1889,7 @@ const SIDEKICK_INSTRUCTIONS = `Houtini-lm is a local LLM sidekick. It runs on th
|
|
|
1486
1889
|
`When to reach for it: bounded, self-contained tasks you can describe in one message — explanations, boilerplate, test stubs, code review of pasted or file-loaded source, translations, commit messages, format conversion, brainstorming. Trades wall-clock time for tokens (typically 3-30× slower than frontier models).\n\n` +
|
|
1487
1890
|
`When not to: tasks that need tool access, cross-file reasoning you haven't captured, or work fast enough to answer directly before the delegation round-trip completes.\n\n` +
|
|
1488
1891
|
`Call \`discover\` in delegation-heavy sessions to see what model is loaded, its capability profile, and — after the first real call — its measured speed. The response footer reports cumulative tokens kept in the user's quota.`;
|
|
1489
|
-
const server = new Server({ name: 'houtini-lm', version: '
|
|
1892
|
+
const server = new Server({ name: 'houtini-lm', version: '3.1.0' }, { capabilities: { tools: {}, resources: {} }, instructions: SIDEKICK_INSTRUCTIONS });
|
|
1490
1893
|
// ── MCP Resources ─────────────────────────────────────────────────────
|
|
1491
1894
|
// Exposes session performance metrics as a readable resource so Claude can
|
|
1492
1895
|
// proactively check offload efficiency and make smarter delegation decisions.
|
|
@@ -1519,7 +1922,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1519
1922
|
totalTokensOffloaded: session.promptTokens + session.completionTokens,
|
|
1520
1923
|
},
|
|
1521
1924
|
perModel: modelStats,
|
|
1522
|
-
endpoint: LM_BASE_URL,
|
|
1925
|
+
endpoint: redactUrl(LM_BASE_URL),
|
|
1523
1926
|
};
|
|
1524
1927
|
return {
|
|
1525
1928
|
contents: [{
|
|
@@ -1533,30 +1936,36 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1533
1936
|
});
|
|
1534
1937
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
1535
1938
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1536
|
-
const { name
|
|
1939
|
+
const { name } = request.params;
|
|
1940
|
+
// `arguments` is optional in the MCP CallTool schema — a client may omit it
|
|
1941
|
+
// entirely for a param-less tool (e.g. `stats` with no filter). Default to an
|
|
1942
|
+
// empty object so handlers that destructure args never throw on `undefined`.
|
|
1943
|
+
const args = request.params.arguments ?? {};
|
|
1537
1944
|
const progressToken = request.params._meta?.progressToken;
|
|
1538
1945
|
try {
|
|
1539
1946
|
switch (name) {
|
|
1540
1947
|
case 'chat': {
|
|
1541
1948
|
const { message, system, temperature, max_tokens, json_schema, model } = args;
|
|
1542
1949
|
const route = await routeToModel('chat', model);
|
|
1950
|
+
const responseFormat = toResponseFormat(json_schema);
|
|
1543
1951
|
const messages = [];
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1952
|
+
messages.push({
|
|
1953
|
+
role: 'system',
|
|
1954
|
+
content: buildSystemPrompt({
|
|
1955
|
+
base: system && system.trim() ? system.trim() : 'You are a precise technical assistant.',
|
|
1956
|
+
formatLine: 'Be direct — no preamble, no restating the question. Use markdown formatting where it helps.',
|
|
1957
|
+
modelConstraint: route.hints.outputConstraint,
|
|
1958
|
+
structuredOutput: !!responseFormat,
|
|
1959
|
+
}),
|
|
1960
|
+
});
|
|
1550
1961
|
messages.push({ role: 'user', content: message });
|
|
1551
|
-
const responseFormat = json_schema
|
|
1552
|
-
? { type: 'json_schema', json_schema: { name: json_schema.name, strict: json_schema.strict ?? true, schema: json_schema.schema } }
|
|
1553
|
-
: undefined;
|
|
1554
1962
|
const resp = await chatCompletionStreaming(messages, {
|
|
1555
|
-
temperature: temperature ?? route.hints.chatTemp,
|
|
1556
|
-
maxTokens: max_tokens,
|
|
1963
|
+
temperature: validTemperature(temperature) ?? route.hints.chatTemp,
|
|
1964
|
+
maxTokens: validMaxTokens(max_tokens),
|
|
1557
1965
|
model: route.modelId,
|
|
1558
1966
|
responseFormat,
|
|
1559
1967
|
progressToken,
|
|
1968
|
+
sampling: extractSamplingParams(args),
|
|
1560
1969
|
});
|
|
1561
1970
|
const footer = formatFooter(resp);
|
|
1562
1971
|
return { content: [{ type: 'text', text: resp.content + footer }] };
|
|
@@ -1564,12 +1973,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1564
1973
|
case 'custom_prompt': {
|
|
1565
1974
|
const { system, context, instruction, temperature, max_tokens, json_schema, model } = args;
|
|
1566
1975
|
const route = await routeToModel('analysis', model);
|
|
1976
|
+
const responseFormat = toResponseFormat(json_schema);
|
|
1567
1977
|
const messages = [];
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
: (
|
|
1571
|
-
|
|
1572
|
-
|
|
1978
|
+
messages.push({
|
|
1979
|
+
role: 'system',
|
|
1980
|
+
content: buildSystemPrompt({
|
|
1981
|
+
base: system && system.trim() ? system.trim() : 'You are a precise technical assistant.',
|
|
1982
|
+
formatLine: 'Be direct — no preamble, no restating the question. Use markdown formatting where it helps.',
|
|
1983
|
+
modelConstraint: route.hints.outputConstraint,
|
|
1984
|
+
structuredOutput: !!responseFormat,
|
|
1985
|
+
}),
|
|
1986
|
+
});
|
|
1573
1987
|
// Multi-turn format prevents context bleed in smaller models.
|
|
1574
1988
|
// Context goes in a separate user→assistant exchange so the model
|
|
1575
1989
|
// "acknowledges" it before receiving the actual instruction.
|
|
@@ -1578,15 +1992,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1578
1992
|
messages.push({ role: 'assistant', content: 'Understood. I have read the full context. What would you like me to do with it?' });
|
|
1579
1993
|
}
|
|
1580
1994
|
messages.push({ role: 'user', content: instruction });
|
|
1581
|
-
const responseFormat = json_schema
|
|
1582
|
-
? { type: 'json_schema', json_schema: { name: json_schema.name, strict: json_schema.strict ?? true, schema: json_schema.schema } }
|
|
1583
|
-
: undefined;
|
|
1584
1995
|
const resp = await chatCompletionStreaming(messages, {
|
|
1585
|
-
temperature: temperature ?? route.hints.chatTemp,
|
|
1586
|
-
maxTokens: max_tokens,
|
|
1996
|
+
temperature: validTemperature(temperature) ?? route.hints.chatTemp,
|
|
1997
|
+
maxTokens: validMaxTokens(max_tokens),
|
|
1587
1998
|
model: route.modelId,
|
|
1588
1999
|
responseFormat,
|
|
1589
2000
|
progressToken,
|
|
2001
|
+
sampling: extractSamplingParams(args),
|
|
1590
2002
|
});
|
|
1591
2003
|
const footer = formatFooter(resp);
|
|
1592
2004
|
return {
|
|
@@ -1597,15 +2009,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1597
2009
|
const { code, task, language, max_tokens: codeMaxTokens, model } = args;
|
|
1598
2010
|
const lang = language || 'unknown';
|
|
1599
2011
|
const route = await routeToModel('code', model);
|
|
1600
|
-
const outputConstraint = route.hints.outputConstraint
|
|
1601
|
-
? ` ${route.hints.outputConstraint}`
|
|
1602
|
-
: '';
|
|
1603
2012
|
// Task goes in system message so smaller models don't lose it once
|
|
1604
2013
|
// the code block fills the attention window. Code is sole user content.
|
|
1605
2014
|
const codeMessages = [
|
|
1606
2015
|
{
|
|
1607
2016
|
role: 'system',
|
|
1608
|
-
content:
|
|
2017
|
+
content: buildSystemPrompt({
|
|
2018
|
+
base: `You are a senior ${lang} developer. Your task: ${task}`,
|
|
2019
|
+
formatLine: 'Be specific — reference line numbers, function names, and concrete fixes. Output your analysis as a markdown list.',
|
|
2020
|
+
modelConstraint: route.hints.outputConstraint,
|
|
2021
|
+
}),
|
|
1609
2022
|
},
|
|
1610
2023
|
{
|
|
1611
2024
|
role: 'user',
|
|
@@ -1614,9 +2027,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1614
2027
|
];
|
|
1615
2028
|
const codeResp = await chatCompletionStreaming(codeMessages, {
|
|
1616
2029
|
temperature: route.hints.codeTemp,
|
|
1617
|
-
|
|
2030
|
+
// Pass the (validated) value, undefined when omitted, so the
|
|
2031
|
+
// 25%-of-context auto-derivation in chatCompletionStreamingInner fires
|
|
2032
|
+
// — matching code_task_files. Forcing DEFAULT_MAX_TOKENS here made
|
|
2033
|
+
// options.maxTokens always truthy, capping long generations at 16K.
|
|
2034
|
+
maxTokens: validMaxTokens(codeMaxTokens),
|
|
1618
2035
|
model: route.modelId,
|
|
1619
2036
|
progressToken,
|
|
2037
|
+
sampling: extractSamplingParams(args),
|
|
1620
2038
|
});
|
|
1621
2039
|
const codeFooter = formatFooter(codeResp, lang);
|
|
1622
2040
|
const suggestionLine = route.suggestion ? `\n${route.suggestion}` : '';
|
|
@@ -1641,7 +2059,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1641
2059
|
// Read all files in parallel. One unreadable file doesn't sink the call —
|
|
1642
2060
|
// failures become inline error sections so the model can still reason about
|
|
1643
2061
|
// the rest of the bundle.
|
|
1644
|
-
const reads = await Promise.allSettled(paths.map(async (p) => ({ path: p, content: await
|
|
2062
|
+
const reads = await Promise.allSettled(paths.map(async (p) => ({ path: p, content: await readGuardedFile(p) })));
|
|
1645
2063
|
const sections = [];
|
|
1646
2064
|
let successCount = 0;
|
|
1647
2065
|
reads.forEach((r, i) => {
|
|
@@ -1663,9 +2081,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1663
2081
|
}
|
|
1664
2082
|
const lang = language || 'unknown';
|
|
1665
2083
|
const route = await routeToModel('code', model);
|
|
1666
|
-
const outputConstraint = route.hints.outputConstraint
|
|
1667
|
-
? ` ${route.hints.outputConstraint}`
|
|
1668
|
-
: '';
|
|
1669
2084
|
const combined = sections.join('\n\n');
|
|
1670
2085
|
// Pre-flight prefill estimate. Huge inputs can legitimately exceed
|
|
1671
2086
|
// the MCP client's ~60s request timeout during prompt processing, and
|
|
@@ -1680,7 +2095,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1680
2095
|
// avoids the under-prediction a ratio-of-averages produces on inputs
|
|
1681
2096
|
// much larger than the historical mean.
|
|
1682
2097
|
const estimate = await estimatePrefill(combined.length, route.modelId);
|
|
1683
|
-
|
|
2098
|
+
// A poor fit (low R² — e.g. bimodal samples straddling a backend
|
|
2099
|
+
// restart with different perf settings) must not refuse the call: a
|
|
2100
|
+
// false refusal is worse than a false-ok that the prefill keepalive
|
|
2101
|
+
// and timeout machinery already handle.
|
|
2102
|
+
const isConfidentEstimate = (estimate.basis === 'linear-fit' && estimate.fit.r2 >= 0.5) ||
|
|
2103
|
+
estimate.basis === 'ratio';
|
|
1684
2104
|
if (isConfidentEstimate && estimate.estimatedSeconds > PREFILL_REFUSE_THRESHOLD_SEC) {
|
|
1685
2105
|
const estSec = Math.round(estimate.estimatedSeconds);
|
|
1686
2106
|
const basisLine = estimate.basis === 'linear-fit'
|
|
@@ -1708,7 +2128,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1708
2128
|
const codeMessages = [
|
|
1709
2129
|
{
|
|
1710
2130
|
role: 'system',
|
|
1711
|
-
content:
|
|
2131
|
+
content: buildSystemPrompt({
|
|
2132
|
+
base: `You are a senior ${lang} developer. Your task: ${task}\n\nThe user has provided ${paths.length} file(s), concatenated below with \`=== filename ===\` headers. Reference files by name in your output.`,
|
|
2133
|
+
formatLine: 'Be specific — line numbers, function names, concrete fixes. Output your analysis as a markdown list.',
|
|
2134
|
+
modelConstraint: route.hints.outputConstraint,
|
|
2135
|
+
}),
|
|
1712
2136
|
},
|
|
1713
2137
|
{
|
|
1714
2138
|
role: 'user',
|
|
@@ -1719,9 +2143,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1719
2143
|
// auto-derivation in chatCompletionStreamingInner fires when the caller omits it.
|
|
1720
2144
|
const codeResp = await chatCompletionStreaming(codeMessages, {
|
|
1721
2145
|
temperature: route.hints.codeTemp,
|
|
1722
|
-
maxTokens: codeMaxTokens,
|
|
2146
|
+
maxTokens: validMaxTokens(codeMaxTokens),
|
|
1723
2147
|
model: route.modelId,
|
|
1724
2148
|
progressToken,
|
|
2149
|
+
sampling: extractSamplingParams(args),
|
|
1725
2150
|
});
|
|
1726
2151
|
const readSummary = successCount === paths.length
|
|
1727
2152
|
? `${paths.length} file(s) read`
|
|
@@ -1744,7 +2169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1744
2169
|
return {
|
|
1745
2170
|
content: [{
|
|
1746
2171
|
type: 'text',
|
|
1747
|
-
text: `Status: OFFLINE\nEndpoint: ${LM_BASE_URL}\n${reason}\n\nThe local LLM is not available right now. Do not attempt to delegate tasks to it.`,
|
|
2172
|
+
text: `Status: OFFLINE\nEndpoint: ${redactUrl(LM_BASE_URL)}\n${reason}\n\nThe local LLM is not available right now. Do not attempt to delegate tasks to it.`,
|
|
1748
2173
|
}],
|
|
1749
2174
|
};
|
|
1750
2175
|
}
|
|
@@ -1753,12 +2178,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1753
2178
|
return {
|
|
1754
2179
|
content: [{
|
|
1755
2180
|
type: 'text',
|
|
1756
|
-
text: `Status: ONLINE (no model loaded)\nEndpoint: ${LM_BASE_URL}\nLatency: ${ms}ms\n\nThe server is running but no model is loaded. Ask the user to load a model in LM Studio.`,
|
|
2181
|
+
text: `Status: ONLINE (no model loaded)\nEndpoint: ${redactUrl(LM_BASE_URL)}\nLatency: ${ms}ms\n\nThe server is running but no model is loaded. Ask the user to load a model in LM Studio.`,
|
|
1757
2182
|
}],
|
|
1758
2183
|
};
|
|
1759
2184
|
}
|
|
1760
2185
|
const loaded = models.filter((m) => m.state === 'loaded' || !m.state);
|
|
1761
2186
|
const available = models.filter((m) => m.state === 'not-loaded');
|
|
2187
|
+
// Models are downloaded but none is loaded (LM Studio with nothing
|
|
2188
|
+
// active). `loaded` still includes state-less models from backends that
|
|
2189
|
+
// don't report load state, so this fires only when every model is
|
|
2190
|
+
// genuinely not-loaded. Report it distinctly instead of presenting an
|
|
2191
|
+
// unloaded model as active — delegating to it would trigger an on-demand
|
|
2192
|
+
// load on the first call and likely blow the client's request timeout.
|
|
2193
|
+
if (loaded.length === 0) {
|
|
2194
|
+
const names = (available.length > 0 ? available : models).map((m) => m.id).join(', ');
|
|
2195
|
+
return {
|
|
2196
|
+
content: [{
|
|
2197
|
+
type: 'text',
|
|
2198
|
+
text: `Status: ONLINE (no model loaded)\nEndpoint: ${redactUrl(LM_BASE_URL)}\nLatency: ${ms}ms\n\nThe server is running but no model is currently loaded. Downloaded models the user can load: ${names}\n\nDo not delegate tasks until a model is loaded.`,
|
|
2199
|
+
}],
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
1762
2202
|
const primary = loaded[0] || models[0];
|
|
1763
2203
|
const ctx = getContextLength(primary);
|
|
1764
2204
|
const primaryProfile = await getModelProfileAsync(primary);
|
|
@@ -1800,7 +2240,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1800
2240
|
: getBackend() === 'ollama' ? 'Ollama'
|
|
1801
2241
|
: 'OpenAI-compatible';
|
|
1802
2242
|
let text = `Status: ONLINE\n` +
|
|
1803
|
-
`Endpoint: ${LM_BASE_URL} (${backendLabel})\n` +
|
|
2243
|
+
`Endpoint: ${redactUrl(LM_BASE_URL)} (${backendLabel})\n` +
|
|
1804
2244
|
`Connection latency: ${ms}ms (does not reflect inference speed)\n` +
|
|
1805
2245
|
`Active model: ${primary.id}\n` +
|
|
1806
2246
|
`Context window: ${ctx.toLocaleString()} tokens\n` +
|
|
@@ -1889,13 +2329,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1889
2329
|
const usageInfo = data.usage
|
|
1890
2330
|
? `${data.usage.prompt_tokens} tokens embedded`
|
|
1891
2331
|
: '';
|
|
2332
|
+
// Round to 7 significant figures for transport. Embedding components
|
|
2333
|
+
// are ~[-1, 1], so this is lossless for any similarity use but roughly
|
|
2334
|
+
// halves the serialised size — which for high-dimension models
|
|
2335
|
+
// (4k–8k dims) keeps the tool result from bloating the client context
|
|
2336
|
+
// or exceeding its result-size limit. Dimensions are preserved.
|
|
2337
|
+
const compact = embedding.map((x) => Number(x.toPrecision(7)));
|
|
1892
2338
|
return {
|
|
1893
2339
|
content: [{
|
|
1894
2340
|
type: 'text',
|
|
1895
2341
|
text: JSON.stringify({
|
|
1896
2342
|
model: data.model,
|
|
1897
2343
|
dimensions: embedding.length,
|
|
1898
|
-
embedding,
|
|
2344
|
+
embedding: compact,
|
|
1899
2345
|
usage: usageInfo,
|
|
1900
2346
|
}),
|
|
1901
2347
|
}],
|
|
@@ -1910,7 +2356,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1910
2356
|
const lines = [];
|
|
1911
2357
|
lines.push(`## Houtini LM stats`);
|
|
1912
2358
|
lines.push('');
|
|
1913
|
-
lines.push(`**Endpoint**: ${LM_BASE_URL} (${backendLabel})`);
|
|
2359
|
+
lines.push(`**Endpoint**: ${redactUrl(LM_BASE_URL)} (${backendLabel})`);
|
|
1914
2360
|
if (lifetime.firstSeenAt) {
|
|
1915
2361
|
lines.push(`**First call on this workstation**: ${new Date(lifetime.firstSeenAt).toISOString().slice(0, 10)}`);
|
|
1916
2362
|
}
|
|
@@ -1999,7 +2445,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1999
2445
|
async function main() {
|
|
2000
2446
|
const transport = new StdioServerTransport();
|
|
2001
2447
|
await server.connect(transport);
|
|
2002
|
-
process.stderr.write(`Houtini LM server running (${LM_BASE_URL})\n`);
|
|
2448
|
+
process.stderr.write(`Houtini LM server running (${redactUrl(LM_BASE_URL)})\n`);
|
|
2003
2449
|
// Background: profile all available models via HF → SQLite cache
|
|
2004
2450
|
// Non-blocking — server is already accepting requests
|
|
2005
2451
|
listModelsRaw()
|